feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login

This commit is contained in:
ali_mohaghegh
2026-08-25 19:08:15 +03:30
parent 170aed66ea
commit 4e105c5cd5
103 changed files with 16352 additions and 0 deletions
@@ -0,0 +1,168 @@
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { toast } from "sonner";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, useLogout } from "./panelMenu";
import { apiV26 } from "../../lib/api";
import type { PendingArrival, Warehouse } from "../../lib/types";
import { faNum } from "../../lib/formatters";
// رسید کالاهای در راه — مدیر پروژه تأیید و بین انبارها تقسیم می‌کند (v2.6)
export function ArrivalsPage() {
const location = useLocation();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const [items, setItems] = useState<PendingArrival[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [selected, setSelected] = useState<Record<string, string[]>>({}); // decisionId -> warehouseIds
const [qty, setQty] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (user?.role !== "pm") return;
let alive = true;
void (async () => {
try {
const [arr, whs] = await Promise.all([
apiV26.pm.onTheWay(),
apiV26.warehouses.list(user.project_id || undefined),
]);
if (!alive) return;
setItems(arr);
setWarehouses(whs);
} catch (err) {
if (alive) toast.error(err instanceof Error ? err.message : "خطا در دریافت کالاهای در راه");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, [user?.role, user?.project_id]);
const pending = useMemo(() => items.filter((i) => i.state !== "arrived"), [items]);
const toggleWarehouse = (decisionId: string, whId: string) => {
setSelected((s) => {
const cur = s[decisionId] || [];
return { ...s, [decisionId]: cur.includes(whId) ? cur.filter((x) => x !== whId) : [...cur, whId] };
});
};
const confirm = async (it: PendingArrival) => {
const qtyStr = qty[it.item_decision_id]?.trim();
const total = parseFloat(qtyStr || "");
if (!total || total <= 0) {
toast.error("تعداد رسیده را وارد کنید.");
return;
}
const allocIds = selected[it.item_decision_id] || [];
if (!allocIds.length) {
toast.error("حداقل یک انبار را برای تقسیم انتخاب کنید.");
return;
}
// تقسیم مساوی بین انبارهای انتخاب‌شده (به‌صورت پیش‌فرض؛ کاربر می‌تواند بعداً اصلاح کند)
const per = Math.round((total / allocIds.length) * 10000) / 10000;
const allocations = allocIds.map((whId, idx) => ({
warehouse_id: whId,
quantity: idx === allocIds.length - 1 ? +(total - per * (allocIds.length - 1)).toFixed(4) : per,
}));
setSaving(true);
try {
const res = await apiV26.pm.confirmReceipt({
item_decision_id: it.item_decision_id,
received_qty: total,
allocations,
});
toast.success(`رسید ثبت شد — ${res.posted_transactions.length} تراکنش خرید ثبت شد.`);
setItems((list) => list.filter((x) => x.item_decision_id !== it.item_decision_id));
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ثبت رسید");
} finally {
setSaving(false);
}
};
if (loading) return <div className="min-h-screen" />;
if (user?.role !== "pm") {
return (
<div className="min-h-screen">
<Header title="رسید کالاها" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<PageLayout>
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه است." />
</PageLayout>
</div>
);
}
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-4">
{!pending.length ? (
<EmptyState emoji="🚚" text="کالای در راهی برای تأیید رسید وجود ندارد." />
) : (
pending.map((it) => (
<div key={it.item_decision_id} className="rounded-xl border border-line bg-panel p-4 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[14px] font-bold">{it.material.name}</span>
<Badge variant="outline">
{it.state === "on_the_way" ? "در راه" : "رسید ناقص"}
</Badge>
<span className="text-[12px] text-muted">
سفارش: {faNum(parseFloat(it.ordered_qty))} {it.unit?.name_fa || it.unit?.code || ""} — رسیده: {faNum(parseFloat(it.received_qty))}
</span>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="field">
<label>تعداد رسیده (در واحد قلم)</label>
<input
type="number"
className="h-9 w-full rounded-lg border border-line bg-panel px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
value={qty[it.item_decision_id] || ""}
onChange={(e) => setQty((q) => ({ ...q, [it.item_decision_id]: e.target.value }))}
placeholder={it.ordered_qty}
/>
</div>
<div className="field">
<label>تقسیم بین انبارها (چندتایی)</label>
<div className="flex flex-wrap gap-1.5 pt-1">
{warehouses.map((w) => {
const on = (selected[it.item_decision_id] || []).includes(w.id);
return (
<button
key={w.id}
type="button"
className={`rounded-lg border px-3 py-1.5 text-[12.5px] ${on ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel text-muted"}`}
onClick={() => toggleWarehouse(it.item_decision_id, w.id)}
>
{w.name}
</button>
);
})}
</div>
</div>
</div>
<div className="mt-3 flex justify-end">
<Button size="sm" variant="green" disabled={saving} onClick={() => void confirm(it)}>
ثبت رسید و ورود به انبار
</Button>
</div>
</div>
))
)}
</PageLayout>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,201 @@
import { useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Header, PageLayout } from "../../components/layout/Header";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, useLogout } from "./panelMenu";
import { useInventoryData, unitName, whName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { gregorianToJalali, MONTHS, toFa } from "../../lib/jalali";
import type { InventoryTransaction } from "../../lib/types";
function gregKey(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function todayKey(): string {
return gregKey(new Date());
}
// کنترل روزانه — پورت کامل تقویم شمسی نسخه‌ی ارسالی
export function DailyControlPage() {
const location = useLocation();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const navigate = useNavigate();
const isPM = user?.role === "pm";
const [anchor, setAnchor] = useState<Date>(new Date());
const [sel, setSel] = useState<string>(todayKey());
const [actRec, setActRec] = useState<string | null>(null);
const txByDay = useMemo(() => {
const map = new Map<string, InventoryTransaction[]>();
data.transactions.forEach((t) => {
const k = gregKey(new Date(t.transaction_date));
const arr = map.get(k) || [];
arr.push(t);
map.set(k, arr);
});
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
return map;
}, [data.transactions]);
// cells: 42 روز از شنبه قبلِ اولین روز ماه جلالی
const cells = useMemo(() => {
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
let first: Date | null = null;
for (let i = 0; i <= 33; i++) {
const d = new Date(anchor.getTime() - i * 86400000);
const [py, pm] = gregorianToJalali(d.getFullYear(), d.getMonth() + 1, d.getDate());
if (py !== ty || pm !== tm) {
first = new Date(anchor.getTime() - (i - 1) * 86400000);
break;
}
}
if (!first) first = new Date(anchor.getTime() - 33 * 86400000);
const offset = (first.getDay() + 1) % 7; // شنبه = 0
const gridStart = new Date(first.getTime() - offset * 86400000);
const out: { d: Date; inMonth: boolean; isToday: boolean; n: number }[] = [];
for (let c = 0; c < 42; c++) {
const day = new Date(gridStart.getTime() + c * 86400000);
const [jy, jm] = gregorianToJalali(day.getFullYear(), day.getMonth() + 1, day.getDate());
const k = gregKey(day);
out.push({
d: day,
inMonth: jy === ty && jm === tm,
isToday: k === todayKey(),
n: (txByDay.get(k) || []).length,
});
}
return out;
}, [anchor, txByDay]);
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
const monthTitle = `${toFa(ty)} ${MONTHS[tm - 1]}`;
const dayTx = txByDay.get(sel) || [];
const withdrawals = dayTx.filter((t) => t.transaction_type === "WITHDRAWAL");
const wGroups = useMemo(() => {
const gmap = new Map<string, { recId: string; q: number; last: string; tx: InventoryTransaction }>();
withdrawals.forEach((t) => {
const g = gmap.get(t.inventory_id);
if (!g) {
gmap.set(t.inventory_id, { recId: t.inventory_id, q: Math.abs(t.quantity_number || 0), last: t.transaction_date, tx: t });
} else {
g.q += Math.abs(t.quantity_number || 0);
if (t.transaction_date > g.last) g.last = t.transaction_date;
}
});
return [...gmap.values()];
}, [withdrawals]);
const isToday = sel === todayKey();
const selDate = new Date(Number(sel.split("-")[0]), Number(sel.split("-")[1]) - 1, Number(sel.split("-")[2]));
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="grid gap-4 lg:grid-cols-2">
{/* تقویم */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<div className="text-[14px] font-bold">📅 {monthTitle}</div>
<div className="flex gap-1.5">
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() + 33 * 86400000))}>◀ ماه بعد</button>
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => { setAnchor(new Date()); setSel(todayKey()); setActRec(null); }}>امروز</button>
<button className="rounded-lg border border-line px-3 py-1 text-[12px]" onClick={() => setAnchor(new Date(anchor.getTime() - 33 * 86400000))}>▶ ماه قبل</button>
</div>
</div>
<div className="grid grid-cols-7 gap-1">
{["ش", "ی", "د", "س", "چ", "پ", "ج"].map((d) => (
<div key={d} className="py-1 text-center text-[11px] font-bold text-muted">{d}</div>
))}
{cells.map((c, i) => {
const [jd] = gregorianToJalali(c.d.getFullYear(), c.d.getMonth() + 1, c.d.getDate());
const k = gregKey(c.d);
return (
<button
key={i}
type="button"
onClick={() => { setSel(k); setActRec(null); }}
className={`relative rounded-lg py-2 text-[12px] ${!c.inMonth ? "text-muted/40" : ""} ${c.isToday ? "bg-accent/15 font-bold text-accent" : ""} ${sel === k ? "ring-2 ring-accent" : "hover:bg-accent/5"}`}
>
{toFa(jd)}
{c.n > 0 && (
<span className="absolute -top-0.5 -left-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red text-[9px] font-bold text-white">
{toFa(c.n)}
</span>
)}
</button>
);
})}
</div>
</div>
{/* برداشت‌های روز */}
<div className="rounded-xl border border-line bg-panel p-4">
<div className="mb-1 flex items-center justify-between">
<h2 className="text-[14px] font-bold">🕘 کنترل روزانه — {isToday ? "امروز" : ""} {faNum(selDate.getDate())} {MONTHS[selDate.getMonth()]} {toFa(selDate.getFullYear())}</h2>
<span className="rounded-lg bg-blue/10 px-2 py-1 text-[11.5px] font-bold text-blue">▼ برداشت‌ها: {faNum(wGroups.length)} قلم</span>
</div>
<p className="mb-3 text-[11.5px] leading-5 text-muted">
مواردی که این روز از انبار برداشت و به ساختمان برده شده‌اند. روی هر مورد بزنید و برگشت / مفقودی / اتلاف را انتخاب کنید.
</p>
{!wGroups.length ? (
<div className="rounded-lg border border-dashed border-line p-4 text-[12.5px] text-muted">
{isToday ? "امروز هنوز برداشتی ثبت نشده است." : "در این روز برداشتی ثبت نشده است."} هر برداشتی که در «تغییر موجودی» ثبت کنید، همین‌جا نمایش داده می‌شود.
</div>
) : (
<div className="flex flex-col gap-1.5">
{wGroups.map((g) => (
<div key={g.recId} className={`rounded-lg border p-2.5 ${actRec === g.recId ? "border-accent bg-accent/5" : "border-line bg-panel"}`}>
<button type="button" className="flex w-full items-center gap-2 text-start" onClick={() => setActRec(actRec === g.recId ? null : g.recId)}>
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-blue/10 text-blue">➖</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-semibold">{g.tx.material?.name || "—"}</span>
<span className="block truncate text-[11px] text-muted">
{[g.tx.brand?.name, Object.entries(g.tx.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · "), whName(data.warehouses, g.tx.warehouse_id)].filter(Boolean).join(" · ")}
</span>
</span>
<span className="text-[13px] font-bold text-blue">−{faNum(g.q)} {unitName(data.lookup.units, g.tx.unit?.id)}</span>
<span className="text-[10.5px] text-muted">{new Date(g.last).toLocaleTimeString("fa-IR", { hour: "2-digit", minute: "2-digit" })}</span>
</button>
{actRec === g.recId && isPM && (
<div className="mt-2 flex gap-1.5 border-t border-line pt-2">
<button
className="rounded-lg border border-green/30 bg-green/5 px-3 py-1 text-[11.5px] text-green"
onClick={() => navigate(`/inventory/change?type=RETURN&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
↩️ برگشت
</button>
<button
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
onClick={() => navigate(`/inventory/change?type=LOSS&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
📦 مفقودی
</button>
<button
className="rounded-lg border border-blue/30 bg-blue/5 px-3 py-1 text-[11.5px] text-blue"
onClick={() => navigate(`/inventory/change?type=WASTE&wh=${g.tx.warehouse_id}&mat=${encodeURIComponent(g.tx.material?.name || "")}`)}
>
🗑️ اتلاف
</button>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</PageLayout>
</div>
);
}
@@ -0,0 +1,430 @@
import { useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
import { useAuthStore } from "../../store/auth";
import { PanelMenu } from "../../components/PanelMenu";
import { panelMenuItems, useLogout } from "./panelMenu";
import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData";
import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types";
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
const TX_META: Record<TransactionType, { label: string; color: string; icon: string }> = {
PURCHASE: { label: "خرید", color: "#16a34a", icon: "🛒" },
WITHDRAWAL: { label: "برداشت", color: "#1d4ed8", icon: "➖" },
RETURN: { label: "برگشت", color: "#16a34a", icon: "↩️" },
TRANSFER_IN: { label: "انتقال (ورودی)", color: "#16a34a", icon: "⬅️" },
TRANSFER_OUT: { label: "انتقال (خروجی)", color: "#1d4ed8", icon: "➡️" },
DAMAGE: { label: "ضایعات", color: "#1d4ed8", icon: "⚠️" },
WASTE: { label: "اتلاف", color: "#1d4ed8", icon: "🗑️" },
LOSS: { label: "مفقودی", color: "#1d4ed8", icon: "📦" },
FOUND: { label: "پیدا شده", color: "#16a34a", icon: "🔍" },
STOCK_COUNT: { label: "شمارش موجودی", color: "#64748b", icon: "✅" },
};
interface Group {
key: string;
whId: string;
matId: string;
specs: Record<string, string>;
unitId: number | null;
qty: number;
updatedAt: string;
recs: InventoryRow[];
brands: Record<string, number>;
brandOrder: string[];
}
function specsHash(specs: Record<string, string> | null | undefined): string {
const s = specs || {};
const keys = Object.keys(s).filter((k) => s[k] !== "" && s[k] != null).sort();
if (!keys.length) return "default";
return keys.map((k) => `${k}=${s[k]}`).join("|");
}
// داشبورد انبار — پورت کامل نسخه‌ی ارسالی (گروه‌بندی + خط زمانی + فیلترها)
export function InventoryPage() {
const location = useLocation();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const { warehouses, inventory, transactions, lookup, loading } = useInventoryData();
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; // مدیر تدارکات = ادمین دفتر
const isPM = user?.role === "pm";
const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({
category: "", warehouse: "", material: "", project: "",
});
const [sort, setSort] = useState<"asc" | "desc" | "">("");
const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [selTx, setSelTx] = useState<string | null>(null);
const [expBrand, setExpBrand] = useState<string | null>(null);
// ادمین: انبارهای همه پروژه‌ها یا پروژه‌ی انتخابی
const visibleWarehouses = useMemo(() => {
if (isAdmin && filters.project) return warehouses.filter((w) => w.project_id === filters.project);
if (isAdmin) return warehouses;
return warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""));
}, [warehouses, isAdmin, filters.project, user?.project_id]);
const visibleIds = useMemo(() => new Set(visibleWarehouses.map((w) => w.id)), [visibleWarehouses]);
const groups = useMemo<Group[]>(() => {
const gmap = new Map<string, Group>();
const order: string[] = [];
inventory.forEach((r) => {
const whId = r.warehouse?.id;
if (!whId || !visibleIds.has(whId)) return;
if (filters.category && r.material.category?.id !== filters.category) return;
if (filters.warehouse && whId !== filters.warehouse) return;
if (filters.material && !r.material.name.includes(filters.material.trim())) return;
const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`;
let g = gmap.get(gk);
if (!g) {
g = { key: gk, whId, matId: r.material.id, specs: r.specs || {}, unitId: r.unit?.id ?? null, qty: 0, updatedAt: r.updated_at, recs: [], brands: {}, brandOrder: [] };
gmap.set(gk, g);
order.push(gk);
}
g.qty += r.quantity_number || 0;
if (r.updated_at > g.updatedAt) g.updatedAt = r.updated_at;
g.recs.push(r);
if (r.brand) {
if (!(r.brand.id in g.brands)) {
g.brands[r.brand.id] = 0;
g.brandOrder.push(r.brand.id);
}
g.brands[r.brand.id] += r.quantity_number || 0;
}
});
const out = order.map((k) => gmap.get(k)!);
if (sort === "asc") out.sort((a, b) => a.qty - b.qty);
else if (sort === "desc") out.sort((a, b) => b.qty - a.qty);
else out.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
return out;
}, [inventory, visibleIds, filters, sort]);
const groupTxs = (g: Group) =>
transactions
.filter((t) => g.recs.some((r) => r.id === t.inventory_id))
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const brandTxs = (g: Group, bid: string) =>
transactions
.filter((t) => t.brand?.id === bid && g.recs.some((r) => r.id === t.inventory_id))
.slice()
.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime());
const hasFilter = !!(filters.category || filters.warehouse || filters.material || (isAdmin && filters.project));
if (loading) return <div className="min-h-screen" />;
if (!user || (!isAdmin && !isPM)) {
return (
<div className="min-h-screen">
<Header title="داشبورد انبار" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<PageLayout>
<EmptyState emoji="🔒" text="دسترسی به انبار فقط برای مدیر پروژه یا ادمین است." />
</PageLayout>
</div>
);
}
return (
<div className="min-h-screen">
<Header
title="سامانه مدیریت انبار"
sub={isAdmin ? "فقط‌خواندنی" : user?.project_name ? `مدیر پروژه — ${user.project_name}` : "مدیر پروژه"}
menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />}
/>
<PageLayout className="flex flex-col gap-3">
{/* فیلترها */}
<div className="flex flex-wrap items-end gap-2 rounded-xl border border-line bg-panel p-3">
<div className="field">
<label>دسته‌بندی</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
value={filters.category}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, material: "" }))}
>
<option value="">همه دسته‌بندی‌ها</option>
{lookup.categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
{isAdmin && (
<div className="field">
<label>پروژه</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
value={filters.project}
onChange={(e) => setFilters((f) => ({ ...f, project: e.target.value, warehouse: "", material: "" }))}
>
<option value="">همه پروژه‌ها</option>
{lookup.projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
)}
<div className="field">
<label>انبار</label>
<select
className="h-8 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
value={filters.warehouse}
onChange={(e) => setFilters((f) => ({ ...f, warehouse: e.target.value, material: "" }))}
>
<option value="">همه انبارها</option>
{visibleWarehouses.map((w) => (
<option key={w.id} value={w.id}>
{w.name}{isAdmin ? ` — ${lookup.projects.find((p) => p.id === w.project_id)?.name || ""}` : ""}
</option>
))}
</select>
</div>
<div className="field">
<label>متریال</label>
<input
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-[12.5px]"
placeholder="تایپ نام متریال…"
value={filters.material}
onChange={(e) => setFilters((f) => ({ ...f, material: e.target.value }))}
/>
</div>
<button
className="h-8 rounded-lg border border-line bg-panel px-3 text-[12px]"
onClick={() => setSort((s) => (s === "" ? "asc" : s === "asc" ? "desc" : ""))}
>
{sort === "asc" ? "تعداد: صعودی ▲" : sort === "desc" ? "تعداد: نزولی ▼" : "مرتب‌سازی تعداد"}
</button>
{hasFilter && (
<button
className="h-8 rounded-lg border border-red/30 bg-red/5 px-3 text-[12px] text-red"
onClick={() => setFilters({ category: "", warehouse: "", material: "", project: "" })}
>
✕ پاک کردن فیلترها
</button>
)}
<Badge variant="outline">{faNum(groups.length)} قلم کالا</Badge>
</div>
{/* جدول گروه‌بندی‌شده */}
<div className="overflow-hidden rounded-xl border border-line bg-panel">
{!groups.length ? (
<EmptyState emoji="📋" text="هیچ رکورد موجودی‌ای مطابق فیلترهای انتخابی یافت نشد." />
) : (
<table className="w-full text-[12.5px]">
<thead>
<tr className="border-b border-line bg-panel text-right text-muted">
<th className="w-8 py-2" />
<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 pe-3">آخرین به‌روزرسانی</th>
<th className="w-8 py-2" />
</tr>
</thead>
<tbody>
{groups.map((g) => {
const isExp = expandedKey === g.key;
const matCat = lookup.materials.find((m) => m.id === g.matId)?.category;
return (
<GroupRow
key={g.key}
g={g}
isExp={isExp}
isAdmin={isAdmin}
matName={materialName(lookup.materials, g.matId)}
matCatName={matCat ? catName(lookup.categories, matCat.id) : "—"}
wh={whName(warehouses, g.whId)}
projName={lookup.projects.find((p) => p.id === warehouses.find((w) => w.id === g.whId)?.project_id)?.name || ""}
unit={unitName(lookup.units, g.unitId)}
onToggle={() => {
setExpandedKey(isExp ? null : g.key);
setSelTx(null);
setExpBrand(null);
}}
timeline={
isExp ? (
<Timeline
g={g}
txs={groupTxs(g)}
allTxs={transactions}
brandTxs={brandTxs}
selTx={selTx}
setSelTx={setSelTx}
expBrand={expBrand}
setExpBrand={setExpBrand}
matName={materialName(lookup.materials, g.matId)}
unit={unitName(lookup.units, g.unitId)}
brandNameFn={(id) => brandName(lookup.brands, id)}
whNameFn={(id) => whName(warehouses, id)}
/>
) : null
}
/>
);
})}
</tbody>
</table>
)}
</div>
<div className="text-[11px] text-muted">
۱۰ نوع تراکنش استاندارد • افزایش موجودی سبز، کاهش آبی • کلیک روی هر سطر: خط زمانی با «مانده» لحظه‌به‌لحظه و تفکیک برندها
</div>
</PageLayout>
</div>
);
}
function GroupRow({ g, isExp, isAdmin, matName, matCatName, wh, projName, unit, onToggle, timeline }: {
g: Group;
isExp: boolean;
isAdmin: boolean;
matName: string;
matCatName: string;
wh: string;
projName: string;
unit: string;
onToggle: () => void;
timeline: React.ReactNode;
}) {
return (
<>
<tr className={`border-b border-line/60 cursor-pointer ${isExp ? "bg-accent/5" : "hover:bg-accent/5"}`} onClick={onToggle}>
<td className="py-2 text-center text-muted">{isExp ? "▾" : "▸"}</td>
<td className="py-2 pe-3 font-semibold">{matName}</td>
<td className="py-2 pe-3">{matCatName}</td>
<td className="py-2 pe-3">
{wh}
{isAdmin && <small className="block text-muted">{projName}</small>}
</td>
<td className="py-2 pe-3 text-muted">
{Object.entries(g.specs || {}).map(([k, v]) => `${k}: ${v}`).join(" · ") || "—"}
</td>
<td className="py-2 pe-3 font-bold">{faNum(g.qty)}</td>
<td className="py-2 pe-3">{unit}</td>
<td className="py-2 pe-3 text-muted">{isoToJalaliStr(g.updatedAt)}</td>
<td className="py-2 text-muted">🕘</td>
</tr>
{isExp && (
<tr className="border-b border-line/40 bg-accent-bg/20">
<td colSpan={9} className="p-3">{timeline}</td>
</tr>
)}
</>
);
}
function Timeline({ g, txs, allTxs, brandTxs, selTx, setSelTx, expBrand, setExpBrand, matName, unit, brandNameFn, whNameFn }: {
g: Group;
txs: InventoryTransaction[];
allTxs: InventoryTransaction[];
brandTxs: (g: Group, bid: string) => InventoryTransaction[];
selTx: string | null;
setSelTx: (id: string | null) => void;
expBrand: string | null;
setExpBrand: (id: string | null) => void;
matName: string;
unit: string;
brandNameFn: (id: string | null | undefined) => string;
whNameFn: (id: string | null | undefined) => string;
}) {
const track = (list: InventoryTransaction[]) => {
let bal = 0;
return list.map((t) => {
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
bal += t.quantity_number || 0;
const sel = selTx === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => setSelTx(sel ? null : t.id)}
className={`flex min-w-[120px] flex-col gap-0.5 rounded-lg border p-2 text-start ${sel ? "border-accent bg-accent/10" : "border-line bg-panel"}`}
style={{ borderTop: `3px solid ${meta.color}` }}
>
<span className="text-[11px] font-bold" style={{ color: meta.color }}>{meta.icon} {meta.label}</span>
<span className="text-[12px] font-bold" style={{ color: meta.color }}>
{t.quantity_number >= 0 ? "+" : "−"}{faNum(Math.abs(t.quantity_number))} {unit}
</span>
<span className="text-[10.5px] text-muted">مانده: <b>{faNum(bal)}</b> {unit}</span>
</button>
);
});
};
if (!txs.length) return <div className="text-[12px] text-muted">تراکنشی ثبت نشده است.</div>;
const brandSec = expBrand ? (
<div className="mt-2">
<div className="mb-1 text-[12px] font-semibold">جریان برند: {brandNameFn(expBrand)} ({faNum(brandTxs(g, expBrand).length)} تراکنش)</div>
<div className="flex gap-1.5 overflow-x-auto">{track(brandTxs(g, expBrand))}</div>
</div>
) : null;
const selDetail = selTx ? (() => {
const t = allTxs.find((x) => x.id === selTx);
if (!t) return null;
const meta = TX_META[t.transaction_type] || { label: t.transaction_type, color: "#64748b", icon: "•" };
const pairTx = t.transfer_pair_id ? allTxs.find((x) => x.transfer_pair_id === t.transfer_pair_id && x.id !== t.id) : null;
return (
<div className="mt-2 rounded-lg border p-2 text-[12px]" style={{ borderTop: `3px solid ${meta.color}` }}>
<b style={{ color: meta.color }}>{meta.label}</b>
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-muted">
<span>تاریخ و ساعت: <b>{isoToJalaliStr(t.transaction_date)}</b></span>
{t.transaction_type === "TRANSFER_OUT" || t.transaction_type === "TRANSFER_IN" ? (
<>
<span>از انبار: <b>{t.transaction_type === "TRANSFER_OUT" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
<span>به انبار: <b>{t.transaction_type === "TRANSFER_IN" ? whNameFn(t.warehouse_id) : whNameFn(pairTx?.warehouse_id)}</b></span>
</>
) : (
<span>انبار: <b>{whNameFn(t.warehouse_id)}</b></span>
)}
{t.brand?.name && <span>برند: <b>{t.brand.name}</b></span>}
{t.performed_by && <span>انجام‌دهنده: <b>{t.performed_by}</b></span>}
{t.note && <span>یادداشت: <b>{t.note}</b></span>}
{t.transaction_type === "STOCK_COUNT" && t.recorded_qty != null && t.physical_qty != null && (
<span>
ثبت‌شده: {faNum(t.recorded_qty)} • شمارش‌شده: {faNum(t.physical_qty)} • اختلاف: {faNum(t.physical_qty - t.recorded_qty)}
</span>
)}
</div>
</div>
);
})() : null;
return (
<div>
<div className="mb-1 flex flex-wrap items-center gap-2 text-[12px]">
<b>خط زمانی — {matName}</b>
{Object.entries(g.specs || {}).length > 0 && (
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">
{Object.entries(g.specs).map(([k, v]) => `${k}: ${v}`).join(" · ")}
</span>
)}
<span className="rounded bg-panel px-1.5 py-0.5 text-[10.5px] text-muted">{faNum(g.brandOrder.length)} برند</span>
</div>
<div className="mb-2 flex flex-wrap gap-1.5">
{g.brandOrder.map((bid) => (
<button
key={bid}
type="button"
onClick={() => setExpBrand(expBrand === bid ? null : bid)}
className={`rounded-lg border px-2 py-1 text-[11.5px] ${expBrand === bid ? "border-accent bg-accent/10 text-accent" : "border-line bg-panel"}`}
>
{brandNameFn(bid)}: <b>{faNum(g.brands[bid])}</b> {unit}{expBrand === bid ? " ✕" : ""}
</button>
))}
</div>
{brandSec}
<div className="mb-1 mt-2 text-[12px] font-semibold">جریان کلی رکورد — ترکیب همه برندها ({faNum(txs.length)} تراکنش)</div>
<div className="flex gap-1.5 overflow-x-auto pb-1">{track(txs)}</div>
{selDetail}
</div>
);
}
@@ -0,0 +1,155 @@
import { useState } from "react";
import { useLocation } 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 } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
// سازمان‌دهی (فقط ادمین) — پروژه‌ها، مدیران و انبارهای هر پروژه
export function OrganizationPage() {
const location = useLocation();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const [newWh, setNewWh] = useState<Record<string, string>>({});
const [newWhDesc, setNewWhDesc] = useState<Record<string, string>>({});
if (user?.role !== "admin" && user?.role !== "procurement_manager") {
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 addWarehouse = async (projectId: string) => {
const name = (newWh[projectId] || "").trim();
if (!name) return toast.error("نام انبار الزامی است");
try {
await apiV26.warehouses.create({ project_id: projectId, name, description: (newWhDesc[projectId] || "").trim() || null });
setNewWh((s) => ({ ...s, [projectId]: "" }));
setNewWhDesc((s) => ({ ...s, [projectId]: "" }));
toast.success(`انبار «${name}» اضافه شد ✓`);
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
}
};
const moveWarehouse = async (id: string, projectId: string) => {
try {
await apiV26.warehouses.update(id, { project_id: projectId });
toast.success("انبار منتقل شد ✓");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در انتقال انبار");
}
};
const removeWarehouse = async (id: string) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try {
await apiV26.warehouses.delete(id);
toast.success("انبار حذف شد");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
}
};
return (
<div className="min-h-screen">
<Header title="سازمان‌دهی" sub="ادمین" menu={<PanelMenu title="پنل مدیریت انبار" items={panelMenuItems(location.pathname, user?.role, logout)} />} />
<PageLayout className="flex flex-col gap-3">
<div className="rounded-lg border border-blue/20 bg-blue/5 px-3 py-2 text-[12px] leading-6 text-muted">
ℹ️ پروژه‌ها از سیستم تدارکات می‌آیند. اینجا مشخص می‌کنید هر پروژه با کدام مدیر و انبار(ها) کار می‌کند؛
هر مدیر پروژه به همه انبارهای پروژه خودش دسترسی دارد. همه تغییرات فوراً در داشبورد و فرم‌ها اعمال می‌شود.
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{data.lookup.projects.map((p) => {
const whs = data.warehouses.filter((w) => w.project_id === p.id);
return (
<div key={p.id} className="rounded-xl border border-line bg-panel p-4">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-[14px] font-bold">🏢 {p.name}</h3>
<span className="rounded-lg bg-panel px-2 py-1 text-[11px] text-muted">
{faNum(whs.length)} انبار
</span>
</div>
<div className="mb-3">
<h4 className="mb-1 text-[12px] font-bold text-muted">👤 مدیر پروژه</h4>
{p.pm_user_id ? (
<div className="flex items-center gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<span className="font-semibold">{p.pm_name || "—"}</span>
<span className="text-[10.5px] text-muted">(مدیر مسئول پروژه)</span>
</div>
) : (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">
مدیری تعیین نشده است — از بخش کاربران (Casdoor) نقش مدیر پروژه را به کاربر بدهید و در تنظیمات پروژه وصل کنید.
</div>
)}
</div>
<h4 className="mb-1 text-[12px] font-bold text-muted">🏬 انبارهای پروژه</h4>
{!whs.length ? (
<div className="rounded-lg border border-dashed border-line px-3 py-2 text-[11.5px] text-muted">انباری ندارد — از فرم زیر اضافه کنید.</div>
) : (
<div className="mb-2 flex flex-col gap-1.5">
{whs.map((w) => (
<div key={w.id} className="flex items-center justify-between gap-2 rounded-lg border border-line px-3 py-2 text-[12.5px]">
<div className="min-w-0">
<b>{w.name}</b>
<small className="block truncate text-muted">{w.description || ""}</small>
</div>
<div className="flex items-center gap-1.5">
<select
className="h-7 rounded-lg border border-line bg-panel px-2 text-[11px]"
value={w.project_id}
onChange={(e) => void moveWarehouse(w.id, e.target.value)}
title="انتقال به پروژه دیگر"
>
{data.lookup.projects.map((p2) => (
<option key={p2.id} value={p2.id}>{p2.name}</option>
))}
</select>
<button className="text-muted hover:text-red" onClick={() => void removeWarehouse(w.id)} title="حذف انبار">✕</button>
</div>
</div>
))}
</div>
)}
<div className="flex gap-2">
<input
className="h-8 w-44 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="نام انبار جدید"
value={newWh[p.id] || ""}
onChange={(e) => setNewWh((s) => ({ ...s, [p.id]: e.target.value }))}
/>
<input
className="h-8 w-52 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیحات (اختیاری)"
value={newWhDesc[p.id] || ""}
onChange={(e) => setNewWhDesc((s) => ({ ...s, [p.id]: e.target.value }))}
/>
<button className="rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-white" onClick={() => void addWarehouse(p.id)}>
+ افزودن انبار
</button>
</div>
</div>
);
})}
</div>
</PageLayout>
</div>
);
}
@@ -0,0 +1,158 @@
import { useState } from "react";
import { useLocation } 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, projName } from "./useInventoryData";
import { faNum } from "../../lib/formatters";
import { isoToJalaliStr } from "../../lib/jalali";
// انبارها — پورت کامل نسخه‌ی ارسالی (مدیر پروژه: افزودن/ویرایش/حذف)
export function WarehousesPage() {
const location = useLocation();
const logout = useLogout();
const user = useAuthStore((s) => s.user);
const data = useInventoryData();
const isAdmin = user?.role === "admin";
const isPM = user?.role === "pm";
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");
const [editId, setEditId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editDesc, setEditDesc] = useState("");
const addWarehouse = async () => {
const name = newName.trim();
if (!name) return toast.error("نام انبار الزامی است");
const projectId = user?.project_id;
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
try {
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
setNewName("");
setNewDesc("");
toast.success(`انبار «${name}» اضافه شد ✓`);
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در افزودن انبار");
}
};
const saveEdit = async (id: string) => {
if (!editName.trim()) return toast.error("نام انبار نمی‌تواند خالی باشد");
try {
await apiV26.warehouses.update(id, { name: editName.trim(), description: editDesc.trim() || null });
setEditId(null);
toast.success("تغییرات ذخیره شد");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در ذخیره");
}
};
const removeWarehouse = async (id: string) => {
if (!confirm("انبار حذف شود؟ (انبارهای دارای سابقه تراکنش قابل حذف نیستند)")) return;
try {
await apiV26.warehouses.delete(id);
toast.success("انبار حذف شد");
await data.refresh();
} catch (err) {
toast.error(err instanceof Error ? err.message : "خطا در حذف انبار");
}
};
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">
{isAdmin && (
<div className="rounded-lg border border-amber/30 bg-amber/5 px-3 py-2 text-[12px] text-amber">
🔒 نقش ادمین فقط‌خواندنی است — مدیریت انبارها با مدیران پروژه است.
</div>
)}
{isPM && (
<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-2">
<input
className="h-9 w-52 rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="نام انبار (مثلاً انبار موقت)"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<input
className="h-9 w-64 rounded-lg border border-line bg-panel px-3 text-sm"
placeholder="توضیحات (اختیاری)"
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
/>
<button className="rounded-lg bg-accent px-4 py-2 text-[12.5px] font-bold text-white" onClick={() => void addWarehouse()}>
+ افزودن
</button>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{data.warehouses.map((w) => {
const cnt = data.inventory.filter((r) => r.warehouse?.id === w.id).length;
const txc = data.transactions.filter((t) => t.warehouse_id === w.id).length;
return (
<div key={w.id} className="rounded-xl border border-line bg-panel p-4">
<h3 className="text-[14px] font-bold">🏬 {w.name}</h3>
<div className="mt-0.5 text-[11.5px] text-muted">{w.description || "—"}</div>
<div className="mt-2 flex flex-col gap-1 text-[12px] text-muted">
<span>پروژه: <b className="text-foreground">{projName(data.lookup.projects, w.project_id)}</b></span>
<span>اقلام موجود: <b className="text-foreground">{faNum(cnt)}</b></span>
<span>تراکنش‌ها: <b className="text-foreground">{faNum(txc)}</b></span>
<span>تاریخ ایجاد: <b className="text-foreground">{isoToJalaliStr(w.created_at)}</b></span>
</div>
{isPM && (
<div className="mt-3 flex gap-1.5 border-t border-line pt-2">
<button
className="rounded-lg border border-line px-3 py-1 text-[11.5px]"
onClick={() => { setEditId(w.id); setEditName(w.name); setEditDesc(w.description || ""); }}
>
✏️ ویرایش
</button>
<button className="rounded-lg border border-red/30 px-3 py-1 text-[11.5px] text-red" onClick={() => void removeWarehouse(w.id)}>
✕ حذف
</button>
</div>
)}
{editId === w.id && isPM && (
<div className="mt-2 flex flex-col gap-1.5 rounded-lg border border-dashed border-accent/40 p-2">
<input
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="نام انبار"
value={editName}
onChange={(e) => setEditName(e.target.value)}
/>
<input
className="h-8 rounded-lg border border-line bg-panel px-2 text-sm"
placeholder="توضیحات"
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
/>
<div className="flex gap-1.5">
<button className="rounded-lg bg-accent px-3 py-1 text-[11.5px] font-bold text-white" onClick={() => void saveEdit(w.id)}>ذخیره</button>
<button className="rounded-lg border border-line px-3 py-1 text-[11.5px]" onClick={() => setEditId(null)}>انصراف</button>
</div>
</div>
)}
</div>
);
})}
</div>
</PageLayout>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import type { MenuItem } from "../../components/PanelMenu";
import { useAuthStore } from "../../store/auth";
// خروج از حساب — پاک‌کردن نشست و بازگشت به صفحه‌ی ورود
export function useLogout() {
return () => {
useAuthStore.getState().logout();
window.location.href = "/login";
};
}
// آیتم‌های منوی همبرگری پنل مدیریت انبار — با نشانگر پنل فعلی + «بازگشت به دفتر» + خروج
export function panelMenuItems(pathname: string, role: string | null | undefined, onLogout?: () => void): MenuItem[] {
const isAdmin = role === "admin" || role === "procurement_manager";
const isPM = role === "pm";
const items: MenuItem[] = [
{ label: "داشبورد انبار", to: "/inventory", active: pathname === "/inventory" },
{ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") },
{ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") },
{ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") },
];
if (isAdmin) {
items.push({ label: "سازمان‌دهی", to: "/inventory/org", active: pathname.startsWith("/inventory/org") });
}
if (isPM) {
items.push({ label: "رسید کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
}
items.push({ divider: true });
items.push({ label: "بازگشت به دفتر", to: "/" });
if (onLogout) {
items.push({ divider: true });
items.push({ label: "خروج از حساب", onClick: onLogout });
}
return items;
}
@@ -0,0 +1,109 @@
// ============================================================
// Inventory data hook — loads all dash data (scoped by role/project)
// ============================================================
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiV26 } from "../../lib/api";
import { useAuthStore } from "../../store/auth";
import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types";
export interface LookupData {
categories: { id: string; slug: string; name: string }[];
units: { id: number; code: string; name_fa: string | null }[];
brands: { id: string; name: string }[];
materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[];
projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[];
}
export interface InventoryData {
warehouses: Warehouse[];
inventory: InventoryRow[];
transactions: InventoryTransaction[];
lookup: LookupData;
loading: boolean;
refresh: () => Promise<void>;
}
export function useInventoryData(): InventoryData {
const user = useAuthStore((s) => s.user);
const isPM = user?.role === "pm";
const projectId = isPM ? user.project_id || undefined : undefined;
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [inventory, setInventory] = useState<InventoryRow[]>([]);
const [transactions, setTransactions] = useState<InventoryTransaction[]>([]);
const [lookup, setLookup] = useState<LookupData>({
categories: [], units: [], brands: [], materials: [], projects: [],
});
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([
apiV26.lookup.categories(),
apiV26.lookup.units(),
apiV26.lookup.brands(),
apiV26.lookup.materials(),
apiV26.lookup.projects(),
apiV26.warehouses.list(projectId),
apiV26.inventory.list({ project_id: projectId }),
apiV26.inventory.transactions({ project_id: projectId }),
]);
setLookup({ categories: cats, units, brands, materials: mats, projects: projs });
setWarehouses(whs);
setInventory(inv);
setTransactions(txs);
} finally {
setLoading(false);
}
}, [projectId]);
useEffect(() => {
setLoading(true);
void load();
}, [load]);
const scopedWarehouses = useMemo(
() => (user?.role === "admin" ? warehouses : warehouses.filter((w) => String(w.project_id) === String(user?.project_id || ""))),
[warehouses, user?.role, user?.project_id],
);
return {
warehouses: scopedWarehouses,
inventory,
transactions,
lookup,
loading,
refresh: load,
};
}
export function unitName(units: LookupData["units"], id: number | null | undefined): string {
const u = units.find((x) => x.id === id);
return u ? u.name_fa || u.code : "—";
}
export function brandName(brands: LookupData["brands"], id: string | null | undefined): string {
const b = brands.find((x) => x.id === id);
return b ? b.name : "—";
}
export function materialName(materials: LookupData["materials"], id: string): string {
const m = materials.find((x) => x.id === id);
return m ? m.name : "—";
}
export function catName(categories: LookupData["categories"], id: string | null | undefined): string {
const c = categories.find((x) => x.id === id);
return c ? c.name : "—";
}
export function projName(projects: LookupData["projects"], id: string | null | undefined): string {
const p = projects.find((x) => x.id === id);
return p ? p.name : "—";
}
export function whName(warehouses: Warehouse[], id: string | null | undefined): string {
const w = warehouses.find((x) => x.id === id);
return w ? w.name : "—";
}