feat(inventory): list-based ChangeInventoryPage + mobile-first UX + return ledger controls
- All ChangeInventoryPage sections (withdrawal/damage/waste/loss/transfer/ stock-count/project-transfer) become list-based like the withdrawal section: pick a warehouse, see its stock rows, material name is only an optional filter; manual material/brand/unit entry removed everywhere except PURCHASE. - RETURN redesigned: day-range presets (today/yesterday/7d/30d + Jalali custom range) with server-side filters, immutable-ledger returnable remainder per withdrawal (REF:[tx-id] note tag + FIFO attribution for legacy returns, round4 float-safe), destination warehouse selector. - PROJECT_TRANSFER: row-based list with one destination project per batch; sequential submits keep failed rows inline with errors and drop succeeded rows; history dashboard untouched. - PURCHASE: unit locked to catalog (search-only Combobox), wizard-style '+ register new material' option with yellow warning, optional Key-Value specs editor (replaces mandatory text chips), continuous-entry UX (warehouse/date kept, auto-focus, session log, duplicate warning). - Mobile-first: responsive table/cards core (StockTable), numeric keyboards (inputMode+pattern), sticky bottom submit bar with dynamic counter above MobileTabBar incl. safe-area inset, horizontally scrollable touch chips. - Backend fix (prereq of RETURN): GET /inventory/transactions date filters crashed with 500 under asyncpg (string vs timestamptz); parse to aware UTC datetimes (DB runs Etc/UTC). - docs/inventory-ledger-gaps.md: ledger gap report (server-side return cap, standalone TRANSFER_IN, transfer row fallback, audit coverage, legacy REF backfill, UTC day semantics, pagination gaps).
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// ============================================================
|
||||
// جدول «پیدا شده» — مفقودیهای باقیماندهی انبار (LOSS − FOUND)
|
||||
// با انتخاب انبار همهی مفقودیها میآیند و نام متریال فقط فیلتر است.
|
||||
// ============================================================
|
||||
|
||||
import type { InventoryTransaction } from "../../../lib/types";
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { specsLine, type RowDraft } from "./StockTable";
|
||||
|
||||
export interface LossItem { tx: InventoryTransaction; remaining: number; }
|
||||
|
||||
interface FoundTableProps {
|
||||
items: LossItem[];
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
emptyMsg: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function parseLocal(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function FoundTable({ items, rows, setRows, unitNameFn, emptyMsg, loading }: FoundTableProps) {
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">در حال بارگذاری مفقودیها…</div>;
|
||||
}
|
||||
if (emptyMsg) {
|
||||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* دسکتاپ */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] overflow-y-auto overflow-x-auto rounded-lg border border-line">
|
||||
<table className="w-full text-[12.5px] min-w-[620px]">
|
||||
<thead className="sticky top-0 z-10 border-b border-line bg-panel text-right text-muted shadow-2xs">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3">متریال</th>
|
||||
<th className="py-2.5 px-3">مشخصهها</th>
|
||||
<th className="py-2.5 px-3">برند</th>
|
||||
<th className="py-2.5 px-3">واحد</th>
|
||||
<th className="py-2.5 px-3">مانده مفقودی</th>
|
||||
<th className="py-2.5 px-3">تعداد پیداشده</th>
|
||||
<th className="py-2.5 px-3">یادداشت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{items.map(({ tx, remaining }, i) => {
|
||||
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||
return (
|
||||
<tr key={tx.id + "|" + i} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||
<td className="py-2 px-3 font-semibold text-ink">{tx.material?.name || "—"}</td>
|
||||
<td className="py-2 px-3 text-muted">{specsLine(tx.specs)}</td>
|
||||
<td className="py-2 px-3">{tx.brand?.name || "—"}</td>
|
||||
<td className="py-2 px-3">{unitNameFn(tx.unit?.id)}</td>
|
||||
<td className="py-2 px-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr focus:border-accent",
|
||||
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-[10px] font-bold text-rose-700">بیشتر از مفقودی!</div>}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||
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>
|
||||
</div>
|
||||
|
||||
{/* موبایل: کارتها */}
|
||||
<div className="sm:hidden">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||
{items.map(({ tx, remaining }, i) => {
|
||||
const over = parseLocal(rows[i]?.qty || "") > remaining;
|
||||
return (
|
||||
<div key={tx.id + "|" + i} className={cn("rounded-xl border bg-panel p-3", over ? "border-red-400" : "border-line")}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[13.5px] font-bold text-ink">{tx.material?.name || "—"}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||
{tx.brand?.name || "بدون برند"} · {unitNameFn(tx.unit?.id)}
|
||||
</div>
|
||||
{tx.specs && Object.keys(tx.specs).length > 0 && (
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(tx.specs)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="shrink-0 rounded-lg bg-bg px-2 py-1 text-center">
|
||||
<div className="text-[10px] text-muted">مانده مفقودی</div>
|
||||
<div className="text-[14px] font-black text-rose-600">{faNum(remaining)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-11 w-full rounded-lg border bg-panel px-3 text-[15px] font-bold ltr focus:border-accent",
|
||||
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)))}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||
placeholder="توضیح"
|
||||
value={rows[i]?.note || ""}
|
||||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, note: e.target.value } : x)))}
|
||||
/>
|
||||
</div>
|
||||
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده مفقودی!</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user