inogen/app/components/dashboard/project-management/mange-ideas-tech-page.tsx

795 lines
25 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 { 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 { 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_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: "idea_status",
label: "وضعیت ایده",
sortable: true,
width: "260px",
},
{
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",
},
];
// idea_income , idea_registration_date , idea_originality , personnel_number
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 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 (!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: "idea",
OutputFields: ["count(idea_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 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 = (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"; // قرمز
case "اجرا شده":
return "#FBBF24"; // زرد/نارنجی
default:
return "#6B7280"; // خاکستری پیش‌فرض
}
};
// 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-gray-300">-</span>;
}
const color = days > 0 ? "#3AEA83" : days < 0 ? "#F76276" : undefined;
return (
<span
dir="ltr"
className="font-medium 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":
// 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 "idea_income":
return (
<span className="font-medium text-emerald-400">
{formatCurrency(String(value))}
</span>
);
case "personnel_number":
// case "idea_originality":
return (
<span className="text-gray-300">{formatNumber(value as any)} </span>
);
case "idea_registration_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 "idea_title":
return <span className="font-medium text-white">{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-gray-300">
{(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 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: 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-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>
);
}