// ============================================================ // ویجت ۴ — کنترل روزانه (عملیات امروز) // • کارت‌های 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 = { 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 = { 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 { 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 = (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>(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; 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; 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; 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) : {}; 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; people: DailyControlPerson[]; items: DailyControlItem[]; } // پاسخ API (نسخه‌ی جدید یا قدیمی) → مدل نمایشیِ کاملاً تضمین‌شده export function normalizeDailyControl(raw: unknown): DailyControlView { const data = (raw ?? {}) as Partial & { performers?: unknown }; let people = toArray(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(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 ( ); } // ——— یک ردیف فید عملیات ——— // نام کامل متریال و همه‌ی متادیتا (بدون برش متن)؛ کمیت و ساعت در ستون // جداگانه و 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 (
  • {/* ردیف ۱: نشان نوع + نام کامل متریال/مشخصات و کمیت */}
    {type.label} {item.material} {specs && — {specs}}
    {signedQty(item.quantity)} {unitName}
    {/* ردیف ۲: متادیتای کامل + ساعت */}
    {LABELS.warehouse}: {item.warehouse} {item.brand && ( <> • {LABELS.brand}: {item.brand} )} {item.performed_by && ( <> • {LABELS.performer}: {item.performed_by} )} {item.delivered_to && ( <> • {LABELS.recipient}: {item.delivered_to} )}
    {timeOnly(item.transaction_date)}
  • ); } export function DailyControlPanel() { const query = useDailyControl(); // مدل نمایشیِ نرمال‌شده: هیچ آرایه‌ای undefined نیست (پاسخ خالی/قدیمی هم امن است) const view = useMemo( () => (query.data ? normalizeDailyControl(query.data) : null), [query.data], ); const [filter, setFilter] = useState(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 ( {query.isLoading ? ( ) : query.isError || !view ? ( ) : (
    {/* نوار فشرده‌ی KPI — همیشه چهار ستون در دسکتاپ؛ قابل کلیک برای فیلتر فید */}
    {OPS_CATEGORY_ORDER.map((category) => ( toggleCategory(category)} /> ))}
    {/* اشخاص درگیر در عملیات امروز — کاشی دو‌ردیفی */}

    {LABELS.peopleTitle}

    {LABELS.peopleSub}

    {view.people.length === 0 ? (

    {LABELS.noPeople}

    ) : (
      {view.people.map((person, index) => { const active = filter?.kind === "person" && filter.name === person.name && filter.role === person.role; const breakdown = personBreakdown(person); return (
    • ); })}
    )}
    {/* ریز عملیات امروز */}

    {LABELS.feedTitle}

    {filter && ( )}
    {visibleItems.length === 0 ? (

    {items.length === 0 ? LABELS.emptyFeed : LABELS.emptyFiltered}

    ) : (
      {visibleItems.map((item) => ( ))}
    )}
    )}
    ); }