Merge branch 'main' into issue-31-delete-dead-docs

This commit is contained in:
2026-02-09 01:21:52 +00:00
24 changed files with 1263 additions and 464 deletions

View File

@@ -5,6 +5,7 @@ server:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 port: 8080
base_url: "http://localhost:8080" base_url: "http://localhost:8080"
# read_only: false # Reject all write operations; toggle at runtime with SIGUSR1
database: database:
host: "psql.kindred.internal" host: "psql.kindred.internal"

0
docs/BOM_MERGE.md Normal file
View File

View File

@@ -36,10 +36,10 @@ a blank field during a design review or procurement cycle.
## Design ## Design
The audit tool is a new page in the existing web UI (`/audit`), built with The audit tool is a page in the web UI (`/audit`), built with the React SPA
the same server-rendered Go templates + vanilla JS approach as the items and (same architecture as the items, projects, and schemas pages). It adds one
projects pages. It adds one new API endpoint for the completeness data and new API endpoint for the completeness data and reuses existing endpoints for
reuses existing endpoints for updates. updates.
### Completeness Scoring ### Completeness Scoring

View File

@@ -30,12 +30,14 @@ YAML values support environment variable expansion using `${VAR_NAME}` syntax. E
| `server.host` | string | `"0.0.0.0"` | Bind address | | `server.host` | string | `"0.0.0.0"` | Bind address |
| `server.port` | int | `8080` | HTTP port | | `server.port` | int | `8080` | HTTP port |
| `server.base_url` | string | — | External URL (e.g. `https://silo.example.com`). Used for OIDC callback URLs and session cookie domain. Required when OIDC is enabled. | | `server.base_url` | string | — | External URL (e.g. `https://silo.example.com`). Used for OIDC callback URLs and session cookie domain. Required when OIDC is enabled. |
| `server.read_only` | bool | `false` | Start in read-only mode. All write endpoints return 503. Can be toggled at runtime with `SIGUSR1`. |
```yaml ```yaml
server: server:
host: "0.0.0.0" host: "0.0.0.0"
port: 8080 port: 8080
base_url: "https://silo.example.com" base_url: "https://silo.example.com"
read_only: false
``` ```
--- ---

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

@@ -465,6 +465,26 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
s.broker.Publish("item.created", mustMarshal(resp)) s.broker.Publish("item.created", mustMarshal(resp))
} }
// HandleGetItemByUUID retrieves an item by its stable UUID (the items.id column).
// Used by silo-mod to resolve FreeCAD document SiloUUID properties to part numbers.
func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uuid := chi.URLParam(r, "uuid")
item, err := s.items.GetByID(ctx, uuid)
if err != nil {
s.logger.Error().Err(err).Msg("failed to get item by UUID")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
return
}
if item == nil || item.ArchivedAt != nil {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeJSON(w, http.StatusOK, itemToResponse(item))
}
// HandleGetItem retrieves an item by part number. // HandleGetItem retrieves an item by part number.
// Supports query param: ?include=properties to include current revision properties. // Supports query param: ?include=properties to include current revision properties.
func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) { func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {

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

@@ -119,6 +119,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Route("/items", func(r chi.Router) { r.Route("/items", func(r chi.Router) {
r.Get("/", server.HandleListItems) r.Get("/", server.HandleListItems)
r.Get("/search", server.HandleFuzzySearch) r.Get("/search", server.HandleFuzzySearch)
r.Get("/by-uuid/{uuid}", server.HandleGetItemByUUID)
r.Get("/export.csv", server.HandleExportCSV) r.Get("/export.csv", server.HandleExportCSV)
r.Get("/template.csv", server.HandleCSVTemplate) r.Get("/template.csv", server.HandleCSVTemplate)
r.Get("/export.ods", server.HandleExportODS) r.Get("/export.ods", server.HandleExportODS)
@@ -165,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

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

View File

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

View File

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

View File

@@ -1,59 +1,79 @@
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from "react-router-dom";
import { useAuth } from '../hooks/useAuth'; import { useAuth } from "../hooks/useAuth";
import { useDensity } from "../hooks/useDensity";
const navLinks = [ const navLinks = [
{ to: '/', label: 'Items' }, { to: "/", label: "Items" },
{ to: '/projects', label: 'Projects' }, { to: "/projects", label: "Projects" },
{ to: '/schemas', label: 'Schemas' }, { to: "/schemas", label: "Schemas" },
{ to: '/audit', label: 'Audit' }, { to: "/audit", label: "Audit" },
{ to: '/settings', label: 'Settings' }, { to: "/settings", label: "Settings" },
]; ];
const roleBadgeStyle: Record<string, React.CSSProperties> = { const roleBadgeStyle: Record<string, React.CSSProperties> = {
admin: { background: 'rgba(203,166,247,0.2)', color: 'var(--ctp-mauve)' }, admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" },
editor: { background: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' }, editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
viewer: { background: 'rgba(148,226,213,0.2)', color: 'var(--ctp-teal)' }, viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" },
}; };
export function AppShell() { export function AppShell() {
const { user, loading, logout } = useAuth(); const { user, loading, logout } = useAuth();
const [density, toggleDensity] = useDensity();
if (loading) { if (loading) {
return ( return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}> <div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
}}
>
<div className="spinner" /> <div className="spinner" />
</div> </div>
); );
} }
return ( return (
<> <div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<header <header
style={{ style={{
backgroundColor: 'var(--ctp-mantle)', backgroundColor: "var(--ctp-mantle)",
borderBottom: '1px solid var(--ctp-surface0)', borderBottom: "1px solid var(--ctp-surface0)",
padding: '1rem 2rem', padding: "var(--d-header-py) var(--d-header-px)",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'space-between', justifyContent: "space-between",
flexShrink: 0,
}} }}
> >
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, color: 'var(--ctp-mauve)' }}>Silo</h1> <h1
style={{
fontSize: "var(--d-header-logo)",
fontWeight: 600,
color: "var(--ctp-mauve)",
}}
>
Silo
</h1>
<nav style={{ display: 'flex', gap: '1.5rem' }}> <nav style={{ display: "flex", gap: "var(--d-nav-gap)" }}>
{navLinks.map((link) => ( {navLinks.map((link) => (
<NavLink <NavLink
key={link.to} key={link.to}
to={link.to} to={link.to}
end={link.to === '/'} end={link.to === "/"}
style={({ isActive }) => ({ style={({ isActive }) => ({
color: isActive ? 'var(--ctp-mauve)' : 'var(--ctp-subtext1)', color: isActive ? "var(--ctp-mauve)" : "var(--ctp-subtext1)",
backgroundColor: isActive ? 'var(--ctp-surface1)' : 'transparent', backgroundColor: isActive
? "var(--ctp-surface1)"
: "transparent",
fontWeight: 500, fontWeight: 500,
padding: '0.5rem 1rem', padding: "var(--d-nav-py) var(--d-nav-px)",
borderRadius: '0.5rem', borderRadius: "var(--d-nav-radius)",
textDecoration: 'none', textDecoration: "none",
transition: 'all 0.2s', transition: "all 0.2s",
})} })}
> >
{link.label} {link.label}
@@ -62,32 +82,60 @@ export function AppShell() {
</nav> </nav>
{user && ( {user && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}> <div
<span style={{ color: 'var(--ctp-subtext1)', fontSize: '0.9rem' }}> style={{
display: "flex",
alignItems: "center",
gap: "var(--d-user-gap)",
}}
>
<span
style={{
color: "var(--ctp-subtext1)",
fontSize: "var(--d-user-font)",
}}
>
{user.display_name} {user.display_name}
</span> </span>
<span <span
style={{ style={{
display: 'inline-block', display: "inline-block",
padding: '0.15rem 0.5rem', padding: "0.15rem 0.5rem",
borderRadius: '1rem', borderRadius: "1rem",
fontSize: '0.75rem', fontSize: "0.75rem",
fontWeight: 600, fontWeight: 600,
...roleBadgeStyle[user.role], ...roleBadgeStyle[user.role],
}} }}
> >
{user.role} {user.role}
</span> </span>
<button
onClick={toggleDensity}
title={`Switch to ${density === "comfortable" ? "compact" : "comfortable"} view`}
style={{
padding: "0.2rem 0.5rem",
fontSize: "0.7rem",
borderRadius: "0.3rem",
cursor: "pointer",
border: "1px solid var(--ctp-surface1)",
background: "var(--ctp-surface0)",
color: "var(--ctp-subtext1)",
fontFamily: "'JetBrains Mono', monospace",
letterSpacing: "0.05em",
}}
>
{density === "comfortable" ? "COM" : "CMP"}
</button>
<button <button
onClick={logout} onClick={logout}
style={{ style={{
padding: '0.35rem 0.75rem', padding: "0.35rem 0.75rem",
fontSize: '0.8rem', fontSize: "0.8rem",
borderRadius: '0.4rem', borderRadius: "0.4rem",
cursor: 'pointer', cursor: "pointer",
border: 'none', border: "none",
background: 'var(--ctp-surface1)', background: "var(--ctp-surface1)",
color: 'var(--ctp-subtext1)', color: "var(--ctp-subtext1)",
}} }}
> >
Logout Logout
@@ -96,9 +144,11 @@ export function AppShell() {
)} )}
</header> </header>
<main style={{ padding: '1rem 1rem 0 1rem' }}> <main
style={{ flex: 1, padding: "1rem 1rem 0 1rem", overflow: "hidden" }}
>
<Outlet /> <Outlet />
</main> </main>
</> </div>
); );
} }

View File

@@ -0,0 +1,69 @@
import type { ReactNode } from 'react';
interface PageFooterProps {
stats?: ReactNode;
page?: number;
pageSize?: number;
itemCount?: number;
onPageChange?: (page: number) => void;
}
export function PageFooter({ stats, page, pageSize, itemCount, onPageChange }: PageFooterProps) {
const hasPagination = page !== undefined && onPageChange !== undefined;
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 'var(--d-footer-h)',
backgroundColor: 'var(--ctp-surface0)',
borderTop: '1px solid var(--ctp-surface1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 var(--d-footer-px)',
fontSize: 'var(--d-footer-font)',
color: 'var(--ctp-subtext0)',
zIndex: 100,
}}>
<div style={{ display: 'flex', gap: '1.5rem', alignItems: 'center' }}>
{stats}
</div>
{hasPagination && (
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
style={pageBtnStyle}
>
Prev
</button>
<span>
Page {page}
{itemCount !== undefined && ` \u00b7 ${itemCount} items`}
</span>
<button
onClick={() => onPageChange(page + 1)}
disabled={pageSize !== undefined && itemCount !== undefined && itemCount < pageSize}
style={pageBtnStyle}
>
Next
</button>
</div>
)}
</div>
);
}
const pageBtnStyle: React.CSSProperties = {
padding: '0.15rem 0.4rem',
fontSize: 'inherit',
border: 'none',
borderRadius: '0.25rem',
backgroundColor: 'var(--ctp-surface1)',
color: 'var(--ctp-text)',
cursor: 'pointer',
};

View File

@@ -23,7 +23,13 @@ export function AuditTable({
}: AuditTableProps) { }: AuditTableProps) {
if (loading) { if (loading) {
return ( return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}> <div
style={{
padding: "2rem",
color: "var(--ctp-subtext0)",
textAlign: "center",
}}
>
Loading audit data... Loading audit data...
</div> </div>
); );
@@ -31,7 +37,13 @@ export function AuditTable({
if (items.length === 0) { if (items.length === 0) {
return ( return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}> <div
style={{
padding: "2rem",
color: "var(--ctp-subtext0)",
textAlign: "center",
}}
>
No items found No items found
</div> </div>
); );
@@ -39,16 +51,27 @@ export function AuditTable({
return ( return (
<div style={{ overflow: "auto", flex: 1 }}> <div style={{ overflow: "auto", flex: 1 }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }}> <table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead> <thead>
<tr> <tr>
{["Score", "Part Number", "Description", "Category", "Sourcing", "Missing"].map( {[
(h) => ( "Score",
<th key={h} style={thStyle}> "Part Number",
{h} "Description",
</th> "Category",
), "Sourcing",
)} "Missing",
].map((h) => (
<th key={h} style={thStyle}>
{h}
</th>
))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -68,7 +91,8 @@ export function AuditTable({
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!isSelected) if (!isSelected)
e.currentTarget.style.backgroundColor = "var(--ctp-surface0)"; e.currentTarget.style.backgroundColor =
"var(--ctp-surface0)";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!isSelected) if (!isSelected)
@@ -100,7 +124,15 @@ export function AuditTable({
> >
{item.part_number} {item.part_number}
</td> </td>
<td style={{ ...tdStyle, maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <td
style={{
...tdStyle,
maxWidth: 300,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.description} {item.description}
</td> </td>
<td style={tdStyle}>{item.category_name || item.category}</td> <td style={tdStyle}>{item.category_name || item.category}</td>
@@ -119,7 +151,8 @@ export function AuditTable({
const thStyle: React.CSSProperties = { const thStyle: React.CSSProperties = {
textAlign: "left", textAlign: "left",
padding: "0.5rem 0.75rem", padding: "var(--d-th-py) var(--d-th-px)",
fontSize: "var(--d-th-font)",
borderBottom: "1px solid var(--ctp-surface1)", borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext0)", color: "var(--ctp-subtext0)",
fontWeight: 500, fontWeight: 500,
@@ -130,7 +163,8 @@ const thStyle: React.CSSProperties = {
}; };
const tdStyle: React.CSSProperties = { const tdStyle: React.CSSProperties = {
padding: "0.4rem 0.75rem", padding: "var(--d-td-py) var(--d-td-px)",
fontSize: "var(--d-td-font)",
borderBottom: "1px solid var(--ctp-surface0)", borderBottom: "1px solid var(--ctp-surface0)",
color: "var(--ctp-text)", color: "var(--ctp-text)",
}; };

View File

@@ -33,9 +33,9 @@ export function AuditToolbar({
style={{ style={{
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
gap: "0.5rem", gap: "var(--d-toolbar-gap)",
alignItems: "center", alignItems: "center",
marginBottom: "0.5rem", marginBottom: "var(--d-toolbar-mb)",
}} }}
> >
<select <select
@@ -95,8 +95,8 @@ export function AuditToolbar({
} }
const selectStyle: React.CSSProperties = { const selectStyle: React.CSSProperties = {
padding: "0.35rem 0.5rem", padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "0.8rem", fontSize: "var(--d-input-font)",
borderRadius: "0.4rem", borderRadius: "0.4rem",
border: "1px solid var(--ctp-surface1)", border: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-surface0)", backgroundColor: "var(--ctp-surface0)",
@@ -104,8 +104,8 @@ const selectStyle: React.CSSProperties = {
}; };
const btnStyle: React.CSSProperties = { const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.6rem", padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "0.8rem", fontSize: "var(--d-input-font)",
borderRadius: "0.4rem", borderRadius: "0.4rem",
border: "none", border: "none",
backgroundColor: "var(--ctp-surface1)", backgroundColor: "var(--ctp-surface1)",

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

@@ -1,36 +0,0 @@
import type { Item } from '../../api/types';
interface FooterStatsProps {
items: Item[];
}
export function FooterStats({ items }: FooterStatsProps) {
const total = items.length;
const parts = items.filter((i) => i.item_type === 'part').length;
const assemblies = items.filter((i) => i.item_type === 'assembly').length;
const documents = items.filter((i) => i.item_type === 'document').length;
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: 28,
backgroundColor: 'var(--ctp-surface0)',
borderTop: '1px solid var(--ctp-surface1)',
display: 'flex',
alignItems: 'center',
padding: '0 2rem',
gap: '2rem',
fontSize: '0.75rem',
color: 'var(--ctp-subtext0)',
zIndex: 100,
}}>
<span>Total: <strong style={{ color: 'var(--ctp-text)' }}>{total}</strong></span>
<span>Parts: <strong style={{ color: 'var(--ctp-blue)' }}>{parts}</strong></span>
<span>Assemblies: <strong style={{ color: 'var(--ctp-green)' }}>{assemblies}</strong></span>
<span>Documents: <strong style={{ color: 'var(--ctp-yellow)' }}>{documents}</strong></span>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from "react";
import type { Item } from '../../api/types'; import type { Item } from "../../api/types";
import { ContextMenu, type ContextMenuItem } from '../ContextMenu'; import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
export interface ColumnDef { export interface ColumnDef {
key: string; key: string;
@@ -8,17 +8,29 @@ export interface ColumnDef {
} }
export const ALL_COLUMNS: ColumnDef[] = [ export const ALL_COLUMNS: ColumnDef[] = [
{ key: 'part_number', label: 'Part Number' }, { key: "part_number", label: "Part Number" },
{ 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: 'projects', label: 'Projects' }, { key: "projects", label: "Projects" },
{ key: 'created', label: 'Created' }, { key: "created", label: "Created" },
{ key: 'actions', label: 'Actions' }, { key: "actions", label: "Actions" },
]; ];
export const DEFAULT_COLUMNS_H = ['part_number', 'item_type', 'description', 'revision']; export const DEFAULT_COLUMNS_H = [
export const DEFAULT_COLUMNS_V = ['part_number', 'item_type', 'description', 'revision', 'created', 'actions']; "part_number",
"item_type",
"description",
"revision",
];
export const DEFAULT_COLUMNS_V = [
"part_number",
"item_type",
"description",
"revision",
"created",
"actions",
];
interface ItemTableProps { interface ItemTableProps {
items: Item[]; items: Item[];
@@ -30,21 +42,25 @@ interface ItemTableProps {
onEdit?: (pn: string) => void; onEdit?: (pn: string) => void;
onDelete?: (pn: string) => void; onDelete?: (pn: string) => void;
sortKey: string; sortKey: string;
sortDir: 'asc' | 'desc'; sortDir: "asc" | "desc";
onSort: (key: string) => void; onSort: (key: string) => void;
} }
const typeColors: Record<string, { bg: string; color: string }> = { const typeColors: Record<string, { bg: string; color: string }> = {
part: { bg: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' }, part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
assembly: { bg: 'rgba(166,227,161,0.2)', color: 'var(--ctp-green)' }, assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
document: { bg: 'rgba(249,226,175,0.2)', color: 'var(--ctp-yellow)' }, document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" },
tooling: { bg: 'rgba(243,139,168,0.2)', color: 'var(--ctp-red)' }, tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
}; };
function formatDate(s: string) { function formatDate(s: string) {
if (!s) return ''; if (!s) return "";
const d = new Date(s); const d = new Date(s);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} }
function copyPN(pn: string) { function copyPN(pn: string) {
@@ -52,8 +68,17 @@ function copyPN(pn: string) {
} }
export function ItemTable({ export function ItemTable({
items, loading, selectedPN, onSelect, visibleColumns, onColumnsChange, items,
onEdit, onDelete, sortKey, sortDir, onSort, loading,
selectedPN,
onSelect,
visibleColumns,
onColumnsChange,
onEdit,
onDelete,
sortKey,
sortDir,
onSort,
}: ItemTableProps) { }: ItemTableProps) {
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
@@ -62,74 +87,99 @@ export function ItemTable({
setCtxMenu({ x: e.clientX, y: e.clientY }); setCtxMenu({ x: e.clientX, y: e.clientY });
}, []); }, []);
const toggleColumn = useCallback((key: string) => { const toggleColumn = useCallback(
if (key === 'part_number') return; // always visible (key: string) => {
const next = visibleColumns.includes(key) if (key === "part_number") return; // always visible
? visibleColumns.filter((c) => c !== key) const next = visibleColumns.includes(key)
: [...visibleColumns, key]; ? visibleColumns.filter((c) => c !== key)
if (next.length > 0) onColumnsChange(next); : [...visibleColumns, key];
}, [visibleColumns, onColumnsChange]); if (next.length > 0) onColumnsChange(next);
},
[visibleColumns, onColumnsChange],
);
const cols = ALL_COLUMNS.filter((c) => visibleColumns.includes(c.key)); const cols = ALL_COLUMNS.filter((c) => visibleColumns.includes(c.key));
const sortedItems = [...items].sort((a, b) => { const sortedItems = [...items].sort((a, b) => {
let av: string | number = ''; let av: string | number = "";
let bv: string | number = ''; let bv: string | number = "";
switch (sortKey) { switch (sortKey) {
case 'part_number': av = a.part_number; bv = b.part_number; break; case "part_number":
case 'item_type': av = a.item_type; bv = b.item_type; break; av = a.part_number;
case 'description': av = a.description; bv = b.description; break; bv = b.part_number;
case 'revision': av = a.current_revision; bv = b.current_revision; break; break;
case 'created': av = a.created_at; bv = b.created_at; break; case "item_type":
default: return 0; av = a.item_type;
bv = b.item_type;
break;
case "description":
av = a.description;
bv = b.description;
break;
case "revision":
av = a.current_revision;
bv = b.current_revision;
break;
case "created":
av = a.created_at;
bv = b.created_at;
break;
default:
return 0;
} }
if (av < bv) return sortDir === 'asc' ? -1 : 1; if (av < bv) return sortDir === "asc" ? -1 : 1;
if (av > bv) return sortDir === 'asc' ? 1 : -1; if (av > bv) return sortDir === "asc" ? 1 : -1;
return 0; return 0;
}); });
const thStyle: React.CSSProperties = { const thStyle: React.CSSProperties = {
padding: '0.35rem 0.75rem', padding: "var(--d-th-py) var(--d-th-px)",
textAlign: 'left', textAlign: "left",
borderBottom: '1px solid var(--ctp-surface1)', borderBottom: "1px solid var(--ctp-surface1)",
color: 'var(--ctp-subtext1)', color: "var(--ctp-subtext1)",
fontWeight: 600, fontWeight: 600,
fontSize: '0.75rem', fontSize: "var(--d-th-font)",
textTransform: 'uppercase', textTransform: "uppercase",
letterSpacing: '0.05em', letterSpacing: "0.05em",
cursor: 'pointer', cursor: "pointer",
userSelect: 'none', userSelect: "none",
whiteSpace: 'nowrap', whiteSpace: "nowrap",
}; };
const tdStyle: React.CSSProperties = { const tdStyle: React.CSSProperties = {
padding: '0.25rem 0.75rem', padding: "var(--d-td-py) var(--d-td-px)",
fontSize: '0.85rem', fontSize: "var(--d-td-font)",
whiteSpace: 'nowrap', whiteSpace: "nowrap",
overflow: 'hidden', overflow: "hidden",
textOverflow: 'ellipsis', textOverflow: "ellipsis",
maxWidth: 300, maxWidth: 300,
}; };
if (loading) { if (loading) {
return <div style={{ padding: '2rem', color: 'var(--ctp-subtext0)' }}>Loading items...</div>; return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)" }}>
Loading items...
</div>
);
} }
return ( return (
<> <>
<div style={{ overflow: 'auto', height: '100%' }}> <div style={{ overflow: "auto", height: "100%" }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}> <table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead onContextMenu={handleHeaderContext}> <thead onContextMenu={handleHeaderContext}>
<tr> <tr>
{cols.map((col) => ( {cols.map((col) => (
<th <th
key={col.key} key={col.key}
style={thStyle} style={thStyle}
onClick={() => col.key !== 'actions' && onSort(col.key)} onClick={() => col.key !== "actions" && onSort(col.key)}
> >
{col.label} {col.label}
{sortKey === col.key && ( {sortKey === col.key && (
<span style={{ marginLeft: 4 }}>{sortDir === 'asc' ? '▲' : '▼'}</span> <span style={{ marginLeft: 4 }}>
{sortDir === "asc" ? "▲" : "▼"}
</span>
)} )}
</th> </th>
))} ))}
@@ -139,10 +189,10 @@ export function ItemTable({
{sortedItems.map((item, idx) => { {sortedItems.map((item, idx) => {
const isSelected = item.part_number === selectedPN; const isSelected = item.part_number === selectedPN;
const rowBg = isSelected const rowBg = isSelected
? 'var(--ctp-surface1)' ? "var(--ctp-surface1)"
: idx % 2 === 0 : idx % 2 === 0
? 'var(--ctp-base)' ? "var(--ctp-base)"
: 'var(--ctp-surface0)'; : "var(--ctp-surface0)";
return ( return (
<tr <tr
@@ -150,68 +200,110 @@ export function ItemTable({
onClick={() => onSelect(item.part_number)} onClick={() => onSelect(item.part_number)}
style={{ style={{
backgroundColor: rowBg, backgroundColor: rowBg,
cursor: 'pointer', cursor: "pointer",
borderBottom: '1px solid var(--ctp-surface0)', borderBottom: "1px solid var(--ctp-surface0)",
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--ctp-surface0)'; if (!isSelected)
e.currentTarget.style.backgroundColor =
"var(--ctp-surface0)";
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = rowBg; if (!isSelected)
e.currentTarget.style.backgroundColor = rowBg;
}} }}
> >
{cols.map((col) => { {cols.map((col) => {
switch (col.key) { switch (col.key) {
case 'part_number': case "part_number":
return ( return (
<td key={col.key} style={tdStyle}> <td key={col.key} style={tdStyle}>
<span <span
onClick={(e) => { e.stopPropagation(); copyPN(item.part_number); }} onClick={(e) => {
e.stopPropagation();
copyPN(item.part_number);
}}
title="Click to copy" title="Click to copy"
style={{ style={{
fontFamily: "'JetBrains Mono', monospace", fontFamily: "'JetBrains Mono', monospace",
color: 'var(--ctp-peach)', color: "var(--ctp-peach)",
cursor: 'copy', cursor: "copy",
}} }}
> >
{item.part_number} {item.part_number}
</span> </span>
</td> </td>
); );
case 'item_type': { case "item_type": {
const tc = typeColors[item.item_type] ?? { bg: 'var(--ctp-surface1)', color: 'var(--ctp-text)' }; const tc = typeColors[item.item_type] ?? {
bg: "var(--ctp-surface1)",
color: "var(--ctp-text)",
};
return ( return (
<td key={col.key} style={tdStyle}> <td key={col.key} style={tdStyle}>
<span style={{ <span
padding: '0.1rem 0.5rem', borderRadius: '1rem', style={{
fontSize: '0.75rem', fontWeight: 500, padding: "0.1rem 0.5rem",
backgroundColor: tc.bg, color: tc.color, borderRadius: "1rem",
}}> fontSize: "0.75rem",
fontWeight: 500,
backgroundColor: tc.bg,
color: tc.color,
}}
>
{item.item_type} {item.item_type}
</span> </span>
</td> </td>
); );
} }
case 'description': case "description":
return <td key={col.key} style={{ ...tdStyle, maxWidth: 400 }}>{item.description}</td>; return (
case 'revision': <td
return <td key={col.key} style={tdStyle}>Rev {item.current_revision}</td>; key={col.key}
case 'projects': style={{ ...tdStyle, maxWidth: 400 }}
return <td key={col.key} style={tdStyle}></td>; >
case 'created': {item.description}
return <td key={col.key} style={tdStyle}>{formatDate(item.created_at)}</td>; </td>
case 'actions': );
case "revision":
return (
<td key={col.key} style={tdStyle}>
Rev {item.current_revision}
</td>
);
case "projects":
return (
<td key={col.key} style={tdStyle}>
</td>
);
case "created":
return (
<td key={col.key} style={tdStyle}>
{formatDate(item.created_at)}
</td>
);
case "actions":
return ( return (
<td key={col.key} style={tdStyle}> <td key={col.key} style={tdStyle}>
<button <button
onClick={(e) => { e.stopPropagation(); onEdit?.(item.part_number); }} onClick={(e) => {
e.stopPropagation();
onEdit?.(item.part_number);
}}
style={actionBtnStyle} style={actionBtnStyle}
> >
Edit Edit
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onDelete?.(item.part_number); }} onClick={(e) => {
style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }} e.stopPropagation();
onDelete?.(item.part_number);
}}
style={{
...actionBtnStyle,
color: "var(--ctp-red)",
}}
> >
Del Del
</button> </button>
@@ -226,7 +318,14 @@ export function ItemTable({
})} })}
{sortedItems.length === 0 && ( {sortedItems.length === 0 && (
<tr> <tr>
<td colSpan={cols.length} style={{ padding: '2rem', textAlign: 'center', color: 'var(--ctp-subtext0)' }}> <td
colSpan={cols.length}
style={{
padding: "2rem",
textAlign: "center",
color: "var(--ctp-subtext0)",
}}
>
No items found No items found
</td> </td>
</tr> </tr>
@@ -239,12 +338,14 @@ export function ItemTable({
x={ctxMenu.x} x={ctxMenu.x}
y={ctxMenu.y} y={ctxMenu.y}
onClose={() => setCtxMenu(null)} onClose={() => setCtxMenu(null)}
items={ALL_COLUMNS.map((col): ContextMenuItem => ({ items={ALL_COLUMNS.map(
label: col.label, (col): ContextMenuItem => ({
checked: visibleColumns.includes(col.key), label: col.label,
onToggle: () => toggleColumn(col.key), checked: visibleColumns.includes(col.key),
disabled: col.key === 'part_number', onToggle: () => toggleColumn(col.key),
}))} disabled: col.key === "part_number",
}),
)}
/> />
)} )}
</> </>
@@ -252,11 +353,11 @@ export function ItemTable({
} }
const actionBtnStyle: React.CSSProperties = { const actionBtnStyle: React.CSSProperties = {
background: 'none', background: "none",
border: 'none', border: "none",
color: 'var(--ctp-subtext1)', color: "var(--ctp-subtext1)",
cursor: 'pointer', cursor: "pointer",
fontSize: '0.8rem', fontSize: "0.8rem",
padding: '0.15rem 0.4rem', padding: "0.15rem 0.4rem",
borderRadius: '0.25rem', borderRadius: "0.25rem",
}; };

View File

@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import { get } from '../../api/client'; import { get } from "../../api/client";
import type { Project } from '../../api/types'; import type { Project } from "../../api/types";
import type { ItemFilters } from '../../hooks/useItems'; import type { ItemFilters } from "../../hooks/useItems";
interface ItemsToolbarProps { interface ItemsToolbarProps {
filters: ItemFilters; filters: ItemFilters;
onFilterChange: (partial: Partial<ItemFilters>) => void; onFilterChange: (partial: Partial<ItemFilters>) => void;
layout: 'horizontal' | 'vertical'; layout: "horizontal" | "vertical";
onLayoutChange: (layout: 'horizontal' | 'vertical') => void; onLayoutChange: (layout: "horizontal" | "vertical") => void;
onExport: () => void; onExport: () => void;
onImport: () => void; onImport: () => void;
onCreate: () => void; onCreate: () => void;
@@ -15,26 +15,40 @@ interface ItemsToolbarProps {
} }
export function ItemsToolbar({ export function ItemsToolbar({
filters, onFilterChange, layout, onLayoutChange, filters,
onExport, onImport, onCreate, isEditor, onFilterChange,
layout,
onLayoutChange,
onExport,
onImport,
onCreate,
isEditor,
}: ItemsToolbarProps) { }: ItemsToolbarProps) {
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
useEffect(() => { useEffect(() => {
get<Project[]>('/api/projects').then(setProjects).catch(() => {}); get<Project[]>("/api/projects")
.then(setProjects)
.catch(() => {});
}, []); }, []);
const scopeBtn = (scope: ItemFilters['searchScope'], label: string) => ( const scopeBtn = (scope: ItemFilters["searchScope"], label: string) => (
<button <button
onClick={() => onFilterChange({ searchScope: scope })} onClick={() => onFilterChange({ searchScope: scope })}
style={{ style={{
padding: '0.3rem 0.6rem', padding: "var(--d-input-py) var(--d-input-px)",
fontSize: '0.8rem', fontSize: "var(--d-input-font)",
border: 'none', border: "none",
borderRadius: '0.3rem', borderRadius: "0.3rem",
cursor: 'pointer', cursor: "pointer",
backgroundColor: filters.searchScope === scope ? 'var(--ctp-mauve)' : 'var(--ctp-surface1)', backgroundColor:
color: filters.searchScope === scope ? 'var(--ctp-crust)' : 'var(--ctp-subtext1)', filters.searchScope === scope
? "var(--ctp-mauve)"
: "var(--ctp-surface1)",
color:
filters.searchScope === scope
? "var(--ctp-crust)"
: "var(--ctp-subtext1)",
}} }}
> >
{label} {label}
@@ -42,15 +56,17 @@ export function ItemsToolbar({
); );
return ( return (
<div style={{ <div
display: 'flex', style={{
flexWrap: 'wrap', display: "flex",
gap: '0.75rem', flexWrap: "wrap",
alignItems: 'center', gap: "var(--d-toolbar-gap)",
padding: '0.75rem 0', alignItems: "center",
borderBottom: '1px solid var(--ctp-surface0)', padding: "var(--d-toolbar-py) 0",
marginBottom: '0.5rem', borderBottom: "1px solid var(--ctp-surface0)",
}}> marginBottom: "var(--d-toolbar-mb)",
}}
>
{/* Search */} {/* Search */}
<input <input
type="text" type="text"
@@ -60,20 +76,20 @@ export function ItemsToolbar({
style={{ style={{
flex: 1, flex: 1,
minWidth: 200, minWidth: 200,
padding: '0.4rem 0.75rem', padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: 'var(--ctp-surface0)', backgroundColor: "var(--ctp-surface0)",
border: '1px solid var(--ctp-surface1)', border: "1px solid var(--ctp-surface1)",
borderRadius: '0.4rem', borderRadius: "0.4rem",
color: 'var(--ctp-text)', color: "var(--ctp-text)",
fontSize: '0.85rem', fontSize: "var(--d-input-font)",
}} }}
/> />
{/* Search scope */} {/* Search scope */}
<div style={{ display: 'flex', gap: '0.25rem' }}> <div style={{ display: "flex", gap: "0.25rem" }}>
{scopeBtn('all', 'All')} {scopeBtn("all", "All")}
{scopeBtn('part_number', 'PN')} {scopeBtn("part_number", "PN")}
{scopeBtn('description', 'Desc')} {scopeBtn("description", "Desc")}
</div> </div>
{/* Type filter */} {/* Type filter */}
@@ -97,34 +113,46 @@ export function ItemsToolbar({
> >
<option value="">All Projects</option> <option value="">All Projects</option>
{projects.map((p) => ( {projects.map((p) => (
<option key={p.code} value={p.code}>{p.code}{p.name ? `${p.name}` : ''}</option> <option key={p.code} value={p.code}>
{p.code}
{p.name ? `${p.name}` : ""}
</option>
))} ))}
</select> </select>
{/* Layout toggle */} {/* Layout toggle */}
<button <button
onClick={() => onLayoutChange(layout === 'horizontal' ? 'vertical' : 'horizontal')} onClick={() =>
title={`Switch to ${layout === 'horizontal' ? 'vertical' : 'horizontal'} layout`} onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
}
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
style={toolBtnStyle} style={toolBtnStyle}
> >
{layout === 'horizontal' ? '⬌' : '⬍'} {layout === "horizontal" ? "⬌" : "⬍"}
</button> </button>
{/* Export */} {/* Export */}
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">Export</button> <button onClick={onExport} style={toolBtnStyle} title="Export CSV">
Export
</button>
{/* Import (editor only) */} {/* Import (editor only) */}
{isEditor && ( {isEditor && (
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">Import</button> <button onClick={onImport} style={toolBtnStyle} title="Import CSV">
Import
</button>
)} )}
{/* Create (editor only) */} {/* Create (editor only) */}
{isEditor && ( {isEditor && (
<button onClick={onCreate} style={{ <button
...toolBtnStyle, onClick={onCreate}
backgroundColor: 'var(--ctp-mauve)', style={{
color: 'var(--ctp-crust)', ...toolBtnStyle,
}}> backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
}}
>
+ New + New
</button> </button>
)} )}
@@ -133,20 +161,20 @@ export function ItemsToolbar({
} }
const selectStyle: React.CSSProperties = { const selectStyle: React.CSSProperties = {
padding: '0.4rem 0.6rem', padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: 'var(--ctp-surface0)', backgroundColor: "var(--ctp-surface0)",
border: '1px solid var(--ctp-surface1)', border: "1px solid var(--ctp-surface1)",
borderRadius: '0.4rem', borderRadius: "0.4rem",
color: 'var(--ctp-text)', color: "var(--ctp-text)",
fontSize: '0.85rem', fontSize: "var(--d-input-font)",
}; };
const toolBtnStyle: React.CSSProperties = { const toolBtnStyle: React.CSSProperties = {
padding: '0.4rem 0.75rem', padding: "var(--d-input-py) var(--d-input-px)",
backgroundColor: 'var(--ctp-surface1)', backgroundColor: "var(--ctp-surface1)",
border: 'none', border: "none",
borderRadius: '0.4rem', borderRadius: "0.4rem",
color: 'var(--ctp-text)', color: "var(--ctp-text)",
fontSize: '0.85rem', fontSize: "var(--d-input-font)",
cursor: 'pointer', cursor: "pointer",
}; };

View File

@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { useLocalStorage } from './useLocalStorage';
export type Density = 'comfortable' | 'compact';
function applyDensity(density: Density) {
document.documentElement.setAttribute('data-density', density);
}
export function useDensity(): [Density, () => void] {
const [density, setDensity] = useLocalStorage<Density>('silo-density', 'comfortable');
applyDensity(density);
const toggle = useCallback(() => {
const next: Density = density === 'comfortable' ? 'compact' : 'comfortable';
setDensity(next);
applyDensity(next);
}, [density, setDensity]);
return [density, toggle];
}

View File

@@ -1,11 +1,20 @@
import { StrictMode } from 'react'; import { StrictMode } from "react";
import { createRoot } from 'react-dom/client'; import { createRoot } from "react-dom/client";
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from "react-router-dom";
import { AuthProvider } from './context/AuthContext'; import { AuthProvider } from "./context/AuthContext";
import { App } from './App'; import { App } from "./App";
import './styles/global.css'; import "./styles/global.css";
createRoot(document.getElementById('root')!).render( // Apply saved density before first paint to prevent FOUC
try {
const saved = localStorage.getItem("silo-density");
const density = saved ? JSON.parse(saved) : "comfortable";
document.documentElement.setAttribute("data-density", density);
} catch {
document.documentElement.setAttribute("data-density", "comfortable");
}
createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>

View File

@@ -6,6 +6,7 @@ import { AuditToolbar } from "../components/audit/AuditToolbar";
import { AuditTable } from "../components/audit/AuditTable"; import { AuditTable } from "../components/audit/AuditTable";
import { AuditDetailPanel } from "../components/audit/AuditDetailPanel"; import { AuditDetailPanel } from "../components/audit/AuditDetailPanel";
import { SplitPanel } from "../components/items/SplitPanel"; import { SplitPanel } from "../components/items/SplitPanel";
import { PageFooter } from "../components/PageFooter";
type PaneMode = { type: "none" } | { type: "detail"; partNumber: string }; type PaneMode = { type: "none" } | { type: "detail"; partNumber: string };
@@ -47,8 +48,8 @@ export function AuditPage() {
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "calc(100vh - 64px)", height: "100%",
paddingBottom: 28, paddingBottom: "var(--d-footer-h)",
}} }}
> >
{error && ( {error && (
@@ -91,45 +92,18 @@ export function AuditPage() {
storageKey="silo-audit-split" storageKey="silo-audit-split"
/> />
{/* Pagination */} <PageFooter
<div stats={
style={{ <>
display: "flex", <span>{summary.total_items} items</span>
justifyContent: "center", <span>Avg: {(summary.avg_score * 100).toFixed(1)}%</span>
alignItems: "center", </>
gap: "0.75rem", }
padding: "0.4rem", page={filters.page}
flexShrink: 0, pageSize={filters.pageSize}
}} itemCount={items.length}
> onPageChange={(p) => updateFilters({ page: p })}
<button />
onClick={() => updateFilters({ page: Math.max(1, filters.page - 1) })}
disabled={filters.page <= 1}
style={pageBtnStyle}
>
Prev
</button>
<span style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}>
Page {filters.page} · {items.length} items
</span>
<button
onClick={() => updateFilters({ page: filters.page + 1 })}
disabled={items.length < filters.pageSize}
style={pageBtnStyle}
>
Next
</button>
</div>
</div> </div>
); );
} }
const pageBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.6rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-text)",
cursor: "pointer",
};

View File

@@ -14,7 +14,7 @@ import { EditItemPane } from "../components/items/EditItemPane";
import { DeleteItemPane } from "../components/items/DeleteItemPane"; import { DeleteItemPane } from "../components/items/DeleteItemPane";
import { ImportItemsPane } from "../components/items/ImportItemsPane"; import { ImportItemsPane } from "../components/items/ImportItemsPane";
import { SplitPanel } from "../components/items/SplitPanel"; import { SplitPanel } from "../components/items/SplitPanel";
import { FooterStats } from "../components/items/FooterStats"; import { PageFooter } from "../components/PageFooter";
type PaneMode = type PaneMode =
| { type: "none" } | { type: "none" }
@@ -170,8 +170,8 @@ export function ItemsPage() {
style={{ style={{
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
height: "calc(100vh - 64px)", height: "100%",
paddingBottom: 28, paddingBottom: "var(--d-footer-h)",
}} }}
> >
{error && ( {error && (
@@ -217,47 +217,40 @@ export function ItemsPage() {
secondary={secondaryPane} secondary={secondaryPane}
/> />
{/* Pagination */} <PageFooter
<div stats={
style={{ <>
display: "flex", <span>
justifyContent: "center", Total:{" "}
alignItems: "center", <strong style={{ color: "var(--ctp-text)" }}>
gap: "0.75rem", {items.length}
padding: "0.4rem", </strong>
flexShrink: 0, </span>
}} <span>
> Parts:{" "}
<button <strong style={{ color: "var(--ctp-blue)" }}>
onClick={() => updateFilters({ page: Math.max(1, filters.page - 1) })} {items.filter((i) => i.item_type === "part").length}
disabled={filters.page <= 1} </strong>
style={pageBtnStyle} </span>
> <span>
Prev Assemblies:{" "}
</button> <strong style={{ color: "var(--ctp-green)" }}>
<span style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}> {items.filter((i) => i.item_type === "assembly").length}
Page {filters.page} · {items.length} items </strong>
</span> </span>
<button <span>
onClick={() => updateFilters({ page: filters.page + 1 })} Documents:{" "}
disabled={items.length < filters.pageSize} <strong style={{ color: "var(--ctp-yellow)" }}>
style={pageBtnStyle} {items.filter((i) => i.item_type === "document").length}
> </strong>
Next </span>
</button> </>
</div> }
page={filters.page}
<FooterStats items={items} /> pageSize={filters.pageSize}
itemCount={items.length}
onPageChange={(p) => updateFilters({ page: p })}
/>
</div> </div>
); );
} }
const pageBtnStyle: React.CSSProperties = {
padding: "0.25rem 0.6rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-text)",
cursor: "pointer",
};

View File

@@ -1,29 +1,92 @@
/* Catppuccin Mocha Theme */ /* Catppuccin Mocha Theme */
:root { :root {
--ctp-rosewater: #f5e0dc; --ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd; --ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7; --ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7; --ctp-mauve: #cba6f7;
--ctp-red: #f38ba8; --ctp-red: #f38ba8;
--ctp-maroon: #eba0ac; --ctp-maroon: #eba0ac;
--ctp-peach: #fab387; --ctp-peach: #fab387;
--ctp-yellow: #f9e2af; --ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1; --ctp-green: #a6e3a1;
--ctp-teal: #94e2d5; --ctp-teal: #94e2d5;
--ctp-sky: #89dceb; --ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec; --ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa; --ctp-blue: #89b4fa;
--ctp-lavender: #b4befe; --ctp-lavender: #b4befe;
--ctp-text: #cdd6f4; --ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de; --ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8; --ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2; --ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c; --ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086; --ctp-overlay0: #6c7086;
--ctp-surface2: #585b70; --ctp-surface2: #585b70;
--ctp-surface1: #45475a; --ctp-surface1: #45475a;
--ctp-surface0: #313244; --ctp-surface0: #313244;
--ctp-base: #1e1e2e; --ctp-base: #1e1e2e;
--ctp-mantle: #181825; --ctp-mantle: #181825;
--ctp-crust: #11111b; --ctp-crust: #11111b;
}
/* ── Density: comfortable (default) ── */
[data-density="comfortable"],
:root {
--d-header-py: 0.625rem;
--d-header-px: 2rem;
--d-header-logo: 1.25rem;
--d-nav-gap: 1rem;
--d-nav-py: 0.35rem;
--d-nav-px: 0.75rem;
--d-nav-radius: 0.4rem;
--d-user-gap: 0.6rem;
--d-user-font: 0.85rem;
--d-th-py: 0.35rem;
--d-th-px: 0.75rem;
--d-th-font: 0.75rem;
--d-td-py: 0.25rem;
--d-td-px: 0.75rem;
--d-td-font: 0.85rem;
--d-toolbar-gap: 0.5rem;
--d-toolbar-py: 0.5rem;
--d-toolbar-mb: 0.35rem;
--d-input-py: 0.35rem;
--d-input-px: 0.6rem;
--d-input-font: 0.85rem;
--d-footer-h: 28px;
--d-footer-font: 0.75rem;
--d-footer-px: 2rem;
}
/* ── Density: compact ── */
[data-density="compact"] {
--d-header-py: 0.35rem;
--d-header-px: 1.25rem;
--d-header-logo: 1.1rem;
--d-nav-gap: 0.5rem;
--d-nav-py: 0.2rem;
--d-nav-px: 0.5rem;
--d-nav-radius: 0.3rem;
--d-user-gap: 0.35rem;
--d-user-font: 0.8rem;
--d-th-py: 0.2rem;
--d-th-px: 0.5rem;
--d-th-font: 0.7rem;
--d-td-py: 0.125rem;
--d-td-px: 0.5rem;
--d-td-font: 0.8rem;
--d-toolbar-gap: 0.35rem;
--d-toolbar-py: 0.25rem;
--d-toolbar-mb: 0.15rem;
--d-input-py: 0.2rem;
--d-input-px: 0.4rem;
--d-input-font: 0.8rem;
--d-footer-h: 24px;
--d-footer-font: 0.7rem;
--d-footer-px: 1.25rem;
} }