diff --git a/app/components/dashboard/project-management/project-management-page.tsx b/app/components/dashboard/project-management/project-management-page.tsx index 95d05e0..d587108 100644 --- a/app/components/dashboard/project-management/project-management-page.tsx +++ b/app/components/dashboard/project-management/project-management-page.tsx @@ -24,6 +24,7 @@ interface ProjectData { ValueP1215S1887ValueID: number; ValueP1215S1887StageID: number; project_no: string; + importance_project : string; title: string; strategic_theme: string; value_technology_and_innovation: string; @@ -46,94 +47,34 @@ interface SortConfig { } const columns = [ - { key: "project_no", label: "شماره پروژه", sortable: true, width: "120px" }, { key: "title", label: "عنوان پروژه", sortable: true, width: "200px" }, - { - key: "strategic_theme", - label: "ماموریت راهبردی", - sortable: true, - width: "160px", - }, - { - key: "value_technology_and_innovation", - label: "ارزش فناوری و نوآوری", - sortable: true, - width: "200px", - }, - { - key: "type_of_innovation", - label: "انواع نوآوری", - sortable: true, - width: "140px", - }, - { - key: "innovation", - label: "نوآوری", - sortable: true, - width: "120px", - }, - { - key: "person_executing", - label: "مجری", - sortable: true, - width: "140px", - }, - { - key: "excellent_observer", - label: "ناظر عالی", - sortable: true, - width: "140px", - }, - { - key: "observer", - label: "ناظر", - sortable: true, - width: "140px", - }, - { - key: "moderator", - label: "مدیر پروژه", - sortable: true, - width: "140px", - }, - { - key: "start_date", - label: "تاریخ شروع", - sortable: true, - width: "120px", - }, - { - key: "end_date", - label: "تاریخ پایان نهایی", - sortable: true, - width: "140px", - }, - { - key: "done_date", - label: "تاریخ انجام نهایی", - sortable: true, - width: "140px", - }, - { - key: "approved_budget", - label: "بودجه مصوب", - sortable: true, - width: "150px", - }, - { - key: "budget_spent", - label: "بودجه هزینه شده", - sortable: true, - width: "150px", - }, + { key: "importance_project", label: "میزان اهمیت", sortable: true, width: "150px" }, + { key: "strategic_theme", label: "مضمون راهبردی", sortable: true, width: "160px" }, + { key: "value_technology_and_innovation", label: "ارزش فناوری و نوآوری", sortable: true, width: "200px" }, + { key: "type_of_innovation", label: "انواع نوآوری", sortable: true, width: "140px" }, + { key: "innovation", label: "میزان نوآوری", sortable: true, width: "120px" }, + { key: "person_executing", label: "مسئول اجرا", sortable: true, width: "140px" }, + { key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" }, + { key: "moderator", label: "مجری", sortable: true, width: "140px" }, + { key: "execution_phase", label: "فاز اجرایی", sortable: true, width: "140px" }, // API فعلاً نداره، باید اضافه شه + { key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" }, + { key: "remaining_time", label: "زمان باقی مانده", sortable: true, width: "140px" }, // API فعلاً نداره + { key: "planned_end_date", label: "تاریخ پایان (برنامه‌ریزی)", sortable: true, width: "160px" }, // API نداره + { key: "extension_duration", label: "مدت زمان تمدید", sortable: true, width: "140px" }, // API نداره + { key: "end_date", label: "تاریخ پایان (واقعی)", sortable: true, width: "160px" }, + { key: "avg_schedule_deviation", label: "متوسط انحراف برنامه‌ای", sortable: true, width: "160px" }, // API نداره + { key: "approved_budget", label: "بودجه مصوب", sortable: true, width: "150px" }, + { key: "budget_spent", label: "بودجه صرف شده", sortable: true, width: "150px" }, + { key: "avg_cost_deviation", label: "متوسط انحراف هزینه‌ای", sortable: true, width: "160px" }, // API نداره ]; + 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(20); + const [pageSize] = useState(25); const [hasMore, setHasMore] = useState(true); const [totalCount, setTotalCount] = useState(0); const [actualTotalCount, setActualTotalCount] = useState(0); @@ -142,9 +83,17 @@ export function ProjectManagementPage() { direction: "asc", }); const observerRef = useRef(null); + const fetchingRef = useRef(false); const fetchProjects = async (reset = false) => { + // Prevent concurrent API calls + if (fetchingRef.current) { + return; + } + try { + fetchingRef.current = true; + if (reset) { setLoading(true); setCurrentPage(1); @@ -159,6 +108,7 @@ export function ProjectManagementPage() { OutputFields: [ "project_no", "title", + "importance_project", "strategic_theme", "value_technology_and_innovation", "type_of_innovation", @@ -236,14 +186,15 @@ export function ProjectManagementPage() { } finally { setLoading(false); setLoadingMore(false); + fetchingRef.current = false; } }; const loadMore = useCallback(() => { - if (!loadingMore && hasMore) { + if (!loadingMore && hasMore && !loading) { setCurrentPage((prev) => prev + 1); } - }, [loadingMore, hasMore]); + }, [loadingMore, hasMore, loading]); useEffect(() => { fetchProjects(true); @@ -256,29 +207,35 @@ export function ProjectManagementPage() { } }, [currentPage]); - // Infinite scroll observer + // Infinite scroll observer useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - if (entries[0].isIntersecting && hasMore && !loadingMore) { - loadMore(); - } - }, - { threshold: 0.1 }, - ); + const scrollContainer = document.querySelector('.overflow-auto'); + + const handleScroll = () => { + if (!scrollContainer || !hasMore || loadingMore) return; + + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const scrollPercentage = (scrollTop + clientHeight) / scrollHeight; + + // Trigger load more when scrolled to 90% of the container + if (scrollPercentage >= 0.9) { + loadMore(); + } + }; - if (observerRef.current) { - observer.observe(observerRef.current); + if (scrollContainer) { + scrollContainer.addEventListener('scroll', handleScroll); } return () => { - if (observerRef.current) { - observer.unobserve(observerRef.current); + if (scrollContainer) { + scrollContainer.removeEventListener('scroll', handleScroll); } }; }, [loadMore, hasMore, loadingMore]); const handleSort = (field: string) => { + fetchingRef.current = false; // Reset fetching state on sort setSortConfig((prev) => ({ field, direction: @@ -316,6 +273,7 @@ export function ProjectManagementPage() { }; const handleRefresh = () => { + fetchingRef.current = false; // Reset fetching state on refresh setCurrentPage(1); setProjects([]); setHasMore(true); @@ -334,20 +292,179 @@ export function ProjectManagementPage() { return new Intl.NumberFormat("fa-IR").format(numericAmount) + " ریال"; }; + const formatNumber = (value: string | number) => { + if (value === undefined || value === null || value === "") return "0"; + const numericValue = typeof value === "string" ? Number(value) : value; + if (Number.isNaN(numericValue)) return "0"; + return new Intl.NumberFormat("fa-IR").format(numericValue as number); + }; + + 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 = (start: string | null, end: string | null): number | null => { + if (!start || !end) return null; // if either missing + const startDate = parseToDate(start); + const endDate = parseToDate(end); + if (!startDate || !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() === "") + 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 { - return new Intl.DateTimeFormat("fa-IR").format(new Date(dateString)); + 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 + } + }; + const renderCellContent = (item: ProjectData, column: any) => { const value = item[column.key as keyof ProjectData]; switch (column.key) { + case "remaining_time": { + const days = calculateRemainingDays(item.start_date, 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": + return ( + + {String(value) || "-"} + + + ); case "approved_budget": case "budget_spent": return ( @@ -372,6 +489,20 @@ export function ProjectManagementPage() { ); case "title": return {String(value)}; + case "importance_project": + return ( + + {String(value)} + + ); default: return {String(value) || "-"}; } @@ -382,38 +513,23 @@ export function ProjectManagementPage() { return (
- {/* Actions */} -
- -
- {/* Data Table */} + {/* Data Table */}
-
- - - +
+ + {columns.map((column) => ( {column.sortable ? (
-
+ )) + )} + +
{/* Infinite scroll trigger */} -
+
{loadingMore && ( -
- - - در حال بارگذاری... - -
- )} - {!hasMore && projects.length > 0 && ( -
- - همه داده‌ها نمایش داده شد - +
+
+ + +
)}
@@ -504,10 +612,7 @@ export function ProjectManagementPage() { {/* Footer */}
- - نمایش {projects.length} از {actualTotalCount} پروژه - - کل پروژه‌ها: {actualTotalCount} + کل پروژه‌ها: {formatNumber(actualTotalCount)}