feat(frontend): fulfillment split, API-driven topics, hub-and-spoke nav, stock-count modal, FOUND ceiling, semantic colors

- bug1: quote header fulfillment counter («رسید: X از Y قلم» amber / «تکمیل
  شد» green / «در راه»); expanded line items visually split into the
  received part (رسید شده ✓ + date + target warehouse from the receipt
  allocations) and the in-transit remainder — quantities always sum to the
  original line (invariant D holds, display-only split)
- bug2: 8-topic grids (suppliers/wizard/units) now come from
  /material-categories via the store (new lib/topics.ts; fixed the mismatched
  «ابزار و تجهیزات ساخت» label); suppliers topic->category resolution no
  longer falls back to an arbitrary category; duplicate month/status lists
  reuse the shared dictionaries; TopicGrid gained a loading EmptyState
- bug3: hub-and-spoke — the tab strip renders only on the warehouse
  dashboard; sub-views hide it and navigate back via «بازگشت به داشبورد»;
  «افزایش موجود» became the «تغییر موجودی» entry button
- bug4: stock count goes through a confirmation modal (current vs counted
  vs variance with کسری/مازاد badges) before posting STOCK_COUNT; numbers
  parsed defensively
- bug5: found qty is capped client-side (red input + inline warning, submit
  disabled) and backend returns 422 when exceeding the missing balance;
  fully-recovered losses drop off the pending list
- bug6+7: quantities render as absolute values with semantic color coding
  (emerald inflow / rose outflow / blue neutral) across dashboard timeline,
  daily control and the change-inventory form
This commit is contained in:
ali_mohaghegh
2026-09-02 18:15:32 +03:30
parent ca84d8664e
commit ac76af2ffe
27 changed files with 500 additions and 217 deletions
@@ -9,7 +9,6 @@ import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { accountMenuItems, inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api";
import type { PendingArrival, Warehouse } from "../../lib/types";
import { faNum, fmtDate } from "../../lib/formatters";
@@ -232,7 +231,6 @@ export function ArrivalsPage() {
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />}
/>
<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">
@@ -6,26 +6,26 @@ import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api";
import { useInventoryData, unitName } from "./useInventoryData";
import { useInventoryData, unitName, whName } from "./useInventoryData";
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
import type { InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, toFa } from "../../lib/jalali";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
type FormType = TransactionType | "TRANSFER";
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
{ t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت خرید کالای جدید یا موجود — دسته‌بندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." },
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#16a34a", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
{ t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#4f46e5", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
{ t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." },
{ t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." },
{ t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودی‌های همان انبار و متریال (یا «همه مفقودی‌ها») نمایش داده می‌شود." },
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#64748b", desc: "شمارش فیزیکی — مقدار شمارش‌شده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." },
// رنگ‌بندی معنایی: سبز زمردی = ورود، رز = خروج، آبی = خنثی/جابه‌جایی/شمارش
{ t: "PURCHASE", label: "خرید", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت خرید کالای جدید یا موجود — دسته‌بندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." },
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدل‌ها (برند و مشخصه متفاوت) نمایش داده می‌شوند. برداشت‌ها در «کنترل روزانه» امروز دیده می‌شوند." },
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شده‌اند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
{ t: "TRANSFER", label: "انتقال", dir: "جابه‌جایی", cls: "mv", color: "#2563eb", desc: "جابه‌جایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لوله‌های سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
{ t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#e11d48", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." },
{ t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." },
{ t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودی‌های همان انبار و متریال (یا «همه مفقودی‌ها») نمایش داده می‌شود." },
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#2563eb", desc: "شمارش فیزیکی — مقدار شمارش‌شده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." },
];
interface RowDraft { key: string; qty: string; note: string; }
@@ -41,7 +41,7 @@ function gregKey(d: Date): string {
function jalDateLabel(d: Date): string {
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
return `${toFa(jd)} ${["فروردین","اردیبهشت","خرداد","تیر","مرداد","شهریور","مهر","آبان","آذر","دی","بهمن","اسفند"][jm-1]} ${toFa(jy)}`;
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
}
// تغییر موجودی — پورت کامل صفحه‌ی ارسالی (همه انواع تراکنش)
@@ -149,6 +149,34 @@ export function ChangeInventoryPage() {
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
}, [data.transactions, whId, matTxt]);
// --- پیدا شده: مانده‌ی مفقودی هر ردیف = مفقودی − مجموع پیداشده‌های قبلیِ همان رکورد
const lossKey = (t: InventoryTransaction) =>
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
const foundRemaining = useMemo(() => {
const foundByKey = new Map<string, number>();
data.transactions
.filter((x) => x.transaction_type === "FOUND")
.forEach((x) => {
const k = lossKey(x);
foundByKey.set(k, (foundByKey.get(k) || 0) + Math.abs(x.quantity_number || 0));
});
return lossTxs.map((t) => {
const loss = Math.abs(t.quantity_number || 0);
const found = foundByKey.get(lossKey(t)) || 0;
return Math.max(0, Math.round((loss - found) * 10000) / 10000);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lossTxs, data.transactions, whId, matTxt]);
// مفقودی‌های کاملاً پیداشده از فهرست «در انتظار پیدا شدن» حذف می‌شوند
const pendingLosses = useMemo(
() =>
lossTxs
.map((tx, i) => ({ tx, remaining: foundRemaining[i] ?? 0 }))
.filter((x) => x.remaining > 0),
[lossTxs, foundRemaining],
);
const postOne = async (op: Record<string, unknown>) => {
await apiV26.inventory.createTransaction(op as never);
};
@@ -194,14 +222,13 @@ export function ChangeInventoryPage() {
};
const submitList = async (t: TransactionType) => {
const isCount = t === "STOCK_COUNT";
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
if (!(n > 0)) return null;
const rec = formRecords[i];
if (!rec) return null;
if (isCount) {
if (t === "STOCK_COUNT") {
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() };
}
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
@@ -211,6 +238,65 @@ export function ChangeInventoryPage() {
await postSeq(ops);
};
// ---------- شمارش موجودی: پیش‌نمایش اختلاف + تأیید قبل از ثبت ----------
interface CountConfirmRow {
material: string;
unit: string;
warehouse: string;
current: number;
counted: number;
variance: number;
}
const [countConfirm, setCountConfirm] = useState<CountConfirmRow[] | null>(null);
const [countBusy, setCountBusy] = useState(false);
const countOps = (): { ops: Record<string, unknown>[]; confirm: CountConfirmRow[] } | null => {
const ops: Record<string, unknown>[] = [];
const confirm: CountConfirmRow[] = [];
rows.forEach((r, i) => {
const n = parseNum(r.qty);
const rec = formRecords[i];
if (!(n > 0) || !rec) return;
ops.push({
transaction_type: "STOCK_COUNT",
warehouse_id: whId,
material_id: rec.material.id,
brand_id: rec.brand?.id || null,
physical_qty: n,
note: r.note,
transaction_date: toServerIso(),
});
confirm.push({
material: rec.material.name,
unit: unitName(data.lookup.units, rec.unit?.id),
warehouse: whName(warehouses, whId),
current: rec.quantity_number,
counted: n,
variance: Math.round((n - rec.quantity_number) * 10000) / 10000,
});
});
if (!ops.length) return null;
return { ops, confirm };
};
const requestCountConfirm = () => {
const built = countOps();
if (!built) return toast.error("هیچ ردیفی پر نشده است");
setCountConfirm(built.confirm);
};
const confirmCount = async () => {
const built = countOps();
if (!built) return;
setCountBusy(true);
try {
await postSeq(built.ops);
setCountConfirm(null);
} finally {
setCountBusy(false);
}
};
const submitReturn = async () => {
const ops: Record<string, unknown>[] = rows
.map((r, i): Record<string, unknown> | null => {
@@ -244,12 +330,14 @@ export function ChangeInventoryPage() {
.map((r, i): Record<string, unknown> | null => {
const n = parseNum(r.qty);
if (!(n > 0)) return null;
const t = lossTxs[i];
if (!t) return null;
const item = pendingLosses[i];
if (!item) return null;
if (n > item.remaining) return null; // سقف سمت کلاینت (بک‌اند هم رد می‌کند)
const t = item.tx;
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
})
.filter((x): x is Record<string, unknown> => x !== null);
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
await postSeq(ops);
};
@@ -259,6 +347,7 @@ export function ChangeInventoryPage() {
else if (type === "RETURN") void submitReturn();
else if (type === "TRANSFER") void submitTransfer();
else if (type === "FOUND") void submitFound();
else if (type === "STOCK_COUNT") requestCountConfirm();
else void submitList(type);
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطای غیرمنتظره در ثبت");
@@ -271,7 +360,18 @@ export function ChangeInventoryPage() {
}
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
if (type === "FOUND") return !!(whId && rows.some((r) => parseNum(r.qty) > 0));
// پیدا شده: هر مقدار واردشده باید داخل مانده‌ی مفقودی همان ردیف باشد
if (type === "FOUND") {
return !!(
whId &&
rows.length > 0 &&
rows.every((r, i) => {
const n = parseNum(r.qty);
return !(n > 0) || n <= (pendingLosses[i]?.remaining ?? 0);
}) &&
rows.some((r) => parseNum(r.qty) > 0)
);
}
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
};
@@ -280,14 +380,14 @@ export function ChangeInventoryPage() {
if (type === "RETURN") {
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
} else if (type === "FOUND") {
setRows(lossTxs.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
setRows(pendingLosses.map((x) => ({ key: x.tx.inventory_id, qty: "", note: "" })));
} else if (type === "TRANSFER") {
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
} else {
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, lossTxs.length]);
}, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, pendingLosses.length]);
const meta = FORM_TYPES.find((f) => f.t === type)!;
@@ -328,7 +428,6 @@ export function ChangeInventoryPage() {
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant="pm" />}
/>
<PageLayout className="flex flex-col gap-3">
{/* انتخاب نوع تراکنش */}
@@ -563,9 +662,15 @@ export function ChangeInventoryPage() {
/>
)}
{/* پیدا شده — مفقودی‌ها */}
{/* پیدا شده — مفقودی‌ها (با سقف: پیدا شده ≤ مانده‌ی مفقودی) */}
{type === "FOUND" && (
<FoundTable rows={rows} setRows={setRows} txs={lossTxs} whId={whId} unitNameFn={(id) => unitName(data.lookup.units, id)} />
<FoundTable
rows={rows}
setRows={setRows}
items={pendingLosses}
whId={whId}
unitNameFn={(id) => unitName(data.lookup.units, id)}
/>
)}
{/* دکمه ثبت */}
@@ -582,6 +687,70 @@ export function ChangeInventoryPage() {
</button>
</div>
</div>
{/* مودال تأیید شمارش موجودی (اختلاف قبل از ثبت) */}
{countConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
موجودی هر ردیف به «تعداد شمارش‌شده» اصلاح می‌شود و اختلاف (کسری/مازاد) ثبت خواهد شد.
</p>
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto">
{countConfirm.map((c, i) => {
const deficit = c.variance < 0;
const surplus = c.variance > 0;
return (
<div key={i} className="rounded-lg border border-line p-2.5 text-[12px]">
<div className="flex flex-wrap items-center gap-2">
<b className="min-w-0 flex-1 truncate">{c.material}</b>
<span className="text-muted">انبار: {c.warehouse}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
<span>موجودی سیستم: <b>{faNum(c.current)} {c.unit}</b></span>
<span>شمارش‌شده: <b>{faNum(c.counted)} {c.unit}</b></span>
<span
className={`rounded-md px-2 py-0.5 text-[11px] font-bold ${
deficit
? "bg-rose-50 text-rose-700"
: surplus
? "bg-emerald-50 text-emerald-700"
: "bg-slate-bg text-muted"
}`}
>
{deficit
? `−${faNum(Math.abs(c.variance))} ${c.unit} (کسری انبار)`
: surplus
? `+${faNum(c.variance)} ${c.unit} (مازاد انبار)`
: "بدون اختلاف ✓"}
</span>
</div>
</div>
);
})}
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
onClick={() => setCountConfirm(null)}
disabled={countBusy}
>
انصراف
</button>
<button
type="button"
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
onClick={() => void confirmCount()}
disabled={countBusy}
>
{countBusy ? "در حال ثبت…" : "تأیید و به‌روزرسانی موجودی"}
</button>
</div>
</div>
</div>
)}
<div className="text-[11px] text-muted">
{isTransfer ? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد هم‌زمان ثبت می‌شود." : `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
</div>
@@ -701,15 +870,15 @@ function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
);
}
function FoundTable({ rows, setRows, txs, whId, unitNameFn }: {
function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
rows: RowDraft[];
setRows: (r: RowDraft[]) => void;
txs: InventoryTransaction[];
items: { tx: InventoryTransaction; remaining: number }[];
whId: string;
unitNameFn: (id: number | null | undefined) => string;
}) {
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبت‌نشده‌ای (یا قابل پیداشدن) در این انبار وجود ندارد.</div>;
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبت‌نشده‌ای (یا کاملاً پیداشده) در این انبار وجود ندارد.</div>;
return (
<div className="overflow-x-auto">
<table className="w-full text-[12.5px]">
@@ -719,40 +888,55 @@ function FoundTable({ rows, setRows, txs, whId, unitNameFn }: {
<th className="py-2 pe-3">مشخصه‌ها</th>
<th className="py-2 pe-3">برند</th>
<th className="py-2 pe-3">واحد</th>
<th className="py-2 pe-3">مفقودی</th>
<th className="py-2 pe-3">مانده‌ی مفقودی</th>
<th className="py-2 pe-3">تعداد پیداشده</th>
<th className="py-2">یادداشت</th>
</tr>
</thead>
<tbody>
{txs.map((t, i) => (
<tr key={t.inventory_id + i} className="border-b border-line/50">
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
<td className="py-1.5 pe-3">
<input
inputMode="decimal"
className="h-7 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
</td>
<td className="py-1.5">
<input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
))}
{items.map(({ tx: t, remaining }, i) => {
const entered = parseNumLocal(rows[i]?.qty || "");
const over = entered > remaining;
return (
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
<td className="py-1.5 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
<td className="py-1.5 pe-3">
<input
inputMode="decimal"
className={`h-7 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${over ? "border-red-500 bg-rose-50 text-rose-700" : "border-line"}`}
placeholder="۰"
value={rows[i]?.qty || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
/>
{over && (
<div className="mt-0.5 text-[10.5px] font-bold text-rose-700">
تعداد پیداشده نمی‌تواند بیشتر از مقدار مفقودی باشد.
</div>
)}
</td>
<td className="py-1.5">
<input
className="h-7 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیح"
value={rows[i]?.note || ""}
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
// تبدیل اعداد فارسی/اعشاری برای اعتبارسنجی نمایشی (بدون ارسال)
function parseNumLocal(s: string): number {
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
return isFinite(v) ? v : 0;
}
@@ -1,11 +1,10 @@
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { accountMenuItems, inventorySubheader, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { useInventoryData, unitName, whName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, jalaliToGregorian, MONTHS, toFa } from "../../lib/jalali";
@@ -21,8 +20,7 @@ function todayKey(): string {
// کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی
export function DailyControlPage() {
const location = useLocation();
const logout = useLogout();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const navigate = useNavigate();
@@ -128,7 +126,6 @@ export function DailyControlPage() {
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={isAdmin ? accountMenuItems(logout) : pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />}
/>
<PageLayout className="flex flex-col gap-3">
{isAdmin && (
@@ -193,7 +190,7 @@ export function DailyControlPage() {
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-1 flex items-center justify-between">
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز " : ""}{toFa(sd)} {MONTHS[sm - 1]} {toFa(sy)}</h2>
<span className="rounded-lg bg-blue/10 px-2 py-1 text-[11.5px] font-bold text-blue">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span>
<span className="rounded-lg bg-rose-50 px-2 py-1 text-[11.5px] font-bold text-rose-600">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span>
</div>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
@@ -207,32 +204,32 @@ export function DailyControlPage() {
{wGroups.map((g) => (
<div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}>
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-blue/10 text-blue">➖</span>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-rose-50 text-rose-600">➖</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span>
<span className="block truncate text-[11px] text-muted">
{[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")}
</span>
</span>
<span className="text-[13px] font-bold text-blue">−{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
<span className="text-[13px] font-bold text-rose-600">{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span>
</button>
{actRec === g.recId && isPM && (
<div className="mt-2 flex gap-1.5 border-t border-line pt-2">
<button
className="rounded-lg border border-green/30 bg-green/5 px-3 py-1 text-[11.5px] text-green"
className="rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-1 text-[11.5px] font-semibold text-emerald-700"
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
↩️ برگشت
</button>
<button
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
📦 مفقودی
</button>
<button
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-1 text-[11.5px] font-semibold text-rose-700"
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
🗑️ اتلاف
+15 -14
View File
@@ -13,17 +13,18 @@ import type { InventoryRow, InventoryTransaction, TransactionType } from "../../
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
// رنگ‌بندی معنایی: سبز زمردی = ورود/افزایش، رز = خروج/کاهش، آبی = خنثی/جابه‌جایی
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
PURCHASE: { label: "خرید", color: "#16a34a", icon: "🛒" },
WITHDRAWAL: { label: "برداشت", color: "#1d4ed8", icon: "➖" },
RETURN: { label: "برگشت", color: "#16a34a", icon: "↩️" },
TRANSFER_IN: { label: "انتقال (ورودی)", color: "#16a34a", icon: "⬅️" },
TRANSFER_OUT: { label: "انتقال (خروجی)", color: "#1d4ed8", icon: "➡️" },
DAMAGE: { label: "ضایعات", color: "#1d4ed8", icon: "⚠️" },
WASTE: { label: "اتلاف", color: "#1d4ed8", icon: "🗑️" },
LOSS: { label: "مفقودی", color: "#1d4ed8", icon: "📦" },
FOUND: { label: "پیدا شده", color: "#16a34a", icon: "🔍" },
STOCK_COUNT: { label: "شمارش موجودی", color: "#64748b", icon: "✅" },
PURCHASE: { label: "خرید", color: "#10b981", icon: "🛒" },
WITHDRAWAL: { label: "برداشت", color: "#e11d48", icon: "➖" },
RETURN: { label: "برگشت", color: "#10b981", icon: "↩️" },
TRANSFER_IN: { label: "انتقال (ورودی)", color: "#2563eb", icon: "⬅️" },
TRANSFER_OUT: { label: "انتقال (خروجی)", color: "#2563eb", icon: "➡️" },
DAMAGE: { label: "ضایعات", color: "#e11d48", icon: "⚠️" },
WASTE: { label: "اتلاف", color: "#e11d48", icon: "🗑️" },
LOSS: { label: "مفقودی", color: "#e11d48", icon: "📦" },
FOUND: { label: "پیدا شده", color: "#10b981", icon: "🔍" },
STOCK_COUNT: { label: "شمارش موجودی", color: "#2563eb", icon: "✅" },
};
interface Group {
@@ -151,11 +152,11 @@ export function InventoryPage() {
<InventoryTopNav pathname={location.pathname} variant={isAdmin ? "admin" : "pm"} />
{isPM && (
<Link
to="/inventory/change?type=PURCHASE"
to="/inventory/change"
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
>
<Plus className="h-3.5 w-3.5" />
افزایش موجود
تغییر موجودی
</Link>
)}
</>
@@ -301,7 +302,7 @@ export function InventoryPage() {
)}
</div>
<div className="text-[11px] text-muted">
۱۰ نوع تراکنش استاندارد • افزایش موجودی سبز، کاهش آبی • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
۱۰ نوع تراکنش استاندارد • سبز زمردی = ورود به انبار · رز = خروج/کاهش · آبی = انتقال و شمارش • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
</div>
</PageLayout>
</div>
@@ -377,7 +378,7 @@ function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpB
>
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
<span className="text-[12px] font-bold" style={{ color: meta.color }}>
{t.quantity_number >= 0 ? "+" : "−"}{faNum(Math.abs(t.quantity_number))} {unit}
{faNum(Math.abs(t.quantity_number))} {unit}
</span>
<span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span>
</button>
@@ -1,12 +1,13 @@
import { Link } from "react-router-dom";
import { cn } from "../../lib/utils";
// ناوبری بالای صفحه در پنل انبار — جایگزین لینک‌های منوی همبرگری
// (مثل سیستم تدارکات: لینک‌ها در بالای صفحه)
// ناوبری بالای صفحه در پنل انبار — فقط روی «داشبورد انبار» (هاب) دیده می‌شود؛
// زیرصفحه‌ها تب ندارند و فقط با «بازگشت به داشبورد» به هاب برمی‌گردند.
//
// - variant="pm" (مدیر پروژه): همه بخش‌های مدیر پروژه — بدون «دفتر تدارکات»
// (مدیر پروژه به سیستم تدارکات دسترسی ندارد) و بدون دکمه‌ی خروج تکی
// (خروج از حساب فقط در منوی همبرگری است).
// - variant="pm" (مدیر پروژه): همه بخش‌ها — بدون «تغییر موجودی» (ورود آن از
// دکمه‌ی «تغییر موجودی» بالای هاب است)، بدون «دفتر تدارکات» (مدیر پروژه
// به سیستم تدارکات دسترسی ندارد) و بدون دکمه‌ی خروج تکی (خروج از حساب
// فقط در منوی همبرگری است).
// - variant="admin" (مدیر تدارکات/ادمین): فقط بخش‌های مدیر تدارکات —
// «بازگشت به دفتر» و «خروج از حساب» در منوی همبرگری می‌مانند.
export function InventoryTopNav({
@@ -26,7 +27,6 @@ export function InventoryTopNav({
]
: [
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
{ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") },
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
{ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") },
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { Warehouse as WarehouseIcon } from "lucide-react";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { accountMenuItems, inventorySubheader, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26, type UserRow } from "../../lib/api";
import { useInventoryData } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
@@ -21,8 +20,7 @@ const ROLE_LABELS: Record<string, string> = {
// سازمان‌دهی (فقط ادمین / مدیر تدارکات) — کاربران، مدیران پروژه و انبارهای هر پروژه
export function OrganizationPage() {
const location = useLocation();
const navigate = useNavigate();
const navigate = useNavigate();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
@@ -162,7 +160,6 @@ export function OrganizationPage() {
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={accountMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant="admin" />}
/>
<PageLayout className="flex flex-col gap-3">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
@@ -6,7 +6,6 @@ import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { inventorySubheader, panelMenuItems, pmMenuItems, useLogout } from "./panelMenu";
import { InventoryTopNav } from "./InventoryTopNav";
import { apiV26 } from "../../lib/api";
import { useInventoryData, projName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
@@ -104,7 +103,6 @@ export function WarehousesPage() {
backLabel="بازگشت به داشبورد"
icon={<WarehouseIcon className="h-4.5 w-4.5" strokeWidth={2} />}
menu={<PanelMenu title="پنل مدیریت انبار" items={pmMenuItems(logout)} />}
actions={<InventoryTopNav pathname={location.pathname} variant="pm" />}
/>
<PageLayout className="flex flex-col gap-3">
{isPM && (