Reviewed-on: https://git.pelekan.org/Saeed0920/inogen/pulls/13 Co-authored-by: saeed0920 <sd.eed1381@gmail.com> Co-committed-by: saeed0920 <sd.eed1381@gmail.com>
983 lines
33 KiB
TypeScript
983 lines
33 KiB
TypeScript
import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
|
||
import { useCallback, useEffect, useRef, useState, useMemo } from "react";
|
||
import toast from "react-hot-toast";
|
||
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 apiService from "~/lib/api";
|
||
import { formatCurrency } from "~/lib/utils";
|
||
import { formatNumber } from "~/lib/utils";
|
||
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<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 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: [],
|
||
});
|
||
|
||
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();
|
||
};
|
||
|
||
// ...existing code...
|
||
|
||
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 = (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<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
|
||
}
|
||
};
|
||
|
||
// 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<string, Record<string, string>> = {};
|
||
categoryDefs.forEach((cat) => {
|
||
maps[cat.key] = {};
|
||
const seen = new Map<string, string>();
|
||
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<string, number>; total: number }> = {};
|
||
categoryDefs.forEach((cat) => {
|
||
const counts: Record<string, number> = {};
|
||
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<string, number> = {};
|
||
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<string, number> = {};
|
||
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<string, number | null> = {};
|
||
|
||
// 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 <span className="text-gray-300">-</span>;
|
||
}
|
||
const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined;
|
||
return (
|
||
<span
|
||
dir="ltr"
|
||
className="flex justify-end gap-1 items-center"
|
||
style={{ color }}
|
||
>
|
||
<span>روز</span> {toPersianDigits(days)}
|
||
</span>
|
||
);
|
||
}
|
||
case "strategic_theme":
|
||
case "value_technology_and_innovation":
|
||
case "type_of_innovation":
|
||
case "innovation":
|
||
case "executive_phase": {
|
||
const color = getCategoryColor(column.key, value);
|
||
return (
|
||
<span className="inline-flex items-center justify-end flex-row-reverse gap-2 w-full">
|
||
<span className="text-gray-300">{!!value ? String(value) : "-"}</span>
|
||
<span
|
||
style={{
|
||
backgroundColor: color,
|
||
display : !value ? "none" : "block",
|
||
}}
|
||
className="inline-block w-2 h-2 rounded-full"
|
||
/>
|
||
</span>
|
||
);
|
||
}
|
||
case "approved_budget":
|
||
case "budget_spent":
|
||
return (
|
||
<span className=" text-emerald-400 font-normal">
|
||
{formatCurrency(String(value))}
|
||
</span>
|
||
);
|
||
case "deviation_from_program":
|
||
case "cost_deviation":
|
||
return (
|
||
<span className="text-sm font-normal">{formatNumber(value as any)}</span>
|
||
);
|
||
case "start_date":
|
||
case "end_date":
|
||
case "done_date":
|
||
return (
|
||
<span className=" text-sm font-normal">{formatDate(String(value))}</span>
|
||
);
|
||
case "project_no":
|
||
return (
|
||
<Badge
|
||
variant="teal"
|
||
className="border-emerald-500/50"
|
||
>
|
||
{String(value)}
|
||
</Badge>
|
||
);
|
||
case "title":
|
||
return <span className="text-sm font-normal text-white">{String(value)}</span>;
|
||
case "importance_project":
|
||
return (
|
||
<Badge
|
||
variant="outline"
|
||
className="border-2 text-sm rounded-lg"
|
||
style={{
|
||
color: getImportanceColor(String(value)),
|
||
borderColor: getImportanceColor(String(value)),
|
||
}}
|
||
>
|
||
{String(value)}
|
||
</Badge>
|
||
);
|
||
default:
|
||
return (
|
||
<span className="font-light text-sm">
|
||
{(value && 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">
|
||
<div className="relative overflow-auto custom-scrollbar max-h-[calc(100vh-120px)]">
|
||
<Table className="table-fixed">
|
||
<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-white font-semibold 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: 20 }).map((_, index) => (
|
||
<TableRow
|
||
key={`skeleton-${index}`}
|
||
className="text-sm leading-tight h-8"
|
||
>
|
||
{columns.map((column) => (
|
||
<TableCell
|
||
key={column.key}
|
||
className="text-right border-emerald-500/20 py-1 px-2 break-words"
|
||
>
|
||
<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 border-emerald-500/20 text-sm py-1 px-2 break-words"
|
||
>
|
||
{renderCellContent(project, column)}
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
|
||
<TableFooter className="sticky py-2 bottom-[-1px] bg-[#3F415A]">
|
||
<TableRow>
|
||
{columns.map((column, colIndex) => {
|
||
// First column: show total projects text similar to API count
|
||
if (colIndex === 0) {
|
||
return (
|
||
<TableCell key={column.key} className="p-3 text-sm text-white font-semibold font-persian">
|
||
کل پروژهها: {formatNumber(actualTotalCount)}
|
||
</TableCell>
|
||
);
|
||
}
|
||
// 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 (
|
||
<TableCell key={column.key} className="p-1">
|
||
<div className="w-full bg-gray-800 rounded-sm overflow-hidden h-3 flex">
|
||
{order.map((k) => {
|
||
const cnt = imp.counts[k] || 0;
|
||
const widthPercent = imp.total > 0 ? (cnt / imp.total) * 100 : 0;
|
||
return (
|
||
<div
|
||
key={k}
|
||
title={`${k} (${cnt})`}
|
||
className="h-3 flex items-center justify-center text-xs font-medium"
|
||
style={{ width: `${widthPercent}%`, backgroundColor: colorFor(k) }}
|
||
>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</TableCell>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<TableCell key={column.key} className="p-1">
|
||
<div className="w-full bg-gray-800 rounded-sm overflow-hidden h-3 flex">
|
||
{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 (
|
||
<div
|
||
key={val}
|
||
title={`${val} (${cnt})`}
|
||
className="h-3 flex items-center justify-center text-xs font-medium"
|
||
style={{ width: `${widthPercent}%`, backgroundColor: color }}
|
||
>
|
||
</div>
|
||
);
|
||
})
|
||
) : (
|
||
<div className="h-3 w-full bg-gray-700" />
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
);
|
||
}
|
||
|
||
// remove bar for type_of_innovation (show empty cell)
|
||
if (column.key === "type_of_innovation") {
|
||
return <TableCell key={column.key} className="p-1" />;
|
||
}
|
||
|
||
// 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 (
|
||
<TableCell key={column.key} className="p-2 text-right font-medium" style={{ color }}>
|
||
{avg == null ? "-" : `${formatNumber(avg)} روز`}
|
||
</TableCell>
|
||
);
|
||
}
|
||
|
||
// For numeric columns: show average rounded
|
||
const numericKeyMap: Record<string, string> = {
|
||
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 (
|
||
<TableCell key={column.key} className="p-2 text-right font-medium text-gray-200">
|
||
{display}
|
||
</TableCell>
|
||
);
|
||
}
|
||
|
||
// Default: empty cell to keep alignment
|
||
return <TableCell key={column.key} className="p-1" />;
|
||
})}
|
||
</TableRow>
|
||
</TableFooter>
|
||
</Table>
|
||
</div>
|
||
</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-3 animate-spin text-emerald-400" />
|
||
<span className="font-persian text-gray-300 text-xs"></span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
|
||
|
||
</Card>
|
||
</div>
|
||
</DashboardLayout>
|
||
);
|
||
}
|