From aed286660a082355f2f5051eb739be8fac2b1927 Mon Sep 17 00:00:00 2001 From: saeed0920 Date: Thu, 18 Sep 2025 10:57:50 +0330 Subject: [PATCH] refactor_#2 (#13) Reviewed-on: https://git.pelekan.org/Saeed0920/inogen/pulls/13 Co-authored-by: saeed0920 Co-committed-by: saeed0920 --- app/app.css | 23 +- .../process-innovation-page.tsx | 246 ++++++------ .../project-management-page.tsx | 354 ++++++++++++++++-- app/components/ecosystem/info-panel.tsx | 132 ++++--- app/components/ecosystem/network-graph.tsx | 6 +- app/components/ui/base-card.tsx | 9 +- app/components/ui/custom-bar-chart.tsx | 50 +-- app/routes/ecosystem.tsx | 19 +- 8 files changed, 545 insertions(+), 294 deletions(-) diff --git a/app/app.css b/app/app.css index 4247830..9202430 100644 --- a/app/app.css +++ b/app/app.css @@ -37,6 +37,7 @@ --color-pr-green : #3AEA83; --color-pr-blue : #69C8EA; --color-pr-red : #F76276; + --color-pr-gray : #3F415A; } html, @@ -246,23 +247,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 +418,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); } diff --git a/app/components/dashboard/project-management/process-innovation-page.tsx b/app/components/dashboard/project-management/process-innovation-page.tsx index 83d5ca3..b7a3748 100644 --- a/app/components/dashboard/project-management/process-innovation-page.tsx +++ b/app/components/dashboard/project-management/process-innovation-page.tsx @@ -16,7 +16,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import toast from "react-hot-toast"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; -import { Card, CardContent } from "~/components/ui/card"; +import { BaseCard } from "~/components/ui/base-card"; import { Checkbox } from "~/components/ui/checkbox"; import { CustomBarChart } from "~/components/ui/custom-bar-chart"; import { @@ -36,6 +36,7 @@ import { import apiService from "~/lib/api"; import { formatNumber } from "~/lib/utils"; import { DashboardLayout } from "../layout"; +import { Card , CardContent} from "~/components/ui/card"; moment.loadPersian({ usePersianDigits: true }); interface ProcessInnovationData { @@ -49,6 +50,11 @@ interface ProcessInnovationData { amount_currency_reduction: string; Reduce_rate_failure: string; observer: string; + // optional detailed fields returned by API + project_description?: string; + start_date?: string; + done_date?: string; + approved_budget?: string; } interface ProjectStats { @@ -152,7 +158,7 @@ export function ProcessInnovationPage() { stats.productionStopsPreventionSum ), description: "تن افزایش یافته", - icon: , + icon: CirclePause, color: "text-emerald-400", }, bottleneckremoval: { @@ -160,7 +166,7 @@ export function ProcessInnovationPage() { title: "رفع گلوگاه", value: formatNumber(stats.bottleneckRemovalCount), description: "تعداد رفع گلوگاه", - icon: , + icon: Funnel, color: "text-emerald-400", }, currencyreduction: { @@ -170,7 +176,7 @@ export function ProcessInnovationPage() { stats.currencyReductionSum.toFixed?.(0) ?? stats.currencyReductionSum ), description: "دلار کاهش یافته", - icon: , + icon: DollarSign , color: "text-emerald-400", }, frequentfailuresreduction: { @@ -181,7 +187,7 @@ export function ProcessInnovationPage() { stats.frequentFailuresReductionSum ), description: "مجموع درصد کاهش خرابی", - icon: , + icon: Wrench, color: "text-emerald-400", }, }); @@ -528,7 +534,7 @@ export function ProcessInnovationPage() { variant="ghost" size="sm" onClick={() => handleProjectDetails(item)} - className="text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/20 p-2 h-auto" + className="text-pr-green hover:text-emerald-300 underline-offset-4 underline font-normal hover:bg-emerald-500/20 p-2 h-auto" > جزئیات بیشتر @@ -541,18 +547,18 @@ export function ProcessInnovationPage() { ); case "project_no": return ( - + {String(value)} ); case "title": - return {String(value)}; + return {String(value)}; case "project_status": return (
+ {formatNumber(String(value))} ); @@ -590,106 +596,96 @@ export function ProcessInnovationPage() { {loading || statsLoading ? // Loading skeleton for stats cards - matching new design Array.from({ length: 4 }).map((_, index) => ( - - -
-
-
-
-
-
-
-
-
-
+ +
+
+
+
+
- - +
+
+
+
+
+ )) - : Object.entries(stateCard).map(([key, card]) => ( - - -
-
-

- {card.title} -

-
- {card.icon} + : Object.entries(stateCard).map(([key, card]) => { + // map percent values for each card key + const percentMap: Record = { + productionstopsprevention: stats.percentProductionStops, + bottleneckremoval: stats.percentBottleneckRemoval, + currencyreduction: stats.percentCurrencyReduction, + frequentfailuresreduction: stats.percentFailuresReduction, + }; + const percentValue = percentMap[key]; + + return ( + +
+
+
+

+ {(card.value)} +

+
+ {card.description} +
-
-

- {card.value} -

-

- {card.description} -

-
- - - ))} +
+ ); + })}
{/* Process Impacts Chart */} - - - - - + + +
{/* Data Table */} @@ -810,7 +806,7 @@ export function ProcessInnovationPage() { {/* Footer */} -
+
@@ -841,15 +837,15 @@ export function ProcessInnovationPage() { - + شرح پروژه
{/* Project Description */}
-

{selectedProjectDetails?.title}

-

+

{selectedProjectDetails?.title}

+

{selectedProjectDetails?.project_description || "-"}

@@ -859,11 +855,11 @@ export function ProcessInnovationPage() {
جزئیات پروژه
-

- +

+ زمان شروع:

- + {selectedProjectDetails?.start_date ? moment( selectedProjectDetails?.start_date, @@ -874,11 +870,11 @@ export function ProcessInnovationPage() {

-

- +

+ زمان پایان:

- + {selectedProjectDetails?.done_date ? moment( selectedProjectDetails?.done_date, @@ -889,27 +885,29 @@ export function ProcessInnovationPage() {

-

- +

+ هزینه برآورد شده:

- - {formatNumber( - Number( - selectedProjectDetails?.approved_budget.replaceAll( - ",", - "" + + {selectedProjectDetails?.approved_budget + ? formatNumber( + Number( + selectedProjectDetails.approved_budget.replaceAll( + ",", + "" + ) + ) ) - ) - ) || "-"} + : "-"}

-

- +

+ نفر مرتبط:

- + {selectedProjectDetails?.observer || "-"}

diff --git a/app/components/dashboard/project-management/project-management-page.tsx b/app/components/dashboard/project-management/project-management-page.tsx index 0c073ac..087f98a 100644 --- a/app/components/dashboard/project-management/project-management-page.tsx +++ b/app/components/dashboard/project-management/project-management-page.tsx @@ -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> = {}; + categoryDefs.forEach((cat) => { + maps[cat.key] = {}; + const seen = new Map(); + 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; total: number }> = {}; + categoryDefs.forEach((cat) => { + const counts: Record = {}; + 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 = {}; + 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 = {}; + 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 = {}; + + // 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 ( روز {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 ( - {String(value) || "-"} + {!!value ? String(value) : "-"} ); + } case "approved_budget": case "budget_spent": return ( - + {formatCurrency(String(value))} ); case "deviation_from_program": case "cost_deviation": return ( - {formatNumber(value as any)} + {formatNumber(value as any)} ); case "start_date": case "end_date": case "done_date": return ( - {formatDate(String(value))} + {formatDate(String(value))} ); case "project_no": return ( {String(value)} ); case "title": - return {String(value)}; + return {String(value)}; case "importance_project": return ( {String(value)} @@ -576,7 +723,7 @@ export function ProjectManagementPage() { ); default: return ( - + {(value && String(value)) || "-"} ); @@ -592,14 +739,15 @@ export function ProjectManagementPage() {
- +
+
{columns.map((column) => ( {column.sortable ? (
+ + + + {columns.map((column, colIndex) => { + // First column: show total projects text similar to API count + if (colIndex === 0) { + return ( + + کل پروژه‌ها: {formatNumber(actualTotalCount)} + + ); + } + // 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 ( + +
+ {order.map((k) => { + const cnt = imp.counts[k] || 0; + const widthPercent = imp.total > 0 ? (cnt / imp.total) * 100 : 0; + return ( +
+
+ ); + })} +
+
+ ); + } + + // 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 ( + +
+ {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 ( +
+
+ ); + }) + ) : ( +
+ )} +
+ + ); + } + + // remove bar for type_of_innovation (show empty cell) + if (column.key === "type_of_innovation") { + return ; + } + + // 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 ( + + {avg == null ? "-" : `${formatNumber(avg)} روز`} + + ); + } + + // For numeric columns: show average rounded + const numericKeyMap: Record = { + 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 ( + + {display} + + ); + } + + // Default: empty cell to keep alignment + return ; + })} + + + +
{/* Infinite scroll trigger */} @@ -685,7 +966,7 @@ export function ProjectManagementPage() { {loadingMore && (
- +
@@ -693,12 +974,7 @@ export function ProjectManagementPage() {
- {/* Footer */} -
-
- کل پروژه‌ها: {formatNumber(actualTotalCount)} -
-
+
diff --git a/app/components/ecosystem/info-panel.tsx b/app/components/ecosystem/info-panel.tsx index d373924..4e5e1c8 100644 --- a/app/components/ecosystem/info-panel.tsx +++ b/app/components/ecosystem/info-panel.tsx @@ -401,21 +401,13 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) {
- + وضعیت زیست‌بوم فناوری و نوآوری - {/* Footer - MOU Count */} - {/* -
- تعداد تفاهم نامه ها - {formatNumber(counts.mou_count)} -
-
*/} - - + تعداد تفاهم نامه ها {formatNumber(counts.mou_count)} @@ -424,7 +416,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) { - + تعداد بازیگران {formatNumber(counts.actor_count)} @@ -433,7 +425,7 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) { {/* Actor Count Display */} - + تنوع بازیگران {/* Middle - Bar Chart */} @@ -454,58 +446,78 @@ export function InfoPanel({ selectedCompany }: InfoPanelProps) { {/* Area Chart Section */} - -
- + +
+ روند ایجاد بازیگران در طول سال‌ها
-
+
{processData.length > 0 ? ( - - - - - formatNumber(value)} - /> - - `سال ${formatPersianYear(value.toString())}` - } - formatter={(value) => [ - formatNumber(value), - "تعداد بازیگران", - ]} - /> - - - + + + + + + + + + + + + formatNumber(value)} + /> + } /> + + {/* ✅ Use gradient for fill */} + ( + + {/* Small circle */} + + {/* Year label above point */} + + {formatPersianYear(payload.year)} + + + )} + /> + + + ) : (
داده‌ای برای نمایش وجود ندارد diff --git a/app/components/ecosystem/network-graph.tsx b/app/components/ecosystem/network-graph.tsx index aa133d2..9c05b67 100644 --- a/app/components/ecosystem/network-graph.tsx +++ b/app/components/ecosystem/network-graph.tsx @@ -508,7 +508,7 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { // Don't render on server side if (!isMounted) { return ( -
+
در حال بارگذاری...
@@ -518,7 +518,7 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { if (isLoading) { return ( -
+
{/* Skeleton Graph Container */}
{/* Center Node Skeleton */} @@ -579,7 +579,7 @@ export function NetworkGraph({ onNodeClick }: NetworkGraphProps) { } return ( -
+
); diff --git a/app/components/ui/base-card.tsx b/app/components/ui/base-card.tsx index 0aacd39..bb06dc5 100644 --- a/app/components/ui/base-card.tsx +++ b/app/components/ui/base-card.tsx @@ -7,6 +7,7 @@ interface BaseCardProps { headerClassName?: string; contentClassName?: string; children: React.ReactNode; + icon ?: React.ComponentType<{ className?: string }>; withHeader?: boolean; } @@ -17,6 +18,7 @@ export function BaseCard({ contentClassName, children, withHeader = false, + icon : Icon, }: BaseCardProps) { return ( - {withHeader && title ? ( + {Icon && title ? ( + + {title} {} + + ) : + withHeader && title ? ( {title} diff --git a/app/components/ui/custom-bar-chart.tsx b/app/components/ui/custom-bar-chart.tsx index dfc053d..7d09269 100644 --- a/app/components/ui/custom-bar-chart.tsx +++ b/app/components/ui/custom-bar-chart.tsx @@ -67,76 +67,56 @@ export function CustomBarChart({ return (
-
- {title && ( -

+ {title &&
+ +

{title}

- )} -
+

}
{data.map((item, index) => { const percentage = globalMaxValue > 0 ? (item.value / globalMaxValue) * 100 : 0; const displayValue: any = item.value; - return (
- {/* Label */} {item.label} - - {/* Bar Container */}
- {/* Add a subtle gradient effect for better visual appeal */} -
+
-
- - {/* Value Label */} {item.valuePrefix || ""} - {formatNumber(parseFloat(displayValue))}% + {formatNumber(parseFloat(displayValue))} {item.valueSuffix || ""} +
); })} {/* Axis Labels */} {showAxisLabels && globalMaxValue > 0 && ( -
- +
+
{formatNumber(0)} @@ -152,7 +132,7 @@ export function CustomBarChart({ {formatNumber(Math.round(globalMaxValue))}
- +
)}
diff --git a/app/routes/ecosystem.tsx b/app/routes/ecosystem.tsx index 866c5fa..5fda44b 100644 --- a/app/routes/ecosystem.tsx +++ b/app/routes/ecosystem.tsx @@ -76,8 +76,8 @@ export default function EcosystemPage() {
- - + + @@ -92,11 +92,10 @@ export default function EcosystemPage() { > - + معرفی {selectedCompany?.category} -
@@ -109,7 +108,7 @@ export default function EcosystemPage() { {selectedCompany?.label { // Hide image and show fallback on error e.currentTarget.style.display = "none"; @@ -147,7 +146,7 @@ export default function EcosystemPage() {
{selectedCompany?.description ? (
-

+

{selectedCompany.description}

@@ -159,22 +158,22 @@ export default function EcosystemPage() {
{/* Left Column - Company Fields */}
-

+

اطلاعات {selectedCompany?.category}

{selectedCompany?.fields && selectedCompany.fields.length > 0 ? ( -
+
{selectedCompany.fields.map((field, index) => (
- + {field.N}: - + {handleValue(field.V)} {field.U && ({field.U})}