feat(frontend): add React UI — procurement office, wizard, suppliers, inventory panel (dash port), role-based login
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react";
|
||||
import type { Attachment, Quote, Store } from "../../lib/types";
|
||||
import { api } from "../../lib/api";
|
||||
import {
|
||||
itemTermsheetIds,
|
||||
lineItemTotal,
|
||||
materialById,
|
||||
materialSpecs,
|
||||
refConverted,
|
||||
specsLabel,
|
||||
} from "../../lib/quoteUtils";
|
||||
import { fmtDate, fmtNum } from "../../lib/formatters";
|
||||
import { groupCommas } from "../../lib/numutil";
|
||||
import { FlowDiagram, type FlowRefs } from "./FlowDiagram";
|
||||
import { itemProjects } from "../../lib/quoteUtils";
|
||||
|
||||
export interface QuoteDetailProps {
|
||||
store: Store;
|
||||
q: Quote;
|
||||
}
|
||||
|
||||
// بخش expanded یک پیشنهاد: اقلام (راست) ↔ ترمشیتها (چپ) با خطوط SVG رنگی
|
||||
export function QuoteDetail({ store, q }: QuoteDetailProps) {
|
||||
const [flowEl, setFlowEl] = useState<HTMLDivElement | null>(null);
|
||||
const [flowRefs] = useState<FlowRefs>(() => ({ items: new Map(), termsheets: new Map() }));
|
||||
const [bump, setBump] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// بعد از نشستن layout (فونت/تصویر) خطوط دوباره رسم شوند
|
||||
const t = setTimeout(() => setBump((b) => b + 1), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, []);
|
||||
void bump;
|
||||
|
||||
// پیوستها (فاکتور/پیشفاکتور) مستقیماً از سرور (MinIO) خوانده میشوند
|
||||
const [attachments, setAttachments] = useState<{ invoices: Attachment[]; proformas: Attachment[] }>({
|
||||
invoices: [],
|
||||
proformas: [],
|
||||
});
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void Promise.all([
|
||||
api.files.list(q.id, "invoice").catch(() => [] as Attachment[]),
|
||||
api.files.list(q.id, "proforma").catch(() => [] as Attachment[]),
|
||||
]).then(([invoices, proformas]) => {
|
||||
if (alive) setAttachments({ invoices, proformas });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [q.id]);
|
||||
|
||||
const tsList = q.termsheets || [];
|
||||
const allItems = q.line_items || [];
|
||||
const items = allItems.filter((li) => !li.rejected);
|
||||
const rejectedItems = allItems.filter((li) => li.rejected);
|
||||
|
||||
// وقتی دادهی جریان عوض شد (پذیرش/رسید/ویرایش) خطوط دوباره رسم شوند
|
||||
const redrawKey = JSON.stringify({
|
||||
items: allItems.map((li) => [li.id, li.accepted_ts_id, !!li.rejected, !!li.arrived_date]),
|
||||
ts: tsList.map((t) => [t.id, t.status]),
|
||||
});
|
||||
|
||||
const itemBox = (li: (typeof allItems)[number], isRejected: boolean) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid));
|
||||
const total = lineItemTotal(li);
|
||||
const projs = itemProjects(q, li);
|
||||
const conv = isRejected ? null : refConverted(store, li);
|
||||
const specText = specsLabel(materialSpecs(m));
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
flowRefs.items.set(li.id, el);
|
||||
el.dataset.tsIds = tsIds.join(",");
|
||||
el.dataset.acceptedTs = li.accepted_ts_id || "";
|
||||
} else {
|
||||
flowRefs.items.delete(li.id);
|
||||
}
|
||||
}}
|
||||
className={`flow-box flow-item${isRejected ? " rejected" : ""}${li.arrived_date ? " deal" : ""}`}
|
||||
>
|
||||
<div className="fb-name">
|
||||
<Package className="me-1 inline h-3.5 w-3.5 text-muted" />
|
||||
{m.category} {m.brand}
|
||||
{specText ? ` · ${specText}` : ""}
|
||||
</div>
|
||||
<div className="fb-meta">
|
||||
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
|
||||
</div>
|
||||
<div className="fb-nums">
|
||||
<span>
|
||||
{fmtNum(li.quantity)} {li.unit || ""}
|
||||
</span>
|
||||
<span>× {groupCommas(li.unit_price, true)}</span>
|
||||
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
|
||||
</div>
|
||||
{conv && (
|
||||
<div className="fb-conv">
|
||||
<Scale className="me-1 inline h-3 w-3" /> ≈ {groupCommas(conv.qty, true)} {conv.unit}
|
||||
{conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const attachParts: React.ReactNode[] = [];
|
||||
attachments.proformas.forEach((pf, i) => {
|
||||
if (!pf.id) return;
|
||||
attachParts.push(
|
||||
<a key={"pf" + i} className="detail-link" href={api.files.downloadUrl(q.id, "proforma", pf.id)} download={pf.name}>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
پیشفاکتور: {pf.name}
|
||||
</a>,
|
||||
);
|
||||
});
|
||||
attachments.invoices.forEach((inv, i) => {
|
||||
if (!inv.id) return;
|
||||
attachParts.push(
|
||||
<a key={"inv" + i} className="detail-link" href={api.files.downloadUrl(q.id, "invoice", inv.id)} download={inv.name}>
|
||||
<Paperclip className="me-1 inline h-3 w-3" />
|
||||
فاکتور اصلی: {inv.name}
|
||||
</a>,
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setFlowEl} className="flow border-t border-line" data-status={q.status}>
|
||||
<div className="flow-cols">
|
||||
<div className="flow-col flow-items-col">
|
||||
<div className="flow-col-h">
|
||||
<Package className="h-3.5 w-3.5" /> اقلام ({fmtNum(items.length)})
|
||||
</div>
|
||||
{items.length ? items.map((li) => itemBox(li, false)) : <div className="flow-note">—</div>}
|
||||
{rejectedItems.length > 0 && (
|
||||
<>
|
||||
<div className="flow-rejected-h">
|
||||
<Ban className="h-3 w-3" /> قلمهای رد شده (از معامله خارجاند)
|
||||
</div>
|
||||
{rejectedItems.map((li) => itemBox(li, true))}
|
||||
<div className="flow-rejected-hint">
|
||||
با بازگشت وضعیت به مرحلهی پیشین (یا ویرایش)، این قلمها برمیگردند.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flow-col flow-terms-col">
|
||||
<div className="flow-col-h">
|
||||
<CreditCard className="h-3.5 w-3.5" /> شرایط پرداخت ({fmtNum(tsList.length)})
|
||||
</div>
|
||||
{tsList.length ? (
|
||||
tsList.map((t) => {
|
||||
const isAccepted = t.status === "accepted";
|
||||
const arrived = q.status === "arrived" && isAccepted;
|
||||
const badge = arrived ? (
|
||||
<span className="fb-badge arrived">رسیده</span>
|
||||
) : isAccepted ? (
|
||||
<span className="fb-badge accepted">✓ پذیرفته</span>
|
||||
) : (
|
||||
<span className="fb-badge offered">پیشنهاد</span>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
ref={(el) => {
|
||||
if (el) flowRefs.termsheets.set(t.id, el);
|
||||
else flowRefs.termsheets.delete(t.id);
|
||||
}}
|
||||
className={`flow-box flow-ts${isAccepted ? (arrived ? " ts-arrived" : " ts-accepted") : ""}`}
|
||||
>
|
||||
<div className="fb-name">
|
||||
<CreditCard className="h-3.5 w-3.5 text-muted" />
|
||||
{t.label} {badge}
|
||||
</div>
|
||||
<div className="fb-ms-list">
|
||||
{(t.milestones || []).length ? (
|
||||
t.milestones.map((m, i) => (
|
||||
<div key={i} className="fb-ms">
|
||||
<CalendarDays className="h-3 w-3" />
|
||||
{m.when_iso ? fmtDate(m.when_iso) : "—"} ·{" "}
|
||||
{m.amount != null ? groupCommas(m.amount, true) + " تومان" : "—"}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="fb-ms">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="flow-note">—</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FlowDiagram flowEl={flowEl} refs={flowRefs} redrawKey={redrawKey} />
|
||||
{attachParts.length > 0 && (
|
||||
<div className="flow-attach">
|
||||
{attachParts.map((p, i) => (
|
||||
<span key={i} className="detail-item">
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user