diff --git a/frontend/src/components/dashboard/DailyControlPanel.tsx b/frontend/src/components/dashboard/DailyControlPanel.tsx new file mode 100644 index 0000000..a3f5a4b --- /dev/null +++ b/frontend/src/components/dashboard/DailyControlPanel.tsx @@ -0,0 +1,183 @@ +// ============================================================ +// ویجت ۴ — کنترل روزانه (امروز) +// کارت‌های KPI: بلیت‌های خروج امروز، برگشت از سایت، ضایعات و پذیرش +// + فهرست دقیق نام تحویل‌دهنده‌ها/انجام‌دهنده‌ها + ریز عملیات امروز. +// هر ۶۰ ثانیه خودکار تازه‌سازی می‌شود (useDailyControl). +// ============================================================ + +import { + ArrowDownToLine, + ArrowUpFromLine, + PackageOpen, + TriangleAlert, + Users, + type LucideIcon, +} from "lucide-react"; +import { useDailyControl } from "../../hooks/useDashboardData"; +import { faNum, fmtDateTime } from "../../lib/formatters"; +import { isoToJalaliStr } from "../../lib/jalali"; +import { cn } from "../../lib/utils"; +import { Badge } from "../ui/badge"; +import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared"; + +const TYPE_FA: Record = { + WITHDRAWAL: "برداشت از انبار", + RETURN: "برگشت از سایت", + DAMAGE: "آسیب‌دیده", + WASTE: "اتلاف", + LOSS: "مفقودی", + PURCHASE: "پذیرش خرید", + TRANSFER_IN: "انتقال ورودی", + TRANSFER_OUT: "انتقال خروجی", + FOUND: "پیدا شده", + STOCK_COUNT: "انبارگردانی", +}; + +const TYPE_STYLE: Record = { + WITHDRAWAL: "bg-blue-bg text-[#1d4ed8]", + RETURN: "bg-amber-bg text-amber", + DAMAGE: "bg-red-bg text-red", + WASTE: "bg-red-bg text-red", + LOSS: "bg-red-bg text-red", + PURCHASE: "bg-green-bg text-green", +}; + +function KpiCard({ + icon: Icon, + label, + tickets, + qty, + tone, +}: { + icon: LucideIcon; + label: string; + tickets: number; + qty: number; + tone: string; +}) { + return ( +
+ + + +
+
{label}
+
+ {faNum(tickets)} + بلیت + · + {faNum(qty.toLocaleString("en-US"))} +
+
+
+ ); +} + +export function DailyControlPanel() { + const query = useDailyControl(); + const d = query.data; + + return ( + + {query.isLoading ? ( + + ) : !d ? ( + + ) : ( +
+ {/* KPI کارت‌ها */} +
+ + + + +
+ +
+ {/* نام دقیق انجام‌دهنده‌ها */} +
+

+ + تحویل‌دهنده‌ها / انجام‌دهنده‌های امروز +

+ {d.performers.length === 0 ? ( +

امروز عملیاتی ثبت نشده است

+ ) : ( +
    + {d.performers.map((p, i) => ( +
  • + + {faNum(i + 1)} + + {p.name} + + {faNum(p.tickets)} بلیت · {faNum(p.qty.toLocaleString("en-US"))} + +
  • + ))} +
+ )} +
+ + {/* ریز عملیات امروز */} +
+

ریز عملیات امروز

+ {d.items.length === 0 ? ( +

هنوز جابه‌جایی‌ای امروز ثبت نشده است

+ ) : ( +
    + {d.items.map((it) => ( +
  • + + {TYPE_FA[it.transaction_type] ?? it.transaction_type} + + {it.material} + + {faNum(it.quantity.toLocaleString("en-US"))} {it.unit_code} + + + {fmtDateTime(it.transaction_date)} + +
  • + ))} +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/DashboardFilterBar.tsx b/frontend/src/components/dashboard/DashboardFilterBar.tsx new file mode 100644 index 0000000..338dd7d --- /dev/null +++ b/frontend/src/components/dashboard/DashboardFilterBar.tsx @@ -0,0 +1,178 @@ +// ============================================================ +// فیلتر سراسری «چه چیزی» — آبشار: دسته‌بندی → متریال → برند → مشخصات +// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب +// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند. وضعیت در Zustand +// نگه داشته می‌شود تا با جابه‌جایی بین تب انبار/بازرگانی از دست نرود. +// ============================================================ + +import { useMemo } from "react"; +import { Filter, X } from "lucide-react"; +import { Combobox } from "../Combobox"; +import { useDashboardFilterOptions } from "../../hooks/useDashboardData"; +import { useDashboardFilter } from "../../store/dashboardFilter"; +import { cn } from "../../lib/utils"; + +const CLEAR_ID = "__clear__"; + +export function DashboardFilterBar({ className }: { className?: string }) { + const filterQuery = useDashboardFilterOptions(); + const state = useDashboardFilter(); + + const categories = filterQuery.data?.categories ?? []; + + // متریال‌های سطح فعلی: اگر دسته انتخاب شده باشد فقط متریال‌های همان دسته + const materialOptions = useMemo(() => { + const cat = categories.find((c) => (c.id ?? "") === (state.categoryId ?? "")); + const list = cat ? cat.materials : categories.flatMap((c) => c.materials); + // حذف متریال تکراری وقتی «همه‌ی دسته‌ها» فعال است + const seen = new Set(); + return list.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true))); + }, [categories, state.categoryId]); + + const selectedMaterial = useMemo( + () => materialOptions.find((m) => m.id === state.materialId) ?? null, + [materialOptions, state.materialId], + ); + + const activeChips = [ + state.categoryLabel && { key: "category", label: state.categoryLabel }, + state.materialLabel && { key: "material", label: state.materialLabel }, + state.brandLabel && { key: "brand", label: state.brandLabel }, + state.specsLabel && { key: "specs", label: state.specsLabel }, + ].filter(Boolean) as { key: string; label: string }[]; + + return ( +
+
+ + + +
+

فیلتر جنس

+

دسته‌بندی ← متریال ← برند ← مشخصات فنی

+
+ {activeChips.length > 0 && ( + + )} +
+ +
+ {/* ۱ — دسته‌بندی */} + ({ id: c.id ?? CLEAR_ID, label: c.name })), + ]} + onChange={(item) => { + if (item.id === CLEAR_ID) { + state.setCategory(null, null); + } else if (item.id === state.categoryId) { + state.setCategory(null, null); // کلیک دوباره = پاک کردن + } else { + state.setCategory(item.id, item.label); + } + }} + /> + + {/* ۲ — متریال (وابسته به دسته) */} + ({ id: m.id, label: m.name })), + ]} + onChange={(item) => { + if (item.id === CLEAR_ID) { + state.setMaterial(null, null); + } else if (item.id === state.materialId) { + state.setMaterial(null, null); + } else { + state.setMaterial(item.id, item.label); + } + }} + /> + + {/* ۳ — برند (از موجودی واقعیِ همان متریال) */} + ({ id: b.id, label: b.name })), + ]} + onChange={(item) => { + if (item.id === CLEAR_ID) { + state.setBrand(null, null); + } else if (item.id === state.brandId) { + state.setBrand(null, null); + } else { + state.setBrand(item.id, item.label); + } + }} + /> + + {/* ۴ — مشخصات فنی (specs_hash از موجودی واقعی) */} + { + const parts = Object.entries(s.specs || {}); + const label = parts.length + ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") + : "بدون مشخصه (پیش‌فرض)"; + return { id: s.hash, label }; + }), + ]} + onChange={(item) => { + if (item.id === CLEAR_ID) { + state.setSpecs(null, null); + } else if (item.id === state.specsHash) { + state.setSpecs(null, null); + } else { + state.setSpecs(item.id, item.label); + } + }} + /> +
+ + {/* چیپ‌های فیلتر فعال — بازخورد فوری انتخاب‌ها */} + {activeChips.length > 0 && ( +
+ {activeChips.map((chip) => ( + + {chip.label} + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/DeadStockTable.tsx b/frontend/src/components/dashboard/DeadStockTable.tsx new file mode 100644 index 0000000..cf28559 --- /dev/null +++ b/frontend/src/components/dashboard/DeadStockTable.tsx @@ -0,0 +1,117 @@ +// ============================================================ +// ویجت ۲ — جدول موجودی راکد (سرمایه‌ی خوابیده) +// ردیف‌هایی با موجودی مثبت که هیچ تراکنشی در بازه‌ی رکود نداشته‌اند. +// آستانه‌ی ۶۰/۹۰ روز با چیپ انتخاب می‌شود؛ روزهای رکود رنگی است. +// ============================================================ + +import { useState } from "react"; +import { Snowflake } from "lucide-react"; +import { useDeadStock } from "../../hooks/useDashboardData"; +import { fmtNum, faNum } from "../../lib/formatters"; +import { isoToJalaliStr } from "../../lib/jalali"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { cn } from "../../lib/utils"; +import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared"; + +function specLabel(specs: Record): string { + const parts = Object.entries(specs || {}); + return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : ""; +} + +function idleSeverity(days: number | null): string { + if (days == null) return "text-muted"; + if (days >= 90) return "text-red"; + if (days >= 60) return "text-amber"; + return "text-muted"; +} + +export function DeadStockTable() { + const [idleDays, setIdleDays] = useState<60 | 90>(60); + const query = useDeadStock(idleDays); + const items = query.data?.items ?? []; + + return ( + + + موجودی راکد — سرمایه‌ی خوابیده + + } + sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازه‌ی انتخابی نداشته‌اند" + actions={ +
+ {([60, 90] as const).map((d) => ( + + ))} +
+ } + > + {query.isLoading ? ( + + ) : items.length === 0 ? ( + + ) : ( + + + + متریال و مشخصات + برند + انبار / پروژه + مقدار راکد + روزهای رکود + آخرین فعالیت + + + + {items.map((it) => { + const specs = specLabel(it.specs); + return ( + + +
{it.material}
+ {specs &&
{specs}
} +
+ {it.brand || "—"} + +
{it.warehouse}
+
{it.project}
+
+ + {fmtNum(it.quantity)}{" "} + + {it.unit?.name_fa || it.unit?.code} + + + + {it.idle_days == null ? "—" : faNum(it.idle_days)} + + + {it.last_activity ? isoToJalaliStr(it.last_activity) : "—"} + +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/DrilldownMatrix.tsx b/frontend/src/components/dashboard/DrilldownMatrix.tsx new file mode 100644 index 0000000..693d20e --- /dev/null +++ b/frontend/src/components/dashboard/DrilldownMatrix.tsx @@ -0,0 +1,169 @@ +// ============================================================ +// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار) +// نمودار ستونی تعاملی: کلیک روی هر ستون پروژه، به انبارهای همان +// پروژه رفته و مسیر (breadcrumb) به‌روز می‌شود — سبک Power BI. +// مقادیر هرگز بین واحدهای ناسازگار مخلوط نمی‌شوند: واحد فعال با +// چیپ‌های بالای نمودار انتخاب می‌شود. +// ============================================================ + +import { useMemo, useState } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { ChevronLeft } from "lucide-react"; +import { useDrilldown } from "../../hooks/useDashboardData"; +import { faNum } from "../../lib/formatters"; +import { + CHART_COLORS, + ChartCard, + ChartEmpty, + ChartLoading, + ChartTooltip, + UnitPicker, + faAxisTick, +} from "./chartShared"; + +interface Slice { + id: string | null; + name: string; + value: number; + rows: number; +} + +export function DrilldownMatrix() { + const query = useDrilldown(); + const [unit, setUnit] = useState(null); + const [project, setProject] = useState<{ id: string; name: string } | null>(null); + + const data = query.data; + + // واحد فعال: انتخاب کاربر یا بزرگ‌ترین مجموع (پایدار بین رندرها) + const activeUnit = useMemo(() => { + if (unit && data?.units?.[unit]) return unit; + const entries = Object.entries(data?.total?.qty_by_unit ?? {}); + if (!entries.length) return null; + return entries.sort((a, b) => b[1] - a[1])[0][0]; + }, [unit, data]); + + const slices: Slice[] = useMemo(() => { + if (!data) return []; + const pick = (q: Record) => (activeUnit ? q[activeUnit] ?? 0 : 0); + if (project) { + const p = data.projects.find((x) => x.id === project.id); + return (p?.warehouses ?? []).map((w) => ({ id: w.id, name: w.name, value: pick(w.qty_by_unit), rows: w.rows })); + } + return data.projects.map((p) => ({ id: p.id, name: p.name, value: pick(p.qty_by_unit), rows: p.rows })); + }, [data, project, activeUnit]); + + const unitLabel = activeUnit ? data?.units?.[activeUnit] || activeUnit : ""; + const levelTotal = slices.reduce((s, x) => s + x.value, 0); + + return ( + : null + } + > + {/* مسیر Drill-Down */} + + + {query.isLoading ? ( + + ) : !slices.length || levelTotal === 0 ? ( + + ) : ( +
+ + + + 4 ? -18 : 0} + textAnchor={slices.length > 4 ? "end" : "middle"} + /> + + String(l)} + valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} ${unitLabel}`} + /> + } + /> + { + // Recharts ورودی ستون را در آرگومان اول می‌دهد + const payload = (entry as { payload?: Slice })?.payload; + if (!project && payload?.id) { + setProject({ id: payload.id, name: payload.name }); + } + }} + className={project ? "" : "cursor-pointer"} + > + {slices.map((s) => ( + + ))} + + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/FlowChart.tsx b/frontend/src/components/dashboard/FlowChart.tsx new file mode 100644 index 0000000..4a67da7 --- /dev/null +++ b/frontend/src/components/dashboard/FlowChart.tsx @@ -0,0 +1,129 @@ +// ============================================================ +// ویجت ۳ — سری زمانی جریان روزانه +// ستون‌های روزانه: ورود (سبز) / برگشت (کهربایی) / خروج که لایه‌ی +// قرمزِ «ضایعات، اتلاف، مفقودی» داخل همان ستون خروج سته — +// به‌علاوه‌ی خط «مانده» (بالانس تجمعی). تاریخ‌ها شمسی، اعداد فارسی. +// ============================================================ + +import { useMemo } from "react"; +import { + Bar, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { useDailyFlow } from "../../hooks/useDashboardData"; +import { isoToJalaliStr } from "../../lib/jalali"; +import { + CHART_COLORS, + ChartCard, + ChartEmpty, + ChartLoading, + ChartTooltip, + faAxisTick, +} from "./chartShared"; + +interface Row { + date: string; + ورود: number; + برگشت: number; + "خروج سایت": number; + "ضایعات و اتلاف": number; + مانده: number; + _total: number; +} + +export function FlowChart() { + const query = useDailyFlow(); + + const rows: Row[] = useMemo( + () => + (query.data?.days ?? []).map((d) => ({ + date: d.date, + ورود: d.inbound, + برگشت: d.returns, + "خروج سایت": Math.max(0, d.outbound - d.waste), + "ضایعات و اتلاف": d.waste, + مانده: d.balance, + _total: d.inbound + d.outbound + d.returns, + })), + [query.data], + ); + + const hasAnyData = rows.some((r) => r._total > 0); + + return ( + + {query.isLoading ? ( + + ) : !hasAnyData ? ( + + ) : ( +
+ + + + isoToJalaliStr(v) ?? v} + tick={{ fontSize: 10.5, fill: "#64748b", fontFamily: "Vazirmatn" }} + tickLine={false} + axisLine={{ stroke: "#e5e9f0" }} + minTickGap={18} + /> + + isoToJalaliStr(String(l)) ?? String(l)} + /> + } + /> + ( + + {String(value)} + + )} + /> + {/* ستون‌های کنار هم: ورود و برگشت */} + + + {/* ستون خروج: دو لایه‌ی سته — بخش قرمز ضایعات داخل ستون خروج */} + + + + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/InTransitGrid.tsx b/frontend/src/components/dashboard/InTransitGrid.tsx new file mode 100644 index 0000000..db49b6b --- /dev/null +++ b/frontend/src/components/dashboard/InTransitGrid.tsx @@ -0,0 +1,125 @@ +// ============================================================ +// ویجت ۶ — شبکه‌ی وضعیت سفارش‌های در راه +// جدول کمی سفارش‌های باز: متریال و تأمین‌کننده | سفارش‌شده | رسیده +// | وضعیت (در راه / تحویل جزئی / تحویل کامل) + نوار پیشرفت. +// داده از /pm/on-the-way (با برچسب برند و مشخصات و فیلتر سراسری). +// ============================================================ + +import { Truck } from "lucide-react"; +import { useInTransit } from "../../hooks/useDashboardData"; +import type { InTransitRow } from "../../lib/types"; +import { faNum } from "../../lib/formatters"; +import { cn } from "../../lib/utils"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { Badge } from "../ui/badge"; +import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared"; + +const STATE_META: Record< + InTransitRow["state"], + { label: string; cls: string; bar: string } +> = { + on_the_way: { label: "در راه", cls: "bg-amber-bg text-amber", bar: "bg-amber" }, + partially_received: { label: "تحویل جزئی", cls: "bg-blue-bg text-[#1d4ed8]", bar: "bg-[#2563eb]" }, + arrived: { label: "تحویل کامل", cls: "bg-green-bg text-green", bar: "bg-green" }, +}; + +function specLabel(specs: Record): string { + const parts = Object.entries(specs || {}); + return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : ""; +} + +function num(v: string): number { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +export function InTransitGrid() { + const query = useInTransit(); + const rows = query.data ?? []; + + return ( + + + سفارش‌های در راه — وضعیت تحویل + + } + sub="اقلام پذیرفته‌شده‌ی خرید تا رسیدن کامل به انبار" + > + {query.isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + + + متریال / تأمین‌کننده + سفارش‌شده + رسیده تاکنون + پیشرفت + وضعیت + + + + {rows.map((r) => { + const ordered = num(r.ordered_qty); + const received = num(r.received_qty); + const pct = ordered > 0 ? Math.min(100, Math.round((received / ordered) * 100)) : 0; + const meta = STATE_META[r.state] ?? STATE_META.on_the_way; + const specs = specLabel(r.specs); + return ( + + +
{r.material.name}
+
+ {r.supplier.name} + {r.brand ? ` · ${r.brand.name}` : ""} +
+ {specs &&
{specs}
} + {r.project && ( +
{r.project.name}
+ )} +
+ + {faNum(ordered.toLocaleString("en-US"))} +
+ {r.unit?.name_fa || r.unit?.code} +
+
+ + {faNum(received.toLocaleString("en-US"))} + + +
+
+
+
+ + {faNum(pct)}٪ + +
+ + + {meta.label} + + + ); + })} + +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/LeadTimeKpiCard.tsx b/frontend/src/components/dashboard/LeadTimeKpiCard.tsx new file mode 100644 index 0000000..d12ed16 --- /dev/null +++ b/frontend/src/components/dashboard/LeadTimeKpiCard.tsx @@ -0,0 +1,42 @@ +// ============================================================ +// ویجت ۵ — KPI زمان تأمین (Lead Time) +// میانگین روزهای «پذیرش رسمی سفارش → رسیدن فیزیکی به انبار» +// از /quotes/stats (میانگین arrived_date − accepted_date). +// ============================================================ + +import { Timer } from "lucide-react"; +import { useQuoteStats } from "../../hooks/useDashboardData"; +import { faNum } from "../../lib/formatters"; +import { ChartCard, ChartLoading } from "./chartShared"; + +export function LeadTimeKpiCard() { + const query = useQuoteStats(); + const days = query.data?.average_lead_time_days; + + return ( + + {query.isLoading ? ( + + ) : ( +
+ + + +
+
+ + {days == null ? "—" : faNum(days)} + + روز +
+

+ {days == null + ? "هنوز سفارشی به مرحله‌ی رسیدن نرسیده است" + : `بر پایه‌ی سفارش‌های رسیده‌شده به انبار`} +

+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/ProcurementFunnel.tsx b/frontend/src/components/dashboard/ProcurementFunnel.tsx new file mode 100644 index 0000000..15d7a1a --- /dev/null +++ b/frontend/src/components/dashboard/ProcurementFunnel.tsx @@ -0,0 +1,124 @@ +// ============================================================ +// ویجت ۷ — قیف تدارکات +// تبدیل تجمعی: کل درخواست‌ها (پیشنهادشده) → پذیرفته‌شده → رسیده به انبار +// از /quotes/stats.funnel (شمارش تجمعی بر پایه‌ی تاریخ‌های واقعی). +// ============================================================ + +import { useMemo } from "react"; +import { Funnel, FunnelChart, LabelList, ResponsiveContainer, Tooltip } from "recharts"; +import { useQuoteStats } from "../../hooks/useDashboardData"; +import { faNum } from "../../lib/formatters"; +import { CHART_COLORS, ChartCard, ChartEmpty, ChartLoading, ChartTooltip } from "./chartShared"; + +interface Step { + name: string; + value: number; + fill: string; + conversion: number | null; +} + +export function ProcurementFunnel() { + const query = useQuoteStats(); + + const steps: Step[] = useMemo(() => { + const f = query.data?.funnel; + if (!f) return []; + const offered = f.offered_total; + return [ + { + name: "کل درخواست‌ها (پیشنهادشده)", + value: f.offered_total, + fill: CHART_COLORS.accent, + conversion: null, + }, + { + name: "پذیرفته‌شده", + value: f.accepted_total, + fill: CHART_COLORS.blue, + conversion: offered > 0 ? Math.round((f.accepted_total / offered) * 100) : 0, + }, + { + name: "رسیده به انبار", + value: f.arrived_total, + fill: CHART_COLORS.green, + conversion: offered > 0 ? Math.round((f.arrived_total / offered) * 100) : 0, + }, + ]; + }, [query.data]); + + const total = steps[0]?.value ?? 0; + + return ( + + {query.isLoading ? ( + + ) : total === 0 ? ( + + ) : ( +
+
+ + + String(l)} + valueFormatter={(v) => `${faNum(v.toLocaleString("en-US"))} درخواست`} + /> + } + /> + + {/* مقدار هر مرحله — با dataKey صریح تا Recharts مقدار درست بدهد */} + { + const n = Number(v); + return Number.isFinite(n) ? faNum(n.toLocaleString("en-US")) : ""; + }} + /> + {/* نام مرحله داخل نوار */} + (v == null ? "" : String(v))} + /> + + + +
+ + {/* مراحل قیف + درصد تبدیل */} +
    + {steps.map((s) => ( +
  • +
    + + {s.name} + + {faNum(s.value.toLocaleString("en-US"))} + +
    + {s.conversion != null && ( +
    + {faNum(s.conversion)}٪ از کل درخواست‌ها +
    + )} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/ProjectDensityChart.tsx b/frontend/src/components/dashboard/ProjectDensityChart.tsx new file mode 100644 index 0000000..e75d977 --- /dev/null +++ b/frontend/src/components/dashboard/ProjectDensityChart.tsx @@ -0,0 +1,122 @@ +// ============================================================ +// ویجت ۸ — تراکم پروژه‌ها (Project Density) +// نمودار میله‌ای افقی: توزیع بار تدارکات روی پروژه‌های فعال — +// سفارش‌های باز (سته‌شده با پیشنهادهای باز). +// ============================================================ + +import { useMemo } from "react"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { useProcurementWorkload } from "../../hooks/useDashboardData"; +import { faNum } from "../../lib/formatters"; +import { + CHART_COLORS, + ChartCard, + ChartEmpty, + ChartLoading, + ChartTooltip, + faAxisTick, +} from "./chartShared"; + +export function ProjectDensityChart() { + const query = useProcurementWorkload(); + + const rows = useMemo(() => { + const projects = query.data?.projects ?? []; + return projects + .filter((p) => p.open_orders > 0 || p.offered_quotes > 0 || p.chosen_items > 0) + .map((p) => ({ + name: p.name, + "سفارش باز (در راه)": p.open_orders, + "پیشنهاد باز": p.offered_quotes, + _ordered: p.ordered_qty, + _received: p.received_qty, + })) + .sort((a, b) => (b["سفارش باز (در راه)"] + b["پیشنهاد باز"]) - (a["سفارش باز (در راه)"] + a["پیشنهاد باز"])); + }, [query.data]); + + return ( + + {query.isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( +
+ + + + + + String(l)} + valueFormatter={(v) => `${faNum(v)} مورد`} + /> + } + /> + ( + + {String(value)} + + )} + /> + + + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/dashboard/chartShared.tsx b/frontend/src/components/dashboard/chartShared.tsx new file mode 100644 index 0000000..fbdfb84 --- /dev/null +++ b/frontend/src/components/dashboard/chartShared.tsx @@ -0,0 +1,170 @@ +// ============================================================ +// ابزار مشترک نمودارها — اعداد فارسی، تاریخ شمسی، تولتیپ RTL +// همه‌ی نمودارهای داشبورد از این قالب استفاده می‌کنند تا خروجی +// Recharts فارسی و هماهنگ با دیزاین‌سیستم (Tailwind @theme) باشد. +// ============================================================ + +import type { ReactNode } from "react"; +import { groupCommas } from "../../lib/numutil"; +import { isoToJalaliStr } from "../../lib/jalali"; +import { faNum } from "../../lib/formatters"; +import { cn } from "../../lib/utils"; + +// پالت معنایی نمودارها — هم‌راستا با توکن‌های @theme در globals.css +export const CHART_COLORS = { + inbound: "#16a34a", // ورود = سبز + outbound: "#2563eb", // خروج = آبی + waste: "#dc2626", // ضایعات/اتلاف/مفقودی = قرمز (لایه‌ی داخل ستون خروج) + returns: "#d97706", // برگشت از سایت = کهربایی + balance: "#0f172a", // مانده = خط سرمه‌ای نقطه‌چین + accent: "#4f46e5", // ایندیگو — رنگ اصلی سیستم + amber: "#d97706", + green: "#16a34a", + red: "#dc2626", + blue: "#2563eb", +} as const; + +// عدد → رقم فارسی با جداکننده‌ی هزارگان (تیک محورها) +export function faAxisTick(value: unknown): string { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return ""; + if (Number.isInteger(n) && Math.abs(n) < 1000) return faNum(n); + return groupCommas(n, true); +} + +// ISO (YYYY-MM-DD) → تاریخ شمسی کوتاه برای محور X +export function jaAxisDate(iso: unknown): string { + const j = isoToJalaliStr(typeof iso === "string" ? iso : String(iso ?? "")); + return j || String(iso ?? ""); +} + +// ——— تولتیپ سفارشی RTL — اعداد فارسی و نام‌های فارسی سری‌ها ——— +interface TooltipEntry { + name?: string | number; + value?: string | number; + color?: string; + dataKey?: string | number; + payload?: Record; +} + +interface TooltipProps { + active?: boolean; + payload?: TooltipEntry[]; + label?: string | number; + labelFormatter?: (label: string | number) => ReactNode; + valueFormatter?: (value: number, name: string) => string; +} + +export function ChartTooltip({ active, payload, label, labelFormatter, valueFormatter }: TooltipProps) { + if (!active || !payload || payload.length === 0) return null; + // ردیف‌های صفر را از تولتیپ حذف می‌کنیم — شلوغیِ بی‌معنا (اصل Power BI) + const rows = payload.filter((p) => { + const v = typeof p.value === "number" ? p.value : Number(p.value); + return Number.isFinite(v) && v !== 0; + }); + return ( +
+ {label !== undefined && label !== "" && ( +
+ {labelFormatter ? labelFormatter(label) : String(label)} +
+ )} +
+ {rows.map((p, i) => { + const v = typeof p.value === "number" ? p.value : Number(p.value); + const name = String(p.name ?? p.dataKey ?? ""); + return ( +
+ + {name} + + {valueFormatter ? valueFormatter(v, name) : groupCommas(v, true)} + +
+ ); + })} +
+
+ ); +} + +// ——— کارت نگه‌دارنده‌ی ویجت — عنوان + توضیح + اکشن ——— +export function ChartCard({ + title, + sub, + actions, + children, + className, +}: { + title: ReactNode; + sub?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; +}) { + return ( +
+
+
+

{title}

+ {sub &&

{sub}

} +
+ {actions &&
{actions}
} +
+ {children} +
+ ); +} + +// ——— حالت خالی/خطای ویجت ——— +export function ChartEmpty({ message = "داده‌ای برای نمایش وجود ندارد" }: { message?: string }) { + return ( +
+ {message} +
+ ); +} + +export function ChartLoading() { + return ( +
+ در حال بارگذاری... +
+ ); +} + +// ——— چیپ انتخاب واحد (وقتی مجموعه‌ای با واحدهای ناسازگار نمایش داده می‌شود) ——— +export function UnitPicker({ + units, + value, + onChange, +}: { + units: Record; + value: string; + onChange: (code: string) => void; +}) { + const codes = Object.keys(units); + if (codes.length <= 1) return null; + return ( +
+ {codes.map((code) => ( + + ))} +
+ ); +} diff --git a/frontend/src/hooks/useDashboardData.ts b/frontend/src/hooks/useDashboardData.ts new file mode 100644 index 0000000..86a395f --- /dev/null +++ b/frontend/src/hooks/useDashboardData.ts @@ -0,0 +1,90 @@ +// ============================================================ +// هوک‌های TanStack Query برای داشبورد MVP +// همه‌ی ویجت‌ها مستقیم به API واقعی وصل‌اند — بدون هیچ داده‌ی ساختگی. +// کلیدهای کوئری شامل پارامترهای فیلتر «چه چیزی» هستند تا با هر +// تغییر فیلتر، فقط ویجت‌های همان تب دوباره خوانده شوند. +// ============================================================ + +import { useQuery } from "@tanstack/react-query"; +import { apiV26 } from "../lib/api"; +import type { DashboardFilterParams } from "../lib/api"; +import { dashboardFilterParams, useDashboardFilter } from "../store/dashboardFilter"; + +function useFilterParams(): DashboardFilterParams { + const state = useDashboardFilter(); + return dashboardFilterParams(state); +} + +// گزینه‌های آبشاری فیلتر سراسری (دسته → متریال → برند → مشخصات) +export function useDashboardFilterOptions() { + return useQuery({ + queryKey: ["dashboard", "filters"], + queryFn: () => apiV26.dashboard.filters(), + staleTime: 5 * 60 * 1000, + }); +} + +// ویجت ۱ — ماتریس Drill-Down (کل سازمان → پروژه → انبار) +export function useDrilldown() { + const params = useFilterParams(); + return useQuery({ + queryKey: ["dashboard", "drilldown", params], + queryFn: () => apiV26.dashboard.drilldown(params), + }); +} + +// ویجت ۲ — موجودی راکد (dead stock) +export function useDeadStock(idleDays: number) { + const params = useFilterParams(); + return useQuery({ + queryKey: ["dashboard", "dead-stock", idleDays, params], + queryFn: () => apiV26.dashboard.deadStock({ ...params, idle_days: idleDays }), + }); +} + +// ویجت ۳ — سری زمانی جریان روزانه +export function useDailyFlow(dateFrom?: string, dateTo?: string) { + const params = useFilterParams(); + return useQuery({ + queryKey: ["dashboard", "flow", dateFrom, dateTo, params], + queryFn: () => apiV26.dashboard.flow({ ...params, date_from: dateFrom, date_to: dateTo }), + }); +} + +// ویجت ۴ — کنترل روزانه (امروز) — هر ۶۰ ثانیه تازه‌سازی می‌شود +export function useDailyControl(date?: string) { + return useQuery({ + queryKey: ["dashboard", "daily-control", date], + queryFn: () => apiV26.dashboard.dailyControl(date ? { date } : {}), + refetchInterval: 60 * 1000, + }); +} + +// ویجت ۵ + ۷ — KPI زمان تأمین و قیف تدارکات (/quotes/stats) +export function useQuoteStats() { + return useQuery({ + queryKey: ["dashboard", "quote-stats"], + queryFn: () => apiV26.dashboard.quoteStats(), + staleTime: 60 * 1000, + }); +} + +// ویجت ۶ — کالاهای در راه (با فیلتر متریال/برند سراسری) +export function useInTransit() { + const state = useDashboardFilter(); + const params = dashboardFilterParams(state); + return useQuery({ + queryKey: ["dashboard", "in-transit", params], + queryFn: () => + apiV26.pm.onTheWay(null, { material_id: params.material_id, brand_id: params.brand_id }), + }); +} + +// ویجت ۸ — بار تدارکات روی پروژه‌ها +export function useProcurementWorkload() { + return useQuery({ + queryKey: ["dashboard", "workload"], + queryFn: () => apiV26.dashboard.workload(), + staleTime: 60 * 1000, + }); +} diff --git a/frontend/src/pages/Dashboard/MvpDashboardPage.tsx b/frontend/src/pages/Dashboard/MvpDashboardPage.tsx new file mode 100644 index 0000000..ac6601f --- /dev/null +++ b/frontend/src/pages/Dashboard/MvpDashboardPage.tsx @@ -0,0 +1,99 @@ +// ============================================================ +// داشبورد MVP — نمای «انبار» و نمای «بازرگانی و لجستیک» +// فیلتر سراسری «چه چیزی» بالای هر دو نما (Zustand — با جابه‌جایی +// بین تب‌ها حفظ می‌شود). تمام ویجت‌ها مستقیم به API واقعی وصل‌اند. +// ============================================================ + +import { useState } from "react"; +import { BarChart3, Warehouse } from "lucide-react"; +import { Header, PageLayout } from "../../components/layout/Header"; +import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; +import { DashboardFilterBar } from "../../components/dashboard/DashboardFilterBar"; +import { DrilldownMatrix } from "../../components/dashboard/DrilldownMatrix"; +import { FlowChart } from "../../components/dashboard/FlowChart"; +import { DeadStockTable } from "../../components/dashboard/DeadStockTable"; +import { DailyControlPanel } from "../../components/dashboard/DailyControlPanel"; +import { LeadTimeKpiCard } from "../../components/dashboard/LeadTimeKpiCard"; +import { InTransitGrid } from "../../components/dashboard/InTransitGrid"; +import { ProcurementFunnel } from "../../components/dashboard/ProcurementFunnel"; +import { ProjectDensityChart } from "../../components/dashboard/ProjectDensityChart"; +import { cn } from "../../lib/utils"; + +type Tab = "warehouse" | "procurement"; + +const TABS: { key: Tab; label: string; icon: typeof Warehouse }[] = [ + { key: "warehouse", label: "داشبورد انبار", icon: Warehouse }, + { key: "procurement", label: "داشبورد بازرگانی و لجستیک", icon: BarChart3 }, +]; + +export function MvpDashboardPage() { + const [tab, setTab] = useState("warehouse"); + + return ( +
+
} + actions={ + /* تب‌های نما — سگمنت‌کنترل هماهنگ با دیزاین‌سیستم */ +
+ {TABS.map(({ key, label, icon: Icon }) => ( + + ))} +
+ } + /> + + + {/* فیلتر سراسری «چه چیزی» — مشترک بین دو نما */} + + + {tab === "warehouse" ? ( + /* ——— نمای A: انبار ——— */ +
+ {/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */} + + {/* ردیف دوم: سری زمانی + کنترل روزانه */} +
+ + +
+ {/* ردیف سوم: موجودی راکد */} + +
+ ) : ( + /* ——— نمای B: بازرگانی و لجستیک ——— */ +
+ {/* KPI + قیف */} +
+ +
+ +
+
+ {/* تراکم پروژه‌ها + سفارش‌های در راه */} +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/store/dashboardFilter.ts b/frontend/src/store/dashboardFilter.ts new file mode 100644 index 0000000..754caee --- /dev/null +++ b/frontend/src/store/dashboardFilter.ts @@ -0,0 +1,80 @@ +// ============================================================ +// Zustand store — فیلتر سراسری «چه چیزی» داشبورد +// دسته‌بندی → متریال → برند → مشخصات فنی +// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود. +// ============================================================ + +import { create } from "zustand"; + +export interface DashboardFilterState { + categoryId: string | null; // null = همه‌ی دسته‌ها + materialId: string | null; + brandId: string | null; + specsHash: string | null; + // برچسب‌های انتخاب‌شده برای نمایش چیپ فعال + categoryLabel: string | null; + materialLabel: string | null; + brandLabel: string | null; + specsLabel: string | null; + + setCategory: (id: string | null, label: string | null) => void; + setMaterial: (id: string | null, label: string | null) => void; + setBrand: (id: string | null, label: string | null) => void; + setSpecs: (hash: string | null, label: string | null) => void; + reset: () => void; +} + +const INITIAL = { + categoryId: null, + materialId: null, + brandId: null, + specsHash: null, + categoryLabel: null, + materialLabel: null, + brandLabel: null, + specsLabel: null, +}; + +export const useDashboardFilter = create((set) => ({ + ...INITIAL, + // آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند + setCategory: (id, label) => + set({ + categoryId: id, + categoryLabel: label, + materialId: null, + materialLabel: null, + brandId: null, + brandLabel: null, + specsHash: null, + specsLabel: null, + }), + setMaterial: (id, label) => + set({ + materialId: id, + materialLabel: label, + brandId: null, + brandLabel: null, + specsHash: null, + specsLabel: null, + }), + setBrand: (id, label) => + set({ + brandId: id, + brandLabel: label, + specsHash: null, + specsLabel: null, + }), + setSpecs: (hash, label) => set({ specsHash: hash, specsLabel: label }), + reset: () => set({ ...INITIAL }), +})); + +// پارامترهای آماده برای endpointهای داشبورد +export function dashboardFilterParams(s: DashboardFilterState) { + return { + category_id: s.categoryId, + material_id: s.materialId, + brand_id: s.brandId, + specs_hash: s.specsHash, + }; +}