import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Header, PageLayout } from "../../components/layout/Header"; import { EmptyState } from "../../components/EmptyState"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { useAuthStore } from "../../store/auth"; import { inventorySubheader } from "./panelMenu"; import { apiV26 } from "../../lib/api"; import { refreshProcurementStore } from "../../hooks"; import type { PendingArrival, Warehouse, ProjectTransfer } from "../../lib/types"; import { faNum, fmtDate } from "../../lib/formatters"; // کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید) // مدیر پروژه و مدیر تدارکات (ادمین) هر دو دسترسی دارند. // هر رکورد یک «سفارش» (پیشنهاد خرید) است؛ با «دریافت»، زیررکوردها (اقلام) // باز می‌شوند و برای هر قلم: تعداد رسیده + ردیف‌های تخصیص (تعداد + انبار پروژه) // تا مانده تخصیص‌نشده به صفر برسد. interface AllocRow { key: number; qty: string; whId: string; } interface ItemDraft { received: string; allocs: AllocRow[]; } export function ArrivalsPage() { const user = useAuthStore((s) => s.user); const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isPM = user?.role === "pm"; const isSupervisor = user?.role === "supervisor"; const canAccess = isAdmin || isPM || isSupervisor; const [items, setItems] = useState([]); const [incomingTransfers, setIncomingTransfers] = useState([]); const [warehouses, setWarehouses] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); // quoteId const [transferSaving, setTransferSaving] = useState(null); // transferId const [expanded, setExpanded] = useState>(new Set()); const [drafts, setDrafts] = useState>({}); const [transferDrafts, setTransferDrafts] = useState>({}); const load = useCallback(async () => { try { const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined; const [arr, whs, incoming] = await Promise.all([ // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM / سرپرست را بررسی می‌کند apiV26.pm.onTheWay(activeProject), apiV26.warehouses.list(activeProject), apiV26.projectTransfers.onTheWay(activeProject), ]); setItems(Array.isArray(arr) ? arr : []); setWarehouses(Array.isArray(whs) ? whs : []); setIncomingTransfers(Array.isArray(incoming) ? incoming : []); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه"); setItems([]); setIncomingTransfers([]); } finally { setLoading(false); } }, [isPM, isSupervisor, user?.project_id]); useEffect(() => { if (!canAccess) return; setLoading(true); void load(); }, [canAccess, load]); // سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص) const orders = useMemo(() => { const map = new Map(); for (const it of items) { if (it.state === "arrived") continue; const list = map.get(it.quote_id) || []; list.push(it); map.set(it.quote_id, list); } return [...map.entries()]; }, [items]); const orderItems = (quoteId: string) => items.filter((i) => i.quote_id === quoteId); const whsOfProject = (projectId: string | null | undefined) => warehouses.filter((w) => String(w.project_id) === String(projectId)); // روزهای گذشته از تأیید (تصمیم خرید) — ستون «روز گذشته» const daysElapsed = (iso: string | null): number | null => { if (!iso) return null; const t = new Date(iso).getTime(); if (Number.isNaN(t)) return null; return Math.max(0, Math.floor((Date.now() - t) / 86400000)); }; const orderDaysElapsed = (its: PendingArrival[]): number | null => { const days = its.map((i) => daysElapsed(i.decided_at)).filter((d): d is number => d !== null); return days.length ? Math.min(...days) : null; }; const toggle = (quoteId: string) => { setExpanded((s) => { const next = new Set(s); if (next.has(quoteId)) next.delete(quoteId); else next.add(quoteId); return next; }); }; // ---------- draft helpers ---------- const draftOf = (it: PendingArrival): ItemDraft => drafts[it.item_decision_id] || { received: "", allocs: [{ key: Date.now(), qty: "", whId: "" }] }; const setReceived = (decisionId: string, received: string) => setDrafts((d) => ({ ...d, [decisionId]: { ...(d[decisionId] || { allocs: [] }), received } })); const addAlloc = (decisionId: string) => setDrafts((d) => { const cur = d[decisionId] || { received: "", allocs: [] }; return { ...d, [decisionId]: { ...cur, allocs: [...cur.allocs, { key: Date.now() + Math.random(), qty: "", whId: "" }] } }; }); const setAlloc = (decisionId: string, key: number, patch: Partial) => setDrafts((d) => { const cur = d[decisionId] || { received: "", allocs: [] }; return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.map((a) => (a.key === key ? { ...a, ...patch } : a)) } }; }); const removeAlloc = (decisionId: string, key: number) => setDrafts((d) => { const cur = d[decisionId] || { received: "", allocs: [] }; return { ...d, [decisionId]: { ...cur, allocs: cur.allocs.filter((a) => a.key !== key) } }; }); const round4 = (n: number) => Math.round(n * 10000) / 10000; // مانده‌ی واقعیِ قابل‌دریافت برای این قلم: سفارش − قبلاً‌رسیده (− پیش‌نویس فعلی) const remainingToReceive = (it: PendingArrival): number => round4(parseFloat(it.ordered_qty) - parseFloat(it.received_qty)); const draftReceivedOf = (it: PendingArrival): number => parseFloat(draftOf(it).received) || 0; const allocSumOf = (it: PendingArrival): number => round4(draftOf(it).allocs.reduce((s, a) => s + ((parseFloat(a.qty) || 0) > 0 ? parseFloat(a.qty) || 0 : 0), 0)); // یک قلم برای ثبت «رسید جزیی» آماده است: تعداد > ۰، داخل سقف باقی‌مانده، // ردیف‌های تخصیص معتبر و مجموع تخصیص == تعداد رسیده. const itemSubmittable = (it: PendingArrival): boolean => { const d = draftOf(it); const received = draftReceivedOf(it); if (!(received > 0)) return false; if (received > remainingToReceive(it)) return false; // سقف: بیش از مانده‌ی سفارش نه if (d.allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) return false; return allocSumOf(it) === received; }; const itemHasErrors = (it: PendingArrival): { ceiling: boolean; sumMismatch: boolean; emptyWh: boolean } => { const d = draftOf(it); const received = draftReceivedOf(it); return { ceiling: received > remainingToReceive(it), sumMismatch: received > 0 && allocSumOf(it) !== received, emptyWh: d.allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId), }; }; // برای ثبت رسید، کافی است حداقل یک قلم مقدار معتبر داشته باشد؛ // قلم‌های بدون مقدار «در راه» می‌مانند (رسید جزیی مجاز است). const orderHasSubmittable = (quoteId: string): boolean => { const its = orderItems(quoteId); return its.length > 0 && its.some((it) => it.state !== "arrived" && itemSubmittable(it)); }; const orderHasErrors = (quoteId: string): boolean => orderItems(quoteId).some((it) => { const errs = itemHasErrors(it); return errs.ceiling || errs.sumMismatch || errs.emptyWh; }); // ---------- submit ---------- const submitOrder = async (quoteId: string) => { const its = orderItems(quoteId).filter((it) => it.state !== "arrived"); const submittable = its.filter(itemSubmittable); if (!submittable.length) { toast.error("حداقل یک قلم با تعداد معتبر (داخل مانده‌ی سفارش و تخصیصِ برابر با تعداد رسیده) وارد کنید."); return; } if (orderHasErrors(quoteId)) { toast.error("برخی قلم‌ها خطای اعتبارسنجی دارند — ابتدا سقف رسید و مجموع تخصیص انبارها را اصلاح کنید."); return; } setSaving(quoteId); try { for (const it of submittable) { const d = draftOf(it); const allocations = d.allocs .filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId) .map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) })); await apiV26.pm.confirmReceipt({ item_decision_id: it.item_decision_id, received_qty: draftReceivedOf(it), allocations, }, isPM ? user?.project_id || undefined : undefined); } toast.success("رسید (کامل یا جزیی) ثبت شد و به انبارها وارد شد ✓ — اقلام باقی‌مانده همچنان در راه هستند"); // همگام‌سازی با دفتر تدارکات: store پیشنهادها را تازه کن تا کارت‌های // پیشنهاد (بدون refresh دستی) رسید را نشان دهند. refreshProcurementStore(); setDrafts((d) => { const next = { ...d }; submittable.forEach((it) => delete next[it.item_decision_id]); return next; }); setExpanded((s) => { const next = new Set(s); next.delete(quoteId); return next; }); await load(); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در ثبت رسید"); } finally { setSaving(null); } }; // ---------- transfer helpers ---------- const transferDraftOf = (t: ProjectTransfer): AllocRow[] => { if (transferDrafts[t.id] && transferDrafts[t.id].length > 0) { return transferDrafts[t.id]; } const defaultWh = warehouses.find((w) => String(w.project_id) === String(t.to_project.id))?.id || warehouses[0]?.id || ""; return [{ key: 1, qty: String(t.quantity), whId: defaultWh }]; }; const setTransferAlloc = (transferId: string, rows: AllocRow[]) => { setTransferDrafts((prev) => ({ ...prev, [transferId]: rows })); }; const handleReceiveTransfer = async (transfer: ProjectTransfer, allocs: AllocRow[]) => { const activeProject = isPM ? user?.project_id || undefined : undefined; const allocSum = round4(allocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0)); const targetQty = parseFloat(String(transfer.quantity)); if (allocSum !== targetQty) { toast.error(`مجموع تخصیص انبارها (${faNum(allocSum)}) باید دقیقاً برابر کل بار ارسالی (${faNum(targetQty)}) باشد.`); return; } if (allocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId)) { toast.error("لطفاً برای تمامی ردیف‌های دارای مقدار، انبار مقصد را انتخاب کنید."); return; } setTransferSaving(transfer.id); try { const payloadAllocs = allocs .filter((a) => (parseFloat(a.qty) || 0) > 0 && a.whId) .map((a) => ({ warehouse_id: a.whId, quantity: parseFloat(a.qty) })); await apiV26.projectTransfers.receive(transfer.id, { allocations: payloadAllocs, }, activeProject); toast.success("تحویل انتقال بین کارگاهی ۱۰۰٪ تأیید شد و به انبار پروژه وارد گردید ✓"); await load(); } catch (err) { toast.error(err instanceof Error ? err.message : "خطا در تأیید تحویل انتقال"); } finally { setTransferSaving(null); } }; if (!isAdmin && !isPM) { if (!canAccess) { return (
); } return (
🚚 این بخش به مرحله «رسید» سفارش‌های خرید و «انتقال بین کارگاهی» متصل است: اقلام ارسالی از سایر پروژه‌ها به صورت ۱۰۰٪ تحویل و بین انبارهای پروژه تخصیص داده می‌شوند؛ سفارش‌های خرید نیز قابلیت رسید کامل یا جزیی دارند.
{loading ? (
در حال بارگذاری…
) : !(orders || []).length && !(incomingTransfers || []).length ? ( ) : ( <> {/* بخش انتقال‌های بین کارگاهی در راه */} {(incomingTransfers || []).length > 0 && (

🔁 انتقال‌های بین کارگاهی در راه ({faNum((incomingTransfers || []).length)})

— تحویل ۱۰۰٪ کامل مصالح ارسالی از سایر پروژه‌ها
{(incomingTransfers || []).map((t) => { const draftAllocs = transferDraftOf(t); const allocSum = round4(draftAllocs.reduce((s, a) => s + (parseFloat(a.qty) || 0), 0)); const targetQty = parseFloat(String(t.quantity)); const isExactMatch = allocSum === targetQty; const hasEmptyWh = draftAllocs.some((a) => (parseFloat(a.qty) || 0) > 0 && !a.whId); const isValid = isExactMatch && !hasEmptyWh && allocSum > 0; const days = daysElapsed(t.transfer_date); const projectWhs = warehouses.filter((w) => String(w.project_id) === String(t.to_project.id)); const destWhOptions = projectWhs.length > 0 ? projectWhs : warehouses; return (
{/* سربرگ انتقال */}
🔁 انتقال از {t.from_project?.name || "پروژه مبدأ"} در راه {days !== null && ( = 7 ? "offered" : "muted"}> روز گذشته: {faNum(days)} روز )}
ارسال‌کننده: {t.transferred_by || "—"} تاریخ ارسال: {fmtDate(t.transfer_date)} پروژه مقصد: {t.to_project?.name || "—"}
{t.material?.name} {t.brand?.name && {t.brand.name}} مقدار ارسالی: {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} {t.specs && Object.keys(t.specs).length > 0 && ( مشخصات: {Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")} )}
{t.note && (
یادداشت ارسال: {t.note}
)}
تخصیص ۱۰۰٪ به انبار(های) پروژه (مجموع باید دقیقاً {faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code || ""} باشد):
{draftAllocs.map((a) => (
{ const next = draftAllocs.map((x) => (x.key === a.key ? { ...x, qty: e.target.value } : x)); setTransferAlloc(t.id, next); }} /> {draftAllocs.length > 1 && ( )}
))}
{isExactMatch ? ( ✓ تخصیص کامل ۱۰۰٪ ({faNum(allocSum)} از {faNum(targetQty)}) آماده تحویل ) : ( اختلاف تخصیص: مجموع تخصیص ({faNum(allocSum)}) باید دقیقاً با مقدار کل ({faNum(targetQty)}) برابر باشد. )} {hasEmptyWh && ( — لطفاً انبار مقصد را انتخاب کنید )}
); })}
)} {/* بخش سفارش‌های خرید در راه */} {orders.map(([quoteId, its]) => { const first = its[0]; const open = expanded.has(quoteId); const hasSubmittable = orderHasSubmittable(quoteId); const hasErrors = orderHasErrors(quoteId); const anyPartial = its.some((it) => it.state === "partially_received" || draftReceivedOf(it) > 0); const days = orderDaysElapsed(its); return (
{/* سربرگ سفارش */}
🧾 {first.supplier.name} {faNum(its.length)} قلم {anyPartial ? "رسید جزیی" : "در راه"} {days !== null && ( = 7 ? "offered" : "muted"}> روز گذشته: {faNum(days)} روز )}
تاریخ پیشنهاد: {fmtDate(first.quote_date)} پروژه: {first.project?.name || "—"}
{/* اقلام سفارش */} {open && (
{its.map((it) => { const d = draftOf(it); const remaining = remainingToReceive(it); const draftReceived = draftReceivedOf(it); const inTransit = round4(remaining - draftReceived); const allocSum = allocSumOf(it); const errs = itemHasErrors(it); const submittable = itemSubmittable(it); const whs = whsOfProject(it.project?.id); const itemDays = daysElapsed(it.decided_at); return (
{it.material.name} سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} 0 || draftReceived > 0 ? "offered" : "outline"}> رسیده: {faNum(parseFloat(it.received_qty) + draftReceived)} / {faNum(parseFloat(it.ordered_qty))} 0 ? "muted" : "outline"}> در راه: {faNum(Math.max(0, inTransit))} {it.state === "partially_received" && رسید ناقص} {itemDays !== null && ( روز گذشته: {faNum(itemDays)} )}
setReceived(it.item_decision_id, e.target.value)} placeholder={String(remaining)} />
{d.allocs.map((a) => (
setAlloc(it.item_decision_id, a.key, { qty: e.target.value })} />
))}
{draftReceived > 0 ? (submittable ? "✓ آماده‌ی ثبت" : "در حال تکمیل…") : "بدون مقدار — در راه می‌ماند"} {errs.ceiling && ( تعداد رسیده نمی‌تواند از مانده‌ی سفارش ({faNum(remaining)}) بیشتر باشد )} {errs.sumMismatch && ( مجموع تخصیص انبارها ({faNum(allocSum)}) باید با تعداد رسیده ({faNum(draftReceived)}) برابر باشد )} {errs.emptyWh && ( برای ردیف‌های دارای تعداد، انبار را انتخاب کنید )}
); })}
رسید جزیی مجاز است: حداقل یک قلم با تعداد معتبر کافی است؛ قلم‌های بدون مقدار در «در راه» می‌مانند.
)}
); })} )}
); }