update dashboard

This commit is contained in:
fatemeh sane
2026-10-21 16:04:07 +03:30
parent 41f93acec5
commit 8b2970cdb5
12 changed files with 1621 additions and 450 deletions
@@ -1,176 +1,597 @@
// ============================================================
// ویجت ۴ — کنترل روزانه (امروز)
// کارت‌های KPI: بلیت‌های خروج امروز، برگشت از سایت، ضایعات و پذیرش
// + فهرست دقیق نام تحویل‌دهنده‌ها/انجام‌دهنده‌ها + ریز عملیات امروز.
// ویجت ۴ — کنترل روزانه (عملیات امروز)
// • کارت‌های KPI: «حواله» (خروج) / «برگشتی» (برگشت) / «ثبت» (ضایعات) /
// «رسید» (پذیرش خرید). کمیت‌ها هرگز بدون واحد جمع نمی‌شوند: با یک واحد،
// «۱۲۲ عدد» و با چند واحد «چندواحدی (۳ قلم کالا)» نمایش داده می‌شود.
// • کارت‌ها و اشخاص، فیلتر فید «ریز عملیات امروز» را روشن/خاموش می‌کنند.
// • هر ردیف فید: نام کامل متریال + مشخصات، کمیت علامت‌دار با واحد فارسی،
// و متادیتای عملیاتی (انبار/برند/ثبت‌کننده/تحویل‌گیرنده) + ساعت تهران.
// • هیچ متن کاربردی بریده نمی‌شود: برش/خط‌چین/عرض محدود در کارت‌ها نیست؛
// نام‌ها و متادیتا کامل و چندخطی دیده می‌شوند.
// هر ۶۰ ثانیه خودکار تازه‌سازی می‌شود (useDailyControl).
// ============================================================
import { useMemo, useState } from "react";
import {
ArrowDownToLine,
ArrowUpFromLine,
PackageOpen,
TriangleAlert,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { useDailyControl } from "../../hooks/useDashboardData";
import { faNum, fmtDateTime, fmtNum } from "../../lib/formatters";
import { faNum, fmtNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
import { cn } from "../../lib/utils";
import { Badge } from "../ui/badge";
import { ChartCard, ChartEmpty, ChartLoading } from "./chartShared";
import type {
DailyControlCategoryStats,
DailyControlData,
DailyControlItem,
DailyControlPerson,
DailyControlPersonRole,
} from "../../lib/types";
const TYPE_FA: Record<string, string> = {
WITHDRAWAL: "برداشت از انبار",
RETURN: "برگشت از سایت",
DAMAGE: "آسیب‌دیده",
WASTE: "اتلاف",
LOSS: "مفقودی",
PURCHASE: "پذیرش خرید",
TRANSFER_IN: "انتقال ورودی",
TRANSFER_OUT: "انتقال خروجی",
FOUND: "پیدا شده",
STOCK_COUNT: "انبارگردانی",
// دسته‌های عملیاتی امروز — عنوان، واحد شمارش فارسی، آیکون و رنگ‌بندی
type OpsCategory = "outbound" | "returns" | "waste" | "purchases";
const OPS_CATEGORY_ORDER: OpsCategory[] = ["outbound", "returns", "waste", "purchases"];
const CATEGORY_META: Record<
OpsCategory,
{ label: string; countNoun: string; icon: LucideIcon; tone: string }
> = {
outbound: {
label: "خروج به سایت",
countNoun: "حواله",
icon: ArrowUpFromLine,
tone: "bg-blue-bg text-blue-700",
},
returns: {
label: "برگشت از سایت",
countNoun: "برگشتی",
icon: ArrowDownToLine,
tone: "bg-amber-bg text-amber",
},
waste: {
label: "ضایعات / اتلاف / مفقودی",
countNoun: "ثبت",
icon: TriangleAlert,
tone: "bg-red-bg text-red",
},
purchases: {
label: "پذیرش خرید (ورود)",
countNoun: "رسید",
icon: PackageOpen,
tone: "bg-green-bg text-green",
},
};
const TYPE_STYLE: Record<string, string> = {
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",
// نوع تراکنش → برچسب نشان و دسته‌ی عملیاتی
const TYPE_META: Record<string, { label: string; category: OpsCategory }> = {
WITHDRAWAL: { label: "برداشت از انبار", category: "outbound" },
RETURN: { label: "برگشت از سایت", category: "returns" },
DAMAGE: { label: "آسیب‌دیده", category: "waste" },
WASTE: { label: "اتلاف", category: "waste" },
LOSS: { label: "مفقودی", category: "waste" },
PURCHASE: { label: "پذیرش خرید", category: "purchases" },
};
const PERSON_ROLE_META: Record<DailyControlPersonRole, { label: string; tone: string }> = {
performer: { label: "ثبت‌کننده انبار", tone: "bg-blue-bg text-blue-700" },
recipient: { label: "تحویل‌گیرنده کارگاه", tone: "bg-amber-bg text-amber" },
};
// اسکرول‌بار نازک و ظریف برای فهرست‌های داخلی (کلاس‌ها به‌صورت literal)
const SCROLLBAR_CLASSES =
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300/70";
const LABELS = {
peopleTitle: "اشخاص درگیر در عملیات امروز",
peopleSub: "(ثبت‌کننده / تحویل‌گیرنده)",
feedTitle: "ریز عملیات امروز",
warehouse: "انبار",
brand: "برند",
performer: "ثبت‌کننده",
recipient: "تحویل‌گیرنده",
transaction: "تراکنش",
noActivity: "ثبتی نشده",
multiUnit: "چندواحدی",
itemUnit: "قلم کالا",
noPeople: "امروز عملیاتی ثبت نشده است",
emptyFeed: "هنوز جابه‌جایی‌ای امروز ثبت نشده است",
emptyFiltered: "عملیاتی با این فیلتر امروز ثبت نشده است",
noSpecs: "بدون مشخصه",
} as const;
// برچسب مشخصات فنی: «کلید: مقدار، …» یا خالی
function specLabel(specs: Record<string, string>): string {
const parts = Object.entries(specs ?? {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "";
}
// کمیت با علامت (+/−) و ارقام فارسی
const signedQty = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${fmtNum(Math.abs(v))}`;
// فقط ساعت تهران (ارقام فارسی) — تاریخ در کل ویجت امروز است و تکرار نمی‌شود
function timeOnly(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "";
const text = new Intl.DateTimeFormat("en-GB", {
timeZone: "Asia/Tehran",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(date);
return faNum(text);
}
// متریک دوم کارت: تک‌واحدی → «۱۲۲ عدد» / چندواحدی → «چندواحدی (۳ قلم کالا)»
function secondaryMetric(stats: DailyControlCategoryStats): string {
if (!stats || stats.tickets === 0) return LABELS.noActivity;
const units = stats.units ?? [];
if (units.length === 1) {
const unit = units[0];
return `${fmtNum(unit.qty)} ${unit.name_fa ?? unit.code}`;
}
return `${LABELS.multiUnit} (${faNum(stats.items)} ${LABELS.itemUnit})`;
}
// تفکیک فعالیت هر شخص: «۲ حواله · ۱ رسید»
function personBreakdown(person: DailyControlPerson): string {
return OPS_CATEGORY_ORDER.filter((category) => person.categories[category] > 0)
.map((category) => `${faNum(person.categories[category])} ${CATEGORY_META[category].countNoun}`)
.join(" · ");
}
// ——— نرمال‌سازی payload ———
// هر آرایه/عدد/متن با پیش‌فرض امن ساخته می‌شود تا هیچ مصرف‌کننده‌ای هرگز
// `.length` یا `.map` را روی undefined اجرا نکند (پاسخ خالی/قدیمی/ناقص هم امن است).
const toNumber = (value: unknown): number => {
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : 0;
};
const toArray = <T,>(value: unknown): T[] => (Array.isArray(value) ? (value as T[]) : []);
const toText = (value: unknown): string => (typeof value === "string" ? value : "");
const toNullableText = (value: unknown): string | null =>
typeof value === "string" ? value : null;
const EMPTY_PERSON_CATEGORIES: DailyControlPerson["categories"] = {
outbound: 0,
returns: 0,
waste: 0,
purchases: 0,
};
function normalizeUnits(raw: unknown): DailyControlCategoryStats["units"] {
return toArray<Partial<DailyControlCategoryStats["units"][number]>>(raw).map((unit) => ({
code: toText(unit?.code),
name_fa: toNullableText(unit?.name_fa),
qty: toNumber(unit?.qty),
}));
}
function normalizeStats(raw: unknown): DailyControlCategoryStats {
const stats = (raw ?? {}) as Partial<DailyControlCategoryStats>;
return {
tickets: toNumber(stats.tickets),
items: toNumber(stats.items),
qty: toNumber(stats.qty),
// همیشه آرایه — منبع کرش قبلی وقتی API فیلد units را نداشت
units: normalizeUnits(stats.units),
};
}
function normalizePerson(raw: unknown): DailyControlPerson | null {
const person = (raw ?? {}) as Partial<DailyControlPerson>;
const name = toText(person.name).trim();
if (!name) return null;
const categories = (person.categories ?? EMPTY_PERSON_CATEGORIES) as Partial<
DailyControlPerson["categories"]
>;
return {
name,
role: person.role === "recipient" ? "recipient" : "performer",
tickets: toNumber(person.tickets),
categories: {
outbound: toNumber(categories.outbound),
returns: toNumber(categories.returns),
waste: toNumber(categories.waste),
purchases: toNumber(categories.purchases),
},
};
}
function normalizeItem(raw: unknown): DailyControlItem | null {
const item = (raw ?? {}) as Partial<DailyControlItem>;
const id = toText(item.id);
if (!id) return null; // ردیف بدون شناسه قابل رندر نیست
const specs =
item.specs && typeof item.specs === "object" && !Array.isArray(item.specs)
? (item.specs as Record<string, string>)
: {};
return {
id,
transaction_type: toText(item.transaction_type),
quantity: toNumber(item.quantity),
unit_code: toText(item.unit_code),
unit_name_fa: toNullableText(item.unit_name_fa),
performed_by: toNullableText(item.performed_by),
delivered_to: toNullableText(item.delivered_to),
brand: toNullableText(item.brand),
note: toNullableText(item.note),
warehouse: toText(item.warehouse),
material: toText(item.material),
specs,
transaction_date: toText(item.transaction_date),
};
}
interface DailyControlView {
date: string;
categories: Record<OpsCategory, DailyControlCategoryStats>;
people: DailyControlPerson[];
items: DailyControlItem[];
}
// پاسخ API (نسخه‌ی جدید یا قدیمی) → مدل نمایشیِ کاملاً تضمین‌شده
export function normalizeDailyControl(raw: unknown): DailyControlView {
const data = (raw ?? {}) as Partial<DailyControlData> & { performers?: unknown };
let people = toArray<unknown>(data.people)
.map(normalizePerson)
.filter((person): person is DailyControlPerson => person !== null);
// سازگاری با payload قدیمی که فقط performers دارد (بدون نقش/تفکیک دسته)
if (people.length === 0) {
people = toArray<{ name?: unknown; tickets?: unknown }>(data.performers)
.map((person) =>
normalizePerson({
name: person?.name,
role: "performer",
tickets: person?.tickets,
categories: EMPTY_PERSON_CATEGORIES,
}),
)
.filter((person): person is DailyControlPerson => person !== null);
}
return {
date: toText(data.date),
categories: {
outbound: normalizeStats(data.outbound),
returns: normalizeStats(data.returns),
waste: normalizeStats(data.waste),
purchases: normalizeStats(data.purchases),
},
people,
items: toArray<unknown>(data.items)
.map(normalizeItem)
.filter((item): item is DailyControlItem => item !== null),
};
}
type FeedFilter =
| { kind: "category"; value: OpsCategory }
| { kind: "person"; name: string; role: DailyControlPersonRole };
// ——— کارت KPI ———
// نوار فشرده: عنوان و آیکون / متریک اصلی / زیرنویس. در ستون‌های باریک عنوان
// به‌طور طبیعی می‌شکند (بدون برش) و هیچ متنی پنهان نمی‌شود.
function KpiCard({
icon: Icon,
label,
tickets,
qty,
tone,
category,
stats,
active,
onToggle,
}: {
icon: LucideIcon;
label: string;
tickets: number;
qty: number;
tone: string;
category: OpsCategory;
stats: DailyControlCategoryStats;
active: boolean;
onToggle: () => void;
}) {
const meta = CATEGORY_META[category];
const Icon = meta.icon;
return (
<div className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2.5">
<span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", tone)}>
<Icon className="h-4.5 w-4.5" />
<button
type="button"
onClick={onToggle}
aria-pressed={active}
title={meta.label}
className={cn(
"flex flex-col justify-between gap-1.5 rounded-xl border border-slate-100 bg-slate-50/40 p-3 text-start transition-all",
active ? "border-indigo-300 ring-1 ring-indigo-200" : "hover:border-slate-200",
)}
>
{/* عنوان + آیکون */}
<span className="flex items-start justify-between gap-1.5">
<span className="min-w-0 break-words text-[11px] font-medium leading-snug text-slate-500">
{meta.label}
</span>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg",
meta.tone,
)}
>
<Icon aria-hidden className="h-4 w-4" />
</span>
</span>
<div className="min-w-0">
<div className="text-[11px] text-muted">{label}</div>
<div className="flex items-baseline gap-1.5 tabular-nums">
<span className="text-[17px] font-extrabold leading-6">{faNum(tickets)}</span>
<span className="text-[10.5px] text-muted">بلیت</span>
<span className="text-[11px] text-muted">·</span>
<span className="text-[12px] font-bold">{fmtNum(qty)}</span>
{/* متریک اصلی */}
<span className="text-sm font-bold tabular-nums text-slate-800 md:text-base">
{faNum(stats.tickets)} {meta.countNoun}
</span>
{/* زیرنویس */}
<span className="text-[10px] leading-normal text-slate-400">
{secondaryMetric(stats)}
</span>
</button>
);
}
// ——— یک ردیف فید عملیات ———
// نام کامل متریال و همه‌ی متادیتا (بدون برش متن)؛ کمیت و ساعت در ستون
// جداگانه و nowrap می‌مانند تا هرگز با متن‌های بلند قاطی نشوند.
function FeedRow({ item }: { item: DailyControlItem }) {
const type = TYPE_META[item.transaction_type] ?? {
label: item.transaction_type,
category: "outbound" as OpsCategory,
};
const specs = specLabel(item.specs);
const unitName = item.unit_name_fa ?? item.unit_code;
return (
<li className="flex shrink-0 flex-col gap-2 rounded-xl border border-slate-100 bg-slate-50/30 p-3.5 transition-all hover:bg-white hover:shadow-xs">
{/* ردیف ۱: نشان نوع + نام کامل متریال/مشخصات و کمیت */}
<div className="flex w-full items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<Badge
className={cn(
"shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium",
CATEGORY_META[type.category].tone,
)}
>
{type.label}
</Badge>
<span className="min-w-0 break-words text-xs font-bold leading-snug text-slate-800 sm:text-sm">
{item.material}
{specs && <span className="font-normal text-slate-400"> — {specs}</span>}
</span>
</div>
<span
dir="ltr"
className="shrink-0 whitespace-nowrap text-xs font-bold tabular-nums text-slate-800 sm:text-sm"
>
{signedQty(item.quantity)} {unitName}
</span>
</div>
</div>
{/* ردیف ۲: متادیتای کامل + ساعت */}
<div className="flex w-full items-center justify-between gap-3 border-t border-slate-100/80 pt-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
<span>
{LABELS.warehouse}: {item.warehouse}
</span>
{item.brand && (
<>
<span aria-hidden className="text-slate-300">
•
</span>
<span>
{LABELS.brand}: {item.brand}
</span>
</>
)}
{item.performed_by && (
<>
<span aria-hidden className="text-slate-300">
•
</span>
<span>
{LABELS.performer}: {item.performed_by}
</span>
</>
)}
{item.delivered_to && (
<>
<span aria-hidden className="text-slate-300">
•
</span>
<span>
{LABELS.recipient}: {item.delivered_to}
</span>
</>
)}
</div>
<span className="shrink-0 whitespace-nowrap text-[11px] font-medium tabular-nums text-slate-400">
{timeOnly(item.transaction_date)}
</span>
</div>
</li>
);
}
export function DailyControlPanel() {
const query = useDailyControl();
const d = query.data;
// مدل نمایشیِ نرمال‌شده: هیچ آرایه‌ای undefined نیست (پاسخ خالی/قدیمی هم امن است)
const view = useMemo(
() => (query.data ? normalizeDailyControl(query.data) : null),
[query.data],
);
const [filter, setFilter] = useState<FeedFilter | null>(null);
const items = useMemo(() => view?.items ?? [], [view]);
const visibleItems = useMemo(() => {
if (!filter) return items;
if (filter.kind === "category") {
return items.filter(
(item) => TYPE_META[item.transaction_type]?.category === filter.value,
);
}
return items.filter((item) =>
filter.role === "performer"
? item.performed_by === filter.name
: item.delivered_to === filter.name,
);
}, [items, filter]);
const toggleCategory = (category: OpsCategory) =>
setFilter((prev) =>
prev?.kind === "category" && prev.value === category
? null
: { kind: "category", value: category },
);
const togglePerson = (person: DailyControlPerson) =>
setFilter((prev) =>
prev?.kind === "person" && prev.name === person.name && prev.role === person.role
? null
: { kind: "person", name: person.name, role: person.role },
);
const filterLabel =
filter?.kind === "category"
? CATEGORY_META[filter.value].label
: filter?.kind === "person"
? `${filter.name} (${PERSON_ROLE_META[filter.role].label})`
: "";
return (
<ChartCard
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden rounded-2xl !border-gray-100/80 [&>header]:shrink-0 [&>header_h2]:text-gray-900 [&>header_h2]:!font-bold [&>header_p]:!text-xs [&>header_p]:!text-gray-400"
title="کنترل روزانه — عملیات امروز"
sub={d ? `تاریخ ${isoToJalaliStr(d.date) ?? d.date} — هر دقیقه به‌روزرسانی می‌شود` : "هر دقیقه به‌روزرسانی می‌شود"}
sub={
view?.date
? `تاریخ ${isoToJalaliStr(view.date) ?? view.date} — هر دقیقه به‌روزرسانی می‌شود`
: "هر دقیقه به‌روزرسانی می‌شود"
}
>
{query.isLoading ? (
<ChartLoading />
) : !d ? (
) : query.isError || !view ? (
<ChartEmpty message="داده‌ی امروز در دسترس نیست" />
) : (
<div className="flex flex-col gap-3">
{/* KPI کارت‌ها */}
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<KpiCard
icon={ArrowUpFromLine}
label="خروج به سایت (برداشت)"
tickets={d.outbound.tickets}
qty={d.outbound.qty}
tone="bg-blue-bg text-[#1d4ed8]"
/>
<KpiCard
icon={ArrowDownToLine}
label="برگشت از سایت"
tickets={d.returns.tickets}
qty={d.returns.qty}
tone="bg-amber-bg text-amber"
/>
<KpiCard
icon={TriangleAlert}
label="ضایعات / اتلاف / مفقودی"
tickets={d.waste.tickets}
qty={d.waste.qty}
tone="bg-red-bg text-red"
/>
<KpiCard
icon={PackageOpen}
label="پذیرش خرید (ورود)"
tickets={d.purchases.tickets}
qty={d.purchases.qty}
tone="bg-green-bg text-green"
/>
<div className="flex min-h-0 flex-1 flex-col">
{/* نوار فشرده‌ی KPI — همیشه چهار ستون در دسکتاپ؛ قابل کلیک برای فیلتر فید */}
<div className="grid shrink-0 grid-cols-2 gap-2.5 md:grid-cols-4">
{OPS_CATEGORY_ORDER.map((category) => (
<KpiCard
key={category}
category={category}
stats={view.categories[category]}
active={filter?.kind === "category" && filter.value === category}
onToggle={() => toggleCategory(category)}
/>
))}
</div>
<div className="grid gap-3 lg:grid-cols-5">
{/* نام دقیق انجام‌دهنده‌ها */}
<div className="rounded-xl border border-line p-3 lg:col-span-2">
<h3 className="mb-2 flex items-center gap-1.5 text-[12.5px] font-extrabold">
<Users className="h-4 w-4 text-muted" />
تحویل‌دهنده‌ها / انجام‌دهنده‌های امروز
</h3>
{d.performers.length === 0 ? (
<p className="py-4 text-center text-[12px] text-muted">امروز عملیاتی ثبت نشده است</p>
<div className="mt-3 grid min-h-0 flex-1 grid-cols-1 gap-3.5 md:grid-cols-12 md:grid-rows-1 md:items-stretch">
{/* اشخاص درگیر در عملیات امروز — کاشی دو‌ردیفی */}
<div className="flex h-[420px] min-h-0 w-full min-w-0 shrink-0 flex-col rounded-xl border border-slate-100 bg-white p-4 md:col-span-5 lg:h-auto lg:max-h-[420px]">
<div className="mb-3 shrink-0">
<h3 className="flex items-center gap-1.5 text-sm font-bold text-slate-800">
<Users aria-hidden className="h-4 w-4 shrink-0 text-slate-400" />
{LABELS.peopleTitle}
</h3>
<p className="mt-0.5 whitespace-nowrap text-[11px] text-slate-400">
{LABELS.peopleSub}
</p>
</div>
{view.people.length === 0 ? (
<p className="py-4 text-center text-[12px] text-muted">{LABELS.noPeople}</p>
) : (
<ul className="flex flex-col divide-y divide-line">
{d.performers.map((p, i) => (
<li key={i} className="flex items-center gap-2 py-1.5">
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-accent-bg text-[11px] font-extrabold text-accent">
{faNum(i + 1)}
</span>
<span className="truncate text-[12.5px] font-bold">{p.name}</span>
<span className="ms-auto shrink-0 text-[11px] tabular-nums text-muted">
{fmtNum(p.tickets)} بلیت · {fmtNum(p.qty)}
</span>
</li>
))}
<ul
className={cn(
"custom-scrollbar flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pe-1",
SCROLLBAR_CLASSES,
)}
>
{view.people.map((person, index) => {
const active =
filter?.kind === "person" &&
filter.name === person.name &&
filter.role === person.role;
const breakdown = personBreakdown(person);
return (
<li key={`${person.role}|${person.name}`}>
<button
type="button"
onClick={() => togglePerson(person)}
aria-pressed={active}
title={person.name}
className={cn(
"flex w-full shrink-0 flex-col gap-2.5 rounded-xl border p-3 text-right transition-all",
active
? "border-blue-300 bg-blue-50/40 ring-1 ring-blue-200"
: "border-slate-100 bg-slate-50/50 hover:border-slate-200 hover:bg-slate-50",
)}
>
{/* ردیف ۱: هویت */}
<span className="flex w-full items-center gap-2">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-100/80 text-xs font-bold tabular-nums text-blue-700">
{faNum(index + 1)}
</span>
<span className="whitespace-nowrap text-xs font-bold text-slate-800 sm:text-sm">
{person.name}
</span>
</span>
{/* ردیف ۲: نقش + آمار */}
<span className="flex w-full flex-wrap items-center justify-between gap-2 border-t border-slate-100/80 pt-2">
<span className="w-fit shrink-0 whitespace-nowrap rounded-md bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-600">
{PERSON_ROLE_META[person.role].label}
</span>
<span className="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-[11px] tabular-nums">
<span className="font-bold text-slate-700">
{faNum(person.tickets)} {LABELS.transaction}
</span>
{breakdown && (
<span className="font-medium text-slate-400">({breakdown})</span>
)}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
{/* ریز عملیات امروز */}
<div className="rounded-xl border border-line p-3 lg:col-span-3">
<h3 className="mb-2 text-[12.5px] font-extrabold">ریز عملیات امروز</h3>
{d.items.length === 0 ? (
<p className="py-4 text-center text-[12px] text-muted">هنوز جابه‌جایی‌ای امروز ثبت نشده است</p>
<div className="flex h-[420px] min-h-0 min-w-0 flex-1 flex-col rounded-xl border border-slate-100 bg-white p-4 md:col-span-7 lg:h-auto lg:max-h-[420px]">
<div className="flex shrink-0 flex-wrap items-center gap-2">
<h3 className="text-sm font-bold text-slate-800">{LABELS.feedTitle}</h3>
{filter && (
<button
type="button"
onClick={() => setFilter(null)}
className="ms-auto inline-flex cursor-pointer items-center gap-1.5 rounded-lg border border-indigo-100 bg-indigo-50 px-2.5 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-100/70"
aria-label={`حذف فیلتر ${filterLabel}`}
title="حذف فیلتر"
>
{filterLabel}
<X aria-hidden className="h-3 w-3 shrink-0" />
</button>
)}
</div>
{visibleItems.length === 0 ? (
<p className="py-4 text-center text-[12px] text-muted">
{items.length === 0 ? LABELS.emptyFeed : LABELS.emptyFiltered}
</p>
) : (
<ul className="max-h-56 flex flex-col divide-y divide-line overflow-y-auto pe-1">
{d.items.map((it) => (
<li key={it.id} className="flex items-center gap-2 py-1.5">
<Badge
className={cn(
"shrink-0 px-2 py-0 text-[10px]",
TYPE_STYLE[it.transaction_type] ?? "bg-slate-bg text-muted",
)}
>
{TYPE_FA[it.transaction_type] ?? it.transaction_type}
</Badge>
<span className="truncate text-[12px] font-bold">{it.material}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted">
{fmtNum(it.quantity)} {it.unit_code}
</span>
<span className="ms-auto shrink-0 text-[10.5px] text-muted" title={it.warehouse}>
{fmtDateTime(it.transaction_date)}
</span>
</li>
<ul
className={cn(
"custom-scrollbar mt-3 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto pe-1",
SCROLLBAR_CLASSES,
)}
>
{visibleItems.map((item) => (
<FeedRow key={item.id} item={item} />
))}
</ul>
)}
@@ -1,13 +1,11 @@
// ============================================================
// فیلتر سراسری «چه چیزی» — آبشار: متریال → برند → مشخصات فنی
// داده از /dashboard/filters می‌آید (بدون هیچ لیست ساختگی) و انتخاب
// هر سطح، سطح‌های پایین‌دست را بازنشانی می‌کند:
// متریال → برندهای همان متریال (کاتالوگ + موجودی)
// متریال → مشخصات فنی موجودِ همان متریال
// هر فیلد «×» مخصوص خودش را دارد و گزینه‌ی «همه‌ی …» در ابتدای هر فهرست
// همان سطح (و سطوح پایین‌دست) را پاک می‌کند؛ دکمه‌ی «حذف فیلترها» کل استور را
// یک‌جا به نمای «کل سازمان» برمی‌گرداند.
// سه فیلتر در یک ردیفِ وسط‌چین با عرض یکسان می‌نشینند (موبایل: تک‌ستونی).
// فیلتر سراسری «چه چیزی» — آبشار: متریال (خانواده) → برند → مشخصات فنی
// • «متریال» فقط خانواده‌های پایه را نشان می‌دهد (بلوک سیمانی، لوله پلی‌اتیلن…)
// نه ردیف‌های دارای سایز/گرید.
// • «مشخصات فنی» پس از انتخاب خانواده، واریانت‌های همان خانواده (سایز/گرید)
// و مشخصه‌های موجودیِ آن خانواده را فهرست می‌کند.
// • همه‌ی گزینه‌ها از API مشتق می‌شوند (بدون هیچ لیست یا نام هاردکدشده).
// • سه فیلتر وسط‌چین و متقارن‌اند؛ روی موبایل تک‌ستونی می‌شوند.
// وضعیت در Zustand نگه داشته می‌شود تا با جابه‌جایی بین تب‌ها از دست نرود.
// ============================================================
@@ -19,18 +17,19 @@ import { useDashboardFilter } from "../../store/dashboardFilter";
import { faNum } from "../../lib/formatters";
import { cn } from "../../lib/utils";
// گزینه‌ی «همه» — انتخابی که همان سطح و سطوح پایین‌دستش را پاک می‌کند.
// شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox) و هرگز در استور ذخیره نمی‌شود.
// گزینه‌ی «همه» — شناسه‌ی خالی یعنی «انتخاب نشده» (قرارداد مشترک با Combobox)
const ALL_OPTION_ID = "";
// این شناسه همیشه در ابتدای فهرست می‌ماند و با جستجوی کاربر فیلتر نمی‌شود
const ALL_PINNED_IDS: string[] = [ALL_OPTION_ID];
// برچسب‌های ثابت UI — placeholder هر فیلد و گزینه‌ی «همه»اش یک متن مشترک دارند
// پیشوندهای شناسه‌ی گزینه‌های «مشخصات» — واریانت کاتالوگ یا مشخصه‌ی موجودی
const SPEC_VARIANT_PREFIX = "v:";
const SPEC_STOCK_PREFIX = "s:";
const LABELS = {
allMaterials: "همه‌ی متریال‌ها",
allFamilies: "همه‌ی متریال‌ها",
allBrands: "همه‌ی برندها",
allSpecs: "همه‌ی مشخصات",
materialFirst: "ابتدا متریال را انتخاب کنید",
familyFirst: "ابتدا متریال را انتخاب کنید",
clearAll: "حذف فیلترها",
wholeOrg: "نمای کل سازمان",
cascade: "متریال ← برند ← مشخصات فنی",
@@ -38,59 +37,80 @@ const LABELS = {
const allOption = (label: string): ComboboxItem => ({ id: ALL_OPTION_ID, label });
// برچسب هر مشخصه: «کلید: مقدار» یا «بدون مشخصه (پیش‌فرض)»
function specLabel(specs: Record<string, string> | null): string {
const parts = Object.entries(specs || {});
return parts.length ? parts.map(([k, v]) => `${k}: ${v}`).join("، ") : "بدون مشخصه (پیش‌فرض)";
}
export function DashboardFilterBar({ className }: { className?: string }) {
const filterQuery = useDashboardFilterOptions();
const state = useDashboardFilter();
// فهرست متریال‌ها مستقیماً از کاتالوگ سراسری می‌آید؛ فقط یکتاسازی شناسه‌ها
// انجام می‌شود تا هیچ گزینه‌ی تکراری در فهرست نباشد.
const materials = useMemo(() => {
const seen = new Set<string>();
return (filterQuery.data?.materials ?? []).filter((m) =>
seen.has(m.id) ? false : (seen.add(m.id), true),
);
}, [filterQuery.data]);
const selectedMaterial = useMemo(
() => materials.find((m) => m.id === state.materialId) ?? null,
[materials, state.materialId],
const families = useMemo(() => filterQuery.data?.families ?? [], [filterQuery.data]);
const selectedFamily = useMemo(
() => families.find((family) => family.id === state.materialFamilyId) ?? null,
[families, state.materialFamilyId],
);
const activeCount = [state.materialId, state.brandId, state.specsHash].filter(Boolean).length;
const activeCount = [state.materialFamilyId, state.brandId, state.spec].filter(Boolean).length;
// انتخاب از فهرست؛ گزینه‌ی «همه» همان سطح را پاک می‌کند (آبشار خودکار)
const pick =
(set: (id: string | null, label: string | null) => void) =>
(item: ComboboxItem) =>
item.id === ALL_OPTION_ID ? set(null, null) : set(item.id, item.label);
const pickFamily = (item: ComboboxItem) =>
item.id === ALL_OPTION_ID
? state.setMaterialFamily(null, null)
: state.setMaterialFamily(item.id, item.label);
// فهرست هر فیلد با گزینه‌ی «همه» در ابتدا
const materialItems = useMemo<ComboboxItem[]>(
() => [allOption(LABELS.allMaterials), ...materials.map((m) => ({ id: m.id, label: m.name }))],
[materials],
const pickBrand = (item: ComboboxItem) =>
item.id === ALL_OPTION_ID
? state.setBrand(null, null)
: state.setBrand(item.id, item.label);
// گزینه‌های مشخصات: «v:<materialId>» برای واریانت و «s:<hash>» برای مشخصه‌ی موجودی
const pickSpec = (item: ComboboxItem) => {
if (item.id === ALL_OPTION_ID) {
state.setSpec(null);
return;
}
if (item.id.startsWith(SPEC_VARIANT_PREFIX)) {
state.setSpec({
kind: "variant",
id: item.id.slice(SPEC_VARIANT_PREFIX.length),
label: item.label,
});
return;
}
if (item.id.startsWith(SPEC_STOCK_PREFIX)) {
state.setSpec({
kind: "stockSpec",
id: item.id.slice(SPEC_STOCK_PREFIX.length),
label: item.label,
});
}
};
const familyItems = useMemo<ComboboxItem[]>(
() => [allOption(LABELS.allFamilies), ...families.map((f) => ({ id: f.id, label: f.name }))],
[families],
);
const brandItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allBrands),
...(selectedMaterial?.brands ?? []).map((b) => ({ id: b.id, label: b.name })),
...(selectedFamily?.brands ?? []).map((brand) => ({ id: brand.id, label: brand.name })),
],
[selectedMaterial],
[selectedFamily],
);
const specItems = useMemo<ComboboxItem[]>(
() => [
allOption(LABELS.allSpecs),
...(selectedMaterial?.specs ?? []).map((s) => ({ id: s.hash, label: specLabel(s.specs) })),
],
[selectedMaterial],
);
const specItems = useMemo<ComboboxItem[]>(() => {
if (!selectedFamily) return [allOption(LABELS.allSpecs)];
// واریانت‌ها (سایز/گرید) — یکتاسازی بر اساس برچسب برای پرهیز از تکرار
const seen = new Set<string>();
const variants = selectedFamily.materials
.filter((material) => (seen.has(material.variant_label) ? false : (seen.add(material.variant_label), true)))
.map((material) => ({
id: `${SPEC_VARIANT_PREFIX}${material.id}`,
label: material.variant_label,
}));
const stockSpecs = selectedFamily.specs.map((spec) => ({
id: `${SPEC_STOCK_PREFIX}${spec.hash}`,
label: spec.label,
}));
return [allOption(LABELS.allSpecs), ...variants, ...stockSpecs];
}, [selectedFamily]);
return (
<section
@@ -124,40 +144,40 @@ export function DashboardFilterBar({ className }: { className?: string }) {
{/* سه فیلتر با عرض یکسان، وسط‌چین و متقارن؛ روی موبایل تک‌ستونی می‌شود */}
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-3 sm:grid-cols-3 sm:gap-4">
{/* ۱ — متریال (سرآغاز آبشار) */}
{/* ۱ — متریال (خانواده‌ی پایه) */}
<Combobox
value={state.materialLabel ?? ""}
placeholder={LABELS.allMaterials}
value={state.materialFamilyLabel ?? ""}
placeholder={LABELS.allFamilies}
allowAdd={false}
disabled={materials.length === 0}
items={materialItems}
disabled={families.length === 0}
items={familyItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setMaterial(null, null)}
onChange={pick(state.setMaterial)}
onClear={() => state.setMaterialFamily(null, null)}
onChange={pickFamily}
/>
{/* ۲ — برند (فقط برندهای همان متریال) */}
{/* ۲ — برند (برندهای همان خانواده) */}
<Combobox
value={state.brandLabel ?? ""}
placeholder={selectedMaterial ? LABELS.allBrands : LABELS.materialFirst}
placeholder={selectedFamily ? LABELS.allBrands : LABELS.familyFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.brands.length === 0}
disabled={!selectedFamily || selectedFamily.brands.length === 0}
items={brandItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setBrand(null, null)}
onChange={pick(state.setBrand)}
onChange={pickBrand}
/>
{/* ۳ — مشخصات فنی (فقط مشخصات موجودِ همان متریال) */}
{/* ۳ — مشخصات فنی (واریانت‌ها + مشخصه‌های موجودی همان خانواده) */}
<Combobox
value={state.specsLabel ?? ""}
placeholder={selectedMaterial ? LABELS.allSpecs : LABELS.materialFirst}
value={state.spec?.label ?? ""}
placeholder={selectedFamily ? LABELS.allSpecs : LABELS.familyFirst}
allowAdd={false}
disabled={!selectedMaterial || selectedMaterial.specs.length === 0}
disabled={!selectedFamily}
items={specItems}
pinnedIds={ALL_PINNED_IDS}
onClear={() => state.setSpecs(null, null)}
onChange={pick(state.setSpecs)}
onClear={() => state.setSpec(null)}
onChange={pickSpec}
/>
</div>
</section>
@@ -1,41 +1,231 @@
// ============================================================
// ویجت ۲ — جدول موجودی راکد (سرمایه‌ی خوابیده)
// ردیف‌هایی با موجودی مثبت که هیچ تراکنشی در بازه‌ی رکود نداشته‌اند.
// آستانه‌ی ۶۰/۹۰ روز با چیپ انتخاب می‌شود؛ روزهای رکود رنگی است.
// ویجت ۲ — موجودی راکد (سرمایه‌ی خوابیده) — فقط‌خواندنی
// داشبورد برای پایش و تحلیل است، نه ویرایش: هیچ استپر/آستانه‌ی ردیفی یا
// پنهان‌سازی وجود ندارد. همه‌ی ردیف‌ها با داده‌ی خام API رندر می‌شوند و
// مرتب‌سازی سه‌حالته (صعودی ← نزولی ← پیش‌فرض) روی هدر سه ستون کلیدی است.
// ============================================================
import { useState } from "react";
import { Snowflake } from "lucide-react";
import { useMemo, useState } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown, Snowflake } from "lucide-react";
import { useDeadStock } from "../../hooks/useDashboardData";
import { fmtNum, faNum } from "../../lib/formatters";
import { faNum, fmtNum } 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";
import type { DeadStockItem } from "../../lib/types";
// صفر = بدون هیچ آستانه‌ای؛ همه‌ی ردیف‌های موجودی برگردانده می‌شوند
const ALL_ITEMS_IDLE_DAYS = 0;
const MAX_LIST_HEIGHT = "max-h-[450px]";
// اسکرول‌بار ظریف (کلاس‌ها به‌صورت literal برای Tailwind)
const SCROLLBAR_CLASSES =
"[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300/70";
// شبکه‌ی مشترک ستون‌ها: هویت + سه ستون متریک با عرض ثابت.
// هدر و همه‌ی ردیف‌ها از همین قالب استفاده می‌کنند تا ستون‌ها در کل لیست
// دقیقاً روی هم بیفتند؛ در موبایل هویت بالا و متریک‌ها در سه ستون مساوی
// زیر آن می‌آیند (sm:contents پوسته‌ی متریک را از چیدمان حذف می‌کند).
const ROW_GRID =
"grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_repeat(3,7rem)] sm:items-center sm:gap-4 lg:gap-6";
const HEADER_GRID =
"grid grid-cols-3 items-center gap-4 px-3.5 pb-2 sm:grid-cols-[minmax(0,1fr)_repeat(3,7rem)] sm:gap-4 lg:gap-6";
const METRICS_GRID = "grid grid-cols-3 gap-4 sm:contents";
const METRIC_CELL = "flex min-w-0 flex-col items-center justify-center gap-0.5 text-center";
const METRIC_LABEL = "whitespace-nowrap text-[9.5px] font-bold leading-4 text-gray-400";
const METRIC_VALUE = "whitespace-nowrap text-sm font-bold leading-5 tabular-nums";
const LABELS = {
sub: "همه‌ی اقلام راکد — برای مرتب‌سازی روی عنوان ستون‌ها کلیک کنید",
qty: "مقدار راکد",
days: "روزهای رکود",
dayUnit: "روز",
lastActivity: "آخرین فعالیت",
brand: "برند",
warehouse: "انبار",
project: "پروژه",
sortHint: "ترتیب: صعودی ← نزولی ← پیش‌فرض",
empty: "موجودی راکدی یافت نشد",
noDate: "—",
} as const;
// ——— مرتب‌سازی سه‌حالته ———
type SortKey = "quantity" | "days" | "activity";
type SortDirection = "asc" | "desc";
interface SortState {
key: SortKey;
direction: SortDirection;
}
const SORT_FIELDS: { key: SortKey; label: string }[] = [
{ key: "quantity", label: LABELS.qty },
{ key: "days", label: LABELS.days },
{ key: "activity", label: LABELS.lastActivity },
];
// برچسب مشخصات: «کلید: مقدار، …»
function specLabel(specs: Record<string, string>): 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";
// شدت رکود نسبت به بیشترین رکود مجموعه (کاملاً داده‌محور)
function severityClass(days: number, maxDays: number): string {
const ratio = maxDays > 0 ? days / maxDays : 0;
if (ratio > 0.66) return "text-red";
if (ratio > 0.33) return "text-amber";
return "text-green";
}
// مقایسه‌ی امن: ردیف‌های بدون تاریخ همیشه در انتها می‌آیند
function compareActivity(a: DeadStockItem, b: DeadStockItem, direction: 1 | -1): number {
const time = (value: string | null): number | null => {
if (!value) return null;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
};
const at = time(a.last_activity);
const bt = time(b.last_activity);
if (at === null && bt === null) return 0;
if (at === null) return 1;
if (bt === null) return -1;
return (at - bt) * direction;
}
export function sortItems(items: DeadStockItem[], sort: SortState | null): DeadStockItem[] {
// بی‌ترتیب = ترتیب پیش‌فرض دیتابیس (بدون هیچ تغییر)
if (!sort) return items;
const direction = sort.direction === "asc" ? 1 : -1;
const list = [...items];
list.sort((a, b) => {
if (sort.key === "quantity") return (a.quantity - b.quantity) * direction;
if (sort.key === "days") return ((a.idle_days ?? 0) - (b.idle_days ?? 0)) * direction;
return compareActivity(a, b, direction);
});
return list;
}
// چرخه‌ی سه‌حالته‌ی هر ستون: صعودی ← نزولی ← برگشت به پیش‌فرض دیتابیس
export function nextSortState(prev: SortState | null, key: SortKey): SortState | null {
if (!prev || prev.key !== key) return { key, direction: "asc" };
if (prev.direction === "asc") return { key, direction: "desc" };
return null;
}
// ——— هدر ستون تعاملی ———
// برچسب متریک خودِ دکمه است. نشانگر مرتب‌سازی همیشه دیده می‌شود (نه فقط با
// هاور) تا از نگاه اول مشخص باشد ستون قابل مرتب‌سازی است: حالت غیرفعال با
// فلش دوگانه‌ی خاکستری ملایم و حالت فعال با فلش جهت‌دار و رنگ برند.
function SortHeader({
field,
sort,
onCycle,
}: {
field: { key: SortKey; label: string };
sort: SortState | null;
onCycle: (key: SortKey) => void;
}) {
const active = sort?.key === field.key;
return (
<button
type="button"
onClick={() => onCycle(field.key)}
aria-pressed={active}
title={LABELS.sortHint}
className={cn(
"group inline-flex w-full min-w-0 cursor-pointer select-none items-center justify-center gap-1 rounded-md px-1 py-1 text-[10px] font-bold transition-colors",
active ? "text-indigo-600" : "text-gray-400 hover:text-gray-600",
)}
>
<span className="truncate">{field.label}</span>
{active ? (
sort.direction === "asc" ? (
<ArrowUp aria-hidden className="h-3 w-3 shrink-0" />
) : (
<ArrowDown aria-hidden className="h-3 w-3 shrink-0" />
)
) : (
<ArrowUpDown
aria-hidden
className="h-3 w-3 shrink-0 text-gray-300 transition-colors group-hover:text-gray-400"
/>
)}
</button>
);
}
// ——— یک ردیف فقط‌خواندنی ———
function DeadStockRow({ item, maxDays }: { item: DeadStockItem; maxDays: number }) {
const specs = specLabel(item.specs);
const unitName = item.unit?.name_fa || item.unit?.code || "";
const days = item.idle_days ?? 0;
return (
<li className="rounded-xl border border-gray-100 bg-white p-3.5 shadow-xs transition-colors hover:border-gray-200">
<div className={ROW_GRID}>
{/* هویت */}
<div className="flex min-w-0 flex-col gap-1 text-start">
<span className="text-sm font-bold text-gray-900">
{item.material}
{specs && <span className="font-medium text-gray-400"> — {specs}</span>}
</span>
<span className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-gray-400">
{item.brand && (
<span>
{LABELS.brand}: {item.brand}
</span>
)}
<span>
{LABELS.warehouse}: {item.warehouse}
</span>
<span>
{LABELS.project}: {item.project}
</span>
</span>
</div>
{/* سه متریک فقط‌خواندنی — در sm به‌صورت contents داخل شبکه‌ی ردیف
می‌نشینند تا دقیقاً زیر ستون‌های هدر قرار بگیرند */}
<div className={METRICS_GRID}>
<div className={METRIC_CELL}>
<span className={METRIC_LABEL}>{LABELS.qty}</span>
<span dir="ltr" className={cn(METRIC_VALUE, "text-gray-800")}>
{fmtNum(item.quantity)} {unitName}
</span>
</div>
<div className={METRIC_CELL}>
<span className={METRIC_LABEL}>{LABELS.days}</span>
<span className={cn(METRIC_VALUE, severityClass(days, maxDays))}>
{item.idle_days == null ? LABELS.noDate : `${faNum(days)} ${LABELS.dayUnit}`}
</span>
</div>
<div className={METRIC_CELL}>
<span className={METRIC_LABEL}>{LABELS.lastActivity}</span>
<span className={cn(METRIC_VALUE, "text-gray-800")}>
{item.last_activity
? isoToJalaliStr(item.last_activity) ?? LABELS.noDate
: LABELS.noDate}
</span>
</div>
</div>
</div>
</li>
);
}
export function DeadStockTable() {
const [idleDays, setIdleDays] = useState<60 | 90>(60);
const query = useDeadStock(idleDays);
const query = useDeadStock(ALL_ITEMS_IDLE_DAYS);
const items = query.data?.items ?? [];
const [sort, setSort] = useState<SortState | null>(null);
const maxDays = useMemo(
() => Math.max(1, ...items.map((item) => item.idle_days ?? 0)),
[items],
);
const sortedItems = useMemo(() => sortItems(items, sort), [items, sort]);
const cycleSort = (key: SortKey) => setSort((prev) => nextSortState(prev, key));
return (
<ChartCard
@@ -45,72 +235,43 @@ export function DeadStockTable() {
موجودی راکد — سرمایه‌ی خوابیده
</span>
}
sub="اقلامی با موجودی مثبت که هیچ تراکنشی در بازه‌ی انتخابی نداشته‌اند"
actions={
<div className="flex items-center gap-1">
{([60, 90] as const).map((d) => (
<button
key={d}
type="button"
onClick={() => setIdleDays(d)}
className={cn(
"rounded-full px-2.5 py-0.5 text-[11px] font-bold transition-colors",
idleDays === d ? "bg-accent text-white" : "bg-slate-bg text-muted hover:text-ink",
)}
>
{faNum(d)} روز و بیشتر
</button>
))}
</div>
}
sub={LABELS.sub}
>
{query.isLoading ? (
<ChartLoading />
) : items.length === 0 ? (
<ChartEmpty message={`موجودی راکدی با آستانه‌ی ${faNum(idleDays)} روز وجود ندارد`} />
<ChartEmpty message={LABELS.empty} />
) : (
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead>متریال و مشخصات</TableHead>
<TableHead>برند</TableHead>
<TableHead>انبار / پروژه</TableHead>
<TableHead className="text-end">مقدار راکد</TableHead>
<TableHead className="text-end">روزهای رکود</TableHead>
<TableHead>آخرین فعالیت</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const specs = specLabel(it.specs);
return (
<TableRow key={it.inventory_id}>
<TableCell>
<div className="text-[13px] font-bold">{it.material}</div>
{specs && <div className="text-[11px] text-muted">{specs}</div>}
</TableCell>
<TableCell className="text-[12px] text-muted">{it.brand || "—"}</TableCell>
<TableCell>
<div className="text-[12px]">{it.warehouse}</div>
<div className="text-[10.5px] text-muted">{it.project}</div>
</TableCell>
<TableCell className="text-end font-bold tabular-nums">
{fmtNum(it.quantity)}{" "}
<span className="text-[10.5px] font-normal text-muted">
{it.unit?.name_fa || it.unit?.code}
</span>
</TableCell>
<TableCell className={cn("text-end font-extrabold tabular-nums", idleSeverity(it.idle_days))}>
{it.idle_days == null ? "—" : faNum(it.idle_days)}
</TableCell>
<TableCell className="text-[11px] text-muted">
{it.last_activity ? isoToJalaliStr(it.last_activity) : "—"}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div
dir="rtl"
className={cn(
"overflow-y-auto scroll-smooth pe-1",
MAX_LIST_HEIGHT,
SCROLLBAR_CLASSES,
)}
>
{/* هدر و ردیف‌ها در یک اسکرول‌کانتینر مشترک‌اند؛ پس عرض محتوا و گاتر
اسکرول‌بار برای هر دو یکسان است و ستون‌های هدر دقیقاً روی ستون‌های
هر ردیف می‌نشینند. هدر sticky است تا هنگام پیمایش دیده شود. */}
<div
className={cn(
HEADER_GRID,
"sticky top-0 z-10 border-b border-gray-100 bg-white",
)}
>
{/* ستون هویت در دسکتاپ؛ در موبایل فضای خالی لازم نیست */}
<span aria-hidden className="hidden sm:block" />
{SORT_FIELDS.map((field) => (
<SortHeader key={field.key} field={field} sort={sort} onCycle={cycleSort} />
))}
</div>
<ul className="flex flex-col gap-2.5 pt-2">
{sortedItems.map((item) => (
<DeadStockRow key={item.inventory_id} item={item} maxDays={maxDays} />
))}
</ul>
</div>
)}
</ChartCard>
);
@@ -25,13 +25,11 @@ import {
ChevronDown,
ChevronLeft,
MousePointerClick,
Package,
RotateCcw,
Square,
X,
} from "lucide-react";
import { useDrilldown } from "../../hooks/useDashboardData";
import { useDashboardFilter, type DashboardFilterState } from "../../store/dashboardFilter";
import { useDashboardFilter } from "../../store/dashboardFilter";
import type { DrilldownProject } from "../../lib/types";
import { faNum, fmtNum } from "../../lib/formatters";
import {
@@ -63,11 +61,21 @@ interface StackGroup {
// می‌نشیند؛ چون dataKey هر <Bar> مستقیماً از سطح پراپرتی‌های داده خوانده می‌شود.
interface StackedBar {
id: string;
label: string;
label: string; // نام کامل (تولتیپ)
// برچسب‌های سلسله‌مراتبی محور X (از payload)
axisLabel: string; // Tier 1: مشخصه/واریانت کوتاه
familyId: string;
familyName: string;
specLabel: string;
specDetail: string | null;
variantLabel: string;
specsHash: string;
materialRowId: string;
brandName: string | null;
unitLabel: string;
total: number;
segments: Record<string, number>;
[segmentKey: string]: number | string | Record<string, number>;
[segmentKey: string]: number | string | null | Record<string, number>;
}
// سطح ریشه + برچسب‌های ثابت UI (یک‌جا تعریف می‌شوند تا در JSX تکرار نشوند)
@@ -83,11 +91,14 @@ const LABELS = {
warehouseScopeHint: "دامنه‌ی انبار — برای تغییر انتخاب‌ها کلیک کنید",
drillHint: "برای مشاهده‌ی سهم پروژه‌ها و انبارها روی میله‌ها کلیک کنید",
lockedHint: "فیلتر انبار فعال است؛ برای drill-down ابتدا آن را بردارید",
removeMaterial: "حذف متریال انتخاب‌شده و بازگشت به نمای متریال‌ها",
removeSpec: "حذف مشخصه و بازگشت به نمای همین خانواده",
totalLabel: "مجموع نمایش داده شده",
itemsLabel: "اقلام نمایش داده شده",
tooltipTotal: "مجموع",
tooltipBreakdown: "تفکیک بر اساس",
family: "خانواده",
spec: "مشخصه",
standard: "استاندارد",
emptyChart: "موجودی‌ای با این فیلترها یافت نشد",
} as const;
@@ -184,9 +195,17 @@ function scopeLabel(
// بلند روی هم نیفتند؛ نام‌های بلندتر در چند خط شکسته می‌شوند.
const AXIS_SLOT_PX = 200; // عرض سهم هر میله روی محور (px)
const MIN_CHART_WIDTH = 800; // کف عرض نمودار (px)
const CHART_HEIGHT = 360; // ارتفاع نمودار (px)
const AXIS_HEIGHT = 110; // ارتفاع ناحیه‌ی برچسب‌های محور X (px)
const AXIS_TICK_MARGIN = 15; // فاصله‌ی خط محور تا خط اول برچسب (px)
const CHART_HEIGHT = 360; // ارتفاع ناحیه‌ی نمودار (px)
const AXIS_HEIGHT = 62; // ناحیه‌ی برچسب Tier 1 (مشخصه) — px
const AXIS_TICK_MARGIN = 10; // فاصله‌ی خط محور تا خط اول برچسب (px)
// هندسه‌ی محور سلسله‌مراتبی (Tier 2/3) — هم‌تراز با layout خودِ BarChart
const CHART_MARGIN_LEFT = 8;
const CHART_MARGIN_RIGHT = 12;
const CHART_MARGIN_TOP = 26;
const CHART_MARGIN_BOTTOM = 12;
const Y_AXIS_WIDTH = 56;
const FAMILY_BAR_HEIGHT = 32; // خط سقف خانواده + نام آن (px)
const SCROLLBAR_ALLOWANCE = 18; // فضای اسکرول‌بار افقی
const LABEL_LINE_CHARS = 20; // سقف نویسه‌ی هر خط برچسب
const LABEL_MAX_LINES = 3; // حداکثر خطوط هر برچسب
const LABEL_LINE_HEIGHT = 18; // فاصله‌ی خطوط برچسب (px)
@@ -281,13 +300,11 @@ function numericValues(qtyByUnit: Record<string, number>): Record<string, number
// در حالت RTL به‌هم نریزند.
function CustomTooltip({
active,
label,
payload,
stacks,
segmentKind,
}: {
active?: boolean;
label?: string | number;
payload?: readonly { payload?: StackedBar }[];
stacks: StackGroup[];
segmentKind: SegmentKind;
@@ -307,11 +324,25 @@ function CustomTooltip({
>
{/* سربرگ: موجودیت اصلی میله (متریال در نمای کلی، پروژه در نمای drill-down) */}
<div className="flex flex-col gap-0.5">
<span className="whitespace-normal text-xs font-bold text-slate-800">
{String(label ?? bar.label)}
</span>
<span className="text-[10px] text-slate-500 sm:text-xs">
{LABELS.tooltipBreakdown} {SEGMENT_META[segmentKind].plural}
<span className="whitespace-normal text-xs font-bold text-slate-800">{bar.label}</span>
<span className="flex flex-wrap items-center gap-1.5 text-[10px] text-slate-500 sm:text-xs">
<span>{SEGMENT_META[segmentKind].plural}</span>
{bar.familyName && (
<>
<span className="text-slate-300">•</span>
<span>
{LABELS.family}: {bar.familyName}
</span>
</>
)}
{bar.specLabel && (
<>
<span className="text-slate-300">•</span>
<span>
{LABELS.spec}: {bar.specLabel}
</span>
</>
)}
</span>
</div>
@@ -356,11 +387,6 @@ function CustomTooltip({
);
}
// اثر انگشت فیلتر سراسری (Zustand) — هر تغییر آن یعنی payload تازه‌ای می‌آید و
// اعتبارسنجی متریال انتخابی باید دوباره اجرا شود.
const selectGlobalFilterKey = (s: DashboardFilterState): string =>
JSON.stringify([s.materialId, s.brandId, s.specsHash]);
// --- MultiSelect Dropdown Component ---
// کنترل چند‌انتخابی دامنه (پروژه/انبار) — به‌عنوان گرهی چند‌انتخابیِ breadcrumb
// رندر می‌شود؛ برچسبش بازتاب انتخاب‌های فعلی است و ردیف «همه‌ی …» انتخاب‌ها را
@@ -528,7 +554,13 @@ export function DrilldownMatrix() {
// فیلتر سراسری، payload را زیر پای حالت محلی عوض می‌کند؛ به‌جای پاک‌کردن
// کورکورانه‌ی انتخاب‌ها، حالت محلی را با داده‌ی تازه اعتبارسنجی می‌کنیم تا نه
// در سطح کهنه گیر کنیم و نه بی‌دلیل به عقب بپریم.
const globalFilterKey = useDashboardFilter(selectGlobalFilterKey);
const filter = useDashboardFilter();
// اثر انگشت فیلتر سراسری (خانواده + برند + مشخصه) — با هر تغییر، payload نو
const globalFilterKey = JSON.stringify([
filter.materialFamilyId,
filter.brandId,
filter.spec?.id ?? null,
]);
// ۱) انتخاب‌های وابسته به payload (پروژه/انبار) نباید به شناسه‌هایی اشاره کنند
// که در نتیجه‌ی فیلتر جاری وجود ندارند؛ وگرنه نمودارِ خالی با چیپ‌های فعال می‌بینیم.
@@ -582,6 +614,35 @@ export function DrilldownMatrix() {
const matchesWarehouse = (wid: string) =>
selectedWarehouseIds.length === 0 || selectedWarehouseIds.includes(wid);
// برچسب‌های سلسله‌مراتبی هر میله (خانواده/مشخصه/واریانت) — فقط در نمای کلی
const materialMeta = new Map<
string,
{
familyId: string;
familyName: string;
specLabel: string;
specDetail: string | null;
variantLabel: string;
specsHash: string;
materialRowId: string;
brandName: string | null;
}
>();
if (level === ROOT_LEVEL) {
data.materials.forEach((mat) => {
materialMeta.set(mat.id, {
familyId: mat.family_id ?? mat.name,
familyName: mat.family_name ?? mat.name,
specLabel: mat.spec_label ?? mat.variant_label ?? LABELS.standard,
specDetail: mat.spec_detail ?? null,
variantLabel: mat.variant_label ?? LABELS.standard,
specsHash: mat.specs_hash ?? "",
materialRowId: mat.material_id ?? "",
brandName: mat.brand_name ?? null,
});
});
}
interface GroupAcc {
name: string;
byUnit: Record<string, number>;
@@ -670,9 +731,19 @@ export function DrilldownMatrix() {
total += value;
groupNames.set(groupId, group.name);
}
const meta = materialMeta.get(bar.id);
bars.push({
id: bar.id,
label: bar.label,
axisLabel: meta ? meta.specLabel : bar.label,
familyId: meta?.familyId ?? "",
familyName: meta?.familyName ?? "",
specLabel: meta?.specLabel ?? "",
specDetail: meta?.specDetail ?? null,
variantLabel: meta?.variantLabel ?? "",
specsHash: meta?.specsHash ?? "",
materialRowId: meta?.materialRowId ?? "",
brandName: meta?.brandName ?? null,
unitLabel: unitCode ? data.units[unitCode] || unitCode : "",
total,
segments,
@@ -681,7 +752,40 @@ export function DrilldownMatrix() {
...segments,
});
}
bars.sort((a, b) => b.total - a.total);
// Tier1: در یک خانواده، برچسب تکراری با برند تفکیک می‌شود
const labelCounts = new Map<string, number>();
bars.forEach((bar) => {
if (!bar.familyId) return;
const key = `${bar.familyId}|${bar.axisLabel}`;
labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
});
bars.forEach((bar) => {
if (!bar.familyId) return;
const key = `${bar.familyId}|${bar.axisLabel}`;
if ((labelCounts.get(key) ?? 0) > 1) {
const suffix = bar.specDetail ?? bar.brandName;
if (suffix) bar.axisLabel = `${bar.axisLabel} · ${suffix}`;
}
});
if (level === ROOT_LEVEL) {
// گروه‌بندی پیوسته: خانواده‌ها بر اساس مجموع موجودی، واریانت‌ها داخل هر خانواده
const familyTotals = new Map<string, number>();
bars.forEach((bar) => {
familyTotals.set(bar.familyId, (familyTotals.get(bar.familyId) ?? 0) + bar.total);
});
bars.sort((a, b) => {
if (a.familyId !== b.familyId) {
return (
(familyTotals.get(b.familyId) ?? 0) - (familyTotals.get(a.familyId) ?? 0) ||
a.familyName.localeCompare(b.familyName, "fa")
);
}
return a.axisLabel.localeCompare(b.axisLabel, "fa", { numeric: true });
});
} else {
bars.sort((a, b) => b.total - a.total);
}
// فهرست استک‌ها = اجتماع گروه‌های حاضر؛ ترتیب (و بنابراین رنگ‌ها) از مجموع
// کمیت‌ها مشتق می‌شود و در حالت تساوی با نام، پایدار می‌ماند.
@@ -720,6 +824,28 @@ export function DrilldownMatrix() {
// کانتینر (و کف تعیین‌شده) نمی‌شود.
const chartWidth = Math.max(plotWidth, MIN_CHART_WIDTH, bars.length * AXIS_SLOT_PX);
// ——— هندسه‌ی محور سلسله‌مراتبی (Tier 2/3) ———
// گروه‌های پیوسته‌ی خانواده از ترتیب میله‌ها مشتق می‌شوند
const familyGroups = useMemo(() => {
const groups: { familyId: string; familyName: string; start: number; count: number }[] = [];
if (level !== ROOT_LEVEL) return groups;
bars.forEach((bar, index) => {
const last = groups[groups.length - 1];
if (last && last.familyId === bar.familyId) last.count += 1;
else
groups.push({ familyId: bar.familyId, familyName: bar.familyName, start: index, count: 1 });
});
return groups;
}, [bars, level]);
const plotLeft = CHART_MARGIN_LEFT + Y_AXIS_WIDTH;
const bandWidth =
bars.length > 0 ? Math.max(0, chartWidth - plotLeft - CHART_MARGIN_RIGHT) / bars.length : 0;
const plotAreaHeight =
CHART_HEIGHT +
(level === ROOT_LEVEL && familyGroups.length > 0 ? FAMILY_BAR_HEIGHT : 0) +
SCROLLBAR_ALLOWANCE;
// ——— قفل هوشمند drill-down ———
// اگر کاربر انبار(های) مشخصی را در دامنه‌ی سراسری فیلتر کرده باشد، عمیق‌تر
// رفتن روی نمودار بی‌معناست؛ کلیک بی‌اثر و نشانگر پیش‌فرض می‌شود.
@@ -732,18 +858,37 @@ export function DrilldownMatrix() {
if (level !== ROOT_LEVEL) return;
const bar = (entry as { payload?: StackedBar })?.payload;
if (!bar) return;
// همگام‌سازی دوطرفه: انتخاب میله → فیلتر سراسری (خانواده + مشخصه/واریانت)
if (bar.familyId) {
filter.setMaterialFamily(bar.familyId, bar.familyName);
if (bar.specsHash && bar.specsHash !== "default") {
filter.setSpec({ kind: "stockSpec", id: bar.specsHash, label: bar.specLabel });
} else if (bar.materialRowId) {
filter.setSpec({ kind: "variant", id: bar.materialRowId, label: bar.variantLabel });
}
}
setSelectedMaterial({ id: bar.id, name: bar.label });
};
// بازگشت کامل به «کل سازمان» — متریال انتخابی و هر دو دامنه پاک می‌شوند
// بازگشت کامل به «کل سازمان» — فیلتر سراسری، دامنه‌ها و مسیر drill پاک می‌شوند
const resetToRoot = () => {
setSelectedMaterial(null);
setSelectedProjectIds([]);
setSelectedWarehouseIds([]);
filter.reset();
};
// حذف تگ متریال — فقط حالت محلی نمودار صفر می‌شود و دامنه‌های سراسری دست‌نخورده می‌مانند
const clearMaterial = () => setSelectedMaterial(null);
// یک پله بالا: حذف مشخصه (خانواده می‌ماند) و بازگشت نمودار به سطح خانواده
const clearSpecAndDrill = () => {
filter.setSpec(null);
setSelectedMaterial(null);
};
// انتخاب خانواده از برچسب محور → متریال = خانواده، مشخصات = «همه»
const selectFamilyGroup = (familyId: string, familyName: string) => {
filter.setMaterialFamily(familyId, familyName);
setSelectedMaterial(null);
};
// دامنه‌ی پروژه فقط دامنه‌ی انبارِ زیرمجموعه‌اش را آزاد می‌کند
const handleProjectScopeChange = (ids: string[]) => {
@@ -835,34 +980,33 @@ export function DrilldownMatrix() {
</>
)}
{/* تگ متریال انتخاب‌شده (سطح ۱) — بدنه و ✕ هر دو انتخاب را حذف می‌کنند */}
{selectedMaterial && (
{/* گره‌ی خانواده‌ی متریال (از فیلتر سراسری) — کلیک: حذف مشخصه و یک پله بالا */}
{filter.materialFamilyId && (
<>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="inline-flex min-w-0 items-center gap-1 rounded-md bg-accent-bg px-2 py-0.5 font-bold text-accent">
<button
type="button"
onClick={clearMaterial}
title={crumbHint(DRILL_LEVEL, selectedMaterial.name)}
className="inline-flex min-w-0 cursor-pointer items-center gap-1 transition-opacity hover:opacity-80"
>
<Package aria-hidden className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[10rem] truncate">{selectedMaterial.name}</span>
</button>
<button
type="button"
onClick={(event) => {
// ✕ نباید به handler بدنه‌ی تگ برسد
event.stopPropagation();
clearMaterial();
}}
aria-label={LABELS.removeMaterial}
title={LABELS.removeMaterial}
className="inline-flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded-full text-accent/70 transition-colors hover:bg-accent/10 hover:text-accent"
>
<X aria-hidden className="h-3 w-3" />
</button>
</span>
<button
type="button"
onClick={clearSpecAndDrill}
title={`${LABELS.family}: ${filter.materialFamilyLabel ?? filter.materialFamilyId}`}
className={crumbClass(!filter.spec)}
>
{filter.materialFamilyLabel ?? filter.materialFamilyId}
</button>
</>
)}
{/* گره‌ی مشخصه (از فیلتر سراسری) — کلیک: حذف مشخصه و بازگشت به خانواده */}
{filter.spec && (
<>
<ChevronLeft aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted" />
<button
type="button"
onClick={clearSpecAndDrill}
title={LABELS.removeSpec}
className={crumbClass(true)}
>
{filter.spec.label}
</button>
</>
)}
@@ -889,8 +1033,26 @@ export function DrilldownMatrix() {
ref={plotRef}
dir="ltr"
className="w-full overflow-x-auto overflow-y-hidden outline-none focus:outline-none [&_svg]:!outline-none [&_svg:focus]:!outline-none [&_svg:active]:!outline-none [&_svg:focus-visible]:!outline-none [&_.recharts-surface]:!outline-none [&_g]:!outline-none [&_g:focus]:!outline-none [&_g:focus-visible]:!outline-none [&_*]:!outline-none"
style={{ width: '100%', height: CHART_HEIGHT }}
style={{ width: '100%', height: plotAreaHeight }}
>
<div className="relative" style={{ width: chartWidth }}>
{/* جداکننده‌های عمودی نقطه‌چین بین خانواده‌ها (فقط نمای کلی) */}
{level === ROOT_LEVEL &&
familyGroups.slice(1).map((group) => (
<span
key={`divider-${group.familyId}`}
aria-hidden
className="pointer-events-none absolute border-s border-dashed border-slate-200"
style={{
left: plotLeft + bandWidth * group.start,
top: CHART_MARGIN_TOP,
height: Math.max(
0,
CHART_HEIGHT - AXIS_HEIGHT - CHART_MARGIN_TOP - CHART_MARGIN_BOTTOM,
),
}}
/>
))}
{/* بدون لایه‌ی دسترس‌پذیری، Recharts روی خود SVG دیگر tabIndex
نمی‌گذارد؛ ولی لایه‌های z-index داخلی‌اش (g[tabindex="-1"]) هنوز
فوکوس‌پذیرند و همان‌ها منبع حلقه‌ی فوکوس‌اند — که با سلکتورهای
@@ -901,11 +1063,16 @@ export function DrilldownMatrix() {
data={bars}
stackOffset="expand"
accessibilityLayer={false}
margin={{ top: 26, right: 12, left: 8, bottom: 12 }}
margin={{
top: CHART_MARGIN_TOP,
right: CHART_MARGIN_RIGHT,
left: CHART_MARGIN_LEFT,
bottom: CHART_MARGIN_BOTTOM,
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e9f0" vertical={false} />
<XAxis
dataKey="label"
dataKey="axisLabel"
tick={AxisTick}
tickLine={false}
axisLine={{ stroke: "#e5e9f0" }}
@@ -918,7 +1085,7 @@ export function DrilldownMatrix() {
tickFormatter={percentTick}
tickLine={false}
axisLine={false}
width={56}
width={Y_AXIS_WIDTH}
domain={[0, 1]}
tickCount={5}
/>
@@ -947,6 +1114,36 @@ export function DrilldownMatrix() {
/>
))}
</BarChart>
{/* Tier 2/3: خط سقف خانواده + نام آن، وسط‌چین زیر همان بازه */}
{level === ROOT_LEVEL && familyGroups.length > 0 && (
<div className="relative" style={{ width: chartWidth, height: FAMILY_BAR_HEIGHT }}>
{familyGroups.map((group) => {
const left = plotLeft + bandWidth * group.start;
const width = bandWidth * group.count;
return (
<div key={group.familyId} className="absolute top-0" style={{ left, width }}>
<div className="relative mx-1 h-2">
<span aria-hidden className="absolute inset-x-0 top-0 border-t border-slate-300" />
<span aria-hidden className="absolute start-0 top-0 h-2 border-s border-slate-300" />
<span aria-hidden className="absolute end-0 top-0 h-2 border-e border-slate-300" />
</div>
<div className="flex justify-center px-1 pt-0.5">
<button
type="button"
onClick={() => selectFamilyGroup(group.familyId, group.familyName)}
title={`${LABELS.family}: ${group.familyName}`}
className="max-w-full cursor-pointer truncate text-[11.5px] font-extrabold text-slate-700 transition-colors hover:text-accent"
>
{group.familyName}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* راهنمای رنگ‌ها — از همان سگمنت‌های payload ساخته می‌شود و نام
+26 -23
View File
@@ -324,18 +324,18 @@ function dayGroups(day: FlowDay): CatMap {
export function FlowChart() {
const filter = useDashboardFilter();
// بدون متریال، کوئری اصلاً اجرا نمی‌شود (جلوگیری از تجمیع واحدهای ناسازگار)
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialId));
// بدون خانواده‌ی متریال، کوئری اصلاً اجرا نمی‌شود (تجمیع واحدهای ناسازگار ممنوع)
const query = useDailyFlow(undefined, undefined, Boolean(filter.materialFamilyId));
const options = useDashboardFilterOptions();
const materials = options.data?.materials ?? [];
const selectedMaterial = materials.find((m) => m.id === filter.materialId) ?? null;
const materialName = selectedMaterial?.name ?? filter.materialLabel ?? "";
const families = options.data?.families ?? [];
const selectedFamily = families.find((family) => family.id === filter.materialFamilyId) ?? null;
const materialName = selectedFamily?.name ?? filter.materialFamilyLabel ?? "";
const unitLabel =
query.data?.unit?.name_fa ??
query.data?.unit?.code ??
selectedMaterial?.unit?.name_fa ??
selectedMaterial?.unit?.code ??
selectedFamily?.unit?.name_fa ??
selectedFamily?.unit?.code ??
"";
// ——— هم‌سازمان‌دهی روزها + سگمنت‌های شناور ———
@@ -461,9 +461,12 @@ export function FlowChart() {
const hasMultipleShades = visibleCategories.some((category) => groupCounts[category] > 1);
// ——— دروازه‌ی تک‌متریالی: بدون متریال، نمودار رندر نمی‌شود ———
if (!filter.materialId) {
if (!filter.materialFamilyId) {
return (
<ChartCard title={LABELS.title}>
<ChartCard
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden [&>header]:shrink-0"
title={LABELS.title}
>
{options.isError ? (
<FlowError />
) : options.isLoading ? (
@@ -471,31 +474,30 @@ export function FlowChart() {
) : (
<div
dir="rtl"
className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-line bg-slate-bg/40 px-4 py-7 text-center"
className="flex min-h-0 w-full flex-1 flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-line bg-slate-bg/40 px-4 py-7 text-center"
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-accent-bg text-accent">
<Package aria-hidden className="h-5 w-5" />
</span>
<p className="max-w-md text-[12.5px] leading-6 text-muted">{LABELS.needMaterial}</p>
{materials.length > 0 && (
{families.length > 0 && (
<div className="flex flex-col items-center gap-2">
<span className="text-[11px] font-bold text-slate-500">{LABELS.quickPick}</span>
<div className="flex max-w-xl flex-wrap justify-center gap-2">
{materials.map((material) => {
const materialUnit =
material.unit?.name_fa ?? material.unit?.code ?? "";
{families.map((family) => {
const familyUnit = family.unit?.name_fa ?? family.unit?.code ?? "";
return (
<button
key={material.id}
key={family.id}
type="button"
onClick={() => filter.setMaterial(material.id, material.name)}
title={material.name}
onClick={() => filter.setMaterialFamily(family.id, family.name)}
title={family.name}
className="inline-flex max-w-[16rem] cursor-pointer items-center gap-1.5 rounded-full border border-line bg-panel px-3 py-1.5 text-[11.5px] font-semibold text-ink shadow-sm transition-colors hover:border-accent hover:text-accent"
>
<span className="truncate">{material.name}</span>
{materialUnit && (
<span className="truncate">{family.name}</span>
{familyUnit && (
<span className="shrink-0 rounded-full bg-slate-bg px-1.5 text-[10px] font-medium text-muted">
{materialUnit}
{familyUnit}
</span>
)}
</button>
@@ -512,6 +514,7 @@ export function FlowChart() {
return (
<ChartCard
className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden [&>header]:shrink-0"
title={LABELS.title}
sub={`${materialName}${unitLabel ? ` — ${LABELS.unit}: ${unitLabel}` : ""}`}
>
@@ -522,9 +525,9 @@ export function FlowChart() {
) : !hasMovement ? (
<ChartEmpty message={LABELS.empty} />
) : (
<div dir="rtl" className="flex flex-col gap-1">
<div dir="rtl" className="flex min-h-0 flex-1 flex-col gap-1">
{/* راهنمای رنگ دسته‌ها (به‌جای Legend پیش‌فرض؛ سری‌های تنالیته معنا ندارند) */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
{visibleCategories.map((category) => (
<span key={category} className="inline-flex items-center gap-1">
<span
@@ -539,7 +542,7 @@ export function FlowChart() {
)}
</div>
<div dir="ltr" className="h-72 w-full">
<div dir="ltr" className="min-h-0 w-full grow basis-64">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={rows}
+3
View File
@@ -46,6 +46,9 @@ import type {
// پارامترهای مشترک فیلتر «چه چیزی» برای endpointهای داشبورد
// (type alias — نه interface — تا به Record<string, unknown> قابل انتساب باشد)
export type DashboardFilterParams = {
// خانواده‌ی متریال (نام پایه) — سرور به همه‌ی واریانت‌های خانواده map می‌کند
material_family?: string | null;
// واریانت مشخص (ردیف کاتالوگ) برای محدودکردن به یک سایز/گرید
material_id?: string | null;
brand_id?: string | null;
specs_hash?: string | null;
+71 -17
View File
@@ -222,18 +222,35 @@ export interface ConfirmedReceipt {
export interface FilterSpecOption {
hash: string;
specs: Record<string, string>;
// برچسب کوتاه مشخصه (مقدارها) — برای محور X و فهرست کشویی
label?: string;
}
export interface FilterMaterialOption {
id: string;
name: string;
// تفکیک «خانواده» (نام پایه) از «واریانت» (سایز/گرید) — از API
family_id?: string;
family_name?: string;
variant_label?: string;
unit: { id: number; code: string; name_fa: string | null } | null;
brands: { id: string; name: string }[];
specs: FilterSpecOption[];
}
// خانواده‌ی متریال: چند ردیف کاتالوگ با یک نام پایه (سایزها/گریدها زیرمجموعه)
export interface FilterFamilyOption {
id: string;
name: string;
unit: { id: number; code: string; name_fa: string | null } | null;
brands: { id: string; name: string }[];
specs: { hash: string; label: string }[];
materials: { id: string; name: string; variant_label: string }[];
}
export interface DashboardFilterOptions {
materials: FilterMaterialOption[];
families: FilterFamilyOption[];
}
// — ماتریس Drill-Down (کل سازمان → پروژه → انبار) —
@@ -257,6 +274,15 @@ export interface DrilldownProject {
export interface DrilldownMaterial {
id: string;
name: string;
// ردیف کاتالوگ + برچسب‌های محور (خانواده/واریانت/مشخصه) و برند
material_id?: string;
specs_hash?: string;
family_id?: string;
family_name?: string;
variant_label?: string;
spec_label?: string;
spec_detail?: string | null;
brand_name?: string | null;
qty_by_unit: QtyByUnit;
rows: number;
projects: DrilldownProject[];
@@ -315,25 +341,53 @@ export interface FlowData {
}
// — کنترل روزانه —
export interface DailyControlUnitStat {
code: string;
name_fa: string | null;
qty: number;
}
// آمار هر دسته‌ی عملیاتی: تعداد تراکنش، تعداد قلم کالای متمایز و تفکیک واحدها
export interface DailyControlCategoryStats {
tickets: number;
items: number;
qty: number;
units: DailyControlUnitStat[];
}
export type DailyControlPersonRole = "performer" | "recipient";
export interface DailyControlPerson {
name: string;
role: DailyControlPersonRole;
tickets: number;
categories: { outbound: number; returns: number; waste: number; purchases: number };
}
export interface DailyControlItem {
id: string;
transaction_type: string;
quantity: number; // علامت‌دار مطابق دفتر
unit_code: string;
unit_name_fa: string | null;
performed_by: string | null;
delivered_to: string | null;
brand: string | null;
note: string | null;
warehouse: string;
material: string;
specs: Record<string, string>;
transaction_date: string;
}
export interface DailyControlData {
date: string;
outbound: { tickets: number; qty: number };
returns: { tickets: number; qty: number };
waste: { tickets: number; qty: number };
purchases: { tickets: number; qty: number };
performers: { name: string; tickets: number; qty: number }[];
items: {
id: string;
transaction_type: string;
quantity: number;
unit_code: string;
performed_by: string | null;
note: string | null;
warehouse: string;
material: string;
specs: Record<string, string>;
transaction_date: string;
}[];
outbound: DailyControlCategoryStats;
returns: DailyControlCategoryStats;
waste: DailyControlCategoryStats;
purchases: DailyControlCategoryStats;
people: DailyControlPerson[];
items: DailyControlItem[];
}
// — بازرگانی: قیف تدارکات + KPI زمان تأمین (/quotes/stats) —
@@ -69,7 +69,7 @@ export function MvpDashboardPage() {
{/* ردیف اول: ماتریس Drill-Down (تمام عرض — ستون فقرات نما) */}
<DrilldownMatrix />
{/* ردیف دوم: سری زمانی + کنترل روزانه */}
<div className="grid gap-3 xl:grid-cols-2">
<div className="grid h-auto min-w-0 items-stretch gap-3 lg:h-[550px] xl:grid-cols-2">
<FlowChart />
<DailyControlPanel />
</div>
+31 -26
View File
@@ -1,63 +1,68 @@
// ============================================================
// Zustand store — فیلتر سراسری «چه چیزی» داشبورد
// متریال → برند → مشخصات فنی
// متریال (خانواده) → برند → مشخصات فنی
// «مشخصات» یکی از دو چیز است: واریانتِ کاتالوگ (سایز/گرید) یا مشخصه‌ی موجودی.
// بین دو تب «انبار» و «بازرگانی» (و تا پایان نشست) حفظ می‌شود.
// ============================================================
import { create } from "zustand";
export interface DashboardFilterState {
materialId: string | null; // null = همه‌ی متریال‌ها
brandId: string | null;
specsHash: string | null;
// برچسب‌های انتخاب‌شده برای نمایش چیپ فعال
materialLabel: string | null;
brandLabel: string | null;
specsLabel: string | null;
// انتخاب مشخصه: واریانت (ردیف کاتالوگ) یا مشخصه‌ی موجودی (specs_hash)
export type SpecSelection =
| { kind: "variant"; id: string; label: string }
| { kind: "stockSpec"; id: string; label: string };
setMaterial: (id: string | null, label: string | null) => void;
export interface DashboardFilterState {
// شناسه/نام خانواده (نام پایه) — نه ردیف تکیِ کاتالوگ
materialFamilyId: string | null;
materialFamilyLabel: string | null;
brandId: string | null;
brandLabel: string | null;
spec: SpecSelection | null;
setMaterialFamily: (id: string | null, label: string | null) => void;
setBrand: (id: string | null, label: string | null) => void;
setSpecs: (hash: string | null, label: string | null) => void;
setSpec: (selection: SpecSelection | null) => void;
reset: () => void;
}
const INITIAL = {
materialId: null,
materialFamilyId: null,
materialFamilyLabel: null,
brandId: null,
specsHash: null,
materialLabel: null,
brandLabel: null,
specsLabel: null,
spec: null,
};
export const useDashboardFilter = create<DashboardFilterState>((set) => ({
...INITIAL,
// آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند (متریال → برند → مشخصات)
setMaterial: (id, label) =>
// آبشاری: تغییر هر سطح، سطح‌های پایین‌دست را پاک می‌کند
setMaterialFamily: (id, label) =>
set({
materialId: id,
materialLabel: label,
materialFamilyId: id,
materialFamilyLabel: label,
brandId: null,
brandLabel: null,
specsHash: null,
specsLabel: null,
spec: null,
}),
setBrand: (id, label) =>
set({
brandId: id,
brandLabel: label,
specsHash: null,
specsLabel: null,
spec: null,
}),
setSpecs: (hash, label) => set({ specsHash: hash, specsLabel: label }),
setSpec: (selection) => set({ spec: selection }),
reset: () => set({ ...INITIAL }),
}));
// پارامترهای آماده برای endpointهای داشبورد
export function dashboardFilterParams(s: DashboardFilterState) {
return {
material_id: s.materialId,
material_family: s.materialFamilyId,
// واریانت = ردیف کاتالوگ همان سایز/گرید
material_id: s.spec?.kind === "variant" ? s.spec.id : null,
brand_id: s.brandId,
specs_hash: s.specsHash,
// مشخصه‌ی موجودی
specs_hash: s.spec?.kind === "stockSpec" ? s.spec.id : null,
};
}