Promote BOM source from metadata JSONB to a dedicated VARCHAR(20)
column with CHECK constraint ('manual' or 'assembly').
- Add migration 012_bom_source.sql (column, data migration, cleanup)
- Add Source field to Relationship and BOMEntry structs
- Update all SQL queries (GetBOM, GetWhereUsed, GetExpandedBOM, Create)
- Update API response/request types with source field
- Update CSV/ODS export to read e.Source instead of metadata
- Update CSV import to set source on relationship directly
- Update frontend types and BOMTab to use top-level source field
432 lines
12 KiB
TypeScript
432 lines
12 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 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}>
|
|
<input
|
|
value={form.source}
|
|
onChange={(e) => setForm({ ...form, source: e.target.value })}
|
|
placeholder="Source"
|
|
style={inputStyle}
|
|
/>
|
|
</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>
|
|
|
|
<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 ?? ""}</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 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",
|
|
};
|