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.
245 lines
9.7 KiB
TypeScript
245 lines
9.7 KiB
TypeScript
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>
|
||
);
|
||
}
|