15 Commits

Author SHA1 Message Date
Forbes
f7aa673d2c fix(sse): disable read deadline for long-lived SSE connections
The server's ReadTimeout (15s) was closing SSE connections shortly after
they were established, causing a rapid connect/disconnect loop. The handler
already disabled WriteTimeout but not ReadTimeout.
2026-02-08 22:52:42 -06:00
2157b40d06 Merge pull request 'feat(web): BOM merge resolution UI (#47)' (#55) from issue-47-bom-merge-ui into main
Reviewed-on: #55
2026-02-09 02:09:02 +00:00
Forbes
25c42bd70b feat(web): add BOM merge resolution UI with source badges and dropdown
- Add source badges (assembly=teal, manual=blue) to BOM display rows
- Add info banner when assembly-sourced entries exist
- Change source input from text field to select dropdown
- Add merge response types to types.ts

Closes #47
2026-02-08 19:56:33 -06:00
8d88f77ff6 Merge pull request 'feat: expose file attachment stats as item properties' (#54) from issue-37-file-stats into main
Reviewed-on: #54
2026-02-09 01:26:17 +00:00
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
12 changed files with 754 additions and 87 deletions

View File

@@ -101,6 +101,8 @@ var manufacturedWeights = map[string]float64{
// Weight 1: engineering detail (category-specific default)
"sourcing_type": 1,
"lifecycle_status": 1,
// Weight 1: engineering detail
"has_files": 1,
// Weight 0.5: less relevant for in-house
"manufacturer": 0.5,
"supplier": 0.5,
@@ -207,6 +209,7 @@ func scoreItem(
categoryProps map[string]schema.PropertyDefinition,
hasBOM bool,
bomChildCount int,
hasFiles bool,
categoryName string,
projects []string,
includeFields bool,
@@ -276,6 +279,7 @@ func scoreItem(
// Score has_bom for manufactured/assembly items.
if sourcingType == "manufactured" || isAssembly {
processField("has_bom", "computed", "boolean", hasBOM)
processField("has_files", "computed", "boolean", hasFiles)
}
// Score property fields from schema.
@@ -412,6 +416,13 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request)
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.
sch := s.schemas["kindred-rd"]
var catSegment *schema.Segment
@@ -440,9 +451,10 @@ func (s *Server) HandleAuditCompleteness(w http.ResponseWriter, r *http.Request)
bomCount := bomCounts[item.ID]
hasBOM := bomCount > 0
hasFiles := fileStats[item.ID].Count > 0
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)
}
@@ -544,6 +556,15 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) {
}
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.
cat := extractCategory(item.PartNumber)
categoryName := cat
@@ -561,7 +582,7 @@ func (s *Server) HandleAuditItemDetail(w http.ResponseWriter, r *http.Request) {
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)
}

View File

@@ -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,
}
}
@@ -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.
var bomCSVHeaders = []string{
"Item", "Level", "Source", "PN", "Seller Description",
@@ -686,14 +741,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 +908,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 +996,7 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
ChildItemID: child.ID,
RelType: "component",
Quantity: quantity,
Source: source,
Metadata: metadata,
CreatedBy: importUsername,
}
@@ -971,3 +1026,197 @@ func (s *Server) HandleImportBOMCSV(w http.ResponseWriter, r *http.Request) {
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"`
StandardCost *float64 `json:"standard_cost,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"`
}
@@ -304,9 +306,20 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
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))
for i, item := range items {
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)
@@ -482,7 +495,15 @@ func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) {
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.
@@ -504,6 +525,14 @@ func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {
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
if r.URL.Query().Get("include") == "properties" {
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 {
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"

View File

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

View File

@@ -16,9 +16,12 @@ func (s *Server) HandleEvents(w http.ResponseWriter, r *http.Request) {
return
}
// Disable the write deadline for this long-lived connection.
// The server's WriteTimeout (15s) would otherwise kill it.
// Disable read and write deadlines for this long-lived connection.
// The server's ReadTimeout/WriteTimeout (15s) would otherwise kill it.
rc := http.NewResponseController(w)
if err := rc.SetReadDeadline(time.Time{}); err != nil {
s.logger.Warn().Err(err).Msg("failed to disable read deadline for SSE")
}
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
s.logger.Warn().Err(err).Msg("failed to disable write deadline for SSE")
}

View File

@@ -134,6 +134,43 @@ func (r *ItemRepository) BatchCheckBOM(ctx context.Context, itemIDs []string) (m
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
// the given item IDs.
func (r *ItemRepository) BatchGetProjectCodes(ctx context.Context, itemIDs []string) (map[string][]string, error) {

View File

@@ -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)

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;
long_description?: string;
standard_cost?: number;
file_count: number;
files_total_size: number;
properties?: Record<string, unknown>;
}
@@ -75,6 +77,7 @@ export interface BOMEntry {
child_revision?: number;
effective_revision: number;
depth?: number;
source: string;
metadata?: Record<string, unknown>;
}
@@ -196,6 +199,7 @@ export interface AddBOMEntryRequest {
unit?: string;
reference_designators?: string[];
child_revision?: number;
source?: string;
metadata?: Record<string, unknown>;
}
@@ -208,6 +212,38 @@ export interface UpdateBOMEntryRequest {
metadata?: Record<string, unknown>;
}
// BOM Merge
export interface MergeBOMResponse {
status: string;
diff: MergeBOMDiff;
warnings: MergeWarning[];
resolve_url: string;
}
export interface MergeBOMDiff {
added: MergeDiffEntry[];
removed: MergeDiffEntry[];
quantity_changed: MergeQtyChange[];
unchanged: MergeDiffEntry[];
}
export interface MergeDiffEntry {
part_number: string;
quantity: number | null;
}
export interface MergeQtyChange {
part_number: string;
old_quantity: number | null;
new_quantity: number | null;
}
export interface MergeWarning {
type: string;
part_number: string;
message: string;
}
// Schema properties
export interface PropertyDef {
type: string;

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { get, post, put, del } from '../../api/client';
import type { BOMEntry } from '../../api/types';
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../../api/client";
import type { BOMEntry } from "../../api/types";
interface BOMTabProps {
partNumber: string;
@@ -16,7 +16,14 @@ interface BOMFormData {
sourcing_link: string;
}
const emptyForm: BOMFormData = { child_part_number: '', quantity: '1', source: '', seller_description: '', unit_cost: '', sourcing_link: '' };
const emptyForm: BOMFormData = {
child_part_number: "",
quantity: "1",
source: "",
seller_description: "",
unit_cost: "",
sourcing_link: "",
};
export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const [entries, setEntries] = useState<BOMEntry[]>([]);
@@ -39,13 +46,14 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const unitCost = (e: BOMEntry) => Number(meta(e).unit_cost) || 0;
const extCost = (e: BOMEntry) => unitCost(e) * (e.quantity ?? 0);
const totalCost = entries.reduce((sum, e) => sum + extCost(e), 0);
const assemblyCount = entries.filter((e) => e.source === "assembly").length;
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 +62,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 +107,176 @@ 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} />
<select
value={form.source}
onChange={(e) => setForm({ ...form, source: e.target.value })}
style={inputStyle}
>
<option value=""></option>
<option value="manual">manual</option>
<option value="assembly">assembly</option>
</select>
</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' }}>
{isEditor && assemblyCount > 0 && (
<div
style={{
padding: "0.35rem 0.6rem",
marginBottom: "0.5rem",
borderRadius: "0.3rem",
backgroundColor: "rgba(148,226,213,0.1)",
border: "1px solid rgba(148,226,213,0.3)",
fontSize: "0.75rem",
color: "var(--ctp-subtext1)",
}}
>
{assemblyCount} assembly-sourced{" "}
{assemblyCount === 1 ? "entry" : "entries"}. Entries removed from the
FreeCAD assembly will remain here until manually deleted.
</div>
)}
<div style={{ overflow: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead>
<tr>
<th style={thStyle}>PN</th>
@@ -174,20 +295,79 @@ 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}>
{m.sourcing_link ? <a href={m.sourcing_link} target="_blank" rel="noreferrer" style={{ color: 'var(--ctp-sapphire)', fontSize: '0.75rem' }}>Link</a> : '—'}
{e.source === "assembly" ? (
<span style={assemblyBadge}>assembly</span>
) : e.source === "manual" ? (
<span style={manualBadge}>manual</span>
) : (
"—"
)}
</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>
) : (
"—"
)}
</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 +376,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 +403,78 @@ 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 sourceBadgeBase: React.CSSProperties = {
padding: "0.1rem 0.4rem",
borderRadius: "1rem",
fontSize: "0.7rem",
fontWeight: 500,
};
const assemblyBadge: React.CSSProperties = {
...sourceBadgeBase,
backgroundColor: "rgba(148,226,213,0.2)",
color: "var(--ctp-teal)",
};
const manualBadge: React.CSSProperties = {
...sourceBadgeBase,
backgroundColor: "rgba(137,180,250,0.2)",
color: "var(--ctp-blue)",
};
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",
};

View File

@@ -12,6 +12,7 @@ export const ALL_COLUMNS: ColumnDef[] = [
{ key: "item_type", label: "Type" },
{ key: "description", label: "Description" },
{ key: "revision", label: "Rev" },
{ key: "files", label: "Files" },
{ key: "projects", label: "Projects" },
{ key: "created", label: "Created" },
{ key: "actions", label: "Actions" },
@@ -28,6 +29,7 @@ export const DEFAULT_COLUMNS_V = [
"item_type",
"description",
"revision",
"files",
"created",
"actions",
];
@@ -67,6 +69,12 @@ function copyPN(pn: string) {
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({
items,
loading,
@@ -120,6 +128,10 @@ export function ItemTable({
av = a.current_revision;
bv = b.current_revision;
break;
case "files":
av = a.file_count;
bv = b.file_count;
break;
case "created":
av = a.created_at;
bv = b.created_at;
@@ -271,6 +283,20 @@ export function ItemTable({
Rev {item.current_revision}
</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":
return (
<td key={col.key} style={tdStyle}>