import { saveAs } from "file-saver"; import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import toast from "react-hot-toast"; import XLSX from "xlsx-js-style"; import { Badge } from "~/components/ui/badge"; import { Card, CardContent } from "~/components/ui/card"; import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "~/components/ui/table"; import { useStoredDate } from "~/hooks/useStoredDate"; import apiService from "~/lib/api"; import { EventBus, formatCurrency, formatNumber, handleDataValue, } from "~/lib/utils"; import type { CalendarDate } from "~/types/util.type"; import { DashboardLayout } from "../layout"; interface ProjectData { WorkflowID: number; ValueP1215S1887ValueID: number; ValueP1215S1887StageID: number; project_no: string; importance_project: string; title: string; strategic_theme: string; value_technology_and_innovation: string; type_of_innovation: string; innovation: string; person_executing: string; excellent_observer: string; observer: string; moderator: string; start_date: string; end_date: string | null; done_date: string | null; approved_budget: string; budget_spent: string; } interface SortConfig { field: string; // uses column.key direction: "asc" | "desc"; } type ColumnDef = { key: string; // UI key label: string; sortable: boolean; width: string; apiField?: string; // API field name; defaults to key computed?: boolean; // not fetched from API }; const columns: ColumnDef[] = [ { key: "title", label: "عنوان پروژه", sortable: true, width: "300px" }, { key: "importance_project", label: "میزان اهمیت", sortable: true, width: "160px", }, { key: "strategic_theme", label: "مضمون راهبردی", sortable: true, width: "200px", }, { key: "value_technology_and_innovation", label: "ارزش فناوری و نوآوری", sortable: true, width: "220px", }, { key: "type_of_innovation", label: "انواع نوآوری", sortable: true, width: "160px", }, { key: "innovation", label: "میزان نوآوری", sortable: true, width: "140px" }, { key: "person_executing", label: "مسئول اجرا", sortable: true, width: "180px", }, { key: "excellent_observer", label: "ناطر عالی", sortable: true, width: "180px", }, { key: "observer", label: "ناظر پروژه", sortable: true, width: "180px" }, { key: "moderator", label: "مجری", sortable: true, width: "180px" }, { key: "executive_phase", label: "فاز اجرایی", sortable: true, width: "160px", }, { key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" }, { key: "remaining_time", label: "زمان باقی مانده", sortable: true, width: "140px", computed: true, }, { key: "end_date", label: "تاریخ پایان (برنامه‌ریزی)", sortable: true, width: "160px", }, { key: "renewed_duration", label: "مدت زمان تمدید", sortable: true, width: "140px", }, { key: "done_date", label: "تاریخ پایان (واقعی)", sortable: true, width: "160px", }, { key: "deviation_from_program", label: "متوسط انحراف برنامه‌ای", sortable: true, width: "160px", }, { key: "approved_budget", label: "بودجه مصوب", sortable: true, width: "150px", }, { key: "budget_spent", label: "بودجه صرف شده", sortable: true, width: "150px", }, { key: "cost_deviation", label: "متوسط انحراف هزینه‌ای", sortable: true, width: "160px", }, ]; export function ProjectManagementPage() { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [pageSize] = useState(25); const [hasMore, setHasMore] = useState(true); const [totalCount, setTotalCount] = useState(0); const [actualTotalCount, setActualTotalCount] = useState(0); const [sortConfig, setSortConfig] = useState({ field: "start_date", direction: "asc", }); const observerRef = useRef(null); const fetchingRef = useRef(false); const scrollTimeoutRef = useRef(null); const scrollContainerRef = useRef(null); // const [date, setDate] = useState({ // start: `${jy}/01/01`, // end: `${jy}/12/30`, // }); const [date, setDate] = useStoredDate(); const fetchProjects = async (reset = false) => { // Prevent concurrent API calls if (fetchingRef.current) { return; } try { fetchingRef.current = true; if (reset) { setLoading(true); setCurrentPage(1); } else { setLoadingMore(true); } const pageToFetch = reset ? 1 : currentPage; const fetchableColumns = columns.filter((c) => !c.computed); const outputFields = fetchableColumns.map((c) => c.apiField ?? c.key); const sortCol = columns.find((c) => c.key === sortConfig.field); const sortField = sortCol?.computed ? undefined : (sortCol?.apiField ?? sortCol?.key); const response = await apiService.select({ ProcessName: "project", OutputFields: outputFields, Pagination: { PageNumber: pageToFetch, PageSize: pageSize }, Sorts: sortField ? [[sortField, sortConfig.direction]] : [], Conditions: [ ["start_date", ">=", date?.start || null, "and"], ["start_date", "<=", date?.end || null], ], }); if (response.state === 0) { // Parse the JSON string from the API response const dataString = response.data; if (dataString && typeof dataString === "string") { try { const parsedData = JSON.parse(dataString); if (Array.isArray(parsedData)) { if (reset) { setProjects(parsedData); setTotalCount(parsedData.length); } else { setProjects((prev) => [...prev, ...parsedData]); setTotalCount((prev) => prev + parsedData.length); } // Check if there are more items to load setHasMore(parsedData.length === pageSize); } else { if (reset) { setProjects([]); setTotalCount(0); } setHasMore(false); } } catch (parseError) { console.error("Error parsing project data:", parseError); if (reset) { setProjects([]); setTotalCount(0); } setHasMore(false); } } else { if (reset) { setProjects([]); setTotalCount(0); } setHasMore(false); } } else { toast.error(response.message || "خطا در دریافت اطلاعات پروژه‌ها"); if (reset) { setProjects([]); setTotalCount(0); } setHasMore(false); } } catch (error) { console.error("Error fetching projects:", error); toast.error("خطا در دریافت اطلاعات پروژه‌ها"); if (reset) { setProjects([]); setTotalCount(0); } setHasMore(false); } finally { setLoading(false); setLoadingMore(false); fetchingRef.current = false; } }; useEffect(() => { const handler = (date: CalendarDate) => { if (date) setDate(date); }; EventBus.on("dateSelected", handler); return () => { EventBus.off("dateSelected", handler); }; }, []); const loadMore = useCallback(() => { if (hasMore && !loading && !loadingMore && !fetchingRef.current) { setCurrentPage((prev) => prev + 1); } }, [hasMore, loading, loadingMore]); useEffect(() => { if (date.end && date.start) { fetchProjects(true); fetchTotalCount(); } }, [sortConfig, date]); useEffect(() => { if (currentPage > 1) { fetchProjects(false); } }, [currentPage]); // Infinite scroll observer with debouncing useEffect(() => { const scrollContainer = scrollContainerRef.current; const handleScroll = () => { if (!scrollContainer || !hasMore || loadingMore || fetchingRef.current) return; // Clear previous timeout if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); } // Debounce scroll events scrollTimeoutRef.current = setTimeout(() => { const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; // Trigger load more when scrolled to 95% of the container 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; // Reset fetching state on sort setSortConfig((prev) => ({ field, direction: prev.field === field && prev.direction === "asc" ? "desc" : "asc", })); setCurrentPage(1); setProjects([]); setHasMore(true); }; const fetchTotalCount = async () => { try { const response = await apiService.select({ ProcessName: "project", OutputFields: ["count(project_no)"], Conditions: [ ["start_date", ">=", date?.start || null, "and"], ["start_date", "<=", date?.end || null], ], }); 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].project_no_count || 0); } } catch (parseError) { console.error("Error parsing count data:", parseError); } } } } catch (error) { console.error("Error fetching total count:", error); } }; // const handleRefresh = () => { // fetchingRef.current = false; // Reset fetching state on refresh // setCurrentPage(1); // setProjects([]); // setHasMore(true); // fetchProjects(true); // fetchTotalCount(); // }; // ...existing code... const toPersianDigits = (input: string | number): string => { const str = String(input); const map: Record = { "0": "۰", "1": "۱", "2": "۲", "3": "۳", "4": "۴", "5": "۵", "6": "۶", "7": "۷", "8": "۸", "9": "۹", }; return str.replace(/[0-9]/g, (d) => map[d] ?? d); }; // ----- Jalali <-> Gregorian conversion helpers (lightweight, local) ----- const isJalaliDateString = (raw: string): boolean => { return /^(\d{4})[\/](\d{1,2})[\/](\d{1,2})$/.test(raw.trim()); }; const div = (a: number, b: number) => ~~(a / b); const jalaliToJDN = (jy: number, jm: number, jd: number): number => { jy = jy - (jy >= 0 ? 474 : 473); const cycle = 1029983; const yCycle = 474 + (jy % 2820); const jdn = jd + (jm <= 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186) + div((yCycle * 682 - 110) as number, 2816) + (yCycle - 1) * 365 + div(jy, 2820) * cycle + (1948320 - 1); return jdn; }; const jdnToGregorian = (jdn: number): [number, number, number] => { let j = jdn + 32044; const g = div(j, 146097); const dg = j % 146097; const c = div((div(dg, 36524) + 1) * 3, 4); const dc = dg - c * 36524; const b = div(dc, 1461); const db = dc % 1461; const a = div((div(db, 365) + 1) * 3, 4); const da = db - a * 365; const y = g * 400 + c * 100 + b * 4 + a; const m = div(da * 5 + 308, 153) - 2; const d = da - div((m + 4) * 153, 5) + 122; const year = y - 4800 + div(m + 2, 12); const month = ((m + 2) % 12) + 1; const day = d + 1; return [year, month, day]; }; const jalaliToGregorianDate = (jy: number, jm: number, jd: number): Date => { const jdn = jalaliToJDN(jy, jm, jd); const [gy, gm, gd] = jdnToGregorian(jdn); return new Date(gy, gm - 1, gd); }; const parseToDate = (value: string | null): Date | null => { if (!value) return null; const raw = String(value).trim(); if (isJalaliDateString(raw)) { const [jy, jm, jd] = raw.split("/").map((s) => Number(s)); if ([jy, jm, jd].some((n) => Number.isNaN(n))) return null; return jalaliToGregorianDate(jy, jm, jd); } const tryDate = new Date(raw); return Number.isNaN(tryDate.getTime()) ? null : tryDate; }; const getTodayMidnight = (): Date => { const now = new Date(); return new Date(now.getFullYear(), now.getMonth(), now.getDate()); }; const calculateRemainingDays = (end: string | null): number | null => { if (!end) return null; // if either missing const endDate = parseToDate(end); if (!endDate) return null; const today = getTodayMidnight(); const MS_PER_DAY = 24 * 60 * 60 * 1000; const diff = Math.round((endDate.getTime() - today.getTime()) / MS_PER_DAY); return diff; }; const formatDate = (dateString: string | null) => { if (!dateString || dateString === "null" || dateString.trim() === "") { return "-"; } // If API already returns Jalali like 1404/05/30, just convert digits 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}`); } // Otherwise, try to parse and render Persian calendar 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 "-"; } }; const phaseColors: Record = { "تحقیق و توسعه": "#FFD700", // Yellow آزمایش: "#1E90FF", // Blue تولید: "#32CD32", // Green default: "#ccc", // Fallback gray }; const getImportanceColor = (importance: string) => { switch (importance?.toLowerCase()) { case "بالا": return "#3AEA83"; case "متوسط": return "#69C8EA"; case "پایین": return "#F76276"; default: return "#6B7280"; // Default gray color } }; // Categories for which we'll generate/display color legends const categoryDefs = [ { key: "strategic_theme", label: "مضمون راهبردی", palette: ["#6D53FB", "#7C3AED", "#5B21B6", "#4C1D95", "#A78BFA"], }, { key: "value_technology_and_innovation", label: "ارزش فناوری و نوآوری", palette: ["#A757FF", "#C084FC", "#8B5CF6", "#7C3AED", "#D8B4FE"], }, { key: "type_of_innovation", label: "انواع نوآوری", palette: ["#E884CE", "#FB7185", "#F472B6", "#F97316", "#FBCFE8"], }, { key: "innovation", label: "میزان نوآوری", palette: ["#C3BF8B", "#10B981", "#F59E0B", "#EF4444", "#FDE68A"], }, { key: "executive_phase", label: "فاز اجرایی", palette: ["#C3BF8B", "#10B981", "#F59E0B", "#EF4444", "#FDE68A"], }, ]; // Build a mapping of value -> color for each category based on loaded projects. // We assign colors deterministically from the category palette in order of appearance. const categoryColorMaps = useMemo(() => { const maps: Record> = {}; categoryDefs.forEach((cat) => { maps[cat.key] = {}; const seen = new Map(); const values: string[] = projects .map((p) => (p as any)[cat.key]) .filter((v) => v !== undefined && v !== null && String(v).trim() !== "") .map((v) => String(v)); // preserve order of first appearance values.forEach((val, idx) => { if (!seen.has(val)) { const color = cat.palette[seen.size % cat.palette.length]; seen.set(val, color); } }); seen.forEach((color, val) => { maps[cat.key][val] = color; }); }); return maps; }, [projects]); // Compute counts and totals for each category so footer segments can be proportional const categoryStats = useMemo(() => { const stats: Record< string, { counts: Record; total: number } > = {}; categoryDefs.forEach((cat) => { const counts: Record = {}; let total = 0; projects.forEach((p) => { const val = String((p as any)[cat.key] ?? "").trim(); if (val !== "") { counts[val] = (counts[val] || 0) + 1; total += 1; } }); stats[cat.key] = { counts, total }; }); // also compute executive_phase counts const execCounts: Record = {}; let execTotal = 0; projects.forEach((p) => { const val = String((p as any)["executive_phase"] ?? "").trim(); if (val !== "") { execCounts[val] = (execCounts[val] || 0) + 1; execTotal += 1; } }); stats["executive_phase"] = { counts: execCounts, total: execTotal }; return stats; }, [projects]); // Importance counts (بالا، متوسط، پایین) for footer bar const importanceCounts = useMemo(() => { const counts: Record = {}; let total = 0; projects.forEach((p) => { const val = String((p as any).importance_project ?? "").trim(); if (val !== "") { counts[val] = (counts[val] || 0) + 1; total += 1; } }); return { counts, total }; }, [projects]); // Numeric averages for specified columns const numericAverages = useMemo(() => { const keys = [ "remaining_time", "renewed_duration", "deviation_from_program", "approved_budget", "budget_spent", "cost_deviation", ]; const res: Record = {}; // remaining_time is computed from end_date const remainingValues: number[] = projects .map((p) => calculateRemainingDays((p as any).end_date)) .filter((v) => v !== null) as number[]; res["remaining_time"] = remainingValues.length ? Math.round( remainingValues.reduce((a, b) => a + b, 0) / remainingValues.length ) : null; // For other keys, parse numeric values keys.forEach((k) => { if (k === "remaining_time") return; const vals: number[] = projects .map((p) => { const raw = (p as any)[k]; if (raw == null) return NaN; const num = Number( String(raw) .toString() .replace(/[^0-9.-]/g, "") ); return Number.isFinite(num) ? num : NaN; }) .filter((n) => !Number.isNaN(n)); res[k] = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null; }); return res; }, [projects]); const getCategoryColor = (categoryKey: string, value: unknown) => { const val = value == null ? "" : String(value); const map = categoryColorMaps[categoryKey] || {}; return map[val] ?? "#6B7280"; // fallback gray }; const renderCellContent = (item: ProjectData, column: ColumnDef) => { const apiField = column.apiField ?? column.key; const value = (item as any)[apiField]; switch (column.key) { case "remaining_time": { const days = calculateRemainingDays(item.end_date); if (days == null) { return -; } const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined; return ( روز {toPersianDigits(days)} ); } case "strategic_theme": case "value_technology_and_innovation": case "type_of_innovation": case "innovation": case "executive_phase": { const color = getCategoryColor(column.key, value); return ( {!!value ? String(value) : "-"} ); } case "approved_budget": case "budget_spent": return ( {formatCurrency(String(value))} ); case "deviation_from_program": case "cost_deviation": return ( {formatNumber(value as any)} ); case "start_date": case "end_date": case "done_date": return ( {formatDate(String(value))} ); case "project_no": return ( {String(value)} ); case "title": return ( {String(value)} ); case "importance_project": return ( {String(value)} ); default: return ( {(value && String(value)) || "-"} ); } }; // const totalPages = Math.ceil(totalCount / pageSize); const exportToExcel = async () => { let arr = []; const data = await fetchExcelData(); debugger; for (let i = 0; i < data.length; i++) { let obj: Record = {}; const project = data[i]; Object.entries(project).forEach(([pKey, pValue]) => { Object.values(columns).forEach((col) => { if (pKey === col.key) { ``; obj[col.label] = handleDataValue( pValue?.includes(",") ? pValue.replaceAll(",", "") : pValue ); } }); }); arr.push(obj); } // تبدیل داده‌ها به worksheet const worksheet = XLSX.utils.json_to_sheet(arr); // ساخت workbook const workbook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(workbook, worksheet, "People"); // تبدیل به فایل باینری const excelBuffer = XLSX.write(workbook, { bookType: "xlsx", type: "array", }); const blob = new Blob([excelBuffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", }); saveAs(blob, "people.xls"); }; const fetchExcelData = async () => { const fetchableColumns = columns.filter((c) => !c.computed); const outputFields = fetchableColumns.map((c) => c.apiField ?? c.key); const sortCol = columns.find((c) => c.key === sortConfig.field); const sortField = sortCol?.computed ? undefined : (sortCol?.apiField ?? sortCol?.key); const response = await apiService.select({ ProcessName: "project", OutputFields: outputFields, Sorts: sortField ? [[sortField, sortConfig.direction]] : [], Conditions: [ ["start_date", ">=", date?.start || null, "and"], ["start_date", "<=", date?.end || null], ], }); const parsedData = JSON.parse(response.data); return parsedData; }; return (
{/*
*/} {/* Data Table */} {/*
DownloadExcle
*/}
{columns.map((column) => ( {column.sortable ? ( ) : ( column.label )} ))} {loading ? ( // Skeleton loading rows (compact) Array.from({ length: 20 }).map((_, index) => ( {columns.map((column) => (
))} )) ) : projects.length === 0 ? ( هیچ پروژه‌ای یافت نشد ) : ( projects.map((project, index) => ( {columns.map((column) => ( {renderCellContent(project, column)} ))} )) )} {columns.map((column, colIndex) => { // First column: show total projects text similar to API count if (colIndex === 0) { return ( کل پروژه‌ها: {formatNumber(actualTotalCount)} ); } // importance_project: render importance bar with specified colors if (column.key === "importance_project") { const imp = importanceCounts; const order = ["بالا", "متوسط", "پایین"]; const colorFor = (k: string) => { switch (k) { case "بالا": return "var(--color-pr-green)"; // green case "متوسط": return "#69C8EA"; // blue-ish case "پایین": return "#F76276"; // red default: return "#6B7280"; } }; return (
{order.map((k) => { const cnt = imp.counts[k] || 0; const widthPercent = imp.total > 0 ? (cnt / imp.total) * 100 : 0; return (
); })}
); } // For category-like columns: strategic_theme, value_technology_and_innovation, innovation, executive_phase const categoryLike = [ "strategic_theme", "value_technology_and_innovation", "innovation", "executive_phase", ]; if (categoryLike.includes(column.key)) { const stat = categoryStats[column.key] || { counts: {}, total: 0, }; const entries = Object.entries(stat.counts); return (
{entries.length > 0 ? ( entries.map(([val, cnt]) => { let color = categoryColorMaps[column.key]?.[val] || "#6B7280"; if (column.key === "executive_phase") { color = (phaseColors as any)[val] || color; } const widthPercent = stat.total > 0 ? (cnt / stat.total) * 100 : 0; return (
); }) ) : (
)}
); } // remove bar for type_of_innovation (show empty cell) if (column.key === "type_of_innovation") { return ; } // remaining_time: show average days with color (green/red/white) if (column.key === "remaining_time") { const avg = numericAverages["remaining_time"] as | number | null; const color = avg == null ? "#9CA3AF" : avg > 0 ? "#3AEA83" : avg < 0 ? "#F76276" : "#FFFFFF"; return ( {avg == null ? "-" : `${formatNumber(avg)} روز`} ); } // For numeric columns: show average rounded const numericKeyMap: Record = { renewed_duration: "renewed_duration", deviation_from_program: "deviation_from_program", approved_budget: "approved_budget", budget_spent: "budget_spent", cost_deviation: "cost_deviation", }; const mapped = (numericKeyMap as any)[column.key]; if (mapped) { const avg = numericAverages[mapped] as number | null; let display = "-"; if (avg != null) { display = mapped.includes("budget") ? formatCurrency(String(Math.round(avg))) : formatNumber(Math.round(avg)); } return ( {display} ); } // Default: empty cell to keep alignment return ; })}
{/* Infinite scroll trigger */}
{loadingMore && (
)}
); }