update dashboard
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user