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