Files
ERP/frontend/src/pages/Dashboard/QuoteDetail.tsx
T
whqreza c64718ef70 fix(quotes): show real material names instead of category in accept modal & detail
Line-item labels in the accept modal («پروژهی هر قلم» and the keep/reject
list) and the quote-detail flow boxes printed category + brand (e.g. the
topic name). Both now use materialLabel() → the material's actual stored
name, consistent with the rest of the dashboard.
2026-09-08 14:51:36 +03:30

249 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
materialLabel,
materialSpecs,
refConverted,
specsLabel,
} from "../../lib/quoteUtils";
import { fmtDate, fmtDateTime, 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));
// رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی):
// مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد).
const qty = li.quantity ?? 0;
const recv = !isRejected ? li.received_qty || 0 : 0;
const remaining = Math.max(0, qty - recv);
const whNames = (li.received_warehouses || []).map((w) => w.name);
const showSplit = !isRejected && q.status !== "offered" && qty > 0;
const fullyReceived = showSplit && recv >= qty;
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" : ""}${
fullyReceived ? " fb-fully-received" : ""
}`}
>
<div className="fb-name">
<Package className="me-1 inline h-3 w-3 text-muted" />
{materialLabel(m)}
{specText ? ` · ${specText}` : ""}
</div>
<div className="fb-meta">
<Building2 className="me-1 inline h-3 w-3" /> {projs.length ? projs.join("، ") : "—"}
</div>
{showSplit ? (
<div className="fb-nums">
{recv > 0 && (
<span className="pill-recvd">
رسید شده ✓ {fmtNum(recv)} {li.unit || ""}
</span>
)}
{remaining > 0 && (
<span className="pill-pend">
در راه ⏳ {fmtNum(remaining)} {li.unit || ""}
</span>
)}
<span>× {groupCommas(li.unit_price, true)}</span>
<span className="fb-total">= {total != null ? groupCommas(total, true) + " تومان" : "—"}</span>
</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>
)}
{showSplit && recv > 0 && (
<div className="fb-receipt-meta">
<span className="pill-recvd-mini">✓ {fmtNum(recv)} {li.unit || ""}</span>
{li.arrived_date && <span>تاریخ رسید: {fmtDateTime(li.arrived_date)}</span>}
{whNames.length > 0 && <span>به انبار: {whNames.join("، ")}</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>
);
}