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.
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|