feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -0,0 +1,747 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLocation, useSearchParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { PanelMenu } from "../../components/PanelMenu";
|
||||
import { panelMenuItems, useLogout } from "./panelMenu";
|
||||
import { apiV26 } from "../../lib/api";
|
||||
import { useInventoryData, unitName } from "./useInventoryData";
|
||||
import type { InventoryTransaction, TransactionType } from "../../lib/types";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { gregorianToJalali, toFa } from "../../lib/jalali";
|
||||
|
||||
type FormType = TransactionType | "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: "#16a34a", desc: "ثبت خرید کالای جدید یا موجود — دستهبندی فقط برای کالای جدید لازم است. ثبت حداقل یک مشخصه فنی (نام و مقدار) الزامی است." },
|
||||
{ t: "WITHDRAWAL", label: "برداشت", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "انبار را انتخاب و نام متریال را تایپ کنید؛ همه مدلها (برند و مشخصه متفاوت) نمایش داده میشوند. برداشتها در «کنترل روزانه» امروز دیده میشوند." },
|
||||
{ t: "RETURN", label: "برگشت", dir: "افزایش", cls: "in", color: "#16a34a", desc: "برگشت از مصالحی که امروز برداشت شدهاند — نام متریال را تایپ کنید و مقدار برگشتی هر ردیف را بزنید." },
|
||||
{ t: "TRANSFER", label: "انتقال", dir: "جابهجایی", cls: "mv", color: "#4f46e5", desc: "جابهجایی بین انبارهای پروژه — از انبار مبدأ، مقداری از هر مدل را به انبار مقصد منتقل کنید." },
|
||||
{ t: "DAMAGE", label: "ضایعات", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت ضایعات خرید (مثلاً لولههای سوراخ) — انبار و متریال را انتخاب و تعداد ضایعات هر مدل را وارد کنید." },
|
||||
{ t: "WASTE", label: "اتلاف", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "اتلاف بعد از خرید (خرابی در انبار یا هدررفت در کار) — انبار و متریال را انتخاب کنید." },
|
||||
{ t: "LOSS", label: "مفقودی", dir: "کاهش", cls: "out", color: "#1d4ed8", desc: "ثبت مفقودی — انبار و متریال را انتخاب و تعداد مفقودی هر مدل را وارد کنید." },
|
||||
{ t: "FOUND", label: "پیدا شده", dir: "افزایش", cls: "in", color: "#16a34a", desc: "ثبت پیدا شدن کالاهای مفقود — مفقودیهای همان انبار و متریال (یا «همه مفقودیها») نمایش داده میشود." },
|
||||
{ t: "STOCK_COUNT", label: "شمارش موجودی", dir: "اصلاح", cls: "cnt", color: "#64748b", 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)} ${["فروردین","اردیبهشت","خرداد","تیر","مرداد","شهریور","مهر","آبان","آذر","دی","بهمن","اسفند"][jm-1]} ${toFa(jy)}`;
|
||||
}
|
||||
|
||||
// تغییر موجودی — پورت کامل صفحهی ارسالی (همه انواع تراکنش)
|
||||
export function ChangeInventoryPage() {
|
||||
const location = useLocation();
|
||||
const logout = useLogout();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const data = useInventoryData();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
const [type, setType] = useState<FormType>("PURCHASE");
|
||||
const [whId, setWhId] = useState("");
|
||||
const [fromId, setFromId] = useState("");
|
||||
const [toId, setToId] = useState("");
|
||||
const [matTxt, setMatTxt] = useState("");
|
||||
const [catId, setCatId] = useState("");
|
||||
const [brandTxt, setBrandTxt] = useState("");
|
||||
const [unitTxt, setUnitTxt] = useState("");
|
||||
const [qty, setQty] = useState("");
|
||||
const [dateInput, setDateInput] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||
return d.toISOString().slice(0, 16);
|
||||
});
|
||||
const [specsRows, setSpecsRows] = useState<{ rid: number; label: string; value: string }[]>([
|
||||
{ rid: 1, label: "", value: "" },
|
||||
]);
|
||||
const [note, setNote] = useState("");
|
||||
const [rows, setRows] = useState<RowDraft[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
// prefill از URL (پرش از کنترل روزانه)
|
||||
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";
|
||||
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();
|
||||
return data.inventory.filter((r) => {
|
||||
if (r.warehouse?.id !== whId) return false;
|
||||
if (txt && !r.material.name.includes(txt)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [data.inventory, whId, 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 specsFromRows = () => {
|
||||
const out: Record<string, string> = {};
|
||||
specsRows.forEach((r) => {
|
||||
if (r.label.trim() && r.value.trim()) out[r.label.trim()] = r.value.trim();
|
||||
});
|
||||
return out;
|
||||
};
|
||||
const specsComplete = specsRows.some((r) => r.label.trim() && r.value.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 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("ثبت شد ✓");
|
||||
setRows([]);
|
||||
setMatTxt("");
|
||||
setBrandTxt("");
|
||||
setUnitTxt("");
|
||||
setQty("");
|
||||
setSpecsRows([{ rid: Date.now(), label: "", value: "" }]);
|
||||
setNote("");
|
||||
await data.refresh();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "خطا در ثبت");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- submit handlers ----------
|
||||
const submitPurchase = async () => {
|
||||
const m = matByName(matTxt);
|
||||
const bid = brandIdByName(brandTxt);
|
||||
const uid = unitIdByName(unitTxt);
|
||||
const n = parseNum(qty);
|
||||
if (!whId) return toast.error("انبار را انتخاب کنید");
|
||||
if (!m) return toast.error("متریال در فهرست نیست — نام را دقیق تایپ کنید");
|
||||
if (!m.category && !catId) return toast.error("برای کالای جدید، دستهبندی را انتخاب کنید");
|
||||
if (!bid) return toast.error("برند را انتخاب کنید");
|
||||
if (!uid) return toast.error("واحد را انتخاب کنید");
|
||||
if (!(n > 0)) return toast.error("تعداد باید بزرگتر از صفر باشد");
|
||||
if (!specsComplete) return toast.error("حداقل یک مشخصه فنی (نام و مقدار) ثبت کنید");
|
||||
const op: Record<string, unknown> = {
|
||||
transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid,
|
||||
quantity: n, unit_id: uid, specs: specsFromRows(), note,
|
||||
transaction_date: new Date(dateInput).toISOString(),
|
||||
};
|
||||
await postSeq([op]);
|
||||
};
|
||||
|
||||
const submitList = async (t: TransactionType) => {
|
||||
const isCount = t === "STOCK_COUNT";
|
||||
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 (isCount) {
|
||||
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: new Date(dateInput).toISOString() };
|
||||
}
|
||||
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: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
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: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.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: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.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 t = lossTxs[i];
|
||||
if (!t) return null;
|
||||
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: new Date(dateInput).toISOString() };
|
||||
})
|
||||
.filter((x): x is Record<string, unknown> => x !== null);
|
||||
if (!ops.length) return toast.error("هیچ ردیفی پر نشده است");
|
||||
await postSeq(ops);
|
||||
};
|
||||
|
||||
const doSubmit = () => {
|
||||
if (type === "PURCHASE") void submitPurchase();
|
||||
else if (type === "RETURN") void submitReturn();
|
||||
else if (type === "TRANSFER") void submitTransfer();
|
||||
else if (type === "FOUND") void submitFound();
|
||||
else void submitList(type);
|
||||
};
|
||||
|
||||
const canSubmit = (): boolean => {
|
||||
if (type === "PURCHASE") {
|
||||
return !!(whId && matByName(matTxt) && brandIdByName(brandTxt) && unitIdByName(unitTxt) && parseNum(qty) > 0 && specsComplete);
|
||||
}
|
||||
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.some((r) => parseNum(r.qty) > 0));
|
||||
return !!(whId && matTxt.trim() && rows.some((r) => parseNum(r.qty) > 0));
|
||||
};
|
||||
|
||||
// sync rows when the record list changes
|
||||
useEffect(() => {
|
||||
if (type === "RETURN") {
|
||||
setRows(todayWithdrawals.map((t) => ({ key: t.inventory_id, qty: "", note: "" })));
|
||||
} else if (type === "FOUND") {
|
||||
setRows(lossTxs.map((t) => ({ key: t.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, lossTxs.length]);
|
||||
|
||||
const meta = FORM_TYPES.find((f) => f.t === type)!;
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title="تغییر موجودی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<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="تغییر موجودی" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
|
||||
<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">
|
||||
<Header
|
||||
title="تغییر موجودی"
|
||||
sub={user?.project_name || ""}
|
||||
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
|
||||
/>
|
||||
<PageLayout className="flex flex-col gap-3">
|
||||
{/* انتخاب نوع تراکنش */}
|
||||
<div className="rounded-xl border border-line bg-panel p-3">
|
||||
<h3 className="mb-2 text-[13px] font-bold">نوع تغییر موجودی</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{FORM_TYPES.map((f) => (
|
||||
<button
|
||||
key={f.t}
|
||||
type="button"
|
||||
onClick={() => { setType(f.t); setRows([]); setMatTxt(""); setWhId(""); setFromId(""); setToId(""); }}
|
||||
className={`rounded-lg border px-3 py-2 text-[12.5px] ${type === f.t ? "font-bold text-white" : "border-line bg-panel"}`}
|
||||
style={type === f.t ? { background: f.color, borderColor: f.color } : undefined}
|
||||
>
|
||||
{f.label} <span className="text-[10.5px] opacity-80">({f.dir})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] leading-5 text-muted">{meta.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-line bg-panel p-4">
|
||||
{/* انتخاب انبارها */}
|
||||
{!isTransfer ? (
|
||||
<div className="mb-3 grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>انبار *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={whId}
|
||||
onChange={(e) => { setWhId(e.target.value); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{(type === "PURCHASE" || type === "WITHDRAWAL" || type === "DAMAGE" || type === "WASTE" || type === "LOSS" || type === "STOCK_COUNT" || type === "RETURN" || type === "FOUND") && (
|
||||
<div className="field">
|
||||
<label>{type === "FOUND" ? "متریال (خالی = همه مفقودیها)" : "متریال *"}</label>
|
||||
<input
|
||||
list="dl-mat"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 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>از (انبار مبدأ) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={fromId}
|
||||
onChange={(e) => { setFromId(e.target.value); setToId(""); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب انبار مبدأ —</option>
|
||||
{whOpts.map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>به (انبار مقصد) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={toId}
|
||||
disabled={!fromId}
|
||||
onChange={(e) => { setToId(e.target.value); setMatTxt(""); setRows([]); }}
|
||||
>
|
||||
<option value="">— انتخاب انبار مقصد —</option>
|
||||
{whOpts.filter((w) => w.id !== fromId).map((w) => <option key={w.id} value={w.id}>{w.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>متریال *</label>
|
||||
<input
|
||||
list="dl-mat2"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 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" && (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<div className="field">
|
||||
<label>برند *</label>
|
||||
<input
|
||||
list="dl-brand"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام برند — از پیشنهادها یا جدید…"
|
||||
value={brandTxt}
|
||||
onChange={(e) => setBrandTxt(e.target.value)}
|
||||
/>
|
||||
<datalist id="dl-brand">
|
||||
{data.lookup.brands.map((b) => <option key={b.id} value={b.name} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>واحد *</label>
|
||||
<input
|
||||
list="dl-unit"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="واحد اندازهگیری…"
|
||||
value={unitTxt}
|
||||
onChange={(e) => setUnitTxt(e.target.value)}
|
||||
/>
|
||||
<datalist id="dl-unit">
|
||||
{data.lookup.units.map((u) => <option key={u.id} value={u.name_fa || u.code} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تعداد *</label>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
||||
placeholder="مثلاً ۲۰۰"
|
||||
value={qty}
|
||||
onChange={(e) => setQty(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تاریخ و زمان</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm ltr"
|
||||
value={dateInput}
|
||||
onChange={(e) => setDateInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{!matByName(matTxt) && matTxt.trim() && (
|
||||
<div className="field md:col-span-2">
|
||||
<label>دستهبندی (کالای جدید) *</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
value={catId}
|
||||
onChange={(e) => setCatId(e.target.value)}
|
||||
>
|
||||
<option value="">— انتخاب کنید —</option>
|
||||
{data.lookup.categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
<div className="mt-1 text-[11px] text-muted">این متریال در فهرست نیست؛ فقط برای کالای جدید دستهبندی لازم است.</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="field md:col-span-2">
|
||||
<label>مشخصههای فنی *</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{specsRows.map((r) => (
|
||||
<div key={r.rid} className="flex gap-2">
|
||||
<input
|
||||
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="نام مشخصه (مثلاً سایز)"
|
||||
value={r.label}
|
||||
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, label: e.target.value } : x)))}
|
||||
/>
|
||||
<input
|
||||
className="h-8 w-1/2 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||
placeholder="مقدار (مثلاً ۱۶)"
|
||||
value={r.value}
|
||||
onChange={(e) => setSpecsRows((rs) => rs.map((x) => (x.rid === r.rid ? { ...x, value: e.target.value } : x)))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-2 text-muted hover:text-red"
|
||||
onClick={() => setSpecsRows((rs) => (rs.length > 1 ? rs.filter((x) => x.rid !== r.rid) : rs))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="w-fit rounded-lg border border-dashed border-accent/50 px-3 py-1 text-[12px] text-accent"
|
||||
onClick={() => setSpecsRows((rs) => [...rs, { rid: Date.now(), label: "", value: "" }])}
|
||||
>
|
||||
+ افزودن مشخصه فنی
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field md:col-span-2">
|
||||
<label>یادداشت</label>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-line bg-panel px-3 py-2 text-sm"
|
||||
placeholder="شماره فاکتور، تأمینکننده، توضیحات…"
|
||||
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} txs={lossTxs} whId={whId} unitNameFn={(id) => unitName(data.lookup.units, id)} />
|
||||
)}
|
||||
|
||||
{/* دکمه ثبت */}
|
||||
<div className="mt-4 flex items-center justify-between border-t border-line pt-3">
|
||||
<span className="text-[12px] text-muted">👤 ثبت با نام: <b>{user?.display_name}</b></span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmit() || busy}
|
||||
onClick={doSubmit}
|
||||
className="rounded-lg px-5 py-2 text-[13px] font-bold text-white disabled:opacity-40"
|
||||
style={{ background: meta.color }}
|
||||
>
|
||||
✓ {busy ? "در حال ثبت…" : meta.label === "انتقال" ? "ثبت انتقال" : meta.label === "برگشت" ? "ثبت برگشت" : meta.label === "پیدا شده" ? "ثبت پیدا شدن" : `ثبت ${meta.label}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{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]">
|
||||
<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-1.5 pe-3 text-muted">{Object.entries(r.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{r.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(r.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(r.quantity_number)}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 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-1.5">
|
||||
<input
|
||||
className="h-7 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-1 text-[11.5px] 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]">
|
||||
<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-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 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-1.5">
|
||||
<input
|
||||
className="h-7 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, txs, whId, unitNameFn }: {
|
||||
rows: RowDraft[];
|
||||
setRows: (r: RowDraft[]) => void;
|
||||
txs: InventoryTransaction[];
|
||||
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 (!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]">
|
||||
<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>
|
||||
{txs.map((t, i) => (
|
||||
<tr key={t.inventory_id + i} className="border-b border-line/50">
|
||||
<td className="py-1.5 pe-3 font-semibold">{t.material?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3 text-muted">{Object.entries(t.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{t.brand?.name || "—"}</td>
|
||||
<td className="py-1.5 pe-3">{unitNameFn(t.unit?.id)}</td>
|
||||
<td className="py-1.5 pe-3 font-bold">{faNum(Math.abs(t.quantity_number || 0))}</td>
|
||||
<td className="py-1.5 pe-3">
|
||||
<input
|
||||
inputMode="decimal"
|
||||
className="h-7 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-1.5">
|
||||
<input
|
||||
className="h-7 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user