feat(db): add source column to relationships table (#44)

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
This commit is contained in:
Forbes
2026-02-08 18:45:41 -06:00
parent 80b334f308
commit 163dc9f0f0
6 changed files with 305 additions and 82 deletions

View File

@@ -75,6 +75,7 @@ export interface BOMEntry {
child_revision?: number;
effective_revision: number;
depth?: number;
source: string;
metadata?: Record<string, unknown>;
}
@@ -196,6 +197,7 @@ export interface AddBOMEntryRequest {
unit?: string;
reference_designators?: string[];
child_revision?: number;
source?: string;
metadata?: Record<string, unknown>;
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { get, post, put, del } from '../../api/client';
import type { BOMEntry } from '../../api/types';
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../../api/client";
import type { BOMEntry } from "../../api/types";
interface BOMTabProps {
partNumber: string;
@@ -16,7 +16,14 @@ interface BOMFormData {
sourcing_link: string;
}
const emptyForm: BOMFormData = { child_part_number: '', quantity: '1', source: '', seller_description: '', unit_cost: '', sourcing_link: '' };
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[]>([]);
@@ -42,10 +49,10 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const formToRequest = () => ({
child_part_number: form.child_part_number,
rel_type: 'component' as const,
rel_type: "component" as const,
quantity: Number(form.quantity) || 1,
source: form.source,
metadata: {
source: form.source,
seller_description: form.seller_description,
unit_cost: form.unit_cost,
sourcing_link: form.sourcing_link,
@@ -54,34 +61,42 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const handleAdd = async () => {
try {
await post(`/api/items/${encodeURIComponent(partNumber)}/bom`, formToRequest());
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');
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);
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');
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)}`);
await del(
`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`,
);
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to delete BOM entry');
alert(e instanceof Error ? e.message : "Failed to delete BOM entry");
}
};
@@ -91,71 +106,155 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
setForm({
child_part_number: e.child_part_number,
quantity: String(e.quantity ?? 1),
source: m.source ?? '',
seller_description: m.seller_description ?? '',
unit_cost: m.unit_cost ?? '',
sourcing_link: m.sourcing_link ?? '',
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%',
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)' }}>
<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} />
<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} />
<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} />
<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} />
<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 }} />
<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} />
<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>
<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>;
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>
<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`; }}
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>
<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' }}>
<div style={{ overflow: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead>
<tr>
<th style={thStyle}>PN</th>
@@ -174,20 +273,71 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
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}>{m.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>
<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> : '—'}
{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>
<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>
@@ -196,9 +346,22 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
</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>
<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>
@@ -210,29 +373,59 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
}
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',
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',
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',
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',
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',
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',
padding: "0.2rem 0.4rem",
fontSize: "0.75rem",
border: "none",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
cursor: "pointer",
};