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,72 @@
// ============================================================
// ویرایشگر مشخصه‌های فنی Key-Value (موجودی اولیه)
// خروجی: jsonb استاندارد {عنوان: مقدار} هم‌خوان با specs_hash بک‌اند.
// مشخصات کاملاً اختیاری است؛ ردیف بدون عنوان دور ریخته می‌شود.
// ============================================================
import { faNum } from "../../../lib/formatters";
export interface SpecKvRow { key: string; value: string; }
export function specsKvToObject(rows: SpecKvRow[]): Record<string, string> {
const out: Record<string, string> = {};
for (const r of rows) {
const k = r.key.trim();
if (k) out[k] = r.value.trim();
}
return out;
}
interface SpecsKvEditorProps {
rows: SpecKvRow[];
onChange: (rows: SpecKvRow[]) => void;
disabled?: boolean;
}
export function SpecsKvEditor({ rows, onChange, disabled }: SpecsKvEditorProps) {
const update = (i: number, patch: Partial<SpecKvRow>) =>
onChange(rows.map((r, ri) => (ri === i ? { ...r, ...patch } : r)));
return (
<div className="space-y-2">
{rows.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
disabled={disabled}
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="عنوان مشخصه (مثلاً ضخامت)"
value={r.key}
onChange={(e) => update(i, { key: e.target.value })}
/>
<span className="shrink-0 text-[12px] text-muted">:</span>
<input
type="text"
disabled={disabled}
className="h-10 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
placeholder="مقدار (مثلاً ۲ میلی‌متر)"
value={r.value}
onChange={(e) => update(i, { value: e.target.value })}
/>
<button
type="button"
disabled={disabled}
onClick={() => onChange(rows.filter((_, ri) => ri !== i))}
className="shrink-0 rounded-lg border border-line px-2.5 py-2 text-[13px] text-rose-600 hover:bg-rose-50 disabled:opacity-40"
title="حذف این مشخصه"
>
✕
</button>
</div>
))}
<button
type="button"
disabled={disabled}
onClick={() => onChange([...rows, { key: "", value: "" }])}
className="rounded-lg border border-dashed border-line px-3 py-2 text-[12px] font-medium text-muted hover:bg-bg disabled:opacity-40"
>
+ افزودن مشخصه {rows.length > 0 && `(${faNum(rows.length)})`}
</button>
</div>
);
}