feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Inbox, Plus } from "lucide-react";
|
||||
import type { Supplier } from "../../lib/types";
|
||||
import { nextId } from "../../lib/api";
|
||||
import { useAddSupplier, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { Combobox } from "../../components/Combobox";
|
||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// صفحهی تأمینکنندههای یک متریال + فرم افزودن تأمینکننده
|
||||
export function CategoryPage() {
|
||||
useStoreData();
|
||||
const { topic: topicParam = "", category: catParam = "" } = useParams();
|
||||
|
||||
const materials = useMaterials();
|
||||
const suppliers = useSuppliers();
|
||||
const quotes = useQuotes();
|
||||
const addSupplier = useAddSupplier();
|
||||
const removeSupplier = useRemoveSupplier();
|
||||
|
||||
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]);
|
||||
const matched = useMemo(
|
||||
() => (topic ? topicCategories(topic, materials, suppliers).find((c) => normalize(c) === normalize(catParam)) : undefined),
|
||||
[topic, catParam, materials, suppliers],
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<{ name: string; categories: string[]; location: string; phone: string; contact: string }>({
|
||||
name: "",
|
||||
categories: [],
|
||||
location: "",
|
||||
phone: "",
|
||||
contact: "",
|
||||
});
|
||||
|
||||
// همهی تأمینکنندهها برای انتخاب سریع (task: کومبوکس کششده)
|
||||
const supplierOptions = useMemo(
|
||||
() => suppliers.map((s) => ({ id: s.id, name: s.name })),
|
||||
[suppliers],
|
||||
);
|
||||
|
||||
if (!topic || !matched) {
|
||||
return topic ? <Navigate to={`/topics/${encodeURIComponent(topic)}`} replace /> : <Navigate to="/suppliers" replace />;
|
||||
}
|
||||
|
||||
const sups = suppliersOfCategory(topic, matched, suppliers);
|
||||
|
||||
// متریالهای خریداریشده از هر تأمینکننده
|
||||
const supMats = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
quotes.forEach((q) => {
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m) return;
|
||||
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
|
||||
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
|
||||
const list = map.get(q.supplier_id)!;
|
||||
if (!list.includes(label)) list.push(label);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [quotes, materials]);
|
||||
|
||||
const addSup = () => {
|
||||
const name = form.name.trim();
|
||||
if (!name) {
|
||||
toast.error("نام تأمینکننده را وارد کنید.");
|
||||
return;
|
||||
}
|
||||
// اگر متریالی انتخاب نشده، تأمینکننده به همین متریالِ صفحه اضافه میشود
|
||||
const cats = form.categories.length ? form.categories : [matched];
|
||||
const s: Supplier = {
|
||||
id: nextId("s", suppliers),
|
||||
topic,
|
||||
categories: cats,
|
||||
name,
|
||||
location: form.location.trim(),
|
||||
phone: form.phone.trim(),
|
||||
contact: form.contact.trim(),
|
||||
};
|
||||
void addSupplier.mutateAsync(s);
|
||||
setForm({ name: "", categories: [], location: "", phone: "", contact: "" });
|
||||
toast.success("تأمینکننده افزوده شد.");
|
||||
};
|
||||
|
||||
const removeSup = (id: string) => {
|
||||
const s = suppliers.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
if (confirm(`تأمینکننده «${s.name}» حذف شود؟ پیشنهادهای قبلی او در دفتر باقی میمانند.`)) {
|
||||
void removeSupplier.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title={matched} sub={`تأمینکنندههای این متریال — ${topic}`} backTo="/suppliers" backLabel="موضوعها" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{!sups.length ? (
|
||||
<EmptyState icon={Inbox} text="هنوز تأمینکنندهای برای این متریال ثبت نشده است." />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{sups.map((s) => (
|
||||
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-panel">
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن تأمینکنندهی جدید
|
||||
</h4>
|
||||
<div className="grid max-w-2xl grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label>نام تأمینکننده *</label>
|
||||
<Combobox
|
||||
value={form.name}
|
||||
onChange={(it: { id: string; label: string }) => setForm((f) => ({ ...f, name: it.label }))}
|
||||
items={supplierOptions}
|
||||
allowAdd
|
||||
addSuffix="به عنوان تأمینکنندهٔ جدید"
|
||||
placeholder="تایپ کنید یا از تأمینکنندهها انتخاب کنید"
|
||||
onInputChange={(v) => setForm((f) => ({ ...f, name: v }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>متریال (ها)</label>
|
||||
<MultiSelectCombobox
|
||||
values={form.categories}
|
||||
onChange={(cats) => setForm((f) => ({ ...f, categories: cats }))}
|
||||
options={topicCategories(topic, materials, suppliers)}
|
||||
placeholder="متریال را انتخاب یا تایپ کنید..."
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>موقعیت (شهر - منطقه)</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
placeholder="مثلاً تهران - ظفر"
|
||||
value={form.location}
|
||||
onChange={(e) => setForm((f) => ({ ...f, location: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>تلفن</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
placeholder="۰۲۱-..."
|
||||
value={form.phone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>شخص رابط</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
placeholder="نام فرد"
|
||||
value={form.contact}
|
||||
onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="mt-3" onClick={addSup}>
|
||||
ذخیره تأمینکننده
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Building2, Paperclip, Plus } from "lucide-react";
|
||||
import type { Attachment, Material, Quote, Supplier } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import { activeItems, materialById, materialSpecs, quoteTotal, specsLabel } from "../../lib/quoteUtils";
|
||||
import { fmtDate, faNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { STATUS_LABEL } from "../../lib/constants";
|
||||
|
||||
interface AttachSet {
|
||||
invoices: Attachment[];
|
||||
proformas: Attachment[];
|
||||
}
|
||||
|
||||
export interface PurchaseHistoryProps {
|
||||
supplier: Supplier;
|
||||
quotes: Quote[];
|
||||
materials: Material[];
|
||||
}
|
||||
|
||||
// سابقه خرید از یک تأمینکننده: همهی پیشنهادها (با بج وضعیت، از جدیدترین)
|
||||
// + فاکتورها و پیشفاکتورها (لینک دانلود از MinIO از طریق api.files)
|
||||
export function PurchaseHistory({ supplier, quotes, materials }: PurchaseHistoryProps) {
|
||||
const purchases = useMemo(() => {
|
||||
return quotes
|
||||
.filter((q) => q.supplier_id === supplier.id)
|
||||
.sort((a, b) => {
|
||||
const ta = a.quote_date ? new Date(a.quote_date).getTime() : 0;
|
||||
const tb = b.quote_date ? new Date(b.quote_date).getTime() : 0;
|
||||
return tb - ta;
|
||||
});
|
||||
}, [quotes, supplier.id]);
|
||||
|
||||
// پیوستها بهصورت موازی برای همهی خریدها خوانده میشوند (الگوی AcceptModal)
|
||||
const [attachments, setAttachments] = useState<Record<string, AttachSet>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!purchases.length) return;
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
void Promise.all(
|
||||
purchases.map(async (q) => {
|
||||
const [invoices, proformas] = await Promise.all([
|
||||
api.files.list(q.id, "invoice").catch(() => [] as Attachment[]),
|
||||
api.files.list(q.id, "proforma").catch(() => [] as Attachment[]),
|
||||
]);
|
||||
return [q.id, { invoices, proformas }] as const;
|
||||
}),
|
||||
)
|
||||
.then((pairs) => {
|
||||
if (!alive) return;
|
||||
const map: Record<string, AttachSet> = {};
|
||||
pairs.forEach(([id, set]) => (map[id] = set));
|
||||
setAttachments(map);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [purchases]);
|
||||
|
||||
const wizardLink = `/wizard?topic=${encodeURIComponent(supplier.topic)}&supplier=${encodeURIComponent(supplier.id)}`;
|
||||
|
||||
return (
|
||||
<div className="ph-card">
|
||||
<div className="ph-head">
|
||||
<span className="ph-title">سابقه خرید از این تأمینکننده</span>
|
||||
{purchases.length > 0 && <span className="ph-count">{faNum(purchases.length)} پیشنهاد</span>}
|
||||
</div>
|
||||
|
||||
{purchases.length === 0 && (
|
||||
<div className="ph-empty">
|
||||
<div className="ph-empty-text">هنوز خریدی از این تأمینکننده ثبت نشده است.</div>
|
||||
<Link to={wizardLink} className="btn-line-file no-underline mt-2 inline-flex bg-accent text-white hover:bg-accent-dark">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
اولین پیشنهاد
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{purchases.map((q) => {
|
||||
const items = activeItems(q).map((li) => {
|
||||
const m = materialById(materials, li.material_id);
|
||||
const specs = materialSpecs(m);
|
||||
return `${m.category} ${m.brand}${specs.length ? " " + specsLabel(specs) : ""}`.trim();
|
||||
});
|
||||
const total = quoteTotal(q);
|
||||
const attach = attachments[q.id];
|
||||
const profs = attach?.proformas || [];
|
||||
const invs = attach?.invoices || [];
|
||||
const projects = q.projects && q.projects.length ? q.projects : q.project ? [q.project] : [];
|
||||
return (
|
||||
<div key={q.id} className={`ph-row${q.status === "expired" ? " stale" : ""}`}>
|
||||
<div className="ph-top">
|
||||
<span className="ph-date">{fmtDate(q.quote_date)}</span>
|
||||
<span className={`fb-badge ${q.status}`}>{STATUS_LABEL[q.status] || q.status}</span>
|
||||
<span className="ph-project">
|
||||
<Building2 className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{projects.join("، ") || "بدون پروژه"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ph-items">
|
||||
{items.length ? `${faNum(items.length)} قلم: ${items.join("، ")}` : "بدون قلم"}
|
||||
</div>
|
||||
<div className="ph-total">{groupCommas(total, true)} تومان</div>
|
||||
<div className="ph-attach">
|
||||
{loading && !attach ? (
|
||||
<span className="ph-attach-loading">در حال بارگذاری فاکتورها...</span>
|
||||
) : (
|
||||
<>
|
||||
{profs.map((pf) => (
|
||||
<a
|
||||
key={"pf-" + pf.id}
|
||||
className="detail-link"
|
||||
href={api.files.downloadUrl(q.id, "proforma", pf.id!)}
|
||||
download={pf.name}
|
||||
title="دانلود پیشفاکتور"
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</a>
|
||||
))}
|
||||
{invs.map((inv) => (
|
||||
<a
|
||||
key={"inv-" + inv.id}
|
||||
className="detail-link"
|
||||
href={api.files.downloadUrl(q.id, "invoice", inv.id!)}
|
||||
download={inv.name}
|
||||
title="دانلود فاکتور"
|
||||
>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور: {inv.name}
|
||||
</a>
|
||||
))}
|
||||
{!loading && !profs.length && !invs.length && (
|
||||
<span className="ph-attach-loading">فاکتوری ثبت نشده</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useMemo } from "react";
|
||||
import { useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { TOPICS } from "../../lib/constants";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { TopicGrid } from "../../components/TopicGrid";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
// صفحهی تأمینکنندهها — انتخاب موضوع
|
||||
export function SuppliersPage() {
|
||||
useStoreData();
|
||||
const suppliers = useSuppliers();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const out: Record<string, number> = {};
|
||||
TOPICS.forEach((t) => {
|
||||
out[t.name] = suppliers.filter((s) => normalize(s.topic) === normalize(t.name)).length;
|
||||
});
|
||||
return out;
|
||||
}, [suppliers]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header
|
||||
title="تأمینکنندهها"
|
||||
sub="اول موضوع (دستهبندی) را انتخاب کنید"
|
||||
backTo="/"
|
||||
backLabel="بازگشت به دفتر"
|
||||
/>
|
||||
<PageLayout>
|
||||
<TopicGrid
|
||||
onSelect={(topic) => navigate(`/topics/${encodeURIComponent(topic)}`)}
|
||||
showCounts
|
||||
counts={counts}
|
||||
/>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { FolderOpen, Plus } from "lucide-react";
|
||||
import type { Material } from "../../lib/types";
|
||||
import { nextId } from "../../lib/api";
|
||||
import { useAddMaterial, useMaterials, useQuotes, useRemoveSupplier, useStoreData, useSuppliers } from "../../hooks";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { faNum } from "../../lib/formatters";
|
||||
import { Header, PageLayout } from "../../components/layout/Header";
|
||||
import { EmptyState } from "../../components/EmptyState";
|
||||
import { SupplierCard, suppliersOfCategory, topicCategories, topicFromParam } from "./supplierUtils";
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
// صفحهی متریالهای یک موضوع + تأمینکنندههای بدون متریال + افزودن متریال
|
||||
export function TopicPage() {
|
||||
useStoreData();
|
||||
const { topic: topicParam = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const materials = useMaterials();
|
||||
const suppliers = useSuppliers();
|
||||
const quotes = useQuotes();
|
||||
const addMaterial = useAddMaterial();
|
||||
const removeSupplier = useRemoveSupplier();
|
||||
|
||||
const topic = useMemo(() => topicFromParam(topicParam), [topicParam]);
|
||||
const [newCatName, setNewCatName] = useState("");
|
||||
|
||||
const cats = useMemo(
|
||||
() => (topic ? topicCategories(topic, materials, suppliers) : []),
|
||||
[topic, materials, suppliers],
|
||||
);
|
||||
|
||||
const uncat = useMemo(
|
||||
() =>
|
||||
topic
|
||||
? suppliers.filter((s) => normalize(s.topic) === normalize(topic) && !(s.categories || []).length)
|
||||
: [],
|
||||
[topic, suppliers],
|
||||
);
|
||||
|
||||
if (!topic) return <Navigate to="/suppliers" replace />;
|
||||
|
||||
const addCategory = () => {
|
||||
const name = newCatName.trim();
|
||||
if (!name) return;
|
||||
const dup = materials.some(
|
||||
(m) => normalize(m.topic) === normalize(topic) && normalize(m.category) === normalize(name),
|
||||
);
|
||||
if (dup) {
|
||||
toast.error("این متریال قبلاً ثبت شده است.");
|
||||
return;
|
||||
}
|
||||
const m: Material = {
|
||||
id: nextId("m", materials),
|
||||
name,
|
||||
topic,
|
||||
category: name,
|
||||
brand: "",
|
||||
specs: [],
|
||||
base_unit: "",
|
||||
};
|
||||
void addMaterial.mutateAsync(m);
|
||||
setNewCatName("");
|
||||
toast.success("متریال افزوده شد.");
|
||||
};
|
||||
|
||||
const removeSup = (id: string) => {
|
||||
const s = suppliers.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
if (confirm(`تأمینکننده «${s.name}» حذف شود؟ پیشنهادهای قبلی او در دفتر باقی میمانند.`)) {
|
||||
void removeSupplier.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
// متریالهای خریداریشده از هر تأمینکننده
|
||||
const supMats = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
quotes.forEach((q) => {
|
||||
(q.line_items || []).forEach((li) => {
|
||||
if (li.rejected) return;
|
||||
const m = materials.find((x) => x.id === li.material_id);
|
||||
if (!m) return;
|
||||
const label = `${m.category}${m.brand ? " " + m.brand : ""}${m.specs.length ? " (" + m.specs.join("، ") + ")" : ""}`;
|
||||
if (!map.has(q.supplier_id)) map.set(q.supplier_id, []);
|
||||
const list = map.get(q.supplier_id)!;
|
||||
if (!list.includes(label)) list.push(label);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [quotes, materials]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Header title={topic} sub="یک متریال را انتخاب کنید" backTo="/suppliers" backLabel="موضوعها" />
|
||||
<PageLayout className="flex flex-col gap-4">
|
||||
{cats.length ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{cats.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className="topic-card"
|
||||
onClick={() => navigate(`/topics/${encodeURIComponent(topic)}/${encodeURIComponent(c)}`)}
|
||||
>
|
||||
<div className="t-name" style={{ fontSize: 13 }}>
|
||||
{c}
|
||||
</div>
|
||||
<div className="t-count">{faNum(suppliersOfCategory(topic, c, suppliers).length)} تأمینکننده</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState icon={FolderOpen} text="هنوز متریالی برای این موضوع ثبت نشده — از پایین یک متریال اضافه کنید." />
|
||||
)}
|
||||
|
||||
{uncat.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-[13.5px] font-bold">تأمینکنندههای این موضوع</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{uncat.map((s) => (
|
||||
<SupplierCard key={s.id} s={s} topic={topic} deletable onDelete={removeSup} materials={supMats.get(s.id) || []} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="add-panel">
|
||||
<h4>
|
||||
<Plus className="me-1 inline h-4 w-4 text-accent" /> افزودن متریال جدید
|
||||
</h4>
|
||||
<div className="grid max-w-md grid-cols-[1fr_auto] items-end gap-2">
|
||||
<div className="field">
|
||||
<label>نام متریال (مثلاً میلگرد آجدار ۱۴)</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
placeholder="مثلاً میلگرد آجدار ۱۴"
|
||||
autoComplete="off"
|
||||
value={newCatName}
|
||||
onChange={(e) => setNewCatName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
addCategory();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={addCategory}>افزودن متریال</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] text-muted">
|
||||
بعد از افزودن، داخل همان متریال میتوانید تأمینکننده اضافه یا حذف کنید.
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { MapPin, Phone, User, Plus } from "lucide-react";
|
||||
import type { Material, Supplier } from "../../lib/types";
|
||||
import { normalize } from "../../lib/normalize";
|
||||
import { topicByName } from "../../lib/constants";
|
||||
|
||||
// دستههای یک تاپیک (اتحاد materials + categories تأمینکنندهها)
|
||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
||||
const out = new Set<string>();
|
||||
materials
|
||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
||||
.forEach((m) => out.add(m.category));
|
||||
suppliers
|
||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||
}
|
||||
|
||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||
return suppliers.filter(
|
||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||
);
|
||||
}
|
||||
|
||||
export function topicFromParam(param: string | null): string | null {
|
||||
if (!param) return null;
|
||||
const t = topicByName(param);
|
||||
return t ? t.name : null;
|
||||
}
|
||||
|
||||
// کارت تأمینکننده
|
||||
export function SupplierCard({
|
||||
s,
|
||||
topic,
|
||||
deletable,
|
||||
onDelete,
|
||||
materials = [],
|
||||
}: {
|
||||
s: Supplier;
|
||||
topic: string;
|
||||
deletable?: boolean;
|
||||
onDelete?: (id: string) => void;
|
||||
// متریالهای خریداریشده از این تأمینکننده (بهجای دستهبندی)
|
||||
materials?: string[];
|
||||
}) {
|
||||
return (
|
||||
<div className="sup-card">
|
||||
<div className="sc-head">
|
||||
<Link to={`/suppliers/${s.id}`} className="sc-name-link">
|
||||
{s.name}
|
||||
</Link>
|
||||
{deletable && onDelete && (
|
||||
<button type="button" className="sup-del" title="حذف تأمینکننده" onClick={() => onDelete(s.id)}>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="sc-meta">
|
||||
<span>
|
||||
<MapPin className="me-1 inline h-3 w-3" /> {s.location || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<Phone className="me-1 inline h-3 w-3" /> {s.phone || "—"}
|
||||
</span>
|
||||
<span>
|
||||
<User className="me-1 inline h-3 w-3" /> {s.contact || "—"}
|
||||
</span>
|
||||
</div>
|
||||
{materials.length > 0 && (
|
||||
<div className="mt-2 border-t border-line pt-1.5">
|
||||
<div className="mb-1 flex flex-wrap gap-1">
|
||||
{materials.map((m, i) => (
|
||||
<span key={i} className="spec-chip">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="sc-actions">
|
||||
<Link to={`/wizard?topic=${encodeURIComponent(topic)}&supplier=${encodeURIComponent(s.id)}`} className="btn-line-file no-underline">
|
||||
<Plus className="h-3 w-3" />
|
||||
پیشنهاد جدید
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user