feat(db): add source column to relationships table #50
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
16
migrations/012_bom_source.sql
Normal file
16
migrations/012_bom_source.sql
Normal 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';
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user