feat(arrivals): in-transit goods dashboard - quote cards, per-item receive and warehouse allocation, days elapsed, admin access, received<=ordered limit

This commit is contained in:
ali_mohaghegh
2026-08-27 15:04:25 +03:30
parent f48f3752af
commit a191147eca
4 changed files with 402 additions and 179 deletions
+6
View File
@@ -184,6 +184,10 @@ export interface PendingArrival {
line_item_id: string;
quote_id: string;
quote_status: string;
quote_date: string;
supplier: { id: string; name: string };
project: { id: string; name: string } | null;
decided_at: string | null;
material: { id: string; name: string; category: { id: string; name: string } | null };
unit: { id: number; code: string; name_fa: string | null } | null;
ordered_qty: string;
@@ -193,7 +197,9 @@ export interface PendingArrival {
export interface ConfirmedReceipt {
receipt_id: string;
quote_id: string;
line_item_id: string;
project: { id: string; name: string };
material: { id: string; name: string };
received_at: string;
received_qty: string;
+302 -108
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
@@ -7,96 +7,188 @@ import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, useLogout } from "./panelMenu";
import { panelMenuItems, accountMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api";
import type { PendingArrival, Warehouse } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { faNum, fmtDate } from "../../lib/formatters";
// کالاهای در راه — داشبورد متصل به سیستم تدارکات (مرحله رسید)
// مدیر پروژه و مدیر تدارکات (ادمین) هر دو دسترسی دارند.
// هر رکورد یک «سفارش» (پیشنهاد خرید) است؛ با «دریافت»، زیررکوردها (اقلام)
// باز می‌شوند و برای هر قلم: تعداد رسیده + ردیف‌های تخصیص (تعداد + انبار پروژه)
// تا مانده تخصیص‌نشده به صفر برسد.
interface AllocRow { key: number; qty: string; whId: string; }
interface ItemDraft { received: string; allocs: AllocRow[]; }
// رسید کالاهای در راه — مدیر پروژه تأیید و بین انبارها تقسیم می‌کند (v2.6)
export function ArrivalsPage() {
const location = useLocation();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
const isPM = user?.role === "pm";
const [items, setItems] = useState<PendingArrival[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [selected, setSelected] = useState<Record<string, string[]>>({}); // decisionId -> warehouseIds
const [qty, setQty] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
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 [arr, whs] = await Promise.all([
apiV26.pm.onTheWay(),
apiV26.warehouses.list(isPM ? user?.project_id || undefined : undefined),
]);
setItems(arr);
setWarehouses(whs);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
} finally {
setLoading(false);
}
}, [isPM, user?.project_id]);
useEffect(() => {
if (user?.role !== "pm") return;
let alive = true;
void (async () => {
try {
const [arr, whs] = await Promise.all([
apiV26.pm.onTheWay(),
apiV26.warehouses.list(user.project_id || undefined),
]);
if (!alive) return;
setItems(arr);
setWarehouses(whs);
} catch (err) {
if (alive) toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [user?.role, user?.project_id]);
if (!isAdmin && !isPM) return;
setLoading(true);
void load();
}, [isAdmin, isPM, load]);
const pending = useMemo(() => items.filter((i) => i.state !== "arrived"), [items]);
// سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص)
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 toggleWarehouse = (decisionId: string, whId: string) => {
setSelected((s) => {
const cur = s[decisionId] || [];
return { ...s, [decisionId]: cur.includes(whId) ? cur.filter((x) => x !== whId) : [...cur, whId] };
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;
});
};
const confirm = async (it: PendingArrival) => {
const qtyStr = qty[it.item_decision_id]?.trim();
const total = parseFloat(qtyStr || "");
if (!total || total <= 0) {
toast.error("تعداد رسیده را وارد کنید.");
// ---------- 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 remainingOf = (it: PendingArrival): number => {
const d = draftOf(it);
const received = parseFloat(d.received) || 0;
const allocated = d.allocs.reduce((s, a) => s + ((parseFloat(a.qty) || 0) > 0 ? parseFloat(a.qty) || 0 : 0), 0);
return Math.round((received - allocated) * 10000) / 10000;
};
const itemReady = (it: PendingArrival): boolean => {
const d = draftOf(it);
const received = parseFloat(d.received);
if (!(received > 0)) return false;
// محدودیت: تعداد رسیده نمی‌تواند از تعداد سفارش‌شده بیشتر باشد
if (received > parseFloat(it.ordered_qty)) return false;
if (d.allocs.some((a) => !a.whId)) return false;
return remainingOf(it) === 0;
};
const orderReady = (quoteId: string): boolean => {
const its = orderItems(quoteId);
return its.length > 0 && its.every((it) => it.state === "arrived" || itemReady(it));
};
// ---------- submit ----------
const submitOrder = async (quoteId: string) => {
const its = orderItems(quoteId).filter((it) => it.state !== "arrived");
if (!orderReady(quoteId)) {
toast.error("برای هر قلم: تعداد رسیده را وارد کنید و تا صفر شدن «مانده تخصیص» ردیف‌ها را پر کنید.");
return;
}
const allocIds = selected[it.item_decision_id] || [];
if (!allocIds.length) {
toast.error("حداقل یک انبار را برای تقسیم انتخاب کنید.");
return;
}
// تقسیم مساوی بین انبارهای انتخاب‌شده (به‌صورت پیش‌فرض؛ کاربر می‌تواند بعداً اصلاح کند)
const per = Math.round((total / allocIds.length) * 10000) / 10000;
const allocations = allocIds.map((whId, idx) => ({
warehouse_id: whId,
quantity: idx === allocIds.length - 1 ? +(total - per * (allocIds.length - 1)).toFixed(4) : per,
}));
setSaving(true);
setSaving(quoteId);
try {
const res = await apiV26.pm.confirmReceipt({
item_decision_id: it.item_decision_id,
received_qty: total,
allocations,
for (const it of its) {
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: parseFloat(d.received),
allocations,
});
}
toast.success("رسید سفارش ثبت شد و به انبارها وارد شد ✓");
setDrafts((d) => {
const next = { ...d };
its.forEach((it) => delete next[it.item_decision_id]);
return next;
});
toast.success(`رسید ثبت شد — ${res.posted_transactions.length} تراکنش خرید ثبت شد.`);
setItems((list) => list.filter((x) => x.item_decision_id !== it.item_decision_id));
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(false);
setSaving(null);
}
};
if (loading) return <div className="min-h-screen" />;
if (user?.role !== "pm") {
if (!isAdmin && !isPM) {
return (
<div className="min-h-screen">
<Header title="رسید کالاها" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<Header title="کالاهای در راه" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<PageLayout>
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه است." />
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
</PageLayout>
</div>
);
@@ -105,62 +197,164 @@ export function ArrivalsPage() {
return (
<div className="min-h-screen">
<Header
title="کالاهای در راه و رسید"
sub={user.project_name || ""}
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
title="کالاهای در راه"
sub={isPM ? user?.project_name || "" : "همه پروژه‌ها — مدیر تدارکات"}
menu={isAdmin ? <PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} /> : undefined}
actions={isAdmin || isPM ? <InventoryTopNav pathname={location.pathname} onLogout={logout} variant={isAdmin ? "admin" : "pm"} /> : undefined}
/>
<PageLayout className="flex flex-col gap-4">
{!pending.length ? (
<EmptyState emoji="🚚" text="کالای در راهی برای تأیید رسید وجود ندارد." />
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
🚚 این بخش به مرحله «رسید» سفارش‌های خرید متصل است: تأیید پیشنهاد و پذیرش در سیستم تدارکات انجام شده و
اینجا فقط رسید و تقسیم بین انبارها ثبت می‌شود. هر سفارش را باز کنید، تعداد واقعیِ رسیده هر قلم را وارد کنید
و آن را بین انبارهای پروژه تقسیم کنید — تا «مانده تخصیص» به صفر برسد.
</div>
{loading ? (
<div className="py-6 text-center text-[12.5px] text-muted">در حال بارگذاری…</div>
) : !orders.length ? (
<EmptyState emoji="🚚" text="کالای در راهی برای دریافت وجود ندارد." />
) : (
pending.map((it) => (
<div key={it.item_decision_id} className="rounded-xl border border-line bg-panel p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[14px] font-bold">{it.material.name}</span>
<Badge variant="outline">
{it.state === "on_the_way" ? "در راه" : "رسید ناقص"}
</Badge>
<span className="text-[12px] text-muted">
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} — رسیده: {faNum(parseFloat(it.received_qty))}
</span>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="field">
<label>تعداد رسیده (در واحد قلم)</label>
<input
type="number"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={qty[it.item_decision_id] || ""}
onChange={(e) => setQty((q) => ({ ...q, [it.item_decision_id]: e.target.value }))}
placeholder={it.ordered_qty}
/>
orders.map(([quoteId, its]) => {
const first = its[0];
const open = expanded.has(quoteId);
const ready = orderReady(quoteId);
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={first.state === "partially_received" ? "offered" : "outline"}>
{first.state === "partially_received" ? "رسید ناقص" : "در راه"}
</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>
<div className="field">
<label>تقسیم بین انبارها (چندتایی)</label>
<div className="flex flex-wrap gap-1.5 pt-1">
{warehouses.map((w) => {
const on = (selected[it.item_decision_id] || []).includes(w.id);
{/* اقلام سفارش */}
{open && (
<div className="divide-y divide-line/60">
{its.map((it) => {
const d = draftOf(it);
const remaining = remainingOf(it);
const whs = whsOfProject(it.project?.id);
const itemDays = daysElapsed(it.decided_at);
return (
<button
key={w.id}
type="button"
className={`rounded-lg border px-3 py-1.5 text-[12.5px] ${on ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel text-muted"}`}
onClick={() => toggleWarehouse(it.item_decision_id, w.id)}
>
{w.name}
</button>
<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 ? "offered" : "outline"}>
رسیده: {faNum(parseFloat(it.received_qty))}
</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={it.ordered_qty}
/>
</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>
مانده تخصیص:{" "}
<b className={remaining === 0 ? "text-green" : "text-red"}>
{faNum(remaining)} {it.unit?.name_fa || it.unit?.code || ""}
</b>
</span>
{parseFloat(d.received) > parseFloat(it.ordered_qty) && (
<span className="text-[11px] font-bold text-red">تعداد کالای بیشتری نسبت به کالای رسیده انتخاب کردید</span>
)}
{remaining < 0 && <span className="text-[11px] text-red">تخصیص بیش از تعداد رسیده است!</span>}
{remaining > 0 && <span className="text-[11px] text-muted">با ردیف‌های تخصیص، مانده را به صفر برسانید.</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={!ready || saving === quoteId} onClick={() => void submitOrder(quoteId)}>
{saving === quoteId ? "در حال ثبت…" : "✓ ثبت رسید و ورود به انبار"}
</Button>
</div>
</div>
</div>
)}
</div>
<div className="mt-3 flex justify-end">
<Button size="sm" variant="green" disabled={saving} onClick={() => void confirm(it)}>
ثبت رسید و ورود به انبار
</Button>
</div>
</div>
))
);
})
)}
</PageLayout>
</div>