inogen/app/components/dashboard/project-management/mange-ideas-tech-page.tsx

831 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ChevronDown, ChevronUp, RefreshCw, Eye, Star } from "lucide-react";
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
import toast from "react-hot-toast";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import apiService from "~/lib/api";
import { formatCurrency, formatNumber } from "~/lib/utils";
import { DashboardLayout } from "../layout";
interface IdeaData {
idea_title: string;
idea_registration_date: string;
idea_status: string;
increased_revenue: string;
full_name: string;
personnel_number: string;
management: string;
deputy: string;
innovator_team_members: string;
innovation_type: string;
idea_originality: string;
idea_axis: string;
idea_description: string;
idea_current_status_description: string;
idea_execution_benefits: string;
process_improvements: string;
}
interface PersonRanking {
full_name: string;
full_name_count: number;
ranking: number;
stars: number;
}
interface SortConfig {
field: string;
direction: "asc" | "desc";
}
type ColumnDef = {
key: string;
label: string;
sortable: boolean;
width: string;
};
const columns: ColumnDef[] = [
{ key: "idea_title", label: "عنوان ایده", sortable: true, width: "250px" },
{ key: "idea_registration_date", label: "تاریخ ثبت ایده", sortable: true, width: "180px" },
{ key: "idea_status", label: "وضعیت ایده", sortable: true, width: "150px" },
{ key: "increased_revenue", label: "درآمد حاصل از ایده", sortable: true, width: "180px" },
{ key: "details", label: "جزئیات بیشتر", sortable: false, width: "120px" },
];
export function ManageIdeasTechPage() {
const [ideas, setIdeas] = useState<IdeaData[]>([]);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(10);
const [hasMore, setHasMore] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [actualTotalCount, setActualTotalCount] = useState(0);
const [selectedIdea, setSelectedIdea] = useState<IdeaData | null>(null);
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [sortConfig, setSortConfig] = useState<SortConfig>({
field: "idea_title",
direction: "asc",
});
// People ranking state
const [peopleRanking, setPeopleRanking] = useState<PersonRanking[]>([]);
const [loadingPeople, setLoadingPeople] = useState(false);
const observerRef = useRef<HTMLDivElement>(null);
const fetchingRef = useRef(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
const fetchIdeas = async (reset = false) => {
if (fetchingRef.current) {
return;
}
try {
fetchingRef.current = true;
if (reset) {
setLoading(true);
setCurrentPage(1);
} else {
setLoadingMore(true);
}
const pageToFetch = reset ? 1 : currentPage;
const response = await apiService.select({
ProcessName: "idea",
OutputFields: [
"idea_title",
"idea_registration_date",
"idea_status",
"increased_revenue",
"full_name",
"personnel_number",
"management",
"deputy",
"innovator_team_members",
"innovation_type",
"idea_originality",
"idea_axis",
"idea_description",
"idea_current_status_description",
"idea_execution_benefits",
"process_improvements",
],
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
Sorts: [[sortConfig.field, sortConfig.direction]],
Conditions: [],
});
if (response.state === 0) {
const dataString = response.data;
if (dataString && typeof dataString === "string") {
try {
const parsedData = JSON.parse(dataString);
if (Array.isArray(parsedData)) {
if (reset) {
setIdeas(parsedData);
setTotalCount(parsedData.length);
} else {
setIdeas((prev) => [...prev, ...parsedData]);
setTotalCount((prev) => prev + parsedData.length);
}
setHasMore(parsedData.length === pageSize);
} else {
if (reset) {
setIdeas([]);
setTotalCount(0);
}
setHasMore(false);
}
} catch (parseError) {
console.error("Error parsing idea data:", parseError);
if (reset) {
setIdeas([]);
setTotalCount(0);
}
setHasMore(false);
}
} else {
if (reset) {
setIdeas([]);
setTotalCount(0);
}
setHasMore(false);
}
} else {
toast.error(response.message || "خطا در دریافت اطلاعات ایده‌ها");
if (reset) {
setIdeas([]);
setTotalCount(0);
}
setHasMore(false);
}
} catch (error) {
console.error("Error fetching ideas:", error);
toast.error("خطا در دریافت اطلاعات ایده‌ها");
if (reset) {
setIdeas([]);
setTotalCount(0);
}
setHasMore(false);
} finally {
setLoading(false);
setLoadingMore(false);
fetchingRef.current = false;
}
};
const loadMore = useCallback(() => {
if (hasMore && !loading && !loadingMore && !fetchingRef.current) {
setCurrentPage((prev) => prev + 1);
}
}, [hasMore, loading, loadingMore]);
useEffect(() => {
fetchIdeas(true);
fetchTotalCount();
fetchPeopleRanking();
}, [sortConfig]);
useEffect(() => {
if (currentPage > 1) {
fetchIdeas(false);
}
}, [currentPage]);
// Infinite scroll observer with debouncing
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
const handleScroll = () => {
if (!scrollContainer || !hasMore || loadingMore || fetchingRef.current) return;
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
scrollTimeoutRef.current = setTimeout(() => {
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
if (scrollPercentage >= 0.95) {
loadMore();
}
}, 150);
};
if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
}
return () => {
if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll);
}
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [loadMore, hasMore, loadingMore]);
const handleSort = (field: string) => {
fetchingRef.current = false;
setSortConfig((prev) => ({
field,
direction:
prev.field === field && prev.direction === "asc" ? "desc" : "asc",
}));
setCurrentPage(1);
setIdeas([]);
setHasMore(true);
};
const fetchTotalCount = async () => {
try {
const response = await apiService.select({
ProcessName: "idea",
OutputFields: ["count(idea_title)"],
Conditions: [],
});
if (response.state === 0) {
const dataString = response.data;
if (dataString && typeof dataString === "string") {
try {
const parsedData = JSON.parse(dataString);
if (Array.isArray(parsedData) && parsedData[0]) {
setActualTotalCount(parsedData[0].idea_title_count || 0);
}
} catch (parseError) {
console.error("Error parsing count data:", parseError);
}
}
}
} catch (error) {
console.error("Error fetching total count:", error);
}
};
const fetchPeopleRanking = async () => {
try {
setLoadingPeople(true);
const response = await apiService.select({
ProcessName: "idea",
OutputFields: ["full_name", "count(full_name)"],
GroupBy: ["full_name"],
});
if (response.state === 0) {
const dataString = response.data;
if (dataString && typeof dataString === "string") {
try {
const parsedData = JSON.parse(dataString);
if (Array.isArray(parsedData)) {
// Calculate rankings and stars
const counts = parsedData.map(item => item.full_name_count);
const maxCount = Math.max(...counts);
const minCount = Math.min(...counts);
// Sort by count first (highest first)
const sortedData = parsedData.sort((a, b) => b.full_name_count - a.full_name_count);
const rankedPeople = [];
let currentRank = 1;
let sum = 1;
for (let i = 0; i < sortedData.length; i++) {
const item = sortedData[i];
// If this is not the first person and their count is different from previous
if (i > 0 && sortedData[i - 1].full_name_count !== item.full_name_count) {
currentRank = sum + 1; // New rank based on position
sum++;
}
const normalizedScore = maxCount === minCount
? 1
: (item.full_name_count - minCount) / (maxCount - minCount);
const stars = Math.max(1, Math.round(normalizedScore * 5));
rankedPeople.push({
full_name: item.full_name,
full_name_count: item.full_name_count,
ranking: currentRank,
stars: stars,
});
}
setPeopleRanking(rankedPeople);
}
} catch (parseError) {
console.error("Error parsing people ranking data:", parseError);
}
}
} else {
toast.error(response.message || "خطا در دریافت اطلاعات رتبه‌بندی افراد");
}
} catch (error) {
console.error("Error fetching people ranking:", error);
toast.error("خطا در دریافت اطلاعات رتبه‌بندی افراد");
} finally {
setLoadingPeople(false);
}
};
const toPersianDigits = (input: string | number): string => {
const str = String(input);
const map: Record<string, string> = {
"0": "۰",
"1": "۱",
"2": "۲",
"3": "۳",
"4": "۴",
"5": "۵",
"6": "۶",
"7": "۷",
"8": "۸",
"9": "۹",
};
return str.replace(/[0-9]/g, (d) => map[d] ?? d);
};
const formatDate = (dateString: string | null) => {
if (!dateString || dateString === "null" || dateString.trim() === "") {
return "-";
}
const raw = String(dateString).trim();
const jalaliPattern = /^(\d{4})[\/](\d{1,2})[\/](\d{1,2})$/;
const jalaliMatch = raw.match(jalaliPattern);
if (jalaliMatch) {
const [, y, m, d] = jalaliMatch;
const mm = m.padStart(2, "0");
const dd = d.padStart(2, "0");
return toPersianDigits(`${y}/${mm}/${dd}`);
}
try {
const parsed = new Date(raw);
if (isNaN(parsed.getTime())) return "-";
return new Intl.DateTimeFormat("fa-IR-u-ca-persian", {
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(parsed);
} catch {
return "-";
}
};
// Color palette for idea status
const statusColorPalette = ["#3AEA83", "#69C8EA", "#F76276", "#FFD700", "#A757FF", "#E884CE", "#C3BF8B", "#FB7185"];
// Build a mapping of status value -> color based on loaded ideas
const statusColorMap = useMemo(() => {
const map: Record<string, string> = {};
const seenStatuses = new Set<string>();
ideas.forEach((idea) => {
const status = String(idea.idea_status || "").trim();
if (status && !seenStatuses.has(status)) {
seenStatuses.add(status);
}
});
const statusArray = Array.from(seenStatuses).sort();
statusArray.forEach((status, index) => {
map[status] = statusColorPalette[index % statusColorPalette.length];
});
return map;
}, [ideas]);
const getStatusColor = (status: string) => {
const statusValue = String(status || "").trim();
return statusColorMap[statusValue] || "#6B7280";
};
const handleShowDetails = (idea: IdeaData) => {
setSelectedIdea(idea);
setIsDetailsOpen(true);
};
const renderCellContent = (item: IdeaData, column: ColumnDef) => {
const value = (item as any)[column.key];
switch (column.key) {
case "idea_title":
return (
<span className="text-sm text-white">{String(value)}</span>
);
case "idea_registration_date":
return (
<span className="text-white text-sm">
{formatDate(String(value))}
</span>
);
case "idea_status":
return (
<span className="flex items-center justify-end flex-row-reverse gap-1 w-full">
<span className="text-white text-sm">
{!!value ? String(value) : "-"}
</span>
<span
style={{
backgroundColor: getStatusColor(String(value)),
display: !value ? "none" : "block",
}}
className="inline-block w-2 h-2 rounded-full"
/>
</span>
);
case "increased_revenue":
return (
<span className="text-sm text-white w-full">
{formatCurrency(String(value || "0")).replace("ریال" , "")}
</span>
);
case "details":
return (
<Button
variant="ghost"
size="sm"
onClick={() => handleShowDetails(item)}
className="underline text-pr-green underline-offset-4 text-sm hover:bg-emerald-500/20"
>
جزئیات بیشتر
</Button> );
default:
return (
<span className="text-white text-sm">
{(value && String(value)) || "-"}
</span>
);
}
};
return (
<DashboardLayout title="مدیریت ایده های فناوری و نوآوری">
<div className="space-y-6 h-full">
<div className="grid grid-cols-1 grid-rows-2 lg:grid-cols-3 gap-4 h-full">
{/* People Ranking Table */}
<div className="lg:col-span-1">
<h3 className="text-base text-center my-4 font-persian font-bold text-foreground">
رتبه بندی نوآوران
</h3>
<Card className="bg-transparent border-none rounded-xl overflow-hidden">
<CardContent className="p-0 ">
<div className="relative max-h-[calc(100vh-200px)] overflow-auto custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-50 bg-pr-gray">
<TableRow className="bg-pr-gray">
<TableHead className="text-center font-persian font-semibold text-white bg-pr-gray sticky top-0 z-20 w-16">
رتبه
</TableHead>
<TableHead className="text-right font-persian text-white font-semibold bg-pr-gray sticky top-0 z-20">
ایده پرداز
</TableHead>
<TableHead className="text-center font-persian font-semibold bg-pr-gray text-white sticky top-0 z-20 w-24">
امتیاز
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loadingPeople ? (
Array.from({ length: 10 }).map((_, index) => (
<TableRow key={`skeleton-${index}`} className="text-sm leading-tight h-12">
<TableCell className="text-center py-2 px-2">
<div className="w-6 h-6 bg-muted rounded-full animate-pulse mx-auto" />
</TableCell>
<TableCell className="text-right py-2 px-4">
<div className="h-4 bg-muted rounded animate-pulse w-3/4" />
</TableCell>
<TableCell className="text-center py-2 px-2">
<div className="flex items-center justify-center gap-1">
{Array.from({ length: 5 }).map((_, starIndex) => (
<div key={starIndex} className="w-3 h-3 bg-muted rounded animate-pulse" />
))}
</div>
</TableCell>
</TableRow>
))
) : peopleRanking.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8">
<span className="text-muted-foreground font-persian">
هیچ دادهای یافت نشد
</span>
</TableCell>
</TableRow>
) : (
peopleRanking.map((person) => (
<TableRow key={person.full_name} className="text-sm leading-tight h-10 not-last:border-b-pr-gray border-border">
<TableCell className="text-center py-2 px-2">
<div className="flex items-center justify-center text-white text-sm mx-auto">
{toPersianDigits(person.ranking)}
</div>
</TableCell>
<TableCell className="text-right py-2 px-4">
<span className="font-persian font-medium text-foreground">
{person.full_name}
</span>
</TableCell>
<TableCell className="text-center py-4 px-2">
<div className="flex mx-4 flex-row-reverse items-center justify-center gap-1">
{Array.from({ length: 5 }).map((_, starIndex) => (
<Star
key={starIndex}
className={`w-5 h-5 ${
starIndex < person.stars
? "text-pr-green fill-pr-green"
: "text-pr-gray"
}`}
/>
))}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="p-4 bg-pr-gray">
<div className="text-sm text-white font-semibold font-persian text-right mr-16">
کل افراد: {toPersianDigits(peopleRanking.length)}
</div>
</div>
</CardContent>
</Card>
</div>
{/* Main Ideas Table */}
<div className="col-span-2 row-span-1">
<h3 className="text-base text-center my-4 font-persian font-bold text-foreground">
لیست ایده ها
</h3>
<Card className="bg-transparent backdrop-blur-sm rounded-xl overflow-hidden">
<CardContent className="p-0">
<div className="relative">
<Table
containerRef={scrollContainerRef}
containerClassName="overflow-auto custom-scrollbar max-h-[calc(50vh-100px)]"
>
<TableHeader className="sticky top-0 z-50 bg-pr-gray">
<TableRow className="bg-pr-gray">
{columns.map((column) => (
<TableHead
key={column.key}
className="font-persian whitespace-nowrap text-white text-sm text-center font-semibold bg-pr-gray sticky top-0 z-20"
style={{ width: column.width }}
>
{column.sortable ? (
<button
onClick={() => handleSort(column.key)}
className="flex items-center gap-2"
>
<span>{column.label}</span>
{column.key === "increased_revenue" && (
<span className="text-[#ACACAC] text-right font-light text-[8px]">میلیون <br/>ریال</span>
)}
{sortConfig.field === column.key ? (
sortConfig.direction === "asc" ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
) : (
column.label
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
Array.from({ length: 20 }).map((_, index) => (
<TableRow
key={`skeleton-${index}`}
className="text-sm leading-tight h-12"
>
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right whitespace-nowrap border-success/20 py-2 px-4"
>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-muted rounded-full animate-pulse" />
<div
className="h-3 bg-muted rounded animate-pulse"
style={{ width: `${Math.random() * 60 + 40}%` }}
/>
</div>
</TableCell>
))}
</TableRow>
))
) : ideas.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-8"
>
<span className="text-muted-foreground font-persian">
هیچ ایدهای یافت نشد
</span>
</TableCell>
</TableRow>
) : (
ideas.map((idea, index) => (
<TableRow
key={`${idea.idea_title}-${index}`}
className="text-sm leading-tight h-12 not-last:border-b-[1px] border-b-pr-gray"
>
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right text-sm text-white font-normal whitespace-nowrap py-2 px-4"
>
{renderCellContent(idea, column)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Infinite scroll trigger */}
<div ref={observerRef} className="h-auto">
{loadingMore && (
<div className="flex items-center justify-center py-2">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-success" />
<span className="font-persian text-muted-foreground text-sm">
</span>
</div>
</div>
)}
</div>
</CardContent>
{/* Footer */}
<div className="p-4 bg-pr-gray">
<div className="flex items-center justify-between font-semibold text-sm text-white font-persian">
<span>کل ایدهها: {toPersianDigits(actualTotalCount)}</span>
</div>
</div>
</Card>
</div>
</div>
{/* Details Dialog */}
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-right font-persian text-xl">
جزئیات ایده: {selectedIdea?.idea_title}
</DialogTitle>
</DialogHeader>
{selectedIdea && (
<div className="space-y-6 text-right font-persian">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
نام و نام خانوادگی
</label>
<p className="text-foreground">{selectedIdea.full_name || "-"}</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
شماره پرسنلی
</label>
<p className="text-foreground">{toPersianDigits(selectedIdea.personnel_number) || "-"}</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
مدیریت
</label>
<p className="text-foreground">{selectedIdea.management || "-"}</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
معاونت مربوطه
</label>
<p className="text-foreground">{selectedIdea.deputy || "-"}</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
نوع نوآوری
</label>
<p className="text-foreground">{selectedIdea.innovation_type || "-"}</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
میزان اصالت ایده
</label>
<p className="text-foreground">{selectedIdea.idea_originality || "-"}</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
محور ایده
</label>
<p className="text-foreground">{selectedIdea.idea_axis || "-"}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
اعضای تیم نوآور
</label>
<p className="text-foreground">{selectedIdea.innovator_team_members || "-"}</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
شرح ایده
</label>
<p className="text-foreground leading-relaxed">
{selectedIdea.idea_description || "-"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
توضیح وضعیت فعلی ایده
</label>
<p className="text-foreground leading-relaxed">
{selectedIdea.idea_current_status_description || "-"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
مزایای اجرای ایده
</label>
<p className="text-foreground leading-relaxed">
{selectedIdea.idea_execution_benefits || "-"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
بهبودهای فرآیندی
</label>
<p className="text-foreground leading-relaxed">
{selectedIdea.process_improvements || "-"}
</p>
</div>
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
درآمد حاصل از ایده
</label>
<p className="text-success font-medium">
{formatCurrency(selectedIdea.increased_revenue)}
</p>
</div>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
</DashboardLayout>
);
}