Files
silo/web/src/components/items/BOMTab.tsx
Forbes 25c42bd70b feat(web): add BOM merge resolution UI with source badges and dropdown
- Add source badges (assembly=teal, manual=blue) to BOM display rows
- Add info banner when assembly-sourced entries exist
- Change source input from text field to select dropdown
- Add merge response types to types.ts

Closes #47
2026-02-08 19:56:33 -06:00

481 lines
14 KiB
TypeScript

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<BOMEntry[]>([]);
const [loading, setLoading] = useState(true);
const [showAdd, setShowAdd] = useState(false);
const [editIdx, setEditIdx] = useState<number | null>(null);
const [form, setForm] = useState<BOMFormData>(emptyForm);
const load = useCallback(() => {
setLoading(true);
get<BOMEntry[]>(`/api/items/${encodeURIComponent(partNumber)}/bom`)
.then(setEntries)
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [partNumber]);
useEffect(load, [load]);
const meta = (e: BOMEntry) => (e.metadata ?? {}) as Record<string, string>;
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) => (
<tr style={{ backgroundColor: "var(--ctp-surface0)" }}>
<td style={tdStyle}>
<input
value={form.child_part_number}
onChange={(e) =>
setForm({ ...form, child_part_number: e.target.value })
}
disabled={isEditing}
placeholder="Part number"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<select
value={form.source}
onChange={(e) => setForm({ ...form, source: e.target.value })}
style={inputStyle}
>
<option value=""></option>
<option value="manual">manual</option>
<option value="assembly">assembly</option>
</select>
</td>
<td style={tdStyle}>
<input
value={form.seller_description}
onChange={(e) =>
setForm({ ...form, seller_description: e.target.value })
}
placeholder="Description"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<input
value={form.unit_cost}
onChange={(e) => setForm({ ...form, unit_cost: e.target.value })}
type="number"
step="0.01"
placeholder="0.00"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<input
value={form.quantity}
onChange={(e) => setForm({ ...form, quantity: e.target.value })}
type="number"
step="1"
placeholder="1"
style={{ ...inputStyle, width: 50 }}
/>
</td>
<td style={tdStyle}></td>
<td style={tdStyle}>
<input
value={form.sourcing_link}
onChange={(e) => setForm({ ...form, sourcing_link: e.target.value })}
placeholder="URL"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<button
onClick={() =>
isEditing ? void handleEdit(childPN!) : void handleAdd()
}
style={saveBtnStyle}
>
Save
</button>
<button
onClick={() => {
isEditing ? setEditIdx(null) : setShowAdd(false);
setForm(emptyForm);
}}
style={cancelBtnStyle}
>
Cancel
</button>
</td>
</tr>
);
if (loading)
return <div style={{ color: "var(--ctp-subtext0)" }}>Loading BOM...</div>;
return (
<div>
{/* Toolbar */}
<div
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<span style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
{entries.length} entries
</span>
<span style={{ flex: 1 }} />
<button
onClick={() => {
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`;
}}
style={toolBtnStyle}
>
Export CSV
</button>
{isEditor && (
<button
onClick={() => {
setShowAdd(true);
setEditIdx(null);
setForm(emptyForm);
}}
style={toolBtnStyle}
>
+ Add
</button>
)}
</div>
{isEditor && assemblyCount > 0 && (
<div
style={{
padding: "0.35rem 0.6rem",
marginBottom: "0.5rem",
borderRadius: "0.3rem",
backgroundColor: "rgba(148,226,213,0.1)",
border: "1px solid rgba(148,226,213,0.3)",
fontSize: "0.75rem",
color: "var(--ctp-subtext1)",
}}
>
{assemblyCount} assembly-sourced{" "}
{assemblyCount === 1 ? "entry" : "entries"}. Entries removed from the
FreeCAD assembly will remain here until manually deleted.
</div>
)}
<div style={{ overflow: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead>
<tr>
<th style={thStyle}>PN</th>
<th style={thStyle}>Source</th>
<th style={thStyle}>Seller Desc</th>
<th style={thStyle}>Unit Cost</th>
<th style={thStyle}>QTY</th>
<th style={thStyle}>Ext Cost</th>
<th style={thStyle}>Link</th>
{isEditor && <th style={thStyle}>Actions</th>}
</tr>
</thead>
<tbody>
{showAdd && formRow(false)}
{entries.map((e, idx) => {
if (editIdx === idx) return formRow(true, e.child_part_number);
const m = meta(e);
return (
<tr
key={e.id}
style={{
backgroundColor:
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
}}
>
<td
style={{
...tdStyle,
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
}}
>
{e.child_part_number}
</td>
<td style={tdStyle}>
{e.source === "assembly" ? (
<span style={assemblyBadge}>assembly</span>
) : e.source === "manual" ? (
<span style={manualBadge}>manual</span>
) : (
"—"
)}
</td>
<td
style={{
...tdStyle,
maxWidth: 150,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{e.child_description || m.seller_description || ""}
</td>
<td style={{ ...tdStyle, fontFamily: "monospace" }}>
{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : "—"}
</td>
<td style={tdStyle}>{e.quantity ?? "—"}</td>
<td style={{ ...tdStyle, fontFamily: "monospace" }}>
{extCost(e) ? `$${extCost(e).toFixed(2)}` : "—"}
</td>
<td style={tdStyle}>
{m.sourcing_link ? (
<a
href={m.sourcing_link}
target="_blank"
rel="noreferrer"
style={{
color: "var(--ctp-sapphire)",
fontSize: "0.75rem",
}}
>
Link
</a>
) : (
"—"
)}
</td>
{isEditor && (
<td style={tdStyle}>
<button
onClick={() => startEdit(idx)}
style={actionBtnStyle}
>
Edit
</button>
<button
onClick={() => void handleDelete(e.child_part_number)}
style={{ ...actionBtnStyle, color: "var(--ctp-red)" }}
>
Del
</button>
</td>
)}
</tr>
);
})}
</tbody>
{totalCost > 0 && (
<tfoot>
<tr style={{ borderTop: "2px solid var(--ctp-surface1)" }}>
<td
colSpan={5}
style={{ ...tdStyle, textAlign: "right", fontWeight: 600 }}
>
Total:
</td>
<td
style={{
...tdStyle,
fontFamily: "monospace",
fontWeight: 600,
}}
>
${totalCost.toFixed(2)}
</td>
<td colSpan={isEditor ? 2 : 1} style={tdStyle} />
</tr>
</tfoot>
)}
</table>
</div>
</div>
);
}
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",
};