1464 lines
71 KiB
TypeScript
1464 lines
71 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
import { Header, PageLayout } from "../../components/layout/Header";
|
||
import { Combobox } from "../../components/Combobox";
|
||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||
import { useAuthStore } from "../../store/auth";
|
||
import { inventorySubheader } from "./panelMenu";
|
||
import { api, apiV26 } from "../../lib/api";
|
||
import { notifyDataChanged } from "../../lib/crossTab";
|
||
import { useInventoryData, unitName, whName } from "./useInventoryData";
|
||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||
import { uniqueUnits } from "../../lib/units";
|
||
import type { InventoryTransaction, TransactionType, Project, ProjectTransfer } from "../../lib/types";
|
||
import { faNum, fmtDate } from "../../lib/formatters";
|
||
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
|
||
import { cn } from "../../lib/utils";
|
||
|
||
type FormType = TransactionType | "TRANSFER" | "PROJECT_TRANSFER";
|
||
|
||
const FORM_TYPES: { t: FormType; label: string; dir: string; cls: "in" | "out" | "mv" | "cnt"; color: string; desc: string }[] = [
|
||
{ t: "PURCHASE", label: "موجودی اولیه", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت موجودی اولیه — متریال/برند/واحد را از فهرست انتخاب کنید؛ اگر نامی در فهرست نبود، همان نام تایپشده هنگام ثبت بهصورت خودکار در کاتالوگ سراسری ساخته میشود. ثبت حداقل یک مشخصه فنی الزامی است." },
|
||
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||
{ t: "PROJECT_TRANSFER", label: "انتقال بین کارگاهی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "انتقال متریال از انبار این پروژه به یک پروژه دیگر — پس از ثبت، موجودی در انبار مبدأ کسر شده و کالا به عنوان بار در راه برای پروژه مقصد ارسال میشود تا مدیر پروژه مقصد آن را تحویل بگیرد." },
|
||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#10b981", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||
{ t: "TRANSFER", label: "انتقال", dir: "جابهجایی", cls: "mv", color: "#2563eb", desc: "جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
|
||
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت ضایعات خرید (مثلاً لولههای سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
|
||
{ t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#e11d48", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." },
|
||
{ t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#e11d48", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." },
|
||
{ t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#10b981", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودیهای همان انبار و متریال (یا «همه مفقودیها») نمایش داده میشود." },
|
||
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#2563eb", desc: "شمارش فیزیکی — مقدار شمارششده هر رکورد را وارد کنید تا موجودی اصلاح شود و اختلاف ثبت گردد." },
|
||
];
|
||
|
||
interface RowDraft { key: string; qty: string; note: string; }
|
||
|
||
function todayKey(): string {
|
||
const d = new Date();
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
function gregKey(d: Date): string {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
function jalDateLabel(d: Date): string {
|
||
const [jy, jm, jd] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
|
||
return `${toFa(jd)} ${MONTHS[jm - 1]} ${toFa(jy)}`;
|
||
}
|
||
|
||
export function ChangeInventoryPage() {
|
||
const user = useAuthStore((s) => s.user);
|
||
const data = useInventoryData();
|
||
const [params] = useSearchParams();
|
||
|
||
const [type, setType] = useState<FormType>("PURCHASE");
|
||
const [whId, setWhId] = useState("");
|
||
const [toProjectId, setToProjectId] = useState("");
|
||
const [allProjects, setAllProjects] = useState<Project[]>([]);
|
||
const [transfers, setTransfers] = useState<ProjectTransfer[]>([]);
|
||
const [loadingTransfers, setLoadingTransfers] = useState(false);
|
||
|
||
// Edit modal state
|
||
const [editingTransfer, setEditingTransfer] = useState<ProjectTransfer | null>(null);
|
||
const [editQty, setEditQty] = useState("");
|
||
const [editToProjectId, setEditToProjectId] = useState("");
|
||
const [editNote, setEditNote] = useState("");
|
||
const [editBusy, setEditBusy] = useState(false);
|
||
|
||
// Delete modal state
|
||
const [deleteConfirmTransfer, setDeleteConfirmTransfer] = useState<ProjectTransfer | null>(null);
|
||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||
|
||
const [fromId, setFromId] = useState("");
|
||
const [toId, setToId] = useState("");
|
||
const [matTxt, setMatTxt] = useState("");
|
||
const [brandTxt, setBrandTxt] = useState("");
|
||
const [unitTxt, setUnitTxt] = useState("");
|
||
const [qty, setQty] = useState("");
|
||
const [dateInput, setDateInput] = useState<string>(() => new Date().toISOString());
|
||
const [specsChips, setSpecsChips] = useState<string[]>([]);
|
||
const [note, setNote] = useState("");
|
||
const [rows, setRows] = useState<RowDraft[]>([]);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const t = params.get("type");
|
||
const w = params.get("wh");
|
||
const m = params.get("mat");
|
||
if (t && FORM_TYPES.some((f) => f.t === t)) {
|
||
setType(t as TransactionType);
|
||
if (w) setWhId(w);
|
||
if (m) setMatTxt(m);
|
||
}
|
||
}, [params]);
|
||
|
||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||
const isPM = user?.role === "pm";
|
||
|
||
const warehouses = data.warehouses;
|
||
const whOpts = useMemo(
|
||
() => (isAdmin ? warehouses : warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""))),
|
||
[warehouses, isAdmin, user?.project_id],
|
||
);
|
||
|
||
const formRecords = useMemo(() => {
|
||
const txt = matTxt.trim();
|
||
const wid = type === "TRANSFER" ? fromId : whId;
|
||
return data.inventory.filter((r) => {
|
||
if (r.warehouse?.id !== wid) return false;
|
||
if (txt && !r.material.name.includes(txt)) return false;
|
||
return true;
|
||
});
|
||
}, [data.inventory, whId, fromId, type, matTxt]);
|
||
|
||
const matByName = (name: string) => data.lookup.materials.find((m) => m.name === name) || null;
|
||
const brandIdByName = (name: string) => data.lookup.brands.find((b) => b.name === name)?.id || null;
|
||
const unitIdByName = (name: string) => data.lookup.units.find((u) => (u.name_fa || u.code) === name)?.id || null;
|
||
const parseNum = (s: string) => { const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, "")); return isFinite(v) ? v : NaN; };
|
||
|
||
const toServerIso = () => {
|
||
try {
|
||
const d = new Date(dateInput);
|
||
if (!Number.isNaN(d.getTime())) return d.toISOString();
|
||
} catch { /* نادیده */ }
|
||
return new Date().toISOString();
|
||
};
|
||
|
||
const specsFromChips = () => {
|
||
const out: Record<string, string> = {};
|
||
specsChips.forEach((c) => {
|
||
const k = c.trim();
|
||
if (k) out[k] = "";
|
||
});
|
||
return out;
|
||
};
|
||
const specsComplete = specsChips.some((c) => c.trim());
|
||
|
||
const todayWithdrawals = useMemo(() => {
|
||
const tk = todayKey();
|
||
const txt = matTxt.trim();
|
||
return data.transactions
|
||
.filter((t) => {
|
||
if (t.transaction_type !== "WITHDRAWAL" || t.warehouse_id !== whId) return false;
|
||
if (gregKey(new Date(t.transaction_date)) !== tk) return false;
|
||
if (txt && !(t.material?.name || "").includes(txt)) return false;
|
||
return true;
|
||
})
|
||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||
}, [data.transactions, whId, matTxt]);
|
||
|
||
const lossTxs = useMemo(() => {
|
||
const txt = matTxt.trim();
|
||
return data.transactions
|
||
.filter((t) => {
|
||
if (t.transaction_type !== "LOSS" || t.warehouse_id !== whId) return false;
|
||
if (txt && !(t.material?.name || "").includes(txt)) return false;
|
||
return true;
|
||
})
|
||
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
|
||
}, [data.transactions, whId, matTxt]);
|
||
|
||
const foundRemaining = useMemo(() => {
|
||
const foundByKey = new Map<string, number>();
|
||
data.transactions
|
||
.filter((x) => x.transaction_type === "FOUND")
|
||
.forEach((x) => {
|
||
const k = lossKey(x);
|
||
foundByKey.set(k, (foundByKey.get(k) || 0) + Math.abs(x.quantity_number || 0));
|
||
});
|
||
return lossTxs.map((t) => {
|
||
const loss = Math.abs(t.quantity_number || 0);
|
||
const found = foundByKey.get(lossKey(t)) || 0;
|
||
return Math.max(0, Math.round((loss - found) * 10000) / 10000);
|
||
});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [lossTxs, data.transactions, whId, matTxt]);
|
||
|
||
const lossKey = (t: InventoryTransaction) =>
|
||
[t.warehouse_id, t.material?.id || "", t.brand?.id || "", JSON.stringify(t.specs || {})].join("|");
|
||
|
||
const pendingLosses = useMemo(
|
||
() =>
|
||
lossTxs
|
||
.map((tx, i) => ({ tx, remaining: foundRemaining[i] ?? 0 }))
|
||
.filter((x) => x.remaining > 0),
|
||
[lossTxs, foundRemaining],
|
||
);
|
||
|
||
const postOne = async (op: Record<string, unknown>) => {
|
||
await apiV26.inventory.createTransaction(op as never);
|
||
};
|
||
|
||
const postSeq = async (ops: Record<string, unknown>[]) => {
|
||
setBusy(true);
|
||
try {
|
||
for (const op of ops) await postOne(op);
|
||
toast.success("ثبت شد ✓");
|
||
notifyDataChanged("inventory");
|
||
setRows([]);
|
||
setMatTxt("");
|
||
setBrandTxt("");
|
||
setUnitTxt("");
|
||
setQty("");
|
||
setSpecsChips([]);
|
||
setNote("");
|
||
await data.refresh();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در ثبت");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const loadTransfers = useCallback(async () => {
|
||
if (!user?.project_id) return;
|
||
setLoadingTransfers(true);
|
||
try {
|
||
const list = await apiV26.projectTransfers.list(user.project_id, "outgoing");
|
||
setTransfers(Array.isArray(list) ? list : []);
|
||
} catch (err) {
|
||
console.error("Failed to load project transfers:", err);
|
||
setTransfers([]);
|
||
} finally {
|
||
setLoadingTransfers(false);
|
||
}
|
||
}, [user?.project_id]);
|
||
|
||
useEffect(() => {
|
||
api.projects
|
||
.list()
|
||
.then((res) => setAllProjects(Array.isArray(res) ? res : []))
|
||
.catch(() => setAllProjects([]));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (type === "PROJECT_TRANSFER") {
|
||
void loadTransfers();
|
||
}
|
||
}, [type, loadTransfers]);
|
||
|
||
const whStock = useMemo(() => {
|
||
if (!whId) return [];
|
||
return (data.inventory || []).filter((r) => r.warehouse?.id === whId && r.quantity_number > 0);
|
||
}, [data.inventory, whId]);
|
||
|
||
|
||
|
||
const handleSaveEdit = async () => {
|
||
if (!editingTransfer) return;
|
||
const n = parseNum(editQty);
|
||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||
if (!editToProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
|
||
|
||
setEditBusy(true);
|
||
try {
|
||
await apiV26.projectTransfers.update(editingTransfer.id, {
|
||
quantity: n,
|
||
to_project_id: editToProjectId,
|
||
note: editNote,
|
||
}, user?.project_id);
|
||
toast.success("انتقال با موفقیت ویرایش شد ✓");
|
||
notifyDataChanged("inventory");
|
||
setEditingTransfer(null);
|
||
await data.refresh();
|
||
await loadTransfers();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در ویرایش انتقال");
|
||
} finally {
|
||
setEditBusy(false);
|
||
}
|
||
};
|
||
|
||
const handleConfirmDelete = async () => {
|
||
if (!deleteConfirmTransfer) return;
|
||
setDeleteBusy(true);
|
||
try {
|
||
await apiV26.projectTransfers.delete(deleteConfirmTransfer.id, user?.project_id);
|
||
toast.success("انتقال لغو شد و موجودی به انبار مبدأ بازگردانده شد ✓");
|
||
notifyDataChanged("inventory");
|
||
setDeleteConfirmTransfer(null);
|
||
await data.refresh();
|
||
await loadTransfers();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در لغو انتقال");
|
||
} finally {
|
||
setDeleteBusy(false);
|
||
}
|
||
};
|
||
|
||
const submitPurchase = async () => {
|
||
const matName = matTxt.trim();
|
||
const brandName = brandTxt.trim();
|
||
const unitName = unitTxt.trim();
|
||
const n = parseNum(qty);
|
||
if (!whId) return toast.error("انبار را انتخاب کنید");
|
||
if (!matName) return toast.error("نام متریال را وارد کنید");
|
||
if (!brandName) return toast.error("نام برند را وارد کنید");
|
||
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی ثبت کنید");
|
||
|
||
try {
|
||
let uid = unitIdByName(unitName);
|
||
if (!uid) {
|
||
const created = await apiV26.catalog.createUnit(unitName);
|
||
uid = created.id;
|
||
}
|
||
let bid = brandIdByName(brandName);
|
||
if (!bid) {
|
||
const created = await apiV26.catalog.createBrand(brandName);
|
||
bid = created.id;
|
||
}
|
||
let m = matByName(matName);
|
||
if (!m) {
|
||
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
||
m = { id: created.id, name: created.name, canonical_unit: null };
|
||
}
|
||
const op: Record<string, unknown> = {
|
||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||
quantity: n, unit_id: uid, specs: specsFromChips(), note,
|
||
transaction_date: toServerIso(),
|
||
};
|
||
await postSeq([op]);
|
||
} catch (err) {
|
||
setBusy(false);
|
||
toast.error(err instanceof Error ? err.message : "خطا در ثبت موجودی اولیه");
|
||
}
|
||
};
|
||
|
||
const submitProjectTransfer = async () => {
|
||
const matName = matTxt.trim();
|
||
const brandName = brandTxt.trim();
|
||
const unitName = unitTxt.trim();
|
||
const n = parseNum(qty);
|
||
if (!whId) return toast.error("انبار مبدأ را انتخاب کنید");
|
||
if (!toProjectId) return toast.error("پروژه مقصد را انتخاب کنید");
|
||
if (!matName) return toast.error("نام متریال را وارد کنید");
|
||
if (!unitName) return toast.error("نام واحد را وارد کنید");
|
||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||
|
||
setBusy(true);
|
||
try {
|
||
// Find matching inventory in the selected source warehouse
|
||
const invInWh = (data.inventory || []).filter(
|
||
(r) => r.warehouse?.id === whId && r.material.name.trim() === matName
|
||
);
|
||
const matchedInv = invInWh.find(
|
||
(r) => (brandName ? r.brand?.name === brandName : !r.brand)
|
||
) || invInWh[0];
|
||
|
||
let uid = matchedInv?.unit?.id ?? unitIdByName(unitName);
|
||
if (!uid) {
|
||
const created = await apiV26.catalog.createUnit(unitName);
|
||
uid = created.id;
|
||
}
|
||
|
||
let bid: string | null = matchedInv?.brand?.id ?? (brandName ? brandIdByName(brandName) : null);
|
||
if (brandName && !bid) {
|
||
const created = await apiV26.catalog.createBrand(brandName);
|
||
bid = created.id;
|
||
}
|
||
|
||
let mid = matchedInv?.material.id;
|
||
if (!mid) {
|
||
let m = matByName(matName);
|
||
if (!m) {
|
||
const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] });
|
||
mid = created.id;
|
||
} else {
|
||
mid = m.id;
|
||
}
|
||
}
|
||
|
||
const finalSpecs = matchedInv ? (matchedInv.specs || {}) : specsFromChips();
|
||
|
||
await apiV26.projectTransfers.create({
|
||
from_warehouse_id: whId,
|
||
to_project_id: toProjectId,
|
||
material_id: mid,
|
||
brand_id: bid || null,
|
||
unit_id: uid,
|
||
quantity: n,
|
||
specs: finalSpecs,
|
||
note,
|
||
transfer_date: toServerIso(),
|
||
}, user?.project_id);
|
||
|
||
toast.success("انتقال بین کارگاهی ثبت شد و از انبار مبدأ کسر گردید ✓");
|
||
notifyDataChanged("inventory");
|
||
setQty("");
|
||
setSpecsChips([]);
|
||
setNote("");
|
||
setMatTxt("");
|
||
setBrandTxt("");
|
||
setUnitTxt("");
|
||
setToProjectId("");
|
||
await data.refresh();
|
||
await loadTransfers();
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطا در ثبت انتقال بین کارگاهی");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
const submitList = async (t: TransactionType) => {
|
||
const ops: Record<string, unknown>[] = rows
|
||
.map((r, i): Record<string, unknown> | null => {
|
||
const n = parseNum(r.qty);
|
||
if (!(n > 0)) return null;
|
||
const rec = formRecords[i];
|
||
if (!rec) return null;
|
||
if (t === "STOCK_COUNT") {
|
||
return { transaction_type: "STOCK_COUNT", warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, physical_qty: n, note: r.note, transaction_date: toServerIso() };
|
||
}
|
||
return { transaction_type: t, warehouse_id: whId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
|
||
})
|
||
.filter((x): x is Record<string, unknown> => x !== null);
|
||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||
await postSeq(ops);
|
||
};
|
||
|
||
interface CountConfirmRow {
|
||
material: string;
|
||
unit: string;
|
||
warehouse: string;
|
||
current: number;
|
||
counted: number;
|
||
variance: number;
|
||
}
|
||
const [countConfirm, setCountConfirm] = useState<CountConfirmRow[] | null>(null);
|
||
const [countBusy, setCountBusy] = useState(false);
|
||
|
||
const countOps = (): { ops: Record<string, unknown>[]; confirm: CountConfirmRow[] } | null => {
|
||
const ops: Record<string, unknown>[] = [];
|
||
const confirm: CountConfirmRow[] = [];
|
||
rows.forEach((r, i) => {
|
||
const n = parseNum(r.qty);
|
||
const rec = formRecords[i];
|
||
if (!(n > 0) || !rec) return;
|
||
ops.push({
|
||
transaction_type: "STOCK_COUNT",
|
||
warehouse_id: whId,
|
||
material_id: rec.material.id,
|
||
brand_id: rec.brand?.id || null,
|
||
physical_qty: n,
|
||
note: r.note,
|
||
transaction_date: toServerIso(),
|
||
});
|
||
confirm.push({
|
||
material: rec.material.name,
|
||
unit: unitName(data.lookup.units, rec.unit?.id),
|
||
warehouse: whName(warehouses, whId),
|
||
current: rec.quantity_number,
|
||
counted: n,
|
||
variance: Math.round((n - rec.quantity_number) * 10000) / 10000,
|
||
});
|
||
});
|
||
if (!ops.length) return null;
|
||
return { ops, confirm };
|
||
};
|
||
|
||
const requestCountConfirm = () => {
|
||
const built = countOps();
|
||
if (!built) return toast.error("هیچ ردیفی پر نشده است");
|
||
setCountConfirm(built.confirm);
|
||
};
|
||
|
||
const confirmCount = async () => {
|
||
const built = countOps();
|
||
if (!built) return;
|
||
setCountBusy(true);
|
||
try {
|
||
await postSeq(built.ops);
|
||
setCountConfirm(null);
|
||
} finally {
|
||
setCountBusy(false);
|
||
}
|
||
};
|
||
|
||
const submitReturn = async () => {
|
||
const ops: Record<string, unknown>[] = rows
|
||
.map((r, i): Record<string, unknown> | null => {
|
||
const n = parseNum(r.qty);
|
||
if (!(n > 0)) return null;
|
||
const t = todayWithdrawals[i];
|
||
if (!t) return null;
|
||
return { transaction_type: "RETURN", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
|
||
})
|
||
.filter((x): x is Record<string, unknown> => x !== null);
|
||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||
await postSeq(ops);
|
||
};
|
||
|
||
const submitTransfer = async () => {
|
||
const ops: Record<string, unknown>[] = rows
|
||
.map((r, i): Record<string, unknown> | null => {
|
||
const n = parseNum(r.qty);
|
||
if (!(n > 0)) return null;
|
||
const rec = formRecords[i];
|
||
if (!rec) return null;
|
||
return { transaction_type: "TRANSFER_OUT", warehouse_id: fromId, dest_warehouse_id: toId, material_id: rec.material.id, brand_id: rec.brand?.id || null, quantity: n, unit_id: rec.unit?.id ?? null, specs: rec.specs || {}, note: r.note, transaction_date: toServerIso() };
|
||
})
|
||
.filter((x): x is Record<string, unknown> => x !== null);
|
||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||
await postSeq(ops);
|
||
};
|
||
|
||
const submitFound = async () => {
|
||
const ops: Record<string, unknown>[] = rows
|
||
.map((r, i): Record<string, unknown> | null => {
|
||
const n = parseNum(r.qty);
|
||
if (!(n > 0)) return null;
|
||
const item = pendingLosses[i];
|
||
if (!item) return null;
|
||
if (n > item.remaining) return null;
|
||
const t = item.tx;
|
||
return { transaction_type: "FOUND", warehouse_id: whId, material_id: t.material?.id, brand_id: t.brand?.id || null, quantity: n, unit_id: t.unit?.id ?? null, specs: t.specs || {}, note: r.note, transaction_date: toServerIso() };
|
||
})
|
||
.filter((x): x is Record<string, unknown> => x !== null);
|
||
if (!ops.length) return toast.error("هیچ ردیف معتبری پر نشده است");
|
||
await postSeq(ops);
|
||
};
|
||
|
||
const doSubmit = () => {
|
||
try {
|
||
if (type === "PURCHASE") void submitPurchase();
|
||
else if (type === "PROJECT_TRANSFER") void submitProjectTransfer();
|
||
else if (type === "RETURN") void submitReturn();
|
||
else if (type === "TRANSFER") void submitTransfer();
|
||
else if (type === "FOUND") void submitFound();
|
||
else if (type === "STOCK_COUNT") requestCountConfirm();
|
||
else void submitList(type);
|
||
} catch (err) {
|
||
toast.error(err instanceof Error ? err.message : "خطای غیرمنتظره در ثبت");
|
||
}
|
||
};
|
||
|
||
const canSubmit = (): boolean => {
|
||
if (type === "PURCHASE") {
|
||
return !!(whId && matTxt.trim() && brandTxt.trim() && unitTxt.trim() && parseNum(qty) > 0 && specsComplete);
|
||
}
|
||
if (type === "PROJECT_TRANSFER") {
|
||
return !!(whId && toProjectId && matTxt.trim() && unitTxt.trim() && parseNum(qty) > 0);
|
||
}
|
||
if (type === "TRANSFER") return !!(fromId && toId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||
if (type === "RETURN") return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||
if (type === "FOUND") {
|
||
return !!(
|
||
whId &&
|
||
rows.length > 0 &&
|
||
rows.every((r, i) => {
|
||
const n = parseNum(r.qty);
|
||
return !(n > 0) || n <= (pendingLosses[i]?.remaining ?? 0);
|
||
}) &&
|
||
rows.some((r) => parseNum(r.qty) > 0)
|
||
);
|
||
}
|
||
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (type === "RETURN") {
|
||
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||
} else if (type === "FOUND") {
|
||
setRows(pendingLosses.map((x) => ({ key: x.tx.inventory_id, qty: "", note: "" })));
|
||
} else if (type === "TRANSFER") {
|
||
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
|
||
} else {
|
||
setRows(formRecords.map((r) => ({ key: r.id, qty: "", note: "" })));
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [type, whId, fromId, toId, matTxt, formRecords.length, todayWithdrawals.length, pendingLosses.length]);
|
||
|
||
const meta = FORM_TYPES.find((f) => f.t === type)!;
|
||
|
||
if (isAdmin) {
|
||
return (
|
||
<div className="min-h-screen">
|
||
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||
<PageLayout>
|
||
<div className="rounded-xl border border-line bg-panel p-8 text-center">
|
||
<div className="text-3xl">🔒</div>
|
||
<h3 className="mt-2 text-[15px] font-bold">دسترسی فقطخواندنی</h3>
|
||
<p className="mt-1 text-[12.5px] text-muted">نقش ادمین اجازه ثبت تغییر موجودی ندارد. این بخش برای مدیران پروژه فعال است.</p>
|
||
</div>
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
}
|
||
if (!isPM) {
|
||
return (
|
||
<div className="min-h-screen">
|
||
<Header title="تغییر موجودی" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||
<PageLayout>
|
||
<div className="text-[13px] text-muted">این بخش فقط برای مدیر پروژه است.</div>
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isCount = type === "STOCK_COUNT";
|
||
const isTransfer = type === "TRANSFER";
|
||
|
||
return (
|
||
<div className="min-h-screen overflow-x-hidden pb-20">
|
||
<Header
|
||
title="تغییر موجودی"
|
||
sub={inventorySubheader(user)}
|
||
backTo="/inventory"
|
||
backLabel="بازگشت به داشبورد"
|
||
/>
|
||
<PageLayout className="flex flex-col gap-3 px-3 md:px-6 w-full max-w-full">
|
||
{/* انتخاب نوع تغییر موجودی — چیپهای لمسی مرتب در موبایل */}
|
||
<div className="rounded-xl border border-line bg-panel p-3 w-full shadow-2xs">
|
||
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
||
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:flex-wrap">
|
||
{FORM_TYPES.map((f) => (
|
||
<button
|
||
key={f.t}
|
||
type="button"
|
||
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
||
className={cn(
|
||
"rounded-lg border px-3 py-2 text-xs sm:text-[12.5px] text-start sm:text-center transition-all",
|
||
type === f.t ? "font-bold text-white shadow-xs" : "border-line bg-panel text-ink hover:bg-bg"
|
||
)}
|
||
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
||
>
|
||
<div>{f.label}</div>
|
||
<span className="text-[10px] opacity-80">({f.dir})</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="mt-2.5 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
||
</div>
|
||
|
||
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
|
||
{/* انتخاب انبارها (چیپهای لمسی به جای سلکت خام) */}
|
||
{type === "PROJECT_TRANSFER" ? (
|
||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">از (انبار مبدأ این پروژه) *</label>
|
||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||
{whOpts.map((w) => (
|
||
<button
|
||
key={w.id}
|
||
type="button"
|
||
onClick={() => { setWhId(w.id); }}
|
||
className={cn(
|
||
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
|
||
whId === w.id
|
||
? "bg-rose-600 text-white font-bold shadow-xs"
|
||
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||
)}
|
||
>
|
||
{w.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">به (پروژه مقصد) *</label>
|
||
<select
|
||
className="mt-1 h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
|
||
value={toProjectId}
|
||
onChange={(e) => setToProjectId(e.target.value)}
|
||
>
|
||
<option value="">— انتخاب پروژه مقصد —</option>
|
||
{(allProjects || [])
|
||
.filter((p) => p.id !== user?.project_id)
|
||
.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
) : !isTransfer ? (
|
||
<div className="mb-3 flex flex-col gap-3">
|
||
<div className="flex flex-col gap-1.5">
|
||
<label className="text-xs font-semibold text-muted">انبار *</label>
|
||
<div className="flex flex-wrap gap-1.5">
|
||
{whOpts.map((w) => (
|
||
<button
|
||
key={w.id}
|
||
type="button"
|
||
onClick={() => { setWhId(w.id); setMatTxt(""); setRows([]); }}
|
||
className={cn(
|
||
"rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
|
||
whId === w.id
|
||
? "bg-accent text-white font-bold shadow-xs"
|
||
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||
)}
|
||
>
|
||
{w.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||
<input
|
||
list="dl-mat"
|
||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
|
||
placeholder={type === "RETURN" ? "نام متریال برداشتشده امروز…" : "نام متریال — از پیشنهادها یا جدید…"}
|
||
value={matTxt}
|
||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||
/>
|
||
<datalist id="dl-mat">
|
||
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
|
||
</datalist>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">از (انبار مبدأ) *</label>
|
||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||
{whOpts.map((w) => (
|
||
<button
|
||
key={w.id}
|
||
type="button"
|
||
onClick={() => { setFromId(w.id); setToId(""); setMatTxt(""); setRows([]); }}
|
||
className={cn(
|
||
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors",
|
||
fromId === w.id
|
||
? "bg-accent text-white font-bold shadow-xs"
|
||
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||
)}
|
||
>
|
||
{w.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">به (انبار مقصد) *</label>
|
||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||
{whOpts.filter((w) => w.id !== fromId).map((w) => (
|
||
<button
|
||
key={w.id}
|
||
type="button"
|
||
disabled={!fromId}
|
||
onClick={() => { setToId(w.id); setMatTxt(""); setRows([]); }}
|
||
className={cn(
|
||
"rounded-lg px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-40",
|
||
toId === w.id
|
||
? "bg-accent text-white font-bold shadow-xs"
|
||
: "border border-line bg-bg/50 text-ink hover:bg-panel"
|
||
)}
|
||
>
|
||
{w.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div className="field md:col-span-2">
|
||
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||
<input
|
||
list="dl-mat2"
|
||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm"
|
||
placeholder="نام متریال را تایپ کنید — همه مدلها نمایش داده میشوند…"
|
||
value={matTxt}
|
||
onChange={(e) => { setMatTxt(e.target.value); setRows([]); }}
|
||
/>
|
||
<datalist id="dl-mat2">
|
||
{data.lookup.materials.map((m) => <option key={m.id} value={m.name} />)}
|
||
</datalist>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* فرم خرید / موجودی اولیه یا انتقال بین کارگاهی */}
|
||
{(type === "PURCHASE" || type === "PROJECT_TRANSFER") && (
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{/* چیپهای انتخاب سریع از موجودیهای انبار مبدأ */}
|
||
{type === "PROJECT_TRANSFER" && whId && (
|
||
<div className="field md:col-span-2 rounded-xl border border-rose-500/20 bg-rose-500/5 p-3.5">
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
||
<span>📦</span>
|
||
<span>موجودیهای موجود در {whName(warehouses, whId)} — برای پر کردن خودکار فرم کلیک کنید:</span>
|
||
</span>
|
||
<span className="text-[11px] text-muted">{faNum(whStock.length)} قلم کالا</span>
|
||
</div>
|
||
{whStock.length === 0 ? (
|
||
<div className="text-xs text-muted py-1">هیچ کالایی با موجودی مثبت در این انبار یافت نشد.</div>
|
||
) : (
|
||
<div className="flex flex-wrap gap-2">
|
||
{whStock.map((r) => {
|
||
const isSelected = matTxt === r.material.name && (brandTxt === (r.brand?.name || "") || (!brandTxt && !r.brand));
|
||
const specsSummary = Object.entries(r.specs || {})
|
||
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
|
||
.filter(Boolean)
|
||
.join(" · ");
|
||
return (
|
||
<button
|
||
key={r.id}
|
||
type="button"
|
||
onClick={() => {
|
||
setMatTxt(r.material.name);
|
||
setBrandTxt(r.brand?.name || "");
|
||
setUnitTxt(unitName(data.lookup.units, r.unit?.id) || "");
|
||
const chips = Object.entries(r.specs || {})
|
||
.map(([k, v]) => (v ? `${v}` : k.replace(/^spec_/, "")))
|
||
.filter(Boolean);
|
||
setSpecsChips(chips);
|
||
}}
|
||
className={cn(
|
||
"flex items-center gap-2 rounded-lg border px-3 py-1.5 text-xs text-start transition-all shadow-2xs",
|
||
isSelected
|
||
? "border-rose-500 bg-rose-600 text-white font-bold ring-2 ring-rose-500/30"
|
||
: "border-line bg-panel hover:border-ink/40 text-ink"
|
||
)}
|
||
>
|
||
<span className="font-bold">{r.material.name}</span>
|
||
<span className={isSelected ? "text-white/80" : "text-muted font-normal"}>
|
||
({r.brand?.name || "بدون برند"})
|
||
</span>
|
||
{specsSummary && (
|
||
<span className={isSelected ? "text-white/90" : "text-muted text-[11px]"}>
|
||
[{specsSummary}]
|
||
</span>
|
||
)}
|
||
<span className={cn(
|
||
"rounded-md px-1.5 py-0.5 text-[11.5px] font-black",
|
||
isSelected ? "bg-white/20 text-white" : "bg-bg text-foreground"
|
||
)}>
|
||
{faNum(r.quantity_number)} {unitName(data.lookup.units, r.unit?.id)}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="field md:col-span-2">
|
||
<label className="text-xs font-semibold text-muted">متریال *</label>
|
||
<Combobox
|
||
value={matTxt}
|
||
items={data.lookup.materials.map((mm) => mm.name)}
|
||
allowAdd
|
||
addSuffix="به عنوان متریال جدید"
|
||
placeholder="نام متریال — از فهرست انتخاب یا نام جدید تایپ کنید"
|
||
onInputChange={(v) => { setMatTxt(v); setRows([]); }}
|
||
onChange={(it) => { setMatTxt(it.label); setRows([]); }}
|
||
/>
|
||
{!matByName(matTxt) && matTxt.trim() && (
|
||
<div className="mt-1.5 rounded-lg border border-accent/25 bg-accent-bg/60 px-3 py-2 text-[12px] text-accent-dark">
|
||
«{matTxt.trim()}» در فهرست نیست — هنگام ثبت، بهصورت خودکار در کاتالوگ سراسری ساخته میشود.
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">
|
||
برند {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
|
||
</label>
|
||
<Combobox
|
||
value={brandTxt}
|
||
items={data.lookup.brands.map((b) => b.name)}
|
||
allowAdd
|
||
addSuffix="به عنوان برند جدید"
|
||
placeholder={type === "PROJECT_TRANSFER" ? "نام برند (اختیاری — برای اقلام بدون برند خالی بگذارید)" : "نام برند — از فهرست انتخاب یا نام جدید تایپ کنید"}
|
||
onInputChange={setBrandTxt}
|
||
onChange={(it) => setBrandTxt(it.label)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">واحد *</label>
|
||
<Combobox
|
||
value={unitTxt}
|
||
items={uniqueUnits(data.lookup.units).map((u) => u.name_fa || u.code)}
|
||
allowAdd
|
||
addSuffix="به عنوان واحد جدید"
|
||
placeholder="واحد اندازهگیری — از فهرست یا نام جدید"
|
||
onInputChange={setUnitTxt}
|
||
onChange={(it) => setUnitTxt(it.label)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">تعداد *</label>
|
||
<input
|
||
inputMode="decimal"
|
||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
|
||
placeholder="مثلاً ۲۰۰"
|
||
value={qty}
|
||
onChange={(e) => setQty(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">تاریخ و زمان (شمسی)</label>
|
||
<JalaliDatePicker
|
||
value={dateInput}
|
||
onChange={(iso) => setDateInput(iso ?? new Date().toISOString())}
|
||
placeholder="۱۴۰۳/۰۵/۱۲"
|
||
withTime
|
||
/>
|
||
</div>
|
||
<div className="field md:col-span-2">
|
||
<label className="text-xs font-semibold text-muted">
|
||
مشخصههای فنی {type === "PROJECT_TRANSFER" ? "(اختیاری)" : "*"}
|
||
</label>
|
||
<MultiSelectCombobox
|
||
values={specsChips}
|
||
onChange={setSpecsChips}
|
||
placeholder={type === "PROJECT_TRANSFER" ? "مشخصات فنی (اختیاری — مثلاً مسی، سایز و…)" : "نام مشخصه را تایپ کنید و Enter بزنید (مثلاً سایز)"}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
<div className="field md:col-span-2">
|
||
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||
<textarea
|
||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
|
||
placeholder={type === "PROJECT_TRANSFER" ? "توضیحات بار ارسالی، شماره بارنامه، راننده…" : "شماره فاکتور، تأمینکننده، توضیحات…"}
|
||
value={note}
|
||
onChange={(e) => setNote(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ردیفهای عملیات لیستی */}
|
||
{(type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT") && (
|
||
<RowTable
|
||
isCount={isCount}
|
||
rows={rows}
|
||
setRows={setRows}
|
||
records={formRecords}
|
||
unitNameFn={(id) => unitName(data.lookup.units, id)}
|
||
emptyMsg={
|
||
!whId ? "برای نمایش رکوردها، ابتدا انبار را انتخاب کنید."
|
||
: !matTxt.trim() ? "نام متریال را تایپ کنید تا همه مدلهای آن (با برند و مشخصه متفاوت) نمایش داده شود."
|
||
: !formRecords.length ? "برای این نام متریال در انبار انتخابی رکوردی وجود ندارد. ابتدا «موجودی اولیه» ثبت کنید."
|
||
: ""
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{type === "RETURN" && (
|
||
<ReturnTable rows={rows} setRows={setRows} txs={todayWithdrawals} whId={whId} matTxt={matTxt} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||
)}
|
||
|
||
{type === "TRANSFER" && (
|
||
<RowTable
|
||
isCount={false}
|
||
rows={rows}
|
||
setRows={setRows}
|
||
records={formRecords}
|
||
unitNameFn={(id) => unitName(data.lookup.units, id)}
|
||
emptyMsg={
|
||
!fromId || !toId ? "برای نمایش موجودیها، انبار مبدأ، مقصد و نام متریال را وارد کنید."
|
||
: !matTxt.trim() ? "نام متریال را تایپ کنید…"
|
||
: !formRecords.length ? "در انبار مبدأ برای این متریال موجودی نیست."
|
||
: ""
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{type === "FOUND" && (
|
||
<FoundTable
|
||
rows={rows}
|
||
setRows={setRows}
|
||
items={pendingLosses}
|
||
whId={whId}
|
||
unitNameFn={(id) => unitName(data.lookup.units, id)}
|
||
/>
|
||
)}
|
||
|
||
{/* دکمه ثبت */}
|
||
<div className="mt-4 flex flex-col-reverse sm:flex-row items-center justify-between gap-3 border-t border-line pt-3">
|
||
<span className="text-[11.5px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
||
<button
|
||
type="button"
|
||
disabled={!canSubmit() || busy}
|
||
onClick={doSubmit}
|
||
className="w-full sm:w-auto rounded-lg px-5 py-2.5 text-xs sm:text-[13px] font-bold text-white disabled:opacity-40 shadow-xs transition-transform active:scale-95"
|
||
style={{ background: meta.color }}
|
||
>
|
||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "انتقال بین کارگاهی" ? "ثبت انتقال بین کارگاهی و کسر از انبار" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* بخش سوابق انتقالهای بین کارگاهی این پروژه */}
|
||
{type === "PROJECT_TRANSFER" && (
|
||
<div className="rounded-xl border border-line bg-panel p-3 sm:p-4 w-full shadow-2xs">
|
||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-line pb-3">
|
||
<div>
|
||
<h3 className="text-sm font-bold text-foreground">📋 سوابق انتقالهای بین کارگاهی این پروژه</h3>
|
||
<p className="mt-0.5 text-[11.5px] text-muted">
|
||
مصالح ارسالی به سایر پروژهها؛ تا زمان تأیید تحویل در مقصد میتوانید آنها را ویرایش یا لغو کنید.
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void loadTransfers()}
|
||
className="rounded-lg border border-line px-3 py-1.5 text-xs font-medium hover:bg-bg transition-colors"
|
||
>
|
||
🔄 بهروزرسانی لیست
|
||
</button>
|
||
</div>
|
||
|
||
{loadingTransfers ? (
|
||
<div className="py-8 text-center text-xs text-muted">در حال بارگذاری سوابق…</div>
|
||
) : !(transfers || []).length ? (
|
||
<div className="py-8 text-center text-xs text-muted">هیچ انتقال بین کارگاهی از این پروژه ثبت نشده است.</div>
|
||
) : (
|
||
<div className="mt-3 overflow-x-auto">
|
||
<table className="w-full text-right text-xs min-w-[650px]">
|
||
<thead>
|
||
<tr className="border-b border-line text-muted">
|
||
<th className="py-2.5 pe-3">تاریخ انتقال</th>
|
||
<th className="py-2.5 pe-3">متریال / برند</th>
|
||
<th className="py-2.5 pe-3">مشخصات فنی</th>
|
||
<th className="py-2.5 pe-3">مقدار</th>
|
||
<th className="py-2.5 pe-3">مبدأ → مقصد</th>
|
||
<th className="py-2.5 pe-3">وضعیت</th>
|
||
<th className="py-2.5 text-center">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-line/50">
|
||
{(transfers || []).map((t) => {
|
||
const isInTransit = t.status === "in_transit";
|
||
return (
|
||
<tr key={t.id} className="hover:bg-bg/40">
|
||
<td className="py-3 pe-3 text-muted">{fmtDate(t.transfer_date)}</td>
|
||
<td className="py-3 pe-3 font-semibold">
|
||
<div>{t.material?.name}</div>
|
||
<div className="text-[11px] text-muted font-normal">{t.brand?.name || "بدون برند"}</div>
|
||
</td>
|
||
<td className="py-3 pe-3 text-muted text-[11.5px]">
|
||
{t.specs && Object.keys(t.specs).length > 0
|
||
? Object.entries(t.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")
|
||
: "—"}
|
||
</td>
|
||
<td className="py-3 pe-3 font-bold text-foreground">
|
||
{faNum(t.quantity)} {t.unit?.name_fa || t.unit?.code}
|
||
</td>
|
||
<td className="py-3 pe-3">
|
||
<div className="text-[11px] text-muted">از: {t.from_warehouse?.name}</div>
|
||
<div className="text-[12px] font-medium text-foreground">به: {t.to_project?.name}</div>
|
||
</td>
|
||
<td className="py-3 pe-3">
|
||
{isInTransit ? (
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2.5 py-0.5 text-[11px] font-bold text-amber-700 border border-amber-500/20">
|
||
🟡 در انتظار تأیید مقصد
|
||
</span>
|
||
) : (
|
||
<div className="flex flex-col gap-0.5">
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-bold text-emerald-700 border border-emerald-500/20">
|
||
🟢 تحویلشده در مقصد
|
||
</span>
|
||
{t.to_warehouse?.name && (
|
||
<span className="text-[10.5px] text-muted">انبار: {t.to_warehouse.name}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="py-3 text-center">
|
||
{isInTransit ? (
|
||
<div className="flex items-center justify-center gap-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setEditingTransfer(t);
|
||
setEditQty(String(t.quantity));
|
||
setEditToProjectId(t.to_project?.id || "");
|
||
setEditNote(t.note || "");
|
||
}}
|
||
className="rounded-lg border border-line px-2.5 py-1 text-[11.5px] font-medium hover:bg-bg transition-colors"
|
||
title="ویرایش انتقال"
|
||
>
|
||
✏️ ویرایش
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setDeleteConfirmTransfer(t)}
|
||
className="rounded-lg border border-rose-200 bg-rose-50 px-2.5 py-1 text-[11.5px] font-medium text-rose-700 hover:bg-rose-100 transition-colors"
|
||
title="لغو انتقال"
|
||
>
|
||
✕ لغو
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1 text-[11px] text-muted font-medium">
|
||
🔒 قفل
|
||
</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* مودال ویرایش انتقال */}
|
||
{editingTransfer && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !editBusy && setEditingTransfer(null)}>
|
||
<div className="w-full max-w-md rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||
<h3 className="mb-2 text-[14px] font-bold">✏️ ویرایش انتقال بین کارگاهی</h3>
|
||
<p className="mb-3 text-[11.5px] text-muted">
|
||
تغییر تعداد در انبار مبدأ تعدیل میگردد. پس از تحویل در مقصد، ویرایش غیرفعال خواهد شد.
|
||
</p>
|
||
|
||
<div className="flex flex-col gap-3">
|
||
<div className="rounded-lg border border-line bg-bg/50 p-2.5 text-xs">
|
||
<div>متریال: <b>{editingTransfer.material?.name}</b></div>
|
||
<div className="text-muted mt-0.5">انبار مبدأ: {editingTransfer.from_warehouse?.name}</div>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">پروژه مقصد *</label>
|
||
<select
|
||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm font-medium"
|
||
value={editToProjectId}
|
||
onChange={(e) => setEditToProjectId(e.target.value)}
|
||
>
|
||
{(allProjects || [])
|
||
.filter((p) => p.id !== user?.project_id)
|
||
.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">
|
||
تعداد ({editingTransfer.unit?.name_fa || editingTransfer.unit?.code}) *
|
||
</label>
|
||
<input
|
||
inputMode="decimal"
|
||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-xs sm:text-sm ltr"
|
||
value={editQty}
|
||
onChange={(e) => setEditQty(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="field">
|
||
<label className="text-xs font-semibold text-muted">یادداشت</label>
|
||
<textarea
|
||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-xs sm:text-sm"
|
||
value={editNote}
|
||
onChange={(e) => setEditNote(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
|
||
<button
|
||
type="button"
|
||
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
|
||
onClick={() => setEditingTransfer(null)}
|
||
disabled={editBusy}
|
||
>
|
||
انصراف
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
|
||
onClick={() => void handleSaveEdit()}
|
||
disabled={editBusy}
|
||
>
|
||
{editBusy ? "در حال ذخیره…" : "ذخیره تغییرات"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* دیالوگ تأیید لغو انتقال */}
|
||
{deleteConfirmTransfer && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !deleteBusy && setDeleteConfirmTransfer(null)}>
|
||
<div className="w-full max-w-sm rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||
<h3 className="mb-2 text-[14px] font-bold text-rose-600">⚠️ لغو انتقال بین کارگاهی</h3>
|
||
<p className="mb-3 text-[12px] leading-6 text-foreground">
|
||
آیا از لغو این انتقال اطمینان دارید؟ با لغو انتقال، مقدار <b>{faNum(deleteConfirmTransfer.quantity)} {deleteConfirmTransfer.unit?.name_fa || deleteConfirmTransfer.unit?.code}</b> از متریال <b>{deleteConfirmTransfer.material?.name}</b> فوراً به انبار مبدأ ({deleteConfirmTransfer.from_warehouse?.name}) بازگردانده خواهد شد.
|
||
</p>
|
||
|
||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-line pt-3">
|
||
<button
|
||
type="button"
|
||
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
|
||
onClick={() => setDeleteConfirmTransfer(null)}
|
||
disabled={deleteBusy}
|
||
>
|
||
انصراف
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded-lg bg-rose-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-rose-700 disabled:opacity-50"
|
||
onClick={() => void handleConfirmDelete()}
|
||
disabled={deleteBusy}
|
||
>
|
||
{deleteBusy ? "در حال لغو…" : "بله، لغو و بازگشت موجودی"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* مودال تأیید شمارش موجودی */}
|
||
{countConfirm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => !countBusy && setCountConfirm(null)}>
|
||
<div className="w-full max-w-lg rounded-xl border border-line bg-panel p-4 sm:p-5 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||
<h3 className="mb-1 text-[14px] font-bold">🔍 تأیید شمارش موجودی</h3>
|
||
<p className="mb-3 text-[11.5px] leading-5 text-muted">
|
||
موجودی هر ردیف به «تعداد شمارششده» اصلاح میشود و اختلاف (کسری/مازاد) ثبت خواهد شد.
|
||
</p>
|
||
<div className="flex max-h-72 flex-col gap-2 overflow-y-auto">
|
||
{countConfirm.map((c, i) => {
|
||
const deficit = c.variance < 0;
|
||
const surplus = c.variance > 0;
|
||
return (
|
||
<div key={i} className="rounded-lg border border-line p-2.5 text-[12px]">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<b className="min-w-0 flex-1 truncate">{c.material}</b>
|
||
<span className="text-muted">انبار: {c.warehouse}</span>
|
||
</div>
|
||
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||
<span>موجودی سیستم: <b>{faNum(c.current)} {c.unit}</b></span>
|
||
<span>شمارششده: <b>{faNum(c.counted)} {c.unit}</b></span>
|
||
<span
|
||
className={`rounded-md px-2 py-0.5 text-[11px] font-bold ${
|
||
deficit
|
||
? "bg-rose-50 text-rose-700"
|
||
: surplus
|
||
? "bg-emerald-50 text-emerald-700"
|
||
: "bg-slate-bg text-muted"
|
||
}`}
|
||
>
|
||
{deficit
|
||
? `−${faNum(Math.abs(c.variance))} ${c.unit} (کسری انبار)`
|
||
: surplus
|
||
? `+${faNum(c.variance)} ${c.unit} (مازاد انبار)`
|
||
: "بدون اختلاف ✓"}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="mt-4 flex items-center justify-end gap-2">
|
||
<button
|
||
type="button"
|
||
className="rounded-lg border border-line px-4 py-2 text-[12.5px]"
|
||
onClick={() => setCountConfirm(null)}
|
||
disabled={countBusy}
|
||
>
|
||
انصراف
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="rounded-lg bg-blue-600 px-4 py-2 text-[12.5px] font-bold text-white hover:bg-blue-700 disabled:opacity-50"
|
||
onClick={() => void confirmCount()}
|
||
disabled={countBusy}
|
||
>
|
||
{countBusy ? "در حال ثبت…" : "تأیید و بهروزرسانی موجودی"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="text-[11px] text-muted">
|
||
{type === "PROJECT_TRANSFER"
|
||
? "انتقال بین کارگاهی: موجودی فوراً از انبار مبدأ کسر شده و پس از تأیید تحویل توسط مدیر پروژه مقصد، وارد انبار مقصد میشود."
|
||
: isTransfer
|
||
? "انتقال فقط بین انبارهای یک پروژه ممکن است. با ثبت، خروجی از مبدأ و ورودی به مقصد همزمان ثبت میشود."
|
||
: `تاریخ انتخابی: ${jalDateLabel(new Date(dateInput))} — با «${jalDateLabel(new Date())}» برابر است.`}
|
||
</div>
|
||
</PageLayout>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RowTable({ isCount, rows, setRows, records, unitNameFn, emptyMsg }: {
|
||
isCount: boolean;
|
||
rows: RowDraft[];
|
||
setRows: (r: RowDraft[]) => void;
|
||
records: { id: string; brand: { id: string; name: string } | null; unit: { id: number; code: string; name_fa: string | null } | null; quantity_number: number; specs: Record<string, string> }[];
|
||
unitNameFn: (id: number | null | undefined) => string;
|
||
emptyMsg: string;
|
||
}) {
|
||
if (emptyMsg) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">{emptyMsg}</div>;
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-[12.5px] min-w-[500px]">
|
||
<thead>
|
||
<tr className="border-b border-line text-right text-muted">
|
||
<th className="py-2 pe-3">مشخصهها</th>
|
||
<th className="py-2 pe-3">برند</th>
|
||
<th className="py-2 pe-3">واحد</th>
|
||
<th className="py-2 pe-3">موجودی فعلی</th>
|
||
<th className="py-2 pe-3">{isCount ? "شمارششده" : "تعداد"}</th>
|
||
<th className="py-2">یادداشت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{records.map((r, i) => (
|
||
<tr key={r.id} className="border-b border-line/50">
|
||
<td className="py-2 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||
<td className="py-2 pe-3">{r.brand?.name || "—"}</td>
|
||
<td className="py-2 pe-3">{unitNameFn(r.unit?.id)}</td>
|
||
<td className="py-2 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
||
<td className="py-2 pe-3">
|
||
<input
|
||
inputMode="decimal"
|
||
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||
placeholder={isCount ? "شمارششده" : "۰"}
|
||
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">
|
||
<input
|
||
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||
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 className="mt-2 text-[11px] text-muted">ستون «موجودی فعلی» مقدار موجود هر رکورد است. برای هر ردیف تعداد و یادداشت وارد کنید.</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReturnTable({ rows, setRows, txs, whId, matTxt, unitNameFn }: {
|
||
rows: RowDraft[];
|
||
setRows: (r: RowDraft[]) => void;
|
||
txs: InventoryTransaction[];
|
||
whId: string;
|
||
matTxt: string;
|
||
unitNameFn: (id: number | null | undefined) => string;
|
||
}) {
|
||
if (!whId || !matTxt.trim()) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">برای مشاهده برداشتهای امروز، انبار و نام متریال را وارد کنید.</div>;
|
||
if (!txs.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">امروز برداشتی برای این متریال در انبار انتخابی ثبت نشده است.</div>;
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-[12.5px] min-w-[500px]">
|
||
<thead>
|
||
<tr className="border-b border-line text-right text-muted">
|
||
<th className="py-2 pe-3">مشخصهها</th>
|
||
<th className="py-2 pe-3">برند</th>
|
||
<th className="py-2 pe-3">واحد</th>
|
||
<th className="py-2 pe-3">برداشت امروز</th>
|
||
<th className="py-2 pe-3">تعداد برگشتی</th>
|
||
<th className="py-2">یادداشت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{txs.map((t, i) => (
|
||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
|
||
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||
<td className="py-2 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||
<td className="py-2 pe-3">
|
||
<input
|
||
inputMode="decimal"
|
||
className="h-8 w-24 rounded-lg border border-line bg-panel px-2 text-sm ltr"
|
||
placeholder="۰"
|
||
value={rows[i]?.qty || ""}
|
||
onChange={(e) => setRows(rows.map((x, xi) => (xi === i ? { ...x, qty: e.target.value } : x)))}
|
||
/>
|
||
</td>
|
||
<td className="py-2">
|
||
<input
|
||
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||
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>
|
||
);
|
||
}
|
||
|
||
function FoundTable({ rows, setRows, items, whId, unitNameFn }: {
|
||
rows: RowDraft[];
|
||
setRows: (r: RowDraft[]) => void;
|
||
items: { tx: InventoryTransaction; remaining: number }[];
|
||
whId: string;
|
||
unitNameFn: (id: number | null | undefined) => string;
|
||
}) {
|
||
if (!whId) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">ابتدا انبار را انتخاب کنید.</div>;
|
||
if (!items.length) return <div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">مفقودی ثبتنشدهای در این انبار وجود ندارد.</div>;
|
||
return (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-[12.5px] min-w-[550px]">
|
||
<thead>
|
||
<tr className="border-b border-line text-right text-muted">
|
||
<th className="py-2 pe-3">متریال</th>
|
||
<th className="py-2 pe-3">مشخصهها</th>
|
||
<th className="py-2 pe-3">برند</th>
|
||
<th className="py-2 pe-3">واحد</th>
|
||
<th className="py-2 pe-3">مانده مفقودی</th>
|
||
<th className="py-2 pe-3">تعداد پیداشده</th>
|
||
<th className="py-2">یادداشت</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{items.map(({ tx: t, remaining }, i) => {
|
||
const entered = parseNumLocal(rows[i]?.qty || "");
|
||
const over = entered > remaining;
|
||
return (
|
||
<tr key={t.inventory_id + i} className={`border-b border-line/50 ${over ? "bg-rose-50/40" : ""}`}>
|
||
<td className="py-2 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
||
<td className="py-2 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||
<td className="py-2 pe-3">{t.brand?.name || "—"}</td>
|
||
<td className="py-2 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||
<td className="py-2 pe-3 font-bold text-rose-600">{faNum(remaining)}</td>
|
||
<td className="py-2 pe-3">
|
||
<input
|
||
inputMode="decimal"
|
||
className={`h-8 w-24 rounded-lg border bg-panel px-2 text-sm ltr ${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">
|
||
<input
|
||
className="h-8 w-32 rounded-lg border border-line bg-panel px-2 text-sm"
|
||
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>
|
||
);
|
||
}
|
||
|
||
function parseNumLocal(s: string): number {
|
||
const v = parseFloat(String(s).replace(/[۰-۹]/g, (d) => String("۰۱۲۳۴۵۶۷۸۹".indexOf(d))).replace(/[،,]/g, ""));
|
||
return isFinite(v) ? v : 0;
|
||
} |