inogen/app/components/dashboard/project-management/project-management-page.tsx

623 lines
22 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 { useState, useEffect, useCallback, useRef } from "react";
import { DashboardLayout } from "../layout";
import { Card, CardContent } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
ChevronUp,
ChevronDown,
RefreshCw,
} from "lucide-react";
import apiService from "~/lib/api";
import toast from "react-hot-toast";
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;
direction: "asc" | "desc";
}
const columns = [
{ key: "title", label: "عنوان پروژه", sortable: true, width: "200px" },
{ 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<ProjectData[]>([]);
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<SortConfig>({
field: "start_date",
direction: "asc",
});
const observerRef = useRef<HTMLDivElement>(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);
} else {
setLoadingMore(true);
}
const pageToFetch = reset ? 1 : currentPage;
const response = await apiService.select({
ProcessName: "project",
OutputFields: [
"project_no",
"title",
"importance_project",
"strategic_theme",
"value_technology_and_innovation",
"type_of_innovation",
"innovation",
"person_executing",
"excellent_observer",
"observer",
"moderator",
"start_date",
"end_date",
"done_date",
"approved_budget",
"budget_spent",
],
Pagination: { PageNumber: pageToFetch, PageSize: pageSize },
Sorts: [[sortConfig.field, sortConfig.direction]],
Conditions: [],
});
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;
}
};
const loadMore = useCallback(() => {
if (!loadingMore && hasMore && !loading) {
setCurrentPage((prev) => prev + 1);
}
}, [loadingMore, hasMore, loading]);
useEffect(() => {
fetchProjects(true);
fetchTotalCount();
}, [sortConfig]);
useEffect(() => {
if (currentPage > 1) {
fetchProjects(false);
}
}, [currentPage]);
// Infinite scroll observer
useEffect(() => {
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 (scrollContainer) {
scrollContainer.addEventListener('scroll', handleScroll);
}
return () => {
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:
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: [],
});
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();
};
const formatCurrency = (amount: string | number) => {
if (!amount) return "0 ریال";
// Remove commas and convert to number
const numericAmount =
typeof amount === "string"
? parseFloat(amount.replace(/,/g, ""))
: amount;
if (isNaN(numericAmount)) return "0 ریال";
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<string, string> = {
"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() === "") {
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<string, string> = {
"تحقیق و توسعه": "#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 <span className="text-gray-300">-</span>;
}
const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined;
return (
<span className="font-medium" style={{ color }}>
{toPersianDigits(days)}
</span>
);
}
case "strategic_theme":
case "value_technology_and_innovation":
case "type_of_innovation":
case "innovation":
return (
<span className="inline-flex items-center justify-end flex-row-reverse gap-2 w-full">
<span className="text-gray-300">{String(value) || "-"}</span>
<span style=
{{backgroundColor:`${column.key === "strategic_theme" ? "#6D53FB" : column.key=== "value_technology_and_innovation" ? "#A757FF" : column.key === "type_of_innovation" ? "#E884CE" : "#C3BF8B" }`}}
className="inline-block w-2 h-2 rounded-full bg-emerald-400" />
</span>
);
case "approved_budget":
case "budget_spent":
return (
<span className="font-medium text-emerald-400">
{formatCurrency(String(value))}
</span>
);
case "start_date":
case "end_date":
case "done_date":
return (
<span className="text-gray-300">{formatDate(String(value))}</span>
);
case "project_no":
return (
<Badge
variant="outline"
className="font-mono text-emerald-400 border-emerald-500/50"
>
{String(value)}
</Badge>
);
case "title":
return <span className="font-medium text-white">{String(value)}</span>;
case "importance_project":
return (
<Badge
variant="outline"
className="font-medium border-2"
style={{
color: getImportanceColor(String(value)),
borderColor: getImportanceColor(String(value)),
backgroundColor: `${getImportanceColor(String(value))}20`,
}}
>
{String(value)}
</Badge>
);
default:
return <span className="text-gray-300">{String(value) || "-"}</span>;
}
};
const totalPages = Math.ceil(totalCount / pageSize);
return (
<DashboardLayout title="مدیریت پروژه‌ها">
<div className="p-6 space-y-6">
{/* Data Table */}
<Card className="bg-transparent backdrop-blur-sm rounded-2xl overflow-hidden">
<CardContent className="p-0">
<div className="relative">
<Table containerClassName="overflow-auto custom-scrollbar max-h-[calc(100vh-200px)]">
<TableHeader className="sticky top-0 z-50 bg-[#3F415A]">
<TableRow className="bg-[#3F415A]">
{columns.map((column) => (
<TableHead
key={column.key}
className="text-right font-persian whitespace-nowrap text-gray-200 font-medium bg-[#3F415A] 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>
{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 ? (
// Skeleton loading rows (compact)
Array.from({ length: 10 }).map((_, index) => (
<TableRow key={`skeleton-${index}`} className="text-sm leading-tight h-8">
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
>
<div className="flex items-center gap-2">
<div className="w-2.5 h-2.5 bg-gray-600 rounded-full animate-pulse" />
<div className="h-2.5 bg-gray-600 rounded animate-pulse" style={{ width: `${Math.random() * 60 + 40}%` }} />
</div>
</TableCell>
))}
</TableRow>
))
) : projects.length === 0 ? (
<TableRow>
<TableCell
colSpan={columns.length}
className="text-center py-8"
>
<span className="text-gray-400 font-persian">
هیچ پروژهای یافت نشد
</span>
</TableCell>
</TableRow>
) : (
projects.map((project, index) => (
<TableRow key={`${project.project_no}-${index}`} className="text-sm leading-tight h-8">
{columns.map((column) => (
<TableCell
key={column.key}
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
>
{renderCellContent(project, column)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Infinite scroll trigger */}
<div ref={observerRef} className="h-auto">
{loadingMore && (
<div className="flex items-center justify-center py-1">
<div className="flex items-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-emerald-400" />
<span className="font-persian text-gray-300 text-xs"></span>
</div>
</div>
)}
</div>
</CardContent>
{/* Footer */}
<div className="p-4 bg-gray-700/50">
<div className="flex items-center justify-between text-sm text-gray-300 font-persian">
<span>کل پروژهها: {formatNumber(actualTotalCount)}</span>
</div>
</div>
</Card>
</div>
</DashboardLayout>
);
}