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