feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user