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:
2026-10-03 21:56:46 +03:30
parent 1bf55ba8a6
commit 3931baadd5
14 changed files with 1926 additions and 703 deletions
@@ -0,0 +1,88 @@
// ============================================================
// جدول «اقلام ثبت‌شده در این نشست» برای موجودی اولیه
// بازخورد دیداری برای جلوگیری از ثبت تکراری — فقط حافظه‌ی همان صفحه.
// ============================================================
import { faNum } from "../../../lib/formatters";
import { specsLine } from "./StockTable";
export interface PurchaseSessionEntry {
material: string;
brand: string;
specs: Record<string, string>;
qty: number;
unitLabel: string;
time: string; // HH:MM آماده‌ی نمایش
whName: string;
}
interface PurchaseSessionLogProps {
entries: PurchaseSessionEntry[];
onClear: () => void;
}
export function PurchaseSessionLog({ entries, onClear }: PurchaseSessionLogProps) {
if (!entries.length) return null;
return (
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4">
<div className="mb-2 flex items-center justify-between gap-2 border-b border-line pb-2">
<h3 className="text-[13px] font-bold">
🧾 اقلام ثبت‌شده در این نشست <span className="text-muted font-normal">({faNum(entries.length)} قلم)</span>
</h3>
<button
type="button"
onClick={onClear}
className="rounded-lg border border-line px-2.5 py-1.5 text-[11.5px] text-muted hover:bg-bg"
>
پاک کردن لیست
</button>
</div>
{/* دسکتاپ: جدول فشرده */}
<div className="hidden sm:block">
<div className="max-h-56 overflow-y-auto rounded-lg border border-line">
<table className="w-full text-[12px]">
<thead className="sticky top-0 bg-panel text-right text-muted shadow-2xs">
<tr>
<th className="py-2 px-3">متریال</th>
<th className="py-2 px-3">برند</th>
<th className="py-2 px-3">مشخصه‌ها</th>
<th className="py-2 px-3">تعداد</th>
<th className="py-2 px-3">ساعت</th>
<th className="py-2 px-3">انبار</th>
</tr>
</thead>
<tbody className="divide-y divide-line/40">
{[...entries].reverse().map((e, i) => (
<tr key={i}>
<td className="py-1.5 px-3 font-semibold text-ink">{e.material}</td>
<td className="py-1.5 px-3 text-muted">{e.brand || "—"}</td>
<td className="py-1.5 px-3 text-muted">{specsLine(e.specs)}</td>
<td className="py-1.5 px-3 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></td>
<td className="py-1.5 px-3 text-muted">{e.time}</td>
<td className="py-1.5 px-3 text-muted">{e.whName}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* موبایل: ردیف‌های فشرده */}
<div className="sm:hidden max-h-64 space-y-1.5 overflow-y-auto">
{[...entries].reverse().map((e, i) => (
<div key={i} className="rounded-lg border border-line/70 bg-bg/40 px-3 py-2 text-[12px]">
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 truncate font-bold text-ink">{e.material}</span>
<span className="shrink-0 font-bold">{faNum(e.qty)} <span className="font-normal text-muted">{e.unitLabel}</span></span>
</div>
<div className="mt-0.5 text-[11px] text-muted">
{e.brand || "بدون برند"} · {specsLine(e.specs)} · {e.time} · {e.whName}
</div>
</div>
))}
</div>
</div>
);
}