412 lines
21 KiB
TypeScript
412 lines
21 KiB
TypeScript
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 } 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<PendingArrival[]>([]);
|
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState<string | null>(null); // quoteId
|
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
const [drafts, setDrafts] = useState<Record<string, ItemDraft>>({});
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
|
|
const [arr, whs] = await Promise.all([
|
|
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM / سرپرست را بررسی میکند
|
|
apiV26.pm.onTheWay(activeProject),
|
|
apiV26.warehouses.list(activeProject),
|
|
]);
|
|
setItems(arr);
|
|
setWarehouses(whs);
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
|
|
} 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<string, PendingArrival[]>();
|
|
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<AllocRow>) =>
|
|
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);
|
|
}
|
|
};
|
|
|
|
if (!canAccess) {
|
|
return (
|
|
<div className="min-h-screen">
|
|
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
|
<PageLayout>
|
|
<EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
|
|
</PageLayout>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen">
|
|
<Header
|
|
title="کالاهای در راه"
|
|
sub={inventorySubheader(user)}
|
|
backTo="/inventory"
|
|
backLabel="بازگشت به داشبورد"
|
|
/>
|
|
<PageLayout className="flex flex-col gap-4">
|
|
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
|
|
🚚 این بخش به مرحله «رسید» سفارشهای خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
|
|
اینجا فقط رسید و تقسیم بین انبارها ثبت میشود. <b>رسید جزیی (قلمی و مقداری) مجاز است</b> — هر سفارش را باز
|
|
کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید و آن را بین انبارهای پروژه تقسیم کنید؛ قلمهایی که مقداری برایشان
|
|
وارد نشود در صف «در راه» میمانند. مجموع تخصیص هر قلم باید دقیقاً با تعداد رسیدهی همان قلم برابر باشد و از
|
|
ماندهی سفارش بیشتر نشود.
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
|
|
) : !orders.length ? (
|
|
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
|
|
) : (
|
|
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 (
|
|
<div key={quoteId} className="overflow-hidden rounded-xl border border-line bg-panel shadow-sm">
|
|
{/* سربرگ سفارش */}
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-panel px-4 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-[14px] font-bold">🧾 {first.supplier.name}</span>
|
|
<Badge variant="outline">{faNum(its.length)} قلم</Badge>
|
|
<Badge variant={anyPartial ? "offered" : "outline"}>
|
|
{anyPartial ? "رسید جزیی" : "در راه"}
|
|
</Badge>
|
|
{days !== null && (
|
|
<Badge variant={days >= 7 ? "offered" : "muted"}>
|
|
روز گذشته: {faNum(days)} روز
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="mt-0.5 flex flex-wrap gap-x-3 text-[11.5px] text-muted">
|
|
<span>تاریخ پیشنهاد: <b className="text-foreground">{fmtDate(first.quote_date)}</b></span>
|
|
<span>پروژه: <b className="text-foreground">{first.project?.name || "—"}</b></span>
|
|
</div>
|
|
</div>
|
|
<Button size="sm" variant={open ? "line" : "green"} onClick={() => toggle(quoteId)}>
|
|
{open ? "بستن" : "دریافت"}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* اقلام سفارش */}
|
|
{open && (
|
|
<div className="divide-y divide-line/60">
|
|
{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 (
|
|
<div key={it.item_decision_id} className="px-4 py-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-[13px] font-bold">{it.material.name}</span>
|
|
<Badge variant="outline">
|
|
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""}
|
|
</Badge>
|
|
<Badge variant={parseFloat(it.received_qty) > 0 || draftReceived > 0 ? "offered" : "outline"}>
|
|
رسیده: {faNum(parseFloat(it.received_qty) + draftReceived)} / {faNum(parseFloat(it.ordered_qty))}
|
|
</Badge>
|
|
<Badge variant={inTransit > 0 ? "muted" : "outline"}>
|
|
در راه: {faNum(Math.max(0, inTransit))}
|
|
</Badge>
|
|
{it.state === "partially_received" && <Badge variant="offered">رسید ناقص</Badge>}
|
|
{itemDays !== null && (
|
|
<span className="text-[11px] text-muted">روز گذشته: <b className="text-foreground">{faNum(itemDays)}</b></span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-2 grid gap-2 md:grid-cols-[160px_1fr]">
|
|
<div className="field mb-0">
|
|
<label>تعداد رسیده (واقعی) *</label>
|
|
<input
|
|
type="number"
|
|
inputMode="decimal"
|
|
className="h-8 w-full rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
|
value={d.received}
|
|
onChange={(e) => setReceived(it.item_decision_id, e.target.value)}
|
|
placeholder={String(remaining)}
|
|
/>
|
|
</div>
|
|
<div className="field mb-0">
|
|
<label>تخصیص بین انبارهای پروژه (تعداد + انبار) *</label>
|
|
<div className="flex flex-col gap-1.5">
|
|
{d.allocs.map((a) => (
|
|
<div key={a.key} className="flex gap-1.5">
|
|
<input
|
|
type="number"
|
|
inputMode="decimal"
|
|
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
|
placeholder="تعداد"
|
|
value={a.qty}
|
|
onChange={(e) => setAlloc(it.item_decision_id, a.key, { qty: e.target.value })}
|
|
/>
|
|
<select
|
|
className="h-8 min-w-0 flex-1 rounded-lg border border-line bg-panel px-2 text-[12px]"
|
|
value={a.whId}
|
|
onChange={(e) => setAlloc(it.item_decision_id, a.key, { whId: e.target.value })}
|
|
>
|
|
<option value="">— انتخاب انبار —</option>
|
|
{whs.map((w) => (
|
|
<option key={w.id} value={w.id}>{w.name}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="h-8 px-2 text-muted hover:text-red"
|
|
onClick={() => removeAlloc(it.item_decision_id, a.key)}
|
|
title="حذف ردیف"
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[11.5px] text-accent"
|
|
onClick={() => addAlloc(it.item_decision_id)}
|
|
>
|
|
+ افزودن ردیف تخصیص
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px]">
|
|
<span className={submittable ? "text-green" : "text-muted"}>
|
|
{draftReceived > 0 ? (submittable ? "✓ آمادهی ثبت" : "در حال تکمیل…") : "بدون مقدار — در راه میماند"}
|
|
</span>
|
|
{errs.ceiling && (
|
|
<span className="text-[11px] font-bold text-red">
|
|
تعداد رسیده نمیتواند از ماندهی سفارش ({faNum(remaining)}) بیشتر باشد
|
|
</span>
|
|
)}
|
|
{errs.sumMismatch && (
|
|
<span className="text-[11px] font-bold text-red">
|
|
مجموع تخصیص انبارها ({faNum(allocSum)}) باید با تعداد رسیده ({faNum(draftReceived)}) برابر باشد
|
|
</span>
|
|
)}
|
|
{errs.emptyWh && (
|
|
<span className="text-[11px] font-bold text-red">برای ردیفهای دارای تعداد، انبار را انتخاب کنید</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
<div className="flex items-center justify-between gap-2 bg-panel px-4 py-3">
|
|
<span className="text-[11.5px] text-muted">
|
|
رسید جزیی مجاز است: حداقل یک قلم با تعداد معتبر کافی است؛ قلمهای بدون مقدار در «در راه» میمانند.
|
|
</span>
|
|
<Button size="sm" variant="green" disabled={!hasSubmittable || hasErrors || saving === quoteId} onClick={() => void submitOrder(quoteId)}>
|
|
{saving === quoteId ? "در حال ثبت…" : "✓ ثبت رسید و ورود به انبار"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</PageLayout>
|
|
</div>
|
|
);
|
|
}
|