11 Commits

Author SHA1 Message Date
Forbes
50985ed805 feat: expose file attachment stats as item properties (#37)
Add file_count and files_total_size to item API responses, computed
via batch query on item_files table (no migration needed).

- Add BatchGetFileStats() to audit_queries.go (follows BatchCheckBOM pattern)
- Add file stats to ItemResponse, HandleListItems, HandleGetItem, HandleGetItemByUUID
- Add 'Files' column to ItemTable (default visible in vertical mode)
- Add has_files computed field to audit completeness scoring (weight 1 for manufactured)
2026-02-08 19:25:46 -06:00
9be6f45f09 Merge pull request 'chore(docs): delete stale documentation files' (#52) from issue-31-delete-dead-docs into main
Reviewed-on: #52
2026-02-09 01:22:02 +00:00
ef05aec619 Merge branch 'main' into issue-31-delete-dead-docs 2026-02-09 01:21:52 +00:00
64075d88b5 Merge pull request 'feat(api): add POST /api/items/{partNumber}/bom/merge endpoint' (#51) from issue-45-bom-merge into main
Reviewed-on: #51
2026-02-09 01:21:44 +00:00
eac64f863b Merge branch 'main' into issue-45-bom-merge 2026-02-09 01:21:38 +00:00
aa414adc43 Merge pull request 'feat(db): add source column to relationships table' (#50) from issue-44-bom-source into main
Reviewed-on: #50
2026-02-09 01:21:30 +00:00
9ce9468474 Merge branch 'main' into issue-44-bom-source 2026-02-09 01:21:13 +00:00
2dad658e91 Merge pull request 'feat(api): add GET /api/items/by-uuid/{uuid} endpoint' (#49) from issue-43-uuid-lookup into main
Reviewed-on: #49
2026-02-09 01:21:07 +00:00
Forbes
08e84703d5 chore(docs): delete stale REPOSITORY_STATUS.md (#31)
Generated 2026-01-31, references HTML templates and 8 migrations
that are now outdated. Superseded by STATUS.md and SPECIFICATION.md.

API.md and silo-spec.md were already deleted in earlier commits.
2026-02-08 19:17:53 -06:00
Forbes
fbe4f3a36c feat(api): add POST /api/items/{partNumber}/bom/merge endpoint (#45)
Add BOM merge endpoint for syncing assembly-derived BOM entries from
FreeCAD's silo-mod plugin.

Merge rules:
- Added: entries in request but not in server BOM are auto-created
  with source='assembly'
- Quantity changed: existing entries with different quantity are
  auto-updated
- Unchanged: same part and quantity are skipped
- Unreferenced: assembly-sourced entries in server BOM but not in
  request are flagged as warnings (never auto-deleted)
- Manual entries are silently ignored in unreferenced detection

Also emits SSE 'bom.merged' event on successful merge (#46).
2026-02-08 19:15:27 -06:00
Forbes
163dc9f0f0 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
2026-02-08 18:45:41 -06:00
11 changed files with 668 additions and 85 deletions

View File

@@ -101,6 +101,8 @@ var manufacturedWeights = map[string]float64{
// Weight 1: engineering detail (category-specific default) // Weight 1: engineering detail (category-specific default)
"sourcing_type": 1, "sourcing_type": 1,
"lifecycle_status": 1, "lifecycle_status": 1,
// Weight 1: engineering detail
"has_files": 1,
// Weight 0.5: less relevant for in-house // Weight 0.5: less relevant for in-house
"manufacturer": 0.5, "manufacturer": 0.5,
"supplier": 0.5, "supplier": 0.5,
@@ -207,6 +209,7 @@ func scoreItem(
categoryProps map[string]schema.PropertyDefinition, categoryProps map[string]schema.PropertyDefinition,
hasBOM bool, hasBOM bool,
bomChildCount int, bomChildCount int,
hasFiles bool,
categoryName string, categoryName string,
projects []string, projects []string,
includeFields bool, includeFields bool,
@@ -276,6 +279,7 @@ func scoreItem(
// Score has_bom for manufactured/assembly items. // Score has_bom for manufactured/assembly items.
if sourcingType == "manufactured" || isAssembly { if sourcingType == "manufactured" || isAssembly {
processField("has_bom", "computed", "boolean", hasBOM) processField("has_bom", "computed", "boolean", hasBOM)
processField("has_files", "computed", "boolean", hasFiles)
} }
// Score property fields from schema. // Score property fields from schema.
@@ -412,6 +416,13 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request)
return return
} }
fileStats, err := s.items.BatchGetFileStats(ctx, itemIDs)
if err != nil {
s.logger.Error().Err(err).Msg("failed to batch get file stats")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats")
return
}
// Look up the schema for category resolution. // Look up the schema for category resolution.
sch := s.schemas["kindred-rd"] sch := s.schemas["kindred-rd"]
var catSegment *schema.Segment var catSegment *schema.Segment
@@ -440,9 +451,10 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request)
bomCount := bomCounts[item.ID] bomCount := bomCounts[item.ID]
hasBOM := bomCount > 0 hasBOM := bomCount > 0
hasFiles := fileStats[item.ID].Count > 0
projects := projectCodes[item.ID] projects := projectCodes[item.ID]
result := scoreItem(item, categoryProps, hasBOM, bomCount, categoryName, projects, false) result := scoreItem(item, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, false)
allResults = append(allResults, *result) allResults = append(allResults, *result)
} }
@@ -544,6 +556,15 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) {
} }
projects := projectCodes[item.ID] projects := projectCodes[item.ID]
// Get file stats.
fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID})
if err != nil {
s.logger.Error().Err(err).Str("pn", partNumber).Msg("failed to get file stats for audit")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load file stats")
return
}
hasFiles := fileStats[item.ID].Count > 0
// Category resolution. // Category resolution.
cat := extractCategory(item.PartNumber) cat := extractCategory(item.PartNumber)
categoryName := cat categoryName := cat
@@ -561,7 +582,7 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) {
categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat) categoryProps = sch.PropertySchemas.GetPropertiesForCategory(cat)
} }
result := scoreItem(iwp, categoryProps, hasBOM, bomCount, categoryName, projects, true) result := scoreItem(iwp, categoryProps, hasBOM, bomCount, hasFiles, categoryName, projects, true)
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }

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,
} }
} }
@@ -589,6 +594,56 @@ func (s *Server) HandleGetBOMCost(w http.ResponseWriter, r *http.Request) {
}) })
} }
// BOM merge request/response types
// MergeBOMRequest represents a request to merge assembly BOM entries.
type MergeBOMRequest struct {
Source string `json:"source"`
Entries []MergeBOMEntry `json:"entries"`
}
// MergeBOMEntry represents a single entry in a merge request.
type MergeBOMEntry struct {
ChildPartNumber string `json:"child_part_number"`
Quantity *float64 `json:"quantity"`
}
// MergeBOMResponse represents the result of a BOM merge.
type MergeBOMResponse struct {
Status string `json:"status"`
Diff MergeBOMDiff `json:"diff"`
Warnings []MergeWarning `json:"warnings"`
ResolveURL string `json:"resolve_url"`
}
// MergeBOMDiff categorizes changes from a merge operation.
type MergeBOMDiff struct {
Added []MergeDiffEntry `json:"added"`
Removed []MergeDiffEntry `json:"removed"`
QuantityChanged []MergeQtyChange `json:"quantity_changed"`
Unchanged []MergeDiffEntry `json:"unchanged"`
}
// MergeDiffEntry represents an added, removed, or unchanged BOM entry.
type MergeDiffEntry struct {
PartNumber string `json:"part_number"`
Quantity *float64 `json:"quantity"`
}
// MergeQtyChange represents a BOM entry whose quantity changed.
type MergeQtyChange struct {
PartNumber string `json:"part_number"`
OldQuantity *float64 `json:"old_quantity"`
NewQuantity *float64 `json:"new_quantity"`
}
// MergeWarning represents a warning generated during merge.
type MergeWarning struct {
Type string `json:"type"`
PartNumber string `json:"part_number"`
Message string `json:"message"`
}
// BOM CSV headers matching the user-specified format. // BOM CSV headers matching the user-specified format.
var bomCSVHeaders = []string{ var bomCSVHeaders = []string{
"Item", "Level", "Source", "PN", "Seller Description", "Item", "Level", "Source", "PN", "Seller Description",
@@ -686,14 +741,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 +908,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 +996,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,
} }
@@ -971,3 +1026,197 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, result) writeJSON(w, http.StatusOK, result)
} }
// HandleMergeBOM merges assembly-derived BOM entries into the server's BOM.
// Added entries are created, quantity changes are applied, and entries present
// in the server but missing from the request are flagged as warnings (not deleted).
func (s *Server) HandleMergeBOM(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
parent, err := s.items.GetByPartNumber(ctx, partNumber)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get parent item")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get parent item")
return
}
if parent == nil {
writeError(w, http.StatusNotFound, "not_found", "Parent item not found")
return
}
var req MergeBOMRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if len(req.Entries) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "entries must not be empty")
return
}
// Fetch existing BOM (includes Source field)
existing, err := s.relationships.GetBOM(ctx, parent.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get existing BOM")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get existing BOM")
return
}
// Build lookup map by child part number
existingMap := make(map[string]*db.BOMEntry, len(existing))
for _, e := range existing {
existingMap[e.ChildPartNumber] = e
}
var username *string
if user := auth.UserFromContext(ctx); user != nil {
username = &user.Username
}
diff := MergeBOMDiff{
Added: make([]MergeDiffEntry, 0),
Removed: make([]MergeDiffEntry, 0),
QuantityChanged: make([]MergeQtyChange, 0),
Unchanged: make([]MergeDiffEntry, 0),
}
var warnings []MergeWarning
// Process incoming entries
for _, entry := range req.Entries {
if entry.ChildPartNumber == "" {
continue
}
child, err := s.items.GetByPartNumber(ctx, entry.ChildPartNumber)
if err != nil {
s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to look up child")
warnings = append(warnings, MergeWarning{
Type: "error",
PartNumber: entry.ChildPartNumber,
Message: fmt.Sprintf("Error looking up item: %s", err.Error()),
})
continue
}
if child == nil {
warnings = append(warnings, MergeWarning{
Type: "not_found",
PartNumber: entry.ChildPartNumber,
Message: fmt.Sprintf("Item '%s' not found in database", entry.ChildPartNumber),
})
continue
}
if ex, ok := existingMap[entry.ChildPartNumber]; ok {
// Entry already exists — check quantity
oldQty := ex.Quantity
newQty := entry.Quantity
if quantitiesEqual(oldQty, newQty) {
diff.Unchanged = append(diff.Unchanged, MergeDiffEntry{
PartNumber: entry.ChildPartNumber,
Quantity: newQty,
})
} else {
// Update quantity
if err := s.relationships.Update(ctx, ex.RelationshipID, nil, newQty, nil, nil, nil, nil, username); err != nil {
s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to update quantity")
warnings = append(warnings, MergeWarning{
Type: "error",
PartNumber: entry.ChildPartNumber,
Message: fmt.Sprintf("Failed to update quantity: %s", err.Error()),
})
} else {
diff.QuantityChanged = append(diff.QuantityChanged, MergeQtyChange{
PartNumber: entry.ChildPartNumber,
OldQuantity: oldQty,
NewQuantity: newQty,
})
}
}
delete(existingMap, entry.ChildPartNumber)
} else {
// New entry — create
rel := &db.Relationship{
ParentItemID: parent.ID,
ChildItemID: child.ID,
RelType: "component",
Quantity: entry.Quantity,
Source: "assembly",
CreatedBy: username,
}
if err := s.relationships.Create(ctx, rel); err != nil {
if strings.Contains(err.Error(), "cycle") {
warnings = append(warnings, MergeWarning{
Type: "cycle",
PartNumber: entry.ChildPartNumber,
Message: fmt.Sprintf("Adding '%s' would create a cycle", entry.ChildPartNumber),
})
} else {
s.logger.Error().Err(err).Str("child", entry.ChildPartNumber).Msg("failed to create relationship")
warnings = append(warnings, MergeWarning{
Type: "error",
PartNumber: entry.ChildPartNumber,
Message: fmt.Sprintf("Failed to create: %s", err.Error()),
})
}
continue
}
diff.Added = append(diff.Added, MergeDiffEntry{
PartNumber: entry.ChildPartNumber,
Quantity: entry.Quantity,
})
}
}
// Remaining entries in existingMap are not in the merge request
for pn, e := range existingMap {
if e.Source == "assembly" {
diff.Removed = append(diff.Removed, MergeDiffEntry{
PartNumber: pn,
Quantity: e.Quantity,
})
warnings = append(warnings, MergeWarning{
Type: "unreferenced",
PartNumber: pn,
Message: "Present in server BOM but not in assembly",
})
}
}
resp := MergeBOMResponse{
Status: "merged",
Diff: diff,
Warnings: warnings,
ResolveURL: fmt.Sprintf("/items/%s/bom", partNumber),
}
s.logger.Info().
Str("parent", partNumber).
Int("added", len(diff.Added)).
Int("updated", len(diff.QuantityChanged)).
Int("unchanged", len(diff.Unchanged)).
Int("unreferenced", len(diff.Removed)).
Int("warnings", len(warnings)).
Msg("BOM merge completed")
s.broker.Publish("bom.merged", mustMarshal(map[string]any{
"part_number": partNumber,
"added": len(diff.Added),
"quantity_changed": len(diff.QuantityChanged),
"unchanged": len(diff.Unchanged),
"unreferenced": len(diff.Removed),
}))
writeJSON(w, http.StatusOK, resp)
}
// quantitiesEqual compares two nullable float64 quantities.
func quantitiesEqual(a, b *float64) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}

View File

@@ -260,6 +260,8 @@ type ItemResponse struct {
LongDescription *string `json:"long_description,omitempty"` LongDescription *string `json:"long_description,omitempty"`
StandardCost *float64 `json:"standard_cost,omitempty"` StandardCost *float64 `json:"standard_cost,omitempty"`
ThumbnailKey *string `json:"thumbnail_key,omitempty"` ThumbnailKey *string `json:"thumbnail_key,omitempty"`
FileCount int `json:"file_count"`
FilesTotalSize int64 `json:"files_total_size"`
Properties map[string]any `json:"properties,omitempty"` Properties map[string]any `json:"properties,omitempty"`
} }
@@ -304,9 +306,20 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
return return
} }
// Batch-fetch file attachment stats
ids := make([]string, len(items))
for i, item := range items {
ids[i] = item.ID
}
fileStats, _ := s.items.BatchGetFileStats(ctx, ids)
response := make([]ItemResponse, len(items)) response := make([]ItemResponse, len(items))
for i, item := range items { for i, item := range items {
response[i] = itemToResponse(item) response[i] = itemToResponse(item)
if fs, ok := fileStats[item.ID]; ok {
response[i].FileCount = fs.Count
response[i].FilesTotalSize = fs.TotalSize
}
} }
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
@@ -482,7 +495,15 @@ func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) {
return return
} }
writeJSON(w, http.StatusOK, itemToResponse(item)) response := itemToResponse(item)
if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil {
if fs, ok := fileStats[item.ID]; ok {
response.FileCount = fs.Count
response.FilesTotalSize = fs.TotalSize
}
}
writeJSON(w, http.StatusOK, response)
} }
// HandleGetItem retrieves an item by part number. // HandleGetItem retrieves an item by part number.
@@ -504,6 +525,14 @@ func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {
response := itemToResponse(item) response := itemToResponse(item)
// File attachment stats
if fileStats, err := s.items.BatchGetFileStats(ctx, []string{item.ID}); err == nil {
if fs, ok := fileStats[item.ID]; ok {
response.FileCount = fs.Count
response.FilesTotalSize = fs.TotalSize
}
}
// Include properties from current revision if requested // Include properties from current revision if requested
if r.URL.Query().Get("include") == "properties" { if r.URL.Query().Get("include") == "properties" {
revisions, err := s.items.GetRevisions(ctx, item.ID) revisions, err := s.items.GetRevisions(ctx, item.ID)

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

@@ -166,6 +166,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Put("/thumbnail", server.HandleSetItemThumbnail) r.Put("/thumbnail", server.HandleSetItemThumbnail)
r.Post("/bom", server.HandleAddBOMEntry) r.Post("/bom", server.HandleAddBOMEntry)
r.Post("/bom/import", server.HandleImportBOMCSV) r.Post("/bom/import", server.HandleImportBOMCSV)
r.Post("/bom/merge", server.HandleMergeBOM)
r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry) r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry)
r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry) r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry)
}) })

View File

@@ -134,6 +134,43 @@ func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (m
return result, nil return result, nil
} }
// FileStats holds aggregated file attachment statistics for an item.
type FileStats struct {
Count int
TotalSize int64
}
// BatchGetFileStats returns a map of item ID to file attachment statistics
// for the given item IDs. Items not in the map have no files.
func (r *ItemRepository) BatchGetFileStats(ctx context.Context, itemIDs []string) (map[string]FileStats, error) {
if len(itemIDs) == 0 {
return map[string]FileStats{}, nil
}
rows, err := r.db.pool.Query(ctx, `
SELECT item_id, COUNT(*), COALESCE(SUM(size), 0)
FROM item_files
WHERE item_id = ANY($1)
GROUP BY item_id
`, itemIDs)
if err != nil {
return nil, fmt.Errorf("batch getting file stats: %w", err)
}
defer rows.Close()
result := make(map[string]FileStats)
for rows.Next() {
var itemID string
var fs FileStats
if err := rows.Scan(&itemID, &fs.Count, &fs.TotalSize); err != nil {
return nil, fmt.Errorf("scanning file stats: %w", err)
}
result[itemID] = fs
}
return result, nil
}
// BatchGetProjectCodes returns a map of item ID to project code list for // BatchGetProjectCodes returns a map of item ID to project code list for
// the given item IDs. // the given item IDs.
func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) { func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) {

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

@@ -19,6 +19,8 @@ export interface Item {
sourcing_link?: string; sourcing_link?: string;
long_description?: string; long_description?: string;
standard_cost?: number; standard_cost?: number;
file_count: number;
files_total_size: number;
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
} }
@@ -75,6 +77,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 +199,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",
}; };

View File

@@ -12,6 +12,7 @@ export const ALL_COLUMNS: ColumnDef[] = [
{ key: "item_type", label: "Type" }, { key: "item_type", label: "Type" },
{ key: "description", label: "Description" }, { key: "description", label: "Description" },
{ key: "revision", label: "Rev" }, { key: "revision", label: "Rev" },
{ key: "files", label: "Files" },
{ key: "projects", label: "Projects" }, { key: "projects", label: "Projects" },
{ key: "created", label: "Created" }, { key: "created", label: "Created" },
{ key: "actions", label: "Actions" }, { key: "actions", label: "Actions" },
@@ -28,6 +29,7 @@ export const DEFAULT_COLUMNS_V = [
"item_type", "item_type",
"description", "description",
"revision", "revision",
"files",
"created", "created",
"actions", "actions",
]; ];
@@ -67,6 +69,12 @@ function copyPN(pn: string) {
void navigator.clipboard.writeText(pn); void navigator.clipboard.writeText(pn);
} }
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function ItemTable({ export function ItemTable({
items, items,
loading, loading,
@@ -120,6 +128,10 @@ export function ItemTable({
av = a.current_revision; av = a.current_revision;
bv = b.current_revision; bv = b.current_revision;
break; break;
case "files":
av = a.file_count;
bv = b.file_count;
break;
case "created": case "created":
av = a.created_at; av = a.created_at;
bv = b.created_at; bv = b.created_at;
@@ -271,6 +283,20 @@ export function ItemTable({
Rev {item.current_revision} Rev {item.current_revision}
</td> </td>
); );
case "files":
return (
<td
key={col.key}
style={{ ...tdStyle, textAlign: "center" }}
title={
item.file_count > 0
? `${item.file_count} file${item.file_count !== 1 ? "s" : ""}, ${formatSize(item.files_total_size)}`
: "No files"
}
>
{item.file_count > 0 ? item.file_count : "—"}
</td>
);
case "projects": case "projects":
return ( return (
<td key={col.key} style={tdStyle}> <td key={col.key} style={tdStyle}>