Files
ERP/frontend/src/pages/Dashboard/QuoteDetail.tsx
T
ZCode b469238153 refactor(catalog): reduce the category taxonomy to a supplier label
Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).

Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
  stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
  the inventory serializer lookup) and the material category on /pm/on-the-way;
  the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
  unit_conversions tables (idempotent) and their CREATE statements; seed.py
  truncate list updated

Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
  refConverted, the categoryUnits + unitConversions store slices, api clients,
  hooks and the "≈ converted quantity" line on quote detail and project pages
  (it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
  yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
  plus the lookup's categories fetch and catName helper

Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).

Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
2026-09-20 19:44:23 +00:00

266 lines
11 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, 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,
} 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";
import { FilePreviewModal, useFilePreview } from "../../components/FilePreviewModal";
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);
// پیش‌نمایش پیوست‌ها: کلیک روی نام فایل، فایل را نشان می‌دهد (نه دانلود).
// بایت‌ها با توکن گرفته می‌شوند چون <img>/<iframe> نمی‌توانند هدر بفرستند.
// کلید با پیشوند نوع پیوست ساخته می‌شود تا نوعش گم نشود.
const preview = useFilePreview((key, name) => {
const [kind, id] = key.split(":") as ["invoice" | "proforma", string];
void name;
return api.files.previewUrl(q.id, kind, id);
});
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 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>
)}
</div>
);
};
const attachParts: React.ReactNode[] = [];
attachments.proformas.forEach((pf, i) => {
if (!pf.id) return;
attachParts.push(
<span key={"pf" + i} className="inline-flex items-center">
<button
type="button"
className="detail-link"
title="مشاهده پیش‌فاکتور"
disabled={preview.loadingId === `proforma:${pf.id}`}
onClick={() => void preview.open(`proforma:${pf.id}`, pf.name)}
>
<Paperclip className="me-1 inline h-3 w-3" />
پیش‌فاکتور: {pf.name}
</button>
</span>,
);
});
attachments.invoices.forEach((inv, i) => {
if (!inv.id) return;
attachParts.push(
<span key={"inv" + i} className="inline-flex items-center">
<button
type="button"
className="detail-link"
title="مشاهده فاکتور"
disabled={preview.loadingId === `invoice:${inv.id}`}
onClick={() => void preview.open(`invoice:${inv.id}`, inv.name)}
>
<Paperclip className="me-1 inline h-3 w-3" />
فاکتور اصلی: {inv.name}
</button>
</span>,
);
});
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>
)}
{preview.item && (
<FilePreviewModal url={preview.item.url} name={preview.item.name} onClose={preview.close} />
)}
</div>
);
}