- 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).
155 lines
7.8 KiB
TypeScript
155 lines
7.8 KiB
TypeScript
// ============================================================
|
||
// هستهی نمایش مشترک لیست متریالهای انبار برای بخشهای لیستمحور
|
||
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
|
||
//
|
||
// دسکتاپ (sm+): جدول فشرده با هدر چسبان و ارتفاع بهینه.
|
||
// موبایل (<640px): کارتهای عمودی لمسی با فیلد بزرگ ورود مقدار.
|
||
// هر بخش ستونهای نمایش یکسان را از اینجا میگیرد و فقط رفتار ورودی
|
||
// (تعداد ساده یا شمارششده) را با isCount انتخاب میکند.
|
||
// ============================================================
|
||
|
||
import { faNum } from "../../../lib/formatters";
|
||
|
||
export interface StockRecord {
|
||
id: string;
|
||
material: { id: string; name: string };
|
||
brand: { id: string; name: string } | null;
|
||
unit: { id: number; code: string; name_fa: string | null } | null;
|
||
quantity_number: number;
|
||
specs: Record<string, string>;
|
||
}
|
||
|
||
export interface RowDraft { key: string; qty: string; note: string; }
|
||
|
||
export function specsLine(specs: Record<string, string> | undefined): string {
|
||
return (
|
||
Object.entries(specs || {})
|
||
.map(([k, v]) => `${k}: ${v}`)
|
||
.join(" · ") || "—"
|
||
);
|
||
}
|
||
|
||
interface StockTableProps {
|
||
records: StockRecord[];
|
||
rows: RowDraft[];
|
||
setRows: (r: RowDraft[]) => void;
|
||
/** شمارش موجودی: placeholder «شمارششده» و پیشنمایش مقدار فعلی هنگام خالی بودن */
|
||
isCount?: boolean;
|
||
/** نمایش ستون متریال در دسکتاپ — وقتی فیلتر نام فعال است میتوان پنهان کرد */
|
||
showMaterial?: boolean;
|
||
emptyMsg: string;
|
||
unitNameFn: (id: number | null | undefined) => string;
|
||
}
|
||
|
||
export function StockTable({ records, rows, setRows, isCount = false, showMaterial = true, emptyMsg, unitNameFn }: StockTableProps) {
|
||
if (emptyMsg) {
|
||
return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||
}
|
||
|
||
const qtyPlaceholder = isCount ? "شمارششده" : "۰";
|
||
|
||
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-[560px]">
|
||
<thead className="sticky top-0 z-10 border-b border-line bg-panel shadow-2xs">
|
||
<tr className="text-right text-muted">
|
||
{showMaterial && <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">{isCount ? "شمارششده" : "تعداد"}</th>
|
||
<th className="py-2.5 px-3">یادداشت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-line/40">
|
||
{records.map((r, i) => (
|
||
<tr key={r.id} className="hover:bg-bg/40 transition-colors">
|
||
{showMaterial && (
|
||
<td className="py-2 px-3 font-bold text-ink">{r.material?.name || "—"}</td>
|
||
)}
|
||
<td className="py-2 px-3 text-muted">{specsLine(r.specs)}</td>
|
||
<td className="py-2 px-3">{r.brand?.name || "—"}</td>
|
||
<td className="py-2 px-3">{unitNameFn(r.unit?.id)}</td>
|
||
<td className="py-2 px-3 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||
<td className="py-2 px-3">
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
pattern="[0-9]*"
|
||
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr focus:border-accent"
|
||
placeholder={qtyPlaceholder}
|
||
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (rows[i]?.qty || "")}
|
||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||
/>
|
||
</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 className="mt-2 flex items-center justify-between text-[11px] text-muted">
|
||
<span>ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</span>
|
||
<span>{faNum(records.length)} قلم کالا</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* موبایل: کارتهای عمودی لمسی */}
|
||
<div className="sm:hidden">
|
||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||
{records.map((r, i) => (
|
||
<div key={r.id} className="rounded-xl border border-line bg-panel p-3">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<div className="truncate text-[13.5px] font-bold text-ink">{r.material?.name || "—"}</div>
|
||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||
{r.brand?.name || "بدون برند"} · {unitNameFn(r.unit?.id)}
|
||
</div>
|
||
{r.specs && Object.keys(r.specs).length > 0 && (
|
||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(r.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-ink">{faNum(r.quantity_number)}</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 grid grid-cols-[1fr_1.2fr] gap-2">
|
||
<input
|
||
type="text"
|
||
inputMode="decimal"
|
||
pattern="[0-9]*"
|
||
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[15px] font-bold ltr focus:border-accent"
|
||
placeholder={qtyPlaceholder}
|
||
value={isCount && rows[i]?.qty === "" ? String(r.quantity_number) : (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>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="mt-2 text-[11px] text-muted">{faNum(records.length)} قلم کالا — برای هر قلم، مقدار را وارد کنید.</div>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|