Files
ERP/frontend/src/components/dashboard/DailyControlPanel.tsx
T
2026-10-21 16:04:07 +03:30

605 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// ویجت ۴ — کنترل روزانه (عملیات امروز)
// • کارت‌های 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, 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";
// دسته‌های عملیاتی امروز — عنوان، واحد شمارش فارسی، آیکون و رنگ‌بندی
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_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({
category,
stats,
active,
onToggle,
}: {
category: OpsCategory;
stats: DailyControlCategoryStats;
active: boolean;
onToggle: () => void;
}) {
const meta = CATEGORY_META[category];
const Icon = meta.icon;
return (
<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>
{/* متریک اصلی */}
<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 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();
// مدل نمایشیِ نرمال‌شده: هیچ آرایه‌ای 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={
view?.date
? `تاریخ ${isoToJalaliStr(view.date) ?? view.date} — هر دقیقه به‌روزرسانی می‌شود`
: "هر دقیقه به‌روزرسانی می‌شود"
}
>
{query.isLoading ? (
<ChartLoading />
) : query.isError || !view ? (
<ChartEmpty message="داده‌ی امروز در دسترس نیست" />
) : (
<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="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={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="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={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>
)}
</div>
</div>
</div>
)}
</ChartCard>
);
}