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,221 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { MapPin, Phone, User, Plus, Package, X } from "lucide-react";
import { toast } from "sonner";
import { useMaterials, useQuotes, useStoreData, useSuppliers } from "../../hooks";
import { topicByName } from "../../lib/constants";
import { api } from "../../lib/api";
import { Header, PageLayout } from "../../components/layout/Header";
import { EmptyState } from "../../components/EmptyState";
import { Badge } from "../../components/ui/badge";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
import { ConfirmDeleteDialog } from "../../components/ConfirmDeleteDialog";
import { normalize } from "../../lib/normalize";
import { PurchaseHistory } from "./PurchaseHistory";
// صفحه‌ی مشخصات کامل تأمین‌کننده + سابقه خرید + مدیریت متریال‌های تأمین‌شده
export function SupplierDetailPage() {
useStoreData();
const { id = "" } = useParams();
const suppliers = useSuppliers();
const quotes = useQuotes();
const materials = useMaterials();
const s = useMemo(() => suppliers.find((x) => x.id === id) || null, [suppliers, id]);
const [pendingRemove, setPendingRemove] = useState<string | null>(null);
// متریال‌های متصل واقعی (از اتصال تأمین‌کننده-متریال بک‌اند)
const [linked, setLinked] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!id) return;
let alive = true;
void api.supplierMaterials
.list(id)
.then((rows) => alive && setLinked(rows.map((r) => r.material_name).filter(Boolean)))
.catch(() => alive && setLinked([]));
return () => {
alive = false;
};
}, [id]);
// متریال‌های تأمین‌شده (نام متریال‌ها) برای پیشنهاد در کومبوکس
const materialOptions = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => m.name && set.add(m.name));
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
}, [materials]);
// متریال‌های خریداری‌شده از این تأمین‌کننده (از پیشنهادها)
const purchased = useMemo(() => {
const out = new Set<string>();
quotes.forEach((q) => {
if (q.supplier_id !== id) return;
(q.line_items || []).forEach((li) => {
if (li.rejected) return;
const m = materials.find((x) => x.id === li.material_id);
if (!m || !m.category) return;
out.add(m.category);
});
});
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}, [quotes, materials, id]);
if (!s) {
return (
<div className="min-h-screen">
<Header title="اطلاعات تماس تأمین‌کننده" backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout>
<EmptyState emoji="😕" text="تأمین‌کننده‌ای یافت نشد." />
</PageLayout>
</div>
);
}
const topic = topicByName(s.topic);
const topicName = topic ? topic.name : s.topic;
const firstCat = (s.categories || [])[0] || "";
const breadcrumb = `${topicName ? topicName + " — " : ""}${firstCat ? firstCat + " — " : ""}${s.name}`;
const saveCategories = (names: string[]) => {
// v2.6: اتصال واقعی تأمین‌کننده ↔ متریال (کاتالوگ) — با نام متریال
if (!id || busy) return;
setBusy(true);
void (async () => {
try {
for (const name of names) {
if (linked.some((x) => normalize(x) === normalize(name))) continue;
const mat = materials.find((m) => normalize(m.name) === normalize(name));
if (!mat) {
toast.error(`متریال «${name}» در کاتالوگ یافت نشد — ابتدا از ویزارد بسازید.`);
continue;
}
await api.supplierMaterials.create(id, mat.id);
}
const rows = await api.supplierMaterials.list(id);
setLinked(rows.map((r) => r.material_name).filter(Boolean));
toast.success("متریال‌ها ذخیره شدند.");
} catch (err) {
toast.error(err instanceof Error ? err.message : "ذخیره‌ی متریال‌ها ناموفق بود");
} finally {
setBusy(false);
}
})();
};
const removeCat = (name: string) => {
if (!id || busy) return;
setBusy(true);
void (async () => {
try {
const mat = materials.find((m) => normalize(m.name) === normalize(name));
if (mat) await api.supplierMaterials.remove(id, mat.id);
const rows = await api.supplierMaterials.list(id);
setLinked(rows.map((r) => r.material_name).filter(Boolean));
toast.success("متریال حذف شد.");
} catch (err) {
toast.error(err instanceof Error ? err.message : "حذف متریال ناموفق بود");
} finally {
setBusy(false);
}
})();
};
return (
<div className="min-h-screen">
<Header title="اطلاعات تماس تأمین‌کننده" sub={breadcrumb} backTo="/suppliers" backLabel="تأمین‌کننده‌ها" />
<PageLayout>
<div className="max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<div className="flex items-center gap-3">
<div className="text-xl font-extrabold">{s.name}</div>
<Badge variant="outline">{topicName}</Badge>
</div>
<div className="mt-3 flex flex-col gap-1.5 text-[13.5px]">
<span>
<MapPin className="me-1.5 inline h-4 w-4 text-muted" /> <b>موقعیت:</b> {s.location || "—"}
</span>
<span>
<Phone className="me-1.5 inline h-4 w-4 text-muted" /> <b>تلفن:</b> {s.phone || "—"}
</span>
<span>
<User className="me-1.5 inline h-4 w-4 text-muted" /> <b>شخص رابط:</b> {s.contact || "—"}
</span>
</div>
<div className="mt-4 flex items-center gap-2">
<Link
to={`/wizard?topic=${encodeURIComponent(topicName)}&supplier=${encodeURIComponent(s.id)}`}
className="btn-line-file no-underline bg-accent text-white hover:bg-accent-dark"
>
<Plus className="h-3.5 w-3.5" />
پیشنهاد جدید
</Link>
{firstCat ? (
<Link to={`/topics/${encodeURIComponent(topicName)}/${encodeURIComponent(firstCat)}`} className="btn-line-file no-underline">
‹ بازگشت به دسته
</Link>
) : (
<Link to={`/topics/${encodeURIComponent(topicName)}`} className="btn-line-file no-underline">
‹ بازگشت به موضوع
</Link>
)}
</div>
</div>
{/* متریال‌های این تأمین‌کننده — افزودن/حذف + مشاهده‌ی همه */}
<div className="mt-4 max-w-xl rounded-xl border border-line bg-panel p-5 shadow-sm">
<h4 className="mb-1 text-[13.5px] font-bold">
<Package className="me-1 inline h-4 w-4 text-accent" /> متریال‌های تأمین‌شده
</h4>
<div className="mb-2 text-[11.5px] text-muted">
متریال‌هایی که این تأمین‌کننده عرضه می‌کند را اینجا مدیریت کنید.
</div>
{(linked || []).length > 0 && (
<div className="mb-2 flex flex-wrap gap-1.5">
{linked.map((c) => (
<span key={c} className="spec-chip">
{c}
<button type="button" className="spec-chip-x" title="حذف متریال" onClick={() => setPendingRemove(c)}>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<MultiSelectCombobox
values={[]}
onChange={(cats) => saveCategories(cats)}
options={materialOptions}
placeholder="افزودن متریال..."
/>
{purchased.length > 0 && (
<div className="mt-3 border-t border-line pt-2">
<div className="mb-1 text-[11px] font-semibold text-muted">متریال‌های خریداری‌شده (از پیشنهادها):</div>
<div className="flex flex-wrap gap-1.5">
{purchased.map((c) => (
<span key={c} className="spec-chip">
{c}
</span>
))}
</div>
</div>
)}
</div>
{/* سابقه خرید از این تأمین‌کننده — همه‌ی پیشنهادها + فاکتور/پیش‌فاکتور */}
<div className="mt-4 max-w-4xl">
<PurchaseHistory supplier={s} quotes={quotes} materials={materials} />
</div>
<ConfirmDeleteDialog
open={pendingRemove !== null}
itemName={pendingRemove || ""}
onConfirm={() => {
if (pendingRemove !== null) removeCat(pendingRemove);
setPendingRemove(null);
}}
onCancel={() => setPendingRemove(null)}
/>
</PageLayout>
</div>
);
}