merge: prod-v10 (prod-v8 + prod-v9 consolidation, list-based ChangeInventoryPage + return ledger)
# Conflicts: # frontend/src/pages/Inventory/ChangeInventoryPage.tsx
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState, type RefObject } from "react";
|
||||
import { normalize } from "../lib/normalize";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useClickOutside } from "./useClickOutside";
|
||||
@@ -17,6 +17,7 @@ export interface ComboboxProps {
|
||||
onChange?: (item: ComboboxItem) => void;
|
||||
items: ComboboxSource; // آرایه از string یا {id, name}
|
||||
allowAdd?: boolean; // دکمهی "+ افزودن به عنوان جدید"
|
||||
addPrefix?: string; // قالب «+ {addPrefix} «متن»» — اگر داده شود بر addSuffix اولویت دارد
|
||||
addSuffix?: string; // متن بعد از +
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
@@ -25,6 +26,8 @@ export interface ComboboxProps {
|
||||
onAdd?: (val: string) => void;
|
||||
onInputChange?: (val: string) => void;
|
||||
onBlur?: () => void;
|
||||
// برای فوکوس برنامهای (مثل بازگشت فوکوس پس از ثبت پیوسته)
|
||||
inputRef?: RefObject<HTMLInputElement | null>;
|
||||
// با false، فوکوسِ برنامهای (مثل فوکوسِ خودکار Dialog) منو را باز نمیکند —
|
||||
// فقط کلیک یا تایپِ صریح کاربر بازش میکند.
|
||||
openOnFocus?: boolean;
|
||||
@@ -44,6 +47,7 @@ export function Combobox({
|
||||
onChange,
|
||||
items,
|
||||
allowAdd = true,
|
||||
addPrefix,
|
||||
addSuffix = "به عنوان گزینه جدید",
|
||||
placeholder,
|
||||
className,
|
||||
@@ -52,6 +56,7 @@ export function Combobox({
|
||||
onAdd,
|
||||
onInputChange,
|
||||
onBlur,
|
||||
inputRef,
|
||||
openOnFocus = true,
|
||||
}: ComboboxProps) {
|
||||
const [text, setText] = useState(value ?? "");
|
||||
@@ -125,6 +130,7 @@ export function Combobox({
|
||||
<div ref={ref} className={cn("relative", className)}>
|
||||
<input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-lg border border-line bg-panel px-3 py-1 text-sm text-ink shadow-sm transition-colors placeholder:text-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
inputClassName,
|
||||
@@ -182,9 +188,15 @@ export function Combobox({
|
||||
onMouseEnter={() => setHighlight(filtered.length)}
|
||||
>
|
||||
<span className="font-bold">+</span>
|
||||
<span>
|
||||
«<b>{text.trim()}</b>» {addSuffix}
|
||||
</span>
|
||||
{addPrefix ? (
|
||||
<span>
|
||||
{addPrefix} «<b>{text.trim()}</b>»
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
«<b>{text.trim()}</b>» {addSuffix}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1856,11 +1856,18 @@ export const apiV26 = {
|
||||
const data = await getData<Parameters<typeof rawInventory>[0][]>(`/inventory?${qs}`);
|
||||
return data.map(rawInventory);
|
||||
},
|
||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string } = {}): Promise<InventoryTransaction[]> => {
|
||||
transactions: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<InventoryTransaction[]> => {
|
||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||
return getData<InventoryTransaction[]>(`/inventory/transactions?${qs}`);
|
||||
},
|
||||
// همان transactions با meta.total برای صفحهبندی «نمایش بیشتر»
|
||||
transactionsPaged: async (params: { project_id?: string; warehouse_id?: string; transaction_type?: string; date_from?: string; date_to?: string; sort?: string; page?: number } = {}): Promise<{ rows: InventoryTransaction[]; total: number }> => {
|
||||
const pid = params.project_id ?? activeProjectId() ?? undefined;
|
||||
const qs = new URLSearchParams({ page_size: "100", ...cleanParams({ ...params, project_id: pid }) }).toString();
|
||||
const res = await request<{ data: InventoryTransaction[]; meta?: { total?: number } }>(`/inventory/transactions?${qs}`);
|
||||
return { rows: res.data ?? [], total: res.meta?.total ?? res.data?.length ?? 0 };
|
||||
},
|
||||
createTransaction: async (body: {
|
||||
transaction_type: TransactionType; warehouse_id: string; dest_warehouse_id?: string | null;
|
||||
material_id: string; brand_id?: string | null; quantity?: number | null;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// ============================================================
|
||||
// لیست اقلام «انتقال بین کارگاهی» — همان الگوی برداشت:
|
||||
// انتخاب انبار مبدأ → لیست همهی اقلام موجود → ورودی تعداد هر ردیف.
|
||||
// پروژه مقصد و یادداشت بار، بالای لیست برای کل اقلام انتخاب میشوند.
|
||||
// ردیفهای ناموفق هنگام ثبت متوالی، با پیام خطا در لیست میمانند و
|
||||
// ردیفهای موفق حذف میشوند (ورودیها کلید-محورند تا جابهجا نشوند).
|
||||
// ============================================================
|
||||
|
||||
import { faNum } from "../../../lib/formatters";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { specsLine, type StockRecord } from "./StockTable";
|
||||
|
||||
interface ProjectTransferListProps {
|
||||
records: StockRecord[];
|
||||
qtyOf: (id: string) => string;
|
||||
onQty: (id: string, v: string) => void;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
/** پیام خطای هر ردیف ناموفق (کلید = id رکورد انبار) */
|
||||
errors: Record<string, string>;
|
||||
emptyMsg: string;
|
||||
}
|
||||
|
||||
function parseNum(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function ProjectTransferList({ records, qtyOf, onQty, unitNameFn, errors, emptyMsg }: ProjectTransferListProps) {
|
||||
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-[600px]">
|
||||
<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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{records.map((r) => {
|
||||
const err = errors[r.id];
|
||||
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||
return (
|
||||
<tr key={r.id} className={cn("hover:bg-bg/40 transition-colors", (over || err) && "bg-rose-50/40")}>
|
||||
<td className="px-3 py-2 font-bold text-ink">{r.material?.name || "—"}</td>
|
||||
<td className="px-3 py-2 text-muted">{specsLine(r.specs)}</td>
|
||||
<td className="px-3 py-2">{r.brand?.name || "—"}</td>
|
||||
<td className="px-3 py-2">{unitNameFn(r.unit?.id)}</td>
|
||||
<td className="px-3 py-2 font-bold text-ink">{faNum(r.quantity_number)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<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={qtyOf(r.id)}
|
||||
onChange={(e) => onQty(r.id, e.target.value)}
|
||||
/>
|
||||
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||
{err && <div className="mt-0.5 text-[10px] font-bold text-rose-700">{err}</div>}
|
||||
</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) => {
|
||||
const err = errors[r.id];
|
||||
const over = parseNum(qtyOf(r.id)) > r.quantity_number;
|
||||
return (
|
||||
<div key={r.id} className={cn("rounded-xl border bg-panel p-3", (over || err) ? "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">{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>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"mt-2 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={qtyOf(r.id)}
|
||||
onChange={(e) => onQty(r.id, e.target.value)}
|
||||
/>
|
||||
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از موجودی!</div>}
|
||||
{err && <div className="mt-1 text-[11px] font-bold text-rose-700">{err}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ============================================================
|
||||
// نوار فیلتر بازهی روز بخش «برگشت»
|
||||
// پیشفرضهای لمسی (امروز/دیروز/۷ روز/۳۰ روز) + بازهی دلخواه با تقویم جلالی.
|
||||
// چیپها در موبایل افقی اسکرولپذیرند (no-scrollbar) با ارتفاع تاچ.
|
||||
// ============================================================
|
||||
|
||||
import { JalaliDatePicker } from "../../../components/JalaliDatePicker";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { DATE_PRESETS, type DatePreset } from "./returnLogic";
|
||||
|
||||
interface ReturnFilterBarProps {
|
||||
preset: DatePreset;
|
||||
onPreset: (p: DatePreset) => void;
|
||||
customFrom: string | null;
|
||||
customTo: string | null;
|
||||
onCustomFrom: (iso: string | null) => void;
|
||||
onCustomTo: (iso: string | null) => void;
|
||||
}
|
||||
|
||||
export function ReturnFilterBar({ preset, onPreset, customFrom, customTo, onCustomFrom, onCustomTo }: ReturnFilterBarProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="no-scrollbar -mx-1 flex gap-1.5 overflow-x-auto px-1 pb-0.5">
|
||||
{DATE_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onPreset(p.id)}
|
||||
className={cn(
|
||||
"h-11 shrink-0 whitespace-nowrap rounded-lg px-3.5 text-[12.5px] transition-colors",
|
||||
preset === p.id
|
||||
? "bg-accent font-bold text-white shadow-xs"
|
||||
: "border border-line bg-panel text-ink hover:bg-bg",
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{preset === "custom" && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-semibold text-muted">از تاریخ</label>
|
||||
<JalaliDatePicker value={customFrom} onChange={onCustomFrom} placeholder="از تاریخ…" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-[11px] font-semibold text-muted">تا تاریخ</label>
|
||||
<JalaliDatePicker value={customTo} onChange={onCustomTo} placeholder="تا تاریخ…" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// ============================================================
|
||||
// جدول برداشتهای بازه برای بخش «برگشت»
|
||||
// هر ردیف = یک تراکنش برداشت واقعی (Immutable Ledger) با مانده قابل
|
||||
// برگشت محاسبهشده؛ ورودی مقدار برگشت به همان مانده سقف دارد.
|
||||
// ورودیها کلید-محورند (کلید = tx-id) تا حذف ردیفهای موفق، بقیه را
|
||||
// بههم نریزد.
|
||||
// ============================================================
|
||||
|
||||
import type { InventoryTransaction } from "../../../lib/types";
|
||||
import { faNum, fmtDateTime } from "../../../lib/formatters";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { specsLine } from "./StockTable";
|
||||
|
||||
interface ReturnTableProps {
|
||||
withdrawals: InventoryTransaction[];
|
||||
qtyOf: (id: string) => string;
|
||||
noteOf: (id: string) => string;
|
||||
onQty: (id: string, v: string) => void;
|
||||
onNote: (id: string, v: string) => void;
|
||||
remainders: Map<string, number>;
|
||||
unitNameFn: (id: number | null | undefined) => string;
|
||||
emptyMsg: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
function parseNum(s: string): number {
|
||||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
export function ReturnTable({ withdrawals, qtyOf, noteOf, onQty, onNote, remainders, unitNameFn, emptyMsg, loading }: ReturnTableProps) {
|
||||
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-[760px]">
|
||||
<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>
|
||||
<th className="py-2.5 px-3">مقدار برگشت / توضیح</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/40">
|
||||
{withdrawals.map((w) => {
|
||||
const cap = remainders.get(w.id) ?? 0;
|
||||
const q = parseNum(qtyOf(w.id));
|
||||
const over = q > cap + 1e-9;
|
||||
return (
|
||||
<tr key={w.id} className={cn("hover:bg-bg/40 transition-colors", over && "bg-rose-50/40")}>
|
||||
<td className="whitespace-nowrap px-3 py-2 text-muted">{fmtDateTime(w.transaction_date)}</td>
|
||||
<td className="px-3 py-2 font-bold text-ink">{w.material?.name || "—"}</td>
|
||||
<td className="px-3 py-2 text-muted">{specsLine(w.specs)}</td>
|
||||
<td className="px-3 py-2">{w.brand?.name || "—"}</td>
|
||||
<td className="px-3 py-2">{unitNameFn(w.unit?.id)}</td>
|
||||
<td className="px-3 py-2 font-bold">{faNum(Math.abs(w.quantity_number || 0))}</td>
|
||||
<td className={cn("px-3 py-2 font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*"
|
||||
className={cn(
|
||||
"h-8 w-20 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={qtyOf(w.id)}
|
||||
onChange={(e) => onQty(w.id, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-8 w-28 rounded-lg border border-line bg-panel px-2 text-sm focus:border-accent"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
value={noteOf(w.id)}
|
||||
onChange={(e) => onNote(w.id, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{over && <div className="mt-0.5 text-[10px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted">
|
||||
«مانده قابل برگشت» = مقدار برداشت − برگشتهای ثبتشده همان ردیف. سقف ورودی همین مانده است.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* موبایل: کارتها */}
|
||||
<div className="sm:hidden">
|
||||
<div className="max-h-[min(560px,calc(100vh-320px))] space-y-2 overflow-y-auto pl-1">
|
||||
{withdrawals.map((w) => {
|
||||
const cap = remainders.get(w.id) ?? 0;
|
||||
const q = parseNum(qtyOf(w.id));
|
||||
const over = q > cap + 1e-9;
|
||||
return (
|
||||
<div key={w.id} 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">{w.material?.name || "—"}</div>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted">
|
||||
{w.brand?.name || "بدون برند"} · {unitNameFn(w.unit?.id)}
|
||||
</div>
|
||||
{w.specs && Object.keys(w.specs).length > 0 && (
|
||||
<div className="mt-1 text-[11px] leading-4 text-muted">{specsLine(w.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={cn("text-[14px] font-black", cap <= 0 ? "text-muted" : "text-emerald-700")}>{faNum(cap)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted">
|
||||
<span>{fmtDateTime(w.transaction_date)}</span>
|
||||
<span>· برداشت: <b className="text-ink">{faNum(Math.abs(w.quantity_number || 0))}</b></span>
|
||||
{w.delivered_to && <span>· تحویل به: <b className="text-ink">{w.delivered_to}</b></span>}
|
||||
</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={qtyOf(w.id)}
|
||||
onChange={(e) => onQty(w.id, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="h-11 w-full rounded-lg border border-line bg-panel px-3 text-[13px] focus:border-accent"
|
||||
placeholder="توضیح (اختیاری)"
|
||||
value={noteOf(w.id)}
|
||||
onChange={(e) => onNote(w.id, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{over && <div className="mt-1 text-[11px] font-bold text-rose-700">بیشتر از مانده قابل برگشت!</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// ============================================================
|
||||
// هستهی نمایش مشترک لیست متریالهای انبار برای بخشهای لیستمحور
|
||||
// (برداشت / ضایعات / اتلاف / مفقودی / انتقال داخلی / شمارش موجودی)
|
||||
//
|
||||
// دسکتاپ (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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// ============================================================
|
||||
// منطق بخش «برگشت» — واکشی برداشتها/برگشتها و محاسبه مانده قابل برگشت
|
||||
//
|
||||
// اصل تغییرناپذیری دفتر (Immutable Ledger): هیچ تراکنشی ویرایش/حذف نمیشود؛
|
||||
// مانده هر برداشت = مقدار برداشت − مجموع برگشتهای متصل به آن.
|
||||
// اتصال دو راه دارد:
|
||||
// ۱) برگشتهای جدید note شان [REF:<tx-id>] دارد → اتصال دقیق.
|
||||
// ۲) برگشتهای قدیمی بدون REF (ثبتشده در همان انبار مبدأ) → تخصیص FIFO
|
||||
// بین برداشتهای همان ردیف انبار (متریال+برند+مشخصات+واحد).
|
||||
// همهی جمع/تفریقها با round4 گرد میشوند تا خطای اعشار جاوااسکریپت
|
||||
// (مثل 0.30000000000000004) در ماندهها و سقفها نشت نکند.
|
||||
// ============================================================
|
||||
|
||||
import { apiV26 } from "../../../lib/api";
|
||||
import type { InventoryTransaction } from "../../../lib/types";
|
||||
|
||||
/** گرد کردن به ۴ رقم اعشار — مرجع واحد برای همهی محاسبات مانده */
|
||||
export function round4(n: number): number {
|
||||
return Math.round(n * 10000) / 10000;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// تاریخ — کلید روز میلادی محلی و بازههای پیشفرض
|
||||
// ------------------------------------------------------------
|
||||
|
||||
export function gregKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const x = new Date(d);
|
||||
x.setDate(x.getDate() + n);
|
||||
return x;
|
||||
}
|
||||
|
||||
export type DatePreset = "today" | "yesterday" | "7d" | "30d" | "custom";
|
||||
|
||||
export const DATE_PRESETS: { id: DatePreset; label: string }[] = [
|
||||
{ id: "today", label: "امروز" },
|
||||
{ id: "yesterday", label: "دیروز" },
|
||||
{ id: "7d", label: "۷ روز اخیر" },
|
||||
{ id: "30d", label: "۳۰ روز اخیر" },
|
||||
{ id: "custom", label: "بازه دلخواه" },
|
||||
];
|
||||
|
||||
/** بازهی سرور-ساید برای هر پیشفرض؛ date_to روزِ (inclusive بکاند: < date_to + 24h) */
|
||||
export function presetRange(
|
||||
preset: DatePreset,
|
||||
customFrom?: string,
|
||||
customTo?: string,
|
||||
): { date_from: string; date_to: string } {
|
||||
const today = new Date();
|
||||
switch (preset) {
|
||||
case "today":
|
||||
return { date_from: gregKey(today), date_to: gregKey(today) };
|
||||
case "yesterday": {
|
||||
const y = gregKey(addDays(today, -1));
|
||||
return { date_from: y, date_to: y };
|
||||
}
|
||||
case "7d":
|
||||
return { date_from: gregKey(addDays(today, -6)), date_to: gregKey(today) };
|
||||
case "30d":
|
||||
return { date_from: gregKey(addDays(today, -29)), date_to: gregKey(today) };
|
||||
case "custom":
|
||||
return {
|
||||
date_from: customFrom || gregKey(today),
|
||||
date_to: customTo || customFrom || gregKey(today),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// تگ رفرنس [REF:<uuid>]
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const REF_RE = /\[REF:([0-9a-fA-F-]{36})\]/;
|
||||
|
||||
export function parseRef(note?: string | null): string | null {
|
||||
if (!note) return null;
|
||||
const m = note.match(REF_RE);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** یادداشت نهایی برگشت = یادداشت کاربر + تگ رفرنس برداشت */
|
||||
export function buildReturnNote(userNote: string | undefined, withdrawalTxId: string): string {
|
||||
const tag = `[REF:${withdrawalTxId}]`;
|
||||
const note = (userNote || "").trim();
|
||||
return note ? `${note} ${tag}` : tag;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// واکشی صفحهبندیشده (هر صفحه ۱۰۰ — سقف بکاند)
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const MAX_PAGES = 10; // سقف محافظهکارانه: ۱۰۰۰ رکورد
|
||||
|
||||
export async function fetchWithdrawalsPage(
|
||||
sourceWhId: string,
|
||||
dateFrom: string,
|
||||
dateTo: string,
|
||||
page: number,
|
||||
): Promise<{ rows: InventoryTransaction[]; total: number }> {
|
||||
return apiV26.inventory.transactionsPaged({
|
||||
warehouse_id: sourceWhId,
|
||||
transaction_type: "WITHDRAWAL",
|
||||
date_from: dateFrom,
|
||||
date_to: dateTo,
|
||||
sort: "transaction_date",
|
||||
page,
|
||||
});
|
||||
}
|
||||
|
||||
/** همهی برگشتهای پروژه از ابتدای بازه (برای تطبیق REF) — صفحهبهصفحه تا سقف */
|
||||
export async function fetchProjectReturns(
|
||||
dateFrom: string,
|
||||
): Promise<InventoryTransaction[]> {
|
||||
const out: InventoryTransaction[] = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||
transaction_type: "RETURN",
|
||||
date_from: dateFrom,
|
||||
sort: "transaction_date",
|
||||
page,
|
||||
});
|
||||
out.push(...rows);
|
||||
if (out.length >= total || rows.length === 0) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** همهی تراکنشهای یک نوع در یک انبار (برای محاسبه مانده مفقودیها) */
|
||||
export async function fetchAllTypeInWarehouse(
|
||||
warehouseId: string,
|
||||
transactionType: string,
|
||||
): Promise<InventoryTransaction[]> {
|
||||
const out: InventoryTransaction[] = [];
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const { rows, total } = await apiV26.inventory.transactionsPaged({
|
||||
warehouse_id: warehouseId,
|
||||
transaction_type: transactionType,
|
||||
sort: "transaction_date",
|
||||
page,
|
||||
});
|
||||
out.push(...rows);
|
||||
if (out.length >= total || rows.length === 0) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// مانده قابل برگشت
|
||||
// ------------------------------------------------------------
|
||||
|
||||
/** کلید ردیف انبار: متریال + برند + مشخصات (مرتبشده) + واحد — همارز specs_hash بکاند */
|
||||
function rowKey(t: InventoryTransaction): string {
|
||||
const specs = Object.keys(t.specs || {})
|
||||
.sort()
|
||||
.map((k) => `${k}=${String((t.specs || {})[k])}`)
|
||||
.join("&");
|
||||
return [t.material?.id || "", t.brand?.id || "", specs, t.unit?.id ?? ""].join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* مانده قابل برگشت هر برداشت را حساب میکند.
|
||||
* - برگشتهای REF-دار: مستقیماً از برداشت مقصد کم میشوند (حتی اگر خارج از بازه/انبار ثبت شده باشند).
|
||||
* - برگشتهای REF-دار که برداشت مقصدشان در لیست نیست: نادیده گرفته میشوند (به برداشت خودشان تعلق دارند).
|
||||
* - برگشتهای قدیمی بدون REF (همان انبار مبدأ): FIFO بین برداشتهای همردیف تخصیص مییابند.
|
||||
*/
|
||||
export function computeRemainders(
|
||||
withdrawals: InventoryTransaction[],
|
||||
returns: InventoryTransaction[],
|
||||
sourceWhId: string,
|
||||
): Map<string, number> {
|
||||
const rem = new Map<string, number>();
|
||||
for (const w of withdrawals) rem.set(w.id, round4(Math.abs(w.quantity_number || 0)));
|
||||
|
||||
const inList = new Set(withdrawals.map((w) => w.id));
|
||||
const legacy: InventoryTransaction[] = [];
|
||||
|
||||
for (const r of returns) {
|
||||
const qty = round4(Math.abs(r.quantity_number || 0));
|
||||
if (qty <= 0) continue;
|
||||
const ref = parseRef(r.note);
|
||||
if (ref) {
|
||||
if (inList.has(ref)) {
|
||||
rem.set(ref, round4((rem.get(ref) ?? 0) - qty));
|
||||
}
|
||||
// ref خارج از لیست → به برداشت خودش (خارج از بازه) تعلق دارد؛ نادیده
|
||||
} else {
|
||||
legacy.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
// تخصیص FIFO برگشتهای قدیمیِ همانبار مبدأ بین برداشتهای همردیف (قدیمی→جدید)
|
||||
const buckets = new Map<string, InventoryTransaction[]>();
|
||||
for (const w of withdrawals) {
|
||||
const k = rowKey(w);
|
||||
const list = buckets.get(k);
|
||||
if (list) list.push(w);
|
||||
else buckets.set(k, [w]);
|
||||
}
|
||||
for (const list of buckets.values()) {
|
||||
list.sort(
|
||||
(a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime(),
|
||||
);
|
||||
}
|
||||
for (const r of legacy) {
|
||||
if (r.warehouse_id !== sourceWhId) continue; // برگشت قدیمی به انبار دیگر قابل انتساب نیست
|
||||
const list = buckets.get(rowKey(r));
|
||||
if (!list) continue;
|
||||
let q = round4(Math.abs(r.quantity_number || 0));
|
||||
for (const w of list) {
|
||||
if (q <= 0) break;
|
||||
const cur = rem.get(w.id) ?? 0;
|
||||
if (cur <= 0) continue;
|
||||
const take = Math.min(cur, q);
|
||||
rem.set(w.id, round4(cur - take));
|
||||
q = round4(q - take);
|
||||
}
|
||||
}
|
||||
|
||||
// کف صفر (محافظت در برابر دادههای ناسازگار قدیمی)
|
||||
for (const [k, v] of rem) if (v < 0) rem.set(k, 0);
|
||||
return rem;
|
||||
}
|
||||
|
||||
/** مانده مفقودیِ باقیمانده هر LOSS = Σ|LOSS| − ΣFOUND روی همان ردیف انبار */
|
||||
export function computeLossRemainders(
|
||||
losses: InventoryTransaction[],
|
||||
founds: InventoryTransaction[],
|
||||
): { tx: InventoryTransaction; remaining: number }[] {
|
||||
const foundByKey = new Map<string, number>();
|
||||
for (const f of founds) {
|
||||
const k = rowKey(f);
|
||||
foundByKey.set(k, round4((foundByKey.get(k) || 0) + Math.abs(f.quantity_number || 0)));
|
||||
}
|
||||
return losses
|
||||
.map((tx) => {
|
||||
const loss = Math.abs(tx.quantity_number || 0);
|
||||
const found = foundByKey.get(rowKey(tx)) || 0;
|
||||
return { tx, remaining: Math.max(0, round4(loss - found)) };
|
||||
})
|
||||
.filter((x) => x.remaining > 0);
|
||||
}
|
||||
@@ -786,3 +786,12 @@ button:disabled {
|
||||
padding: 0.45rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* اسکرول افقی بدون نوار اسکرول (چیپهای لمسی موبایل) */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user