diff --git a/internal/api/bom_handlers.go b/internal/api/bom_handlers.go index 4f4e5eb..633ed5d 100644 --- a/internal/api/bom_handlers.go +++ b/internal/api/bom_handlers.go @@ -29,6 +29,7 @@ type BOMEntryResponse struct { ChildRevision *int `json:"child_revision,omitempty"` EffectiveRevision int `json:"effective_revision"` Depth *int `json:"depth,omitempty"` + Source string `json:"source"` Metadata map[string]any `json:"metadata,omitempty"` } @@ -51,6 +52,7 @@ type AddBOMEntryRequest struct { Unit *string `json:"unit,omitempty"` ReferenceDesignators []string `json:"reference_designators,omitempty"` ChildRevision *int `json:"child_revision,omitempty"` + Source string `json:"source,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, ReferenceDesignators: req.ReferenceDesignators, ChildRevision: req.ChildRevision, + Source: req.Source, Metadata: req.Metadata, } if user := auth.UserFromContext(ctx); user != nil { @@ -273,6 +276,7 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) { ReferenceDesignators: req.ReferenceDesignators, ChildRevision: req.ChildRevision, EffectiveRevision: child.CurrentRevision, + Source: rel.Source, Metadata: req.Metadata, } if req.ChildRevision != nil { @@ -434,6 +438,7 @@ func bomEntryToResponse(e *db.BOMEntry) BOMEntryResponse { ReferenceDesignators: refDes, ChildRevision: e.ChildRevision, EffectiveRevision: e.EffectiveRevision, + Source: e.Source, Metadata: e.Metadata, } } @@ -686,14 +691,14 @@ func (s *Server) HandleExportBOMCSV(w http.ResponseWriter, r *http.Request) { } row := []string{ - strconv.Itoa(i + 1), // Item - strconv.Itoa(e.Depth), // Level - getMetaString(e.Metadata, "source"), // Source - e.ChildPartNumber, // PN + strconv.Itoa(i + 1), // Item + strconv.Itoa(e.Depth), // Level + e.Source, // Source + e.ChildPartNumber, // PN getMetaString(e.Metadata, "seller_description"), // Seller Description - unitCostStr, // Unit Cost - qtyStr, // QTY - extCost, // Ext Cost + unitCostStr, // Unit Cost + qtyStr, // QTY + extCost, // Ext Cost getMetaString(e.Metadata, "sourcing_link"), // Sourcing Link } 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 - metadata := make(map[string]any) + source := "" if idx, ok := headerIdx["source"]; ok && idx < len(record) { - if v := strings.TrimSpace(record[idx]); v != "" { - metadata["source"] = v - } + source = strings.TrimSpace(record[idx]) } + metadata := make(map[string]any) if idx, ok := headerIdx["seller description"]; ok && idx < len(record) { if v := strings.TrimSpace(record[idx]); v != "" { metadata["seller_description"] = v @@ -942,6 +946,7 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) { ChildItemID: child.ID, RelType: "component", Quantity: quantity, + Source: source, Metadata: metadata, CreatedBy: importUsername, } diff --git a/internal/api/ods.go b/internal/api/ods.go index c590bf1..2fcf5e8 100644 --- a/internal/api/ods.go +++ b/internal/api/ods.go @@ -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 { st := childItem.SourcingType if st == "manufactured" { @@ -754,7 +754,7 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) { if e.Quantity != nil { qty = *e.Quantity } - source := getMetaString(e.Metadata, "source") + source := e.Source if source == "" && childItem != nil { if childItem.SourcingType == "manufactured" { source = "M" diff --git a/internal/db/relationships.go b/internal/db/relationships.go index 8f6ecb3..afad1df 100644 --- a/internal/db/relationships.go +++ b/internal/db/relationships.go @@ -23,6 +23,7 @@ type Relationship struct { ChildRevision *int Metadata map[string]any ParentRevisionID *string + Source string // "manual" or "assembly" CreatedAt time.Time UpdatedAt time.Time CreatedBy *string @@ -46,6 +47,7 @@ type BOMEntry struct { ChildRevision *int EffectiveRevision int Metadata map[string]any + Source string } // 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, ` INSERT INTO relationships ( 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 `, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit, rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID, - rel.CreatedBy, + rel.CreatedBy, source, ).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt) if err != nil { 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.reference_designators, rel.child_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision, - rel.metadata + rel.metadata, rel.source FROM relationships rel JOIN items parent ON parent.id = rel.parent_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.reference_designators, rel.child_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision, - rel.metadata + rel.metadata, rel.source FROM relationships rel JOIN items parent ON parent.id = rel.parent_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.reference_designators, rel.child_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision, - rel.metadata, + rel.metadata, rel.source, 1 AS depth FROM relationships rel 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.reference_designators, rel.child_revision, COALESCE(rel.child_revision, child.current_revision), - rel.metadata, + rel.metadata, rel.source, bt.depth + 1 FROM relationships rel 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, child_item_id, child_part_number, child_description, rel_type, quantity, unit, reference_designators, - child_revision, effective_revision, metadata, depth + child_revision, effective_revision, metadata, source, depth FROM bom_tree ORDER BY depth, child_part_number `, parentItemID, maxDepth) @@ -366,7 +373,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI &e.ChildItemID, &e.ChildPartNumber, &childDesc, &e.RelType, &e.Quantity, &e.Unit, &e.ReferenceDesignators, &e.ChildRevision, - &e.EffectiveRevision, &metadataJSON, &e.Depth, + &e.EffectiveRevision, &metadataJSON, &e.Source, &e.Depth, ) if err != nil { 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.ReferenceDesignators, &e.ChildRevision, &e.EffectiveRevision, - &metadataJSON, + &metadataJSON, &e.Source, ) if err != nil { return nil, fmt.Errorf("scanning BOM entry: %w", err) diff --git a/migrations/012_bom_source.sql b/migrations/012_bom_source.sql new file mode 100644 index 0000000..cff7e6f --- /dev/null +++ b/migrations/012_bom_source.sql @@ -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'; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e6db599..0df3662 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -75,6 +75,7 @@ export interface BOMEntry { child_revision?: number; effective_revision: number; depth?: number; + source: string; metadata?: Record; } @@ -196,6 +197,7 @@ export interface AddBOMEntryRequest { unit?: string; reference_designators?: string[]; child_revision?: number; + source?: string; metadata?: Record; } diff --git a/web/src/components/items/BOMTab.tsx b/web/src/components/items/BOMTab.tsx index 4ef502a..3cffebf 100644 --- a/web/src/components/items/BOMTab.tsx +++ b/web/src/components/items/BOMTab.tsx @@ -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([]); @@ -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) => ( - + - setForm({ ...form, child_part_number: e.target.value })} - disabled={isEditing} placeholder="Part number" style={inputStyle} /> + + setForm({ ...form, child_part_number: e.target.value }) + } + disabled={isEditing} + placeholder="Part number" + style={inputStyle} + /> - setForm({ ...form, source: e.target.value })} placeholder="Source" style={inputStyle} /> + setForm({ ...form, source: e.target.value })} + placeholder="Source" + style={inputStyle} + /> - setForm({ ...form, seller_description: e.target.value })} placeholder="Description" 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, 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, 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} /> + setForm({ ...form, sourcing_link: e.target.value })} + placeholder="URL" + style={inputStyle} + /> - - + + ); - if (loading) return
Loading BOM...
; + if (loading) + return
Loading BOM...
; return (
{/* Toolbar */} -
- {entries.length} entries +
+ + {entries.length} entries + {isEditor && ( - + )}
-
- +
+
@@ -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 ( - - - - - - - + + + + + + + {isEditor && ( )} @@ -196,9 +346,22 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) { {totalCost > 0 && ( - - - + + + @@ -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", };
PN
{e.child_part_number}{m.source ?? ''}{e.child_description || m.seller_description || ''}{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : '—'}{e.quantity ?? '—'}{extCost(e) ? `$${extCost(e).toFixed(2)}` : '—'}
+ {e.child_part_number} + {e.source ?? ""} + {e.child_description || m.seller_description || ""} + + {unitCost(e) ? `$${unitCost(e).toFixed(2)}` : "—"} + {e.quantity ?? "—"} + {extCost(e) ? `$${extCost(e).toFixed(2)}` : "—"} + - {m.sourcing_link ? Link : '—'} + {m.sourcing_link ? ( + + Link + + ) : ( + "—" + )} - - + +
Total:${totalCost.toFixed(2)}
+ Total: + + ${totalCost.toFixed(2)} +