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

@@ -29,6 +29,7 @@ type BOMEntryResponse struct {
ChildRevision *int `json:"child_revision,omitempty"` ChildRevision *int `json:"child_revision,omitempty"`
EffectiveRevision int `json:"effective_revision"` EffectiveRevision int `json:"effective_revision"`
Depth *int `json:"depth,omitempty"` Depth *int `json:"depth,omitempty"`
Source string `json:"source"`
Metadata map[string]any `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
} }
@@ -51,6 +52,7 @@ type AddBOMEntryRequest struct {
Unit *string `json:"unit,omitempty"` Unit *string `json:"unit,omitempty"`
ReferenceDesignators []string `json:"reference_designators,omitempty"` ReferenceDesignators []string `json:"reference_designators,omitempty"`
ChildRevision *int `json:"child_revision,omitempty"` ChildRevision *int `json:"child_revision,omitempty"`
Source string `json:"source,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"` Metadata map[string]any `json:"metadata,omitempty"`
} }
@@ -240,6 +242,7 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
Unit: req.Unit, Unit: req.Unit,
ReferenceDesignators: req.ReferenceDesignators, ReferenceDesignators: req.ReferenceDesignators,
ChildRevision: req.ChildRevision, ChildRevision: req.ChildRevision,
Source: req.Source,
Metadata: req.Metadata, Metadata: req.Metadata,
} }
if user := auth.UserFromContext(ctx); user != nil { if user := auth.UserFromContext(ctx); user != nil {
@@ -273,6 +276,7 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) {
ReferenceDesignators: req.ReferenceDesignators, ReferenceDesignators: req.ReferenceDesignators,
ChildRevision: req.ChildRevision, ChildRevision: req.ChildRevision,
EffectiveRevision: child.CurrentRevision, EffectiveRevision: child.CurrentRevision,
Source: rel.Source,
Metadata: req.Metadata, Metadata: req.Metadata,
} }
if req.ChildRevision != nil { if req.ChildRevision != nil {
@@ -434,6 +438,7 @@ func bomEntryToResponse(e *db.BOMEntry) BOMEntryResponse {
ReferenceDesignators: refDes, ReferenceDesignators: refDes,
ChildRevision: e.ChildRevision, ChildRevision: e.ChildRevision,
EffectiveRevision: e.EffectiveRevision, EffectiveRevision: e.EffectiveRevision,
Source: e.Source,
Metadata: e.Metadata, Metadata: e.Metadata,
} }
} }
@@ -686,14 +691,14 @@ func (s *Server) HandleExportBOMCSV(w http.ResponseWriter, r *http.Request) {
} }
row := []string{ row := []string{
strconv.Itoa(i + 1), // Item strconv.Itoa(i + 1), // Item
strconv.Itoa(e.Depth), // Level strconv.Itoa(e.Depth), // Level
getMetaString(e.Metadata, "source"), // Source e.Source, // Source
e.ChildPartNumber, // PN e.ChildPartNumber, // PN
getMetaString(e.Metadata, "seller_description"), // Seller Description getMetaString(e.Metadata, "seller_description"), // Seller Description
unitCostStr, // Unit Cost unitCostStr, // Unit Cost
qtyStr, // QTY qtyStr, // QTY
extCost, // Ext Cost extCost, // Ext Cost
getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link
} }
if err := writer.Write(row); err != nil { if err := writer.Write(row); err != nil {
@@ -853,12 +858,11 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
} }
// Build metadata from CSV columns // Build metadata from CSV columns
metadata := make(map[string]any) source := ""
if idx, ok := headerIdx["source"]; ok && idx < len(record) { if idx, ok := headerIdx["source"]; ok && idx < len(record) {
if v := strings.TrimSpace(record[idx]); v != "" { source = strings.TrimSpace(record[idx])
metadata["source"] = v
}
} }
metadata := make(map[string]any)
if idx, ok := headerIdx["seller description"]; ok && idx < len(record) { if idx, ok := headerIdx["seller description"]; ok && idx < len(record) {
if v := strings.TrimSpace(record[idx]); v != "" { if v := strings.TrimSpace(record[idx]); v != "" {
metadata["seller_description"] = v metadata["seller_description"] = v
@@ -942,6 +946,7 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
ChildItemID: child.ID, ChildItemID: child.ID,
RelType: "component", RelType: "component",
Quantity: quantity, Quantity: quantity,
Source: source,
Metadata: metadata, Metadata: metadata,
CreatedBy: importUsername, CreatedBy: importUsername,
} }

View File

@@ -599,7 +599,7 @@ func (s *Server) HandleExportBOMODS(w http.ResponseWriter, r *http.Request) {
} }
} }
source := getMetaString(e.Metadata, "source") source := e.Source
if source == "" && childItem != nil { if source == "" && childItem != nil {
st := childItem.SourcingType st := childItem.SourcingType
if st == "manufactured" { if st == "manufactured" {
@@ -754,7 +754,7 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
if e.Quantity != nil { if e.Quantity != nil {
qty = *e.Quantity qty = *e.Quantity
} }
source := getMetaString(e.Metadata, "source") source := e.Source
if source == "" && childItem != nil { if source == "" && childItem != nil {
if childItem.SourcingType == "manufactured" { if childItem.SourcingType == "manufactured" {
source = "M" source = "M"

View File

@@ -23,6 +23,7 @@ type Relationship struct {
ChildRevision *int ChildRevision *int
Metadata map[string]any Metadata map[string]any
ParentRevisionID *string ParentRevisionID *string
Source string // "manual" or "assembly"
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
CreatedBy *string CreatedBy *string
@@ -46,6 +47,7 @@ type BOMEntry struct {
ChildRevision *int ChildRevision *int
EffectiveRevision int EffectiveRevision int
Metadata map[string]any Metadata map[string]any
Source string
} }
// BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion. // BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion.
@@ -84,16 +86,21 @@ func (r *RelationshipRepository) Create(ctx context.Context, rel *Relationship)
} }
} }
source := rel.Source
if source == "" {
source = "manual"
}
err = r.db.pool.QueryRow(ctx, ` err = r.db.pool.QueryRow(ctx, `
INSERT INTO relationships ( INSERT INTO relationships (
parent_item_id, child_item_id, rel_type, quantity, unit, parent_item_id, child_item_id, rel_type, quantity, unit,
reference_designators, child_revision, metadata, parent_revision_id, created_by reference_designators, child_revision, metadata, parent_revision_id, created_by, source
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, created_at, updated_at RETURNING id, created_at, updated_at
`, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit, `, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit,
rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID, rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID,
rel.CreatedBy, rel.CreatedBy, source,
).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt) ).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt)
if err != nil { if err != nil {
return fmt.Errorf("inserting relationship: %w", err) return fmt.Errorf("inserting relationship: %w", err)
@@ -256,7 +263,7 @@ func (r *RelationshipRepository) GetBOM(ctx context.Context, parentItemID string
rel.rel_type, rel.quantity, rel.unit, rel.rel_type, rel.quantity, rel.unit,
rel.reference_designators, rel.child_revision, rel.reference_designators, rel.child_revision,
COALESCE(rel.child_revision, child.current_revision) AS effective_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
rel.metadata rel.metadata, rel.source
FROM relationships rel FROM relationships rel
JOIN items parent ON parent.id = rel.parent_item_id JOIN items parent ON parent.id = rel.parent_item_id
JOIN items child ON child.id = rel.child_item_id JOIN items child ON child.id = rel.child_item_id
@@ -281,7 +288,7 @@ func (r *RelationshipRepository) GetWhereUsed(ctx context.Context, childItemID s
rel.rel_type, rel.quantity, rel.unit, rel.rel_type, rel.quantity, rel.unit,
rel.reference_designators, rel.child_revision, rel.reference_designators, rel.child_revision,
COALESCE(rel.child_revision, child.current_revision) AS effective_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
rel.metadata rel.metadata, rel.source
FROM relationships rel FROM relationships rel
JOIN items parent ON parent.id = rel.parent_item_id JOIN items parent ON parent.id = rel.parent_item_id
JOIN items child ON child.id = rel.child_item_id JOIN items child ON child.id = rel.child_item_id
@@ -315,7 +322,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
rel.rel_type, rel.quantity, rel.unit, rel.rel_type, rel.quantity, rel.unit,
rel.reference_designators, rel.child_revision, rel.reference_designators, rel.child_revision,
COALESCE(rel.child_revision, child.current_revision) AS effective_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision,
rel.metadata, rel.metadata, rel.source,
1 AS depth 1 AS depth
FROM relationships rel FROM relationships rel
JOIN items parent ON parent.id = rel.parent_item_id JOIN items parent ON parent.id = rel.parent_item_id
@@ -334,7 +341,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
rel.rel_type, rel.quantity, rel.unit, rel.rel_type, rel.quantity, rel.unit,
rel.reference_designators, rel.child_revision, rel.reference_designators, rel.child_revision,
COALESCE(rel.child_revision, child.current_revision), COALESCE(rel.child_revision, child.current_revision),
rel.metadata, rel.metadata, rel.source,
bt.depth + 1 bt.depth + 1
FROM relationships rel FROM relationships rel
JOIN items parent ON parent.id = rel.parent_item_id JOIN items parent ON parent.id = rel.parent_item_id
@@ -347,7 +354,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
SELECT id, parent_item_id, parent_part_number, parent_description, SELECT id, parent_item_id, parent_part_number, parent_description,
child_item_id, child_part_number, child_description, child_item_id, child_part_number, child_description,
rel_type, quantity, unit, reference_designators, rel_type, quantity, unit, reference_designators,
child_revision, effective_revision, metadata, depth child_revision, effective_revision, metadata, source, depth
FROM bom_tree FROM bom_tree
ORDER BY depth, child_part_number ORDER BY depth, child_part_number
`, parentItemID, maxDepth) `, parentItemID, maxDepth)
@@ -366,7 +373,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI
&e.ChildItemID, &e.ChildPartNumber, &childDesc, &e.ChildItemID, &e.ChildPartNumber, &childDesc,
&e.RelType, &e.Quantity, &e.Unit, &e.RelType, &e.Quantity, &e.Unit,
&e.ReferenceDesignators, &e.ChildRevision, &e.ReferenceDesignators, &e.ChildRevision,
&e.EffectiveRevision, &metadataJSON, &e.Depth, &e.EffectiveRevision, &metadataJSON, &e.Source, &e.Depth,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning BOM tree entry: %w", err) return nil, fmt.Errorf("scanning BOM tree entry: %w", err)
@@ -553,7 +560,7 @@ func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) {
&e.RelType, &e.Quantity, &e.Unit, &e.RelType, &e.Quantity, &e.Unit,
&e.ReferenceDesignators, &e.ChildRevision, &e.ReferenceDesignators, &e.ChildRevision,
&e.EffectiveRevision, &e.EffectiveRevision,
&metadataJSON, &metadataJSON, &e.Source,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("scanning BOM entry: %w", err) return nil, fmt.Errorf("scanning BOM entry: %w", err)

View File

@@ -0,0 +1,16 @@
-- Add source column to relationships table to distinguish assembly-derived
-- BOM entries from manually-added ones.
ALTER TABLE relationships
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'manual'
CHECK (source IN ('manual', 'assembly'));
-- Migrate existing metadata.source values where they exist.
-- The metadata field stores source as a free-form string; promote to column.
UPDATE relationships
SET source = 'manual'
WHERE metadata->>'source' IS NOT NULL;
-- Remove the source key from metadata since it's now a dedicated column.
UPDATE relationships
SET metadata = metadata - 'source'
WHERE metadata ? 'source';

View File

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

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from '../../api/client'; import { get, post, put, del } from "../../api/client";
import type { BOMEntry } from '../../api/types'; import type { BOMEntry } from "../../api/types";
interface BOMTabProps { interface BOMTabProps {
partNumber: string; partNumber: string;
@@ -16,7 +16,14 @@ interface BOMFormData {
sourcing_link: string; 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) { export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const [entries, setEntries] = useState<BOMEntry[]>([]); const [entries, setEntries] = useState<BOMEntry[]>([]);
@@ -42,10 +49,10 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const formToRequest = () => ({ const formToRequest = () => ({
child_part_number: form.child_part_number, child_part_number: form.child_part_number,
rel_type: 'component' as const, rel_type: "component" as const,
quantity: Number(form.quantity) || 1, quantity: Number(form.quantity) || 1,
source: form.source,
metadata: { metadata: {
source: form.source,
seller_description: form.seller_description, seller_description: form.seller_description,
unit_cost: form.unit_cost, unit_cost: form.unit_cost,
sourcing_link: form.sourcing_link, sourcing_link: form.sourcing_link,
@@ -54,34 +61,42 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const handleAdd = async () => { const handleAdd = async () => {
try { try {
await post(`/api/items/${encodeURIComponent(partNumber)}/bom`, formToRequest()); await post(
`/api/items/${encodeURIComponent(partNumber)}/bom`,
formToRequest(),
);
setShowAdd(false); setShowAdd(false);
setForm(emptyForm); setForm(emptyForm);
load(); load();
} catch (e) { } 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) => { const handleEdit = async (childPN: string) => {
try { try {
const { child_part_number: _, ...req } = formToRequest(); 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); setEditIdx(null);
setForm(emptyForm); setForm(emptyForm);
load(); load();
} catch (e) { } 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) => { const handleDelete = async (childPN: string) => {
if (!confirm(`Remove ${childPN} from BOM?`)) return; if (!confirm(`Remove ${childPN} from BOM?`)) return;
try { try {
await del(`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`); await del(
`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`,
);
load(); load();
} catch (e) { } 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({ setForm({
child_part_number: e.child_part_number, child_part_number: e.child_part_number,
quantity: String(e.quantity ?? 1), quantity: String(e.quantity ?? 1),
source: m.source ?? '', source: e.source ?? "",
seller_description: m.seller_description ?? '', seller_description: m.seller_description ?? "",
unit_cost: m.unit_cost ?? '', unit_cost: m.unit_cost ?? "",
sourcing_link: m.sourcing_link ?? '', sourcing_link: m.sourcing_link ?? "",
}); });
setEditIdx(idx); setEditIdx(idx);
setShowAdd(false); setShowAdd(false);
}; };
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
padding: '0.2rem 0.4rem', fontSize: '0.8rem', padding: "0.2rem 0.4rem",
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)', fontSize: "0.8rem",
borderRadius: '0.3rem', color: 'var(--ctp-text)', width: '100%', 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) => ( const formRow = (isEditing: boolean, childPN?: string) => (
<tr style={{ backgroundColor: 'var(--ctp-surface0)' }}> <tr style={{ backgroundColor: "var(--ctp-surface0)" }}>
<td style={tdStyle}> <td style={tdStyle}>
<input value={form.child_part_number} onChange={(e) => setForm({ ...form, child_part_number: e.target.value })} <input
disabled={isEditing} placeholder="Part number" style={inputStyle} /> value={form.child_part_number}
onChange={(e) =>
setForm({ ...form, child_part_number: e.target.value })
}
disabled={isEditing}
placeholder="Part number"
style={inputStyle}
/>
</td> </td>
<td style={tdStyle}> <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>
<td style={tdStyle}> <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>
<td style={tdStyle}> <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>
<td style={tdStyle}> <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>
<td style={tdStyle}></td> <td style={tdStyle}></td>
<td style={tdStyle}> <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>
<td style={tdStyle}> <td style={tdStyle}>
<button onClick={() => isEditing ? void handleEdit(childPN!) : void handleAdd()} style={saveBtnStyle}>Save</button> <button
<button onClick={() => { isEditing ? setEditIdx(null) : setShowAdd(false); setForm(emptyForm); }} style={cancelBtnStyle}>Cancel</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> </td>
</tr> </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 ( return (
<div> <div>
{/* Toolbar */} {/* Toolbar */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}> <div
<span style={{ fontSize: '0.85rem', color: 'var(--ctp-subtext1)' }}>{entries.length} entries</span> 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 }} /> <span style={{ flex: 1 }} />
<button <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} style={toolBtnStyle}
> >
Export CSV Export CSV
</button> </button>
{isEditor && ( {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>
<div style={{ overflow: 'auto' }}> <div style={{ overflow: "auto" }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}> <table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead> <thead>
<tr> <tr>
<th style={thStyle}>PN</th> <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); if (editIdx === idx) return formRow(true, e.child_part_number);
const m = meta(e); const m = meta(e);
return ( return (
<tr key={e.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}> <tr
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>{e.child_part_number}</td> key={e.id}
<td style={tdStyle}>{m.source ?? ''}</td> style={{
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.child_description || m.seller_description || ''}</td> backgroundColor:
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : '—'}</td> idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
<td style={tdStyle}>{e.quantity ?? '—'}</td> }}
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{extCost(e) ? `$${extCost(e).toFixed(2)}` : '—'}</td> >
<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}> <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> </td>
{isEditor && ( {isEditor && (
<td style={tdStyle}> <td style={tdStyle}>
<button onClick={() => startEdit(idx)} style={actionBtnStyle}>Edit</button> <button
<button onClick={() => void handleDelete(e.child_part_number)} style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}>Del</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> </td>
)} )}
</tr> </tr>
@@ -196,9 +346,22 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
</tbody> </tbody>
{totalCost > 0 && ( {totalCost > 0 && (
<tfoot> <tfoot>
<tr style={{ borderTop: '2px solid var(--ctp-surface1)' }}> <tr style={{ borderTop: "2px solid var(--ctp-surface1)" }}>
<td colSpan={5} style={{ ...tdStyle, textAlign: 'right', fontWeight: 600 }}>Total:</td> <td
<td style={{ ...tdStyle, fontFamily: 'monospace', fontWeight: 600 }}>${totalCost.toFixed(2)}</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} /> <td colSpan={isEditor ? 2 : 1} style={tdStyle} />
</tr> </tr>
</tfoot> </tfoot>
@@ -210,29 +373,59 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
} }
const thStyle: React.CSSProperties = { const thStyle: React.CSSProperties = {
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)', padding: "0.3rem 0.5rem",
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap', 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 = { 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 = { const toolBtnStyle: React.CSSProperties = {
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem', padding: "0.25rem 0.5rem",
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer', fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
cursor: "pointer",
}; };
const actionBtnStyle: React.CSSProperties = { 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 = { const saveBtnStyle: React.CSSProperties = {
padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem', padding: "0.2rem 0.4rem",
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer', marginRight: '0.25rem', 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 = { const cancelBtnStyle: React.CSSProperties = {
padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem', padding: "0.2rem 0.4rem",
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-subtext1)', cursor: 'pointer', fontSize: "0.75rem",
border: "none",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
cursor: "pointer",
}; };