784 lines
24 KiB
TypeScript
784 lines
24 KiB
TypeScript
import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
|
||
import { useCallback, useEffect, useRef, useState } 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,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "~/components/ui/table";
|
||
import apiService from "~/lib/api";
|
||
import { formatCurrency, 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: "idea_title", label: "عنوان پروژه", sortable: true, width: "200px" },
|
||
// {
|
||
// key: "idea_status",
|
||
// 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: "excellent_observer",
|
||
// label: "ناطر عالی",
|
||
// sortable: true,
|
||
// width: "140px",
|
||
// },
|
||
// { key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" },
|
||
// { key: "moderator", label: "مجری", sortable: true, width: "140px" },
|
||
// {
|
||
// key: "executive_phase",
|
||
// label: "فاز اجرایی",
|
||
// sortable: true,
|
||
// width: "140px",
|
||
// },
|
||
// { 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",
|
||
// },
|
||
// ];
|
||
|
||
const columns: ColumnDef[] = [
|
||
{ key: "idea_title", label: "عنوان ایده", sortable: true, width: "200px" },
|
||
{
|
||
key: "idea_status",
|
||
label: "وضعیت ایده",
|
||
sortable: true,
|
||
width: "260px",
|
||
},
|
||
{ key: "idea_axis", label: "محور ایده", sortable: true, width: "160px" },
|
||
{
|
||
key: "idea_current_status_description",
|
||
label: "توضیح وضعیت فعلی ایده",
|
||
sortable: true,
|
||
width: "220px",
|
||
},
|
||
{
|
||
key: "idea_description",
|
||
label: "شرح ایده",
|
||
sortable: true,
|
||
width: "200px",
|
||
},
|
||
{
|
||
key: "full_name",
|
||
label: "نام و نام خانوادگی",
|
||
sortable: true,
|
||
width: "160px",
|
||
},
|
||
{
|
||
key: "personnel_number",
|
||
label: "شماره پرسنلی",
|
||
sortable: true,
|
||
width: "140px",
|
||
},
|
||
{
|
||
key: "innovator_team_members",
|
||
label: "اعضای تیم نوآور",
|
||
sortable: true,
|
||
width: "200px",
|
||
},
|
||
{
|
||
key: "idea_registration_date",
|
||
label: "تاریخ ثبت ایده",
|
||
sortable: true,
|
||
width: "160px",
|
||
},
|
||
{ key: "deputy", label: "معاونت مربوطه", sortable: true, width: "160px" },
|
||
{ key: "management", label: "مدیریت", sortable: true, width: "140px" },
|
||
{
|
||
key: "idea_execution_benefits",
|
||
label: "مزایای اجرای ایده",
|
||
sortable: true,
|
||
width: "220px",
|
||
},
|
||
{
|
||
key: "innovation_type",
|
||
label: "نوع نوآوری",
|
||
sortable: true,
|
||
width: "160px",
|
||
},
|
||
{
|
||
key: "idea_originality",
|
||
label: "میزان اصالت ایده",
|
||
sortable: true,
|
||
width: "160px",
|
||
},
|
||
{
|
||
key: "idea_income",
|
||
label: "درآمد حاصل از ایده",
|
||
sortable: true,
|
||
width: "160px",
|
||
},
|
||
{
|
||
key: "process_improvements",
|
||
label: "بهبودهای فرآیندی",
|
||
sortable: true,
|
||
width: "180px",
|
||
},
|
||
];
|
||
|
||
export function ManageIdeasTechPage() {
|
||
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: "idea_title",
|
||
direction: "asc",
|
||
});
|
||
const observerRef = useRef<HTMLDivElement>(null);
|
||
const fetchingRef = useRef(false);
|
||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
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: "idea",
|
||
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 (hasMore && !loading && !loadingMore && !fetchingRef.current) {
|
||
setCurrentPage((prev) => prev + 1);
|
||
}
|
||
}, [hasMore, loading, loadingMore]);
|
||
|
||
useEffect(() => {
|
||
fetchProjects(true);
|
||
fetchTotalCount();
|
||
}, [sortConfig]);
|
||
|
||
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: "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 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 "var(--color-pr-green)"; // green
|
||
case "در حال بررسی":
|
||
return "var(--info)"; // آبی
|
||
case "رد شده":
|
||
return "#F76276"; // red
|
||
case "اجرا شده":
|
||
return "#69C8EA";
|
||
default:
|
||
return "var(--muted)"; // خاکستری پیشفرض
|
||
}
|
||
};
|
||
|
||
// idea_income , idea_registration_date , idea_originality , personnel_number
|
||
|
||
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-muted-foreground">-</span>;
|
||
}
|
||
const color =
|
||
days > 0
|
||
? "var(--success)"
|
||
: days < 0
|
||
? "var(--destructive)"
|
||
: undefined;
|
||
return (
|
||
<span
|
||
dir="ltr"
|
||
className="font-medium flex justify-end gap-1 items-center"
|
||
style={{ color }}
|
||
>
|
||
<span>روز</span> {toPersianDigits(days)}
|
||
</span>
|
||
);
|
||
}
|
||
case "idea_income":
|
||
return (
|
||
<span className="font-medium text-success">
|
||
{formatCurrency(String(value))}
|
||
</span>
|
||
);
|
||
case "personnel_number":
|
||
// case "idea_originality":
|
||
return (
|
||
<span className="text-muted-foreground">
|
||
{toPersianDigits(value as any)}{" "}
|
||
</span>
|
||
);
|
||
case "idea_registration_date":
|
||
return (
|
||
<span className="text-muted-foreground">
|
||
{formatDate(String(value))}
|
||
</span>
|
||
);
|
||
case "project_no":
|
||
return (
|
||
<Badge
|
||
variant="outline"
|
||
className="font-mono text-success border-success/50"
|
||
>
|
||
{String(value)}
|
||
</Badge>
|
||
);
|
||
case "idea_title":
|
||
return (
|
||
<span className="font-medium text-foreground">{String(value)}</span>
|
||
);
|
||
case "idea_status":
|
||
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-muted-foreground">
|
||
{(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">
|
||
<Table containerRef={scrollContainerRef} containerClassName="overflow-auto custom-scrollbar max-h-[calc(100vh-200px)]">
|
||
<TableHeader className="sticky top-0 z-50 bg-muted">
|
||
<TableRow className="bg-muted">
|
||
{columns.map((column) => (
|
||
<TableHead
|
||
key={column.key}
|
||
className="text-right font-persian whitespace-nowrap text-foreground font-medium bg-muted 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 whitespace-nowrap border-success/20 py-1 px-2"
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div className="w-2.5 h-2.5 bg-muted rounded-full animate-pulse" />
|
||
<div
|
||
className="h-2.5 bg-muted 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-muted-foreground 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-success/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-success" />
|
||
<span className="font-persian text-muted-foreground text-xs"></span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
|
||
{/* Footer */}
|
||
<div className="p-4 bg-muted/50">
|
||
<div className="flex items-center justify-between text-sm text-muted-foreground font-persian">
|
||
<span>کل پروژهها: {formatNumber(actualTotalCount)}</span>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</DashboardLayout>
|
||
);
|
||
}
|