update and fix styles in project-management page
This commit is contained in:
parent
efe32f8084
commit
737a68d886
22
app/app.css
22
app/app.css
|
|
@ -246,23 +246,6 @@ html[dir="rtl"] body {
|
|||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-neutral-100 dark:bg-neutral-800;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-neutral-300 dark:bg-neutral-600 rounded-full;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-neutral-400 dark:bg-neutral-500;
|
||||
}
|
||||
}
|
||||
|
||||
/* Persian/Farsi font class */
|
||||
|
|
@ -434,20 +417,15 @@ html[dir="rtl"] body {
|
|||
}
|
||||
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.8), rgba(16, 185, 129, 1));
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: rgba(16, 185, 129, 0.6) rgba(30, 41, 59, 0.6); /* thumb track */
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(30, 41, 59, 0.6); /* slate-800 */
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.5), rgba(16, 185, 129, 0.9));
|
||||
border-color: rgba(30, 41, 59, 0.6);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ChevronDown, ChevronUp, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "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";
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
|
|
@ -53,51 +54,51 @@ type ColumnDef = {
|
|||
};
|
||||
|
||||
const columns: ColumnDef[] = [
|
||||
{ key: "title", label: "عنوان پروژه", sortable: true, width: "200px" },
|
||||
{ key: "title", label: "عنوان پروژه", sortable: true, width: "300px" },
|
||||
{
|
||||
key: "importance_project",
|
||||
label: "میزان اهمیت",
|
||||
sortable: true,
|
||||
width: "150px",
|
||||
width: "160px",
|
||||
},
|
||||
{
|
||||
key: "strategic_theme",
|
||||
label: "مضمون راهبردی",
|
||||
sortable: true,
|
||||
width: "160px",
|
||||
width: "200px",
|
||||
},
|
||||
{
|
||||
key: "value_technology_and_innovation",
|
||||
label: "ارزش فناوری و نوآوری",
|
||||
sortable: true,
|
||||
width: "200px",
|
||||
width: "220px",
|
||||
},
|
||||
{
|
||||
key: "type_of_innovation",
|
||||
label: "انواع نوآوری",
|
||||
sortable: true,
|
||||
width: "140px",
|
||||
width: "160px",
|
||||
},
|
||||
{ key: "innovation", label: "میزان نوآوری", sortable: true, width: "120px" },
|
||||
{ key: "innovation", label: "میزان نوآوری", sortable: true, width: "140px" },
|
||||
{
|
||||
key: "person_executing",
|
||||
label: "مسئول اجرا",
|
||||
sortable: true,
|
||||
width: "140px",
|
||||
width: "180px",
|
||||
},
|
||||
{
|
||||
key: "excellent_observer",
|
||||
label: "ناطر عالی",
|
||||
sortable: true,
|
||||
width: "140px",
|
||||
width: "180px",
|
||||
},
|
||||
{ key: "observer", label: "ناظر پروژه", sortable: true, width: "140px" },
|
||||
{ key: "moderator", label: "مجری", sortable: true, width: "140px" },
|
||||
{ key: "observer", label: "ناظر پروژه", sortable: true, width: "180px" },
|
||||
{ key: "moderator", label: "مجری", sortable: true, width: "180px" },
|
||||
{
|
||||
key: "executive_phase",
|
||||
label: "فاز اجرایی",
|
||||
sortable: true,
|
||||
width: "140px",
|
||||
width: "160px",
|
||||
},
|
||||
{ key: "start_date", label: "تاریخ شروع", sortable: true, width: "120px" },
|
||||
{
|
||||
|
|
@ -495,6 +496,149 @@ export function ProjectManagementPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 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];
|
||||
|
|
@ -509,7 +653,7 @@ export function ProjectManagementPage() {
|
|||
return (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="font-medium flex justify-end gap-1 items-center"
|
||||
className="flex justify-end gap-1 items-center"
|
||||
style={{ color }}
|
||||
>
|
||||
<span>روز</span> {toPersianDigits(days)}
|
||||
|
|
@ -520,55 +664,58 @@ export function ProjectManagementPage() {
|
|||
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">{String(value) || "-"}</span>
|
||||
<span className="text-gray-300">{!!value ? 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"}`,
|
||||
backgroundColor: color,
|
||||
display : !value ? "none" : "block",
|
||||
}}
|
||||
className="inline-block w-2 h-2 rounded-full bg-emerald-400"
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
case "approved_budget":
|
||||
case "budget_spent":
|
||||
return (
|
||||
<span className="font-medium text-emerald-400">
|
||||
<span className=" text-emerald-400 font-normal">
|
||||
{formatCurrency(String(value))}
|
||||
</span>
|
||||
);
|
||||
case "deviation_from_program":
|
||||
case "cost_deviation":
|
||||
return (
|
||||
<span className="text-gray-300">{formatNumber(value as any)}</span>
|
||||
<span className="text-sm font-normal">{formatNumber(value as any)}</span>
|
||||
);
|
||||
case "start_date":
|
||||
case "end_date":
|
||||
case "done_date":
|
||||
return (
|
||||
<span className="text-gray-300">{formatDate(String(value))}</span>
|
||||
<span className=" text-sm font-normal">{formatDate(String(value))}</span>
|
||||
);
|
||||
case "project_no":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-emerald-400 border-emerald-500/50"
|
||||
variant="teal"
|
||||
className="border-emerald-500/50"
|
||||
>
|
||||
{String(value)}
|
||||
</Badge>
|
||||
);
|
||||
case "title":
|
||||
return <span className="font-medium text-white">{String(value)}</span>;
|
||||
return <span className="text-sm font-normal text-white">{String(value)}</span>;
|
||||
case "importance_project":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-medium border-2"
|
||||
className="border-2 text-sm rounded-lg"
|
||||
style={{
|
||||
color: getImportanceColor(String(value)),
|
||||
borderColor: getImportanceColor(String(value)),
|
||||
backgroundColor: `${getImportanceColor(String(value))}20`,
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
|
|
@ -576,7 +723,7 @@ export function ProjectManagementPage() {
|
|||
);
|
||||
default:
|
||||
return (
|
||||
<span className="text-gray-300">
|
||||
<span className="font-light text-sm">
|
||||
{(value && String(value)) || "-"}
|
||||
</span>
|
||||
);
|
||||
|
|
@ -592,13 +739,14 @@ export function ProjectManagementPage() {
|
|||
<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)]">
|
||||
<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-gray-200 font-medium bg-[#3F415A] sticky top-0 z-20"
|
||||
className={` text-right font-persian whitespace-nowrap text-white font-semibold bg-[#3F415A] sticky top-0 z-20`}
|
||||
style={{ width: column.width}}
|
||||
>
|
||||
{column.sortable ? (
|
||||
|
|
@ -619,11 +767,13 @@ export function ProjectManagementPage() {
|
|||
</button>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
)
|
||||
}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
// Skeleton loading rows (compact)
|
||||
|
|
@ -635,7 +785,7 @@ export function ProjectManagementPage() {
|
|||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.key}
|
||||
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
|
||||
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" />
|
||||
|
|
@ -668,7 +818,7 @@ export function ProjectManagementPage() {
|
|||
{columns.map((column) => (
|
||||
<TableCell
|
||||
key={column.key}
|
||||
className="text-right whitespace-nowrap border-emerald-500/20 py-1 px-2"
|
||||
className="text-right border-emerald-500/20 text-sm py-1 px-2 break-words"
|
||||
>
|
||||
{renderCellContent(project, column)}
|
||||
</TableCell>
|
||||
|
|
@ -677,15 +827,146 @@ export function ProjectManagementPage() {
|
|||
))
|
||||
)}
|
||||
</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-4 animate-spin text-emerald-400" />
|
||||
<RefreshCw className="w-4 h-3 animate-spin text-emerald-400" />
|
||||
<span className="font-persian text-gray-300 text-xs"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -693,12 +974,7 @@ export function ProjectManagementPage() {
|
|||
</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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user