import { useState, useEffect, useCallback } from "react"; import { get, post, put, del } from "../../api/client"; import type { BOMEntry } from "../../api/types"; interface BOMTabProps { partNumber: string; isEditor: boolean; } interface BOMFormData { child_part_number: string; quantity: string; source: string; seller_description: string; unit_cost: string; sourcing_link: string; } const emptyForm: BOMFormData = { child_part_number: "", quantity: "1", source: "", seller_description: "", unit_cost: "", sourcing_link: "", }; export function BOMTab({ partNumber, isEditor }: BOMTabProps) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [showAdd, setShowAdd] = useState(false); const [editIdx, setEditIdx] = useState(null); const [form, setForm] = useState(emptyForm); const load = useCallback(() => { setLoading(true); get(`/api/items/${encodeURIComponent(partNumber)}/bom`) .then(setEntries) .catch(() => setEntries([])) .finally(() => setLoading(false)); }, [partNumber]); useEffect(load, [load]); const meta = (e: BOMEntry) => (e.metadata ?? {}) as Record; const unitCost = (e: BOMEntry) => Number(meta(e).unit_cost) || 0; const extCost = (e: BOMEntry) => unitCost(e) * (e.quantity ?? 0); const totalCost = entries.reduce((sum, e) => sum + extCost(e), 0); const assemblyCount = entries.filter((e) => e.source === "assembly").length; const formToRequest = () => ({ child_part_number: form.child_part_number, rel_type: "component" as const, quantity: Number(form.quantity) || 1, source: form.source, metadata: { seller_description: form.seller_description, unit_cost: form.unit_cost, sourcing_link: form.sourcing_link, }, }); const handleAdd = async () => { try { await post( `/api/items/${encodeURIComponent(partNumber)}/bom`, formToRequest(), ); setShowAdd(false); setForm(emptyForm); load(); } catch (e) { alert(e instanceof Error ? e.message : "Failed to add BOM entry"); } }; const handleEdit = async (childPN: string) => { try { const { child_part_number: _, ...req } = formToRequest(); await put( `/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`, req, ); setEditIdx(null); setForm(emptyForm); load(); } catch (e) { alert(e instanceof Error ? e.message : "Failed to update BOM entry"); } }; const handleDelete = async (childPN: string) => { if (!confirm(`Remove ${childPN} from BOM?`)) return; try { await del( `/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`, ); load(); } catch (e) { alert(e instanceof Error ? e.message : "Failed to delete BOM entry"); } }; const startEdit = (idx: number) => { const e = entries[idx]!; const m = meta(e); setForm({ child_part_number: e.child_part_number, quantity: String(e.quantity ?? 1), source: e.source ?? "", seller_description: m.seller_description ?? "", unit_cost: m.unit_cost ?? "", sourcing_link: m.sourcing_link ?? "", }); setEditIdx(idx); setShowAdd(false); }; const inputStyle: React.CSSProperties = { padding: "0.2rem 0.4rem", fontSize: "0.8rem", backgroundColor: "var(--ctp-base)", border: "1px solid var(--ctp-surface1)", borderRadius: "0.3rem", color: "var(--ctp-text)", width: "100%", }; const formRow = (isEditing: boolean, childPN?: string) => ( setForm({ ...form, child_part_number: e.target.value }) } disabled={isEditing} placeholder="Part number" style={inputStyle} /> setForm({ ...form, seller_description: e.target.value }) } placeholder="Description" style={inputStyle} /> setForm({ ...form, unit_cost: e.target.value })} type="number" step="0.01" placeholder="0.00" style={inputStyle} /> setForm({ ...form, quantity: e.target.value })} type="number" step="1" placeholder="1" style={{ ...inputStyle, width: 50 }} /> — setForm({ ...form, sourcing_link: e.target.value })} placeholder="URL" style={inputStyle} /> ); if (loading) return
Loading BOM...
; return (
{/* Toolbar */}
{entries.length} entries {isEditor && ( )}
{isEditor && assemblyCount > 0 && (
{assemblyCount} assembly-sourced{" "} {assemblyCount === 1 ? "entry" : "entries"}. Entries removed from the FreeCAD assembly will remain here until manually deleted.
)}
{isEditor && } {showAdd && formRow(false)} {entries.map((e, idx) => { if (editIdx === idx) return formRow(true, e.child_part_number); const m = meta(e); return ( {isEditor && ( )} ); })} {totalCost > 0 && ( )}
PN Source Seller Desc Unit Cost QTY Ext Cost LinkActions
{e.child_part_number} {e.source === "assembly" ? ( assembly ) : e.source === "manual" ? ( manual ) : ( "—" )} {e.child_description || m.seller_description || ""} {unitCost(e) ? `$${unitCost(e).toFixed(2)}` : "—"} {e.quantity ?? "—"} {extCost(e) ? `$${extCost(e).toFixed(2)}` : "—"} {m.sourcing_link ? ( Link ) : ( "—" )}
Total: ${totalCost.toFixed(2)}
); } const thStyle: React.CSSProperties = { padding: "0.3rem 0.5rem", textAlign: "left", borderBottom: "1px solid var(--ctp-surface1)", color: "var(--ctp-subtext1)", fontWeight: 600, fontSize: "0.7rem", textTransform: "uppercase", letterSpacing: "0.05em", whiteSpace: "nowrap", }; const tdStyle: React.CSSProperties = { padding: "0.25rem 0.5rem", borderBottom: "1px solid var(--ctp-surface0)", whiteSpace: "nowrap", }; const toolBtnStyle: React.CSSProperties = { padding: "0.25rem 0.5rem", fontSize: "0.8rem", border: "none", borderRadius: "0.3rem", backgroundColor: "var(--ctp-surface1)", color: "var(--ctp-text)", cursor: "pointer", }; const actionBtnStyle: React.CSSProperties = { background: "none", border: "none", color: "var(--ctp-subtext1)", cursor: "pointer", fontSize: "0.75rem", padding: "0.1rem 0.3rem", }; const saveBtnStyle: React.CSSProperties = { padding: "0.2rem 0.4rem", fontSize: "0.75rem", border: "none", borderRadius: "0.25rem", backgroundColor: "var(--ctp-green)", color: "var(--ctp-crust)", cursor: "pointer", marginRight: "0.25rem", }; const sourceBadgeBase: React.CSSProperties = { padding: "0.1rem 0.4rem", borderRadius: "1rem", fontSize: "0.7rem", fontWeight: 500, }; const assemblyBadge: React.CSSProperties = { ...sourceBadgeBase, backgroundColor: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)", }; const manualBadge: React.CSSProperties = { ...sourceBadgeBase, backgroundColor: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)", }; const cancelBtnStyle: React.CSSProperties = { padding: "0.2rem 0.4rem", fontSize: "0.75rem", border: "none", borderRadius: "0.25rem", backgroundColor: "var(--ctp-surface1)", color: "var(--ctp-subtext1)", cursor: "pointer", };