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,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>
);
}