Files
ERP/frontend/src/pages/Dashboard/QuoteDetail.tsx
T
whqreza 5084623be5 feat(quotes): lock quotes with posted receipts; per-item target project + title dedup
Quotes that already have warehouse receipts can no longer be edited or rolled back:
- backend: quote_has_receipts() guard — replace_quote_children and every backward status transition now return 409 with «این پیشنهاد به‌دلیل ثبت رسید در انبار، غیرقابل ویرایش یا بازگشت است.»
- reopening to «offered» additionally clears item_decisions.project_id (the purchased-project link belongs to acceptance, not to a reopened offer)
- frontend EditModal: hasReceipts locks the whole form — disabled inputs and date pickers (JalaliDatePicker/NumberInput/MilestoneRow/MultiSelectCombobox gained disabled support), add/remove controls for items, termsheets and milestones hidden, status change blocked, amber warning banner; quote deletion now goes through a confirmation dialog (useDeleteQuote)
- date-only semantics: JalaliDatePicker keeps/emits YYYY-MM-DD when withTime=false (typing, «امروز», day selection) and toDateOnly no longer mangles pure date strings

Per-item target project (candidate projects stay intact):
- expand_service exposes the decision target project (decision.project + line_item.target_project)
- the accept payload derives item_decisions.project_id from that target project and sends item_projects: [] so line_item_projects keeps the original candidates untouched
- AcceptModal/EditModal select the target project per line item

Material titles:
- quoteUtils.formatMaterialTitle() composes one normalized, deduplicated «name + brand + specs» title, replacing materialLabel/materialSpecs/specsLabel usage across dashboard cards, quote detail and the modals

Verified: python compile + api/frontend image builds (tsc + vite) clean.
2026-09-16 19:14:06 +03:30

245 lines
9.7 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 {
formatMaterialTitle,
itemTermsheetIds,
lineItemTotal,
materialById,
refConverted,
} 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 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" />
{formatMaterialTitle(m)}
</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>
);
}