diff --git a/config.example.yaml b/config.example.yaml index be667ba..c9089c6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -5,6 +5,7 @@ server: host: "0.0.0.0" port: 8080 base_url: "http://localhost:8080" + # read_only: false # Reject all write operations; toggle at runtime with SIGUSR1 database: host: "psql.kindred.internal" diff --git a/docs/BOM_MERGE.md b/docs/BOM_MERGE.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/COMPONENT_AUDIT.md b/docs/COMPONENT_AUDIT.md index 7eb0ecd..dde5c7b 100644 --- a/docs/COMPONENT_AUDIT.md +++ b/docs/COMPONENT_AUDIT.md @@ -36,10 +36,10 @@ a blank field during a design review or procurement cycle. ## Design -The audit tool is a new page in the existing web UI (`/audit`), built with -the same server-rendered Go templates + vanilla JS approach as the items and -projects pages. It adds one new API endpoint for the completeness data and -reuses existing endpoints for updates. +The audit tool is a page in the web UI (`/audit`), built with the React SPA +(same architecture as the items, projects, and schemas pages). It adds one +new API endpoint for the completeness data and reuses existing endpoints for +updates. ### Completeness Scoring diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f07ce36..f1030bf 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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.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.read_only` | bool | `false` | Start in read-only mode. All write endpoints return 503. Can be toggled at runtime with `SIGUSR1`. | ```yaml server: host: "0.0.0.0" port: 8080 base_url: "https://silo.example.com" + read_only: false ``` --- diff --git a/internal/api/bom_handlers.go b/internal/api/bom_handlers.go index 4f4e5eb..83c6008 100644 --- a/internal/api/bom_handlers.go +++ b/internal/api/bom_handlers.go @@ -29,6 +29,7 @@ type BOMEntryResponse struct { ChildRevision *int `json:"child_revision,omitempty"` EffectiveRevision int `json:"effective_revision"` Depth *int `json:"depth,omitempty"` + Source string `json:"source"` Metadata map[string]any `json:"metadata,omitempty"` } @@ -51,6 +52,7 @@ type AddBOMEntryRequest struct { Unit *string `json:"unit,omitempty"` ReferenceDesignators []string `json:"reference_designators,omitempty"` ChildRevision *int `json:"child_revision,omitempty"` + Source string `json:"source,omitempty"` Metadata map[string]any `json:"metadata,omitempty"` } @@ -240,6 +242,7 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) { Unit: req.Unit, ReferenceDesignators: req.ReferenceDesignators, ChildRevision: req.ChildRevision, + Source: req.Source, Metadata: req.Metadata, } if user := auth.UserFromContext(ctx); user != nil { @@ -273,6 +276,7 @@ func (s *Server) HandleAddBOMEntry(w http.ResponseWriter, r *http.Request) { ReferenceDesignators: req.ReferenceDesignators, ChildRevision: req.ChildRevision, EffectiveRevision: child.CurrentRevision, + Source: rel.Source, Metadata: req.Metadata, } if req.ChildRevision != nil { @@ -434,6 +438,7 @@ func bomEntryToResponse(e *db.BOMEntry) BOMEntryResponse { ReferenceDesignators: refDes, ChildRevision: e.ChildRevision, EffectiveRevision: e.EffectiveRevision, + Source: e.Source, Metadata: e.Metadata, } } @@ -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 +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index aaef469..1c6a6d5 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -465,6 +465,26 @@ func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { 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. // Supports query param: ?include=properties to include current revision properties. func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/ods.go b/internal/api/ods.go index c590bf1..2fcf5e8 100644 --- a/internal/api/ods.go +++ b/internal/api/ods.go @@ -599,7 +599,7 @@ func (s *Server) HandleExportBOMODS(w http.ResponseWriter, r *http.Request) { } } - source := getMetaString(e.Metadata, "source") + source := e.Source if source == "" && childItem != nil { st := childItem.SourcingType if st == "manufactured" { @@ -754,7 +754,7 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) { if e.Quantity != nil { qty = *e.Quantity } - source := getMetaString(e.Metadata, "source") + source := e.Source if source == "" && childItem != nil { if childItem.SourcingType == "manufactured" { source = "M" diff --git a/internal/api/routes.go b/internal/api/routes.go index 32d760a..67b0de6 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -119,6 +119,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Route("/items", func(r chi.Router) { r.Get("/", server.HandleListItems) r.Get("/search", server.HandleFuzzySearch) + r.Get("/by-uuid/{uuid}", server.HandleGetItemByUUID) r.Get("/export.csv", server.HandleExportCSV) r.Get("/template.csv", server.HandleCSVTemplate) 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.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) }) diff --git a/internal/db/relationships.go b/internal/db/relationships.go index 8f6ecb3..afad1df 100644 --- a/internal/db/relationships.go +++ b/internal/db/relationships.go @@ -23,6 +23,7 @@ type Relationship struct { ChildRevision *int Metadata map[string]any ParentRevisionID *string + Source string // "manual" or "assembly" CreatedAt time.Time UpdatedAt time.Time CreatedBy *string @@ -46,6 +47,7 @@ type BOMEntry struct { ChildRevision *int EffectiveRevision int Metadata map[string]any + Source string } // BOMTreeEntry extends BOMEntry with depth for multi-level BOM expansion. @@ -84,16 +86,21 @@ func (r *RelationshipRepository) Create(ctx context.Context, rel *Relationship) } } + source := rel.Source + if source == "" { + source = "manual" + } + err = r.db.pool.QueryRow(ctx, ` INSERT INTO relationships ( parent_item_id, child_item_id, rel_type, quantity, unit, - reference_designators, child_revision, metadata, parent_revision_id, created_by + reference_designators, child_revision, metadata, parent_revision_id, created_by, source ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, created_at, updated_at `, rel.ParentItemID, rel.ChildItemID, rel.RelType, rel.Quantity, rel.Unit, rel.ReferenceDesignators, rel.ChildRevision, metadataJSON, rel.ParentRevisionID, - rel.CreatedBy, + rel.CreatedBy, source, ).Scan(&rel.ID, &rel.CreatedAt, &rel.UpdatedAt) if err != nil { return fmt.Errorf("inserting relationship: %w", err) @@ -256,7 +263,7 @@ func (r *RelationshipRepository) GetBOM(ctx context.Context, parentItemID string rel.rel_type, rel.quantity, rel.unit, rel.reference_designators, rel.child_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision, - rel.metadata + rel.metadata, rel.source FROM relationships rel JOIN items parent ON parent.id = rel.parent_item_id JOIN items child ON child.id = rel.child_item_id @@ -281,7 +288,7 @@ func (r *RelationshipRepository) GetWhereUsed(ctx context.Context, childItemID s rel.rel_type, rel.quantity, rel.unit, rel.reference_designators, rel.child_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision, - rel.metadata + rel.metadata, rel.source FROM relationships rel JOIN items parent ON parent.id = rel.parent_item_id JOIN items child ON child.id = rel.child_item_id @@ -315,7 +322,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI rel.rel_type, rel.quantity, rel.unit, rel.reference_designators, rel.child_revision, COALESCE(rel.child_revision, child.current_revision) AS effective_revision, - rel.metadata, + rel.metadata, rel.source, 1 AS depth FROM relationships rel JOIN items parent ON parent.id = rel.parent_item_id @@ -334,7 +341,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI rel.rel_type, rel.quantity, rel.unit, rel.reference_designators, rel.child_revision, COALESCE(rel.child_revision, child.current_revision), - rel.metadata, + rel.metadata, rel.source, bt.depth + 1 FROM relationships rel JOIN items parent ON parent.id = rel.parent_item_id @@ -347,7 +354,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI SELECT id, parent_item_id, parent_part_number, parent_description, child_item_id, child_part_number, child_description, rel_type, quantity, unit, reference_designators, - child_revision, effective_revision, metadata, depth + child_revision, effective_revision, metadata, source, depth FROM bom_tree ORDER BY depth, child_part_number `, parentItemID, maxDepth) @@ -366,7 +373,7 @@ func (r *RelationshipRepository) GetExpandedBOM(ctx context.Context, parentItemI &e.ChildItemID, &e.ChildPartNumber, &childDesc, &e.RelType, &e.Quantity, &e.Unit, &e.ReferenceDesignators, &e.ChildRevision, - &e.EffectiveRevision, &metadataJSON, &e.Depth, + &e.EffectiveRevision, &metadataJSON, &e.Source, &e.Depth, ) if err != nil { return nil, fmt.Errorf("scanning BOM tree entry: %w", err) @@ -553,7 +560,7 @@ func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) { &e.RelType, &e.Quantity, &e.Unit, &e.ReferenceDesignators, &e.ChildRevision, &e.EffectiveRevision, - &metadataJSON, + &metadataJSON, &e.Source, ) if err != nil { return nil, fmt.Errorf("scanning BOM entry: %w", err) diff --git a/migrations/012_bom_source.sql b/migrations/012_bom_source.sql new file mode 100644 index 0000000..cff7e6f --- /dev/null +++ b/migrations/012_bom_source.sql @@ -0,0 +1,16 @@ +-- Add source column to relationships table to distinguish assembly-derived +-- BOM entries from manually-added ones. +ALTER TABLE relationships + ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'manual' + CHECK (source IN ('manual', 'assembly')); + +-- Migrate existing metadata.source values where they exist. +-- The metadata field stores source as a free-form string; promote to column. +UPDATE relationships +SET source = 'manual' +WHERE metadata->>'source' IS NOT NULL; + +-- Remove the source key from metadata since it's now a dedicated column. +UPDATE relationships +SET metadata = metadata - 'source' +WHERE metadata ? 'source'; diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e6db599..0df3662 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -75,6 +75,7 @@ export interface BOMEntry { child_revision?: number; effective_revision: number; depth?: number; + source: string; metadata?: Record; } @@ -196,6 +197,7 @@ export interface AddBOMEntryRequest { unit?: string; reference_designators?: string[]; child_revision?: number; + source?: string; metadata?: Record; } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 4da96e0..41b21fd 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -1,59 +1,79 @@ -import { NavLink, Outlet } from 'react-router-dom'; -import { useAuth } from '../hooks/useAuth'; +import { NavLink, Outlet } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; +import { useDensity } from "../hooks/useDensity"; const navLinks = [ - { to: '/', label: 'Items' }, - { to: '/projects', label: 'Projects' }, - { to: '/schemas', label: 'Schemas' }, - { to: '/audit', label: 'Audit' }, - { to: '/settings', label: 'Settings' }, + { to: "/", label: "Items" }, + { to: "/projects", label: "Projects" }, + { to: "/schemas", label: "Schemas" }, + { to: "/audit", label: "Audit" }, + { to: "/settings", label: "Settings" }, ]; const roleBadgeStyle: Record = { - admin: { background: 'rgba(203,166,247,0.2)', color: 'var(--ctp-mauve)' }, - editor: { background: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' }, - viewer: { background: 'rgba(148,226,213,0.2)', color: 'var(--ctp-teal)' }, + admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" }, + editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" }, + viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" }, }; export function AppShell() { const { user, loading, logout } = useAuth(); + const [density, toggleDensity] = useDensity(); if (loading) { return ( -
+
); } return ( - <> +
-

Silo

+

+ Silo +

-
-
+
- +
); } diff --git a/web/src/components/PageFooter.tsx b/web/src/components/PageFooter.tsx new file mode 100644 index 0000000..f78f5c8 --- /dev/null +++ b/web/src/components/PageFooter.tsx @@ -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 ( +
+
+ {stats} +
+ + {hasPagination && ( +
+ + + Page {page} + {itemCount !== undefined && ` \u00b7 ${itemCount} items`} + + +
+ )} +
+ ); +} + +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', +}; diff --git a/web/src/components/audit/AuditTable.tsx b/web/src/components/audit/AuditTable.tsx index e75c4c2..c471d9a 100644 --- a/web/src/components/audit/AuditTable.tsx +++ b/web/src/components/audit/AuditTable.tsx @@ -23,7 +23,13 @@ export function AuditTable({ }: AuditTableProps) { if (loading) { return ( -
+
Loading audit data...
); @@ -31,7 +37,13 @@ export function AuditTable({ if (items.length === 0) { return ( -
+
No items found
); @@ -39,16 +51,27 @@ export function AuditTable({ return (
- +
- {["Score", "Part Number", "Description", "Category", "Sourcing", "Missing"].map( - (h) => ( - - ), - )} + {[ + "Score", + "Part Number", + "Description", + "Category", + "Sourcing", + "Missing", + ].map((h) => ( + + ))} @@ -68,7 +91,8 @@ export function AuditTable({ }} onMouseEnter={(e) => { if (!isSelected) - e.currentTarget.style.backgroundColor = "var(--ctp-surface0)"; + e.currentTarget.style.backgroundColor = + "var(--ctp-surface0)"; }} onMouseLeave={(e) => { if (!isSelected) @@ -100,7 +124,15 @@ export function AuditTable({ > {item.part_number} - @@ -119,7 +151,8 @@ export function AuditTable({ const thStyle: React.CSSProperties = { 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)", color: "var(--ctp-subtext0)", fontWeight: 500, @@ -130,7 +163,8 @@ const thStyle: 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)", color: "var(--ctp-text)", }; diff --git a/web/src/components/audit/AuditToolbar.tsx b/web/src/components/audit/AuditToolbar.tsx index c83e851..34476cb 100644 --- a/web/src/components/audit/AuditToolbar.tsx +++ b/web/src/components/audit/AuditToolbar.tsx @@ -33,9 +33,9 @@ export function AuditToolbar({ style={{ display: "flex", flexWrap: "wrap", - gap: "0.5rem", + gap: "var(--d-toolbar-gap)", alignItems: "center", - marginBottom: "0.5rem", + marginBottom: "var(--d-toolbar-mb)", }} > + ); - if (loading) return
Loading BOM...
; + if (loading) + return
Loading BOM...
; return (
{/* Toolbar */} -
- {entries.length} entries +
+ + {entries.length} entries + {isEditor && ( - + )}
-
-
- {h} - + {h} +
+ {item.description} {item.category_name || item.category}
- setForm({ ...form, child_part_number: e.target.value })} - disabled={isEditing} placeholder="Part number" style={inputStyle} /> + + setForm({ ...form, child_part_number: e.target.value }) + } + disabled={isEditing} + placeholder="Part number" + style={inputStyle} + /> - setForm({ ...form, source: e.target.value })} placeholder="Source" style={inputStyle} /> + setForm({ ...form, source: e.target.value })} + placeholder="Source" + style={inputStyle} + /> - setForm({ ...form, seller_description: e.target.value })} placeholder="Description" style={inputStyle} /> + + setForm({ ...form, seller_description: e.target.value }) + } + placeholder="Description" + style={inputStyle} + /> - setForm({ ...form, unit_cost: e.target.value })} type="number" step="0.01" placeholder="0.00" style={inputStyle} /> + setForm({ ...form, unit_cost: e.target.value })} + type="number" + step="0.01" + placeholder="0.00" + style={inputStyle} + /> - setForm({ ...form, quantity: e.target.value })} type="number" step="1" placeholder="1" style={{ ...inputStyle, width: 50 }} /> + setForm({ ...form, quantity: e.target.value })} + type="number" + step="1" + placeholder="1" + style={{ ...inputStyle, width: 50 }} + /> - setForm({ ...form, sourcing_link: e.target.value })} placeholder="URL" style={inputStyle} /> + setForm({ ...form, sourcing_link: e.target.value })} + placeholder="URL" + style={inputStyle} + /> - - + +
+
+
@@ -174,20 +273,71 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) { if (editIdx === idx) return formRow(true, e.child_part_number); const m = meta(e); return ( - - - - - - - + + + + + + + {isEditor && ( )} @@ -196,9 +346,22 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) { {totalCost > 0 && ( - - - + + + @@ -210,29 +373,59 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) { } const thStyle: React.CSSProperties = { - padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)', - color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap', + padding: "0.3rem 0.5rem", + textAlign: "left", + borderBottom: "1px solid var(--ctp-surface1)", + color: "var(--ctp-subtext1)", + fontWeight: 600, + fontSize: "0.7rem", + textTransform: "uppercase", + letterSpacing: "0.05em", + whiteSpace: "nowrap", }; const tdStyle: React.CSSProperties = { - padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap', + padding: "0.25rem 0.5rem", + borderBottom: "1px solid var(--ctp-surface0)", + whiteSpace: "nowrap", }; const toolBtnStyle: React.CSSProperties = { - padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem', - backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer', + padding: "0.25rem 0.5rem", + fontSize: "0.8rem", + border: "none", + borderRadius: "0.3rem", + backgroundColor: "var(--ctp-surface1)", + color: "var(--ctp-text)", + cursor: "pointer", }; const actionBtnStyle: React.CSSProperties = { - background: 'none', border: 'none', color: 'var(--ctp-subtext1)', cursor: 'pointer', fontSize: '0.75rem', padding: '0.1rem 0.3rem', + background: "none", + border: "none", + color: "var(--ctp-subtext1)", + cursor: "pointer", + fontSize: "0.75rem", + padding: "0.1rem 0.3rem", }; const saveBtnStyle: React.CSSProperties = { - padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem', - backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer', marginRight: '0.25rem', + padding: "0.2rem 0.4rem", + fontSize: "0.75rem", + border: "none", + borderRadius: "0.25rem", + backgroundColor: "var(--ctp-green)", + color: "var(--ctp-crust)", + cursor: "pointer", + marginRight: "0.25rem", }; const cancelBtnStyle: React.CSSProperties = { - padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem', - backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-subtext1)', cursor: 'pointer', + padding: "0.2rem 0.4rem", + fontSize: "0.75rem", + border: "none", + borderRadius: "0.25rem", + backgroundColor: "var(--ctp-surface1)", + color: "var(--ctp-subtext1)", + cursor: "pointer", }; diff --git a/web/src/components/items/FooterStats.tsx b/web/src/components/items/FooterStats.tsx deleted file mode 100644 index 02388bd..0000000 --- a/web/src/components/items/FooterStats.tsx +++ /dev/null @@ -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 ( -
- Total: {total} - Parts: {parts} - Assemblies: {assemblies} - Documents: {documents} -
- ); -} diff --git a/web/src/components/items/ItemTable.tsx b/web/src/components/items/ItemTable.tsx index 2be9750..cdc109c 100644 --- a/web/src/components/items/ItemTable.tsx +++ b/web/src/components/items/ItemTable.tsx @@ -1,6 +1,6 @@ -import { useState, useCallback } from 'react'; -import type { Item } from '../../api/types'; -import { ContextMenu, type ContextMenuItem } from '../ContextMenu'; +import { useState, useCallback } from "react"; +import type { Item } from "../../api/types"; +import { ContextMenu, type ContextMenuItem } from "../ContextMenu"; export interface ColumnDef { key: string; @@ -8,17 +8,29 @@ export interface ColumnDef { } export const ALL_COLUMNS: ColumnDef[] = [ - { key: 'part_number', label: 'Part Number' }, - { key: 'item_type', label: 'Type' }, - { key: 'description', label: 'Description' }, - { key: 'revision', label: 'Rev' }, - { key: 'projects', label: 'Projects' }, - { key: 'created', label: 'Created' }, - { key: 'actions', label: 'Actions' }, + { key: "part_number", label: "Part Number" }, + { key: "item_type", label: "Type" }, + { key: "description", label: "Description" }, + { key: "revision", label: "Rev" }, + { key: "projects", label: "Projects" }, + { key: "created", label: "Created" }, + { key: "actions", label: "Actions" }, ]; -export const DEFAULT_COLUMNS_H = ['part_number', 'item_type', 'description', 'revision']; -export const DEFAULT_COLUMNS_V = ['part_number', 'item_type', 'description', 'revision', 'created', 'actions']; +export const DEFAULT_COLUMNS_H = [ + "part_number", + "item_type", + "description", + "revision", +]; +export const DEFAULT_COLUMNS_V = [ + "part_number", + "item_type", + "description", + "revision", + "created", + "actions", +]; interface ItemTableProps { items: Item[]; @@ -30,21 +42,25 @@ interface ItemTableProps { onEdit?: (pn: string) => void; onDelete?: (pn: string) => void; sortKey: string; - sortDir: 'asc' | 'desc'; + sortDir: "asc" | "desc"; onSort: (key: string) => void; } const typeColors: Record = { - part: { bg: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' }, - assembly: { bg: 'rgba(166,227,161,0.2)', color: 'var(--ctp-green)' }, - document: { bg: 'rgba(249,226,175,0.2)', color: 'var(--ctp-yellow)' }, - tooling: { bg: 'rgba(243,139,168,0.2)', color: 'var(--ctp-red)' }, + part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" }, + assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" }, + document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" }, + tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" }, }; function formatDate(s: string) { - if (!s) return ''; + if (!s) return ""; 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) { @@ -52,8 +68,17 @@ function copyPN(pn: string) { } export function ItemTable({ - items, loading, selectedPN, onSelect, visibleColumns, onColumnsChange, - onEdit, onDelete, sortKey, sortDir, onSort, + items, + loading, + selectedPN, + onSelect, + visibleColumns, + onColumnsChange, + onEdit, + onDelete, + sortKey, + sortDir, + onSort, }: ItemTableProps) { const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); @@ -62,74 +87,99 @@ export function ItemTable({ setCtxMenu({ x: e.clientX, y: e.clientY }); }, []); - const toggleColumn = useCallback((key: string) => { - if (key === 'part_number') return; // always visible - const next = visibleColumns.includes(key) - ? visibleColumns.filter((c) => c !== key) - : [...visibleColumns, key]; - if (next.length > 0) onColumnsChange(next); - }, [visibleColumns, onColumnsChange]); + const toggleColumn = useCallback( + (key: string) => { + if (key === "part_number") return; // always visible + const next = visibleColumns.includes(key) + ? visibleColumns.filter((c) => c !== key) + : [...visibleColumns, key]; + if (next.length > 0) onColumnsChange(next); + }, + [visibleColumns, onColumnsChange], + ); const cols = ALL_COLUMNS.filter((c) => visibleColumns.includes(c.key)); const sortedItems = [...items].sort((a, b) => { - let av: string | number = ''; - let bv: string | number = ''; + let av: string | number = ""; + let bv: string | number = ""; switch (sortKey) { - case 'part_number': av = a.part_number; bv = b.part_number; break; - case 'item_type': 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; + case "part_number": + av = a.part_number; + bv = b.part_number; + break; + case "item_type": + 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; }); const thStyle: React.CSSProperties = { - padding: '0.35rem 0.75rem', - textAlign: 'left', - borderBottom: '1px solid var(--ctp-surface1)', - color: 'var(--ctp-subtext1)', + padding: "var(--d-th-py) var(--d-th-px)", + textAlign: "left", + borderBottom: "1px solid var(--ctp-surface1)", + color: "var(--ctp-subtext1)", fontWeight: 600, - fontSize: '0.75rem', - textTransform: 'uppercase', - letterSpacing: '0.05em', - cursor: 'pointer', - userSelect: 'none', - whiteSpace: 'nowrap', + fontSize: "var(--d-th-font)", + textTransform: "uppercase", + letterSpacing: "0.05em", + cursor: "pointer", + userSelect: "none", + whiteSpace: "nowrap", }; const tdStyle: React.CSSProperties = { - padding: '0.25rem 0.75rem', - fontSize: '0.85rem', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', + padding: "var(--d-td-py) var(--d-td-px)", + fontSize: "var(--d-td-font)", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", maxWidth: 300, }; if (loading) { - return
Loading items...
; + return ( +
+ Loading items... +
+ ); } return ( <> -
-
PN
{e.child_part_number}{m.source ?? ''}{e.child_description || m.seller_description || ''}{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : '—'}{e.quantity ?? '—'}{extCost(e) ? `$${extCost(e).toFixed(2)}` : '—'}
+ {e.child_part_number} + {e.source ?? ""} + {e.child_description || m.seller_description || ""} + + {unitCost(e) ? `$${unitCost(e).toFixed(2)}` : "—"} + {e.quantity ?? "—"} + {extCost(e) ? `$${extCost(e).toFixed(2)}` : "—"} + - {m.sourcing_link ? Link : '—'} + {m.sourcing_link ? ( + + Link + + ) : ( + "—" + )} - - + +
Total:${totalCost.toFixed(2)}
+ Total: + + ${totalCost.toFixed(2)} +
+
+
{cols.map((col) => ( ))} @@ -139,10 +189,10 @@ export function ItemTable({ {sortedItems.map((item, idx) => { const isSelected = item.part_number === selectedPN; const rowBg = isSelected - ? 'var(--ctp-surface1)' + ? "var(--ctp-surface1)" : idx % 2 === 0 - ? 'var(--ctp-base)' - : 'var(--ctp-surface0)'; + ? "var(--ctp-base)" + : "var(--ctp-surface0)"; return ( onSelect(item.part_number)} style={{ backgroundColor: rowBg, - cursor: 'pointer', - borderBottom: '1px solid var(--ctp-surface0)', + cursor: "pointer", + borderBottom: "1px solid var(--ctp-surface0)", }} onMouseEnter={(e) => { - if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--ctp-surface0)'; + if (!isSelected) + e.currentTarget.style.backgroundColor = + "var(--ctp-surface0)"; }} onMouseLeave={(e) => { - if (!isSelected) e.currentTarget.style.backgroundColor = rowBg; + if (!isSelected) + e.currentTarget.style.backgroundColor = rowBg; }} > {cols.map((col) => { switch (col.key) { - case 'part_number': + case "part_number": return ( ); - case 'item_type': { - const tc = typeColors[item.item_type] ?? { bg: 'var(--ctp-surface1)', color: 'var(--ctp-text)' }; + case "item_type": { + const tc = typeColors[item.item_type] ?? { + bg: "var(--ctp-surface1)", + color: "var(--ctp-text)", + }; return ( ); } - case 'description': - return ; - case 'revision': - return ; - case 'projects': - return ; - case 'created': - return ; - case 'actions': + case "description": + return ( + + ); + case "revision": + return ( + + ); + case "projects": + return ( + + ); + case "created": + return ( + + ); + case "actions": return ( - @@ -239,12 +338,14 @@ export function ItemTable({ x={ctxMenu.x} y={ctxMenu.y} onClose={() => setCtxMenu(null)} - items={ALL_COLUMNS.map((col): ContextMenuItem => ({ - label: col.label, - checked: visibleColumns.includes(col.key), - onToggle: () => toggleColumn(col.key), - disabled: col.key === 'part_number', - }))} + items={ALL_COLUMNS.map( + (col): ContextMenuItem => ({ + label: col.label, + checked: visibleColumns.includes(col.key), + onToggle: () => toggleColumn(col.key), + disabled: col.key === "part_number", + }), + )} /> )} @@ -252,11 +353,11 @@ export function ItemTable({ } const actionBtnStyle: React.CSSProperties = { - background: 'none', - border: 'none', - color: 'var(--ctp-subtext1)', - cursor: 'pointer', - fontSize: '0.8rem', - padding: '0.15rem 0.4rem', - borderRadius: '0.25rem', + background: "none", + border: "none", + color: "var(--ctp-subtext1)", + cursor: "pointer", + fontSize: "0.8rem", + padding: "0.15rem 0.4rem", + borderRadius: "0.25rem", }; diff --git a/web/src/components/items/ItemsToolbar.tsx b/web/src/components/items/ItemsToolbar.tsx index 7bef3c8..d385339 100644 --- a/web/src/components/items/ItemsToolbar.tsx +++ b/web/src/components/items/ItemsToolbar.tsx @@ -1,13 +1,13 @@ -import { useEffect, useState } from 'react'; -import { get } from '../../api/client'; -import type { Project } from '../../api/types'; -import type { ItemFilters } from '../../hooks/useItems'; +import { useEffect, useState } from "react"; +import { get } from "../../api/client"; +import type { Project } from "../../api/types"; +import type { ItemFilters } from "../../hooks/useItems"; interface ItemsToolbarProps { filters: ItemFilters; onFilterChange: (partial: Partial) => void; - layout: 'horizontal' | 'vertical'; - onLayoutChange: (layout: 'horizontal' | 'vertical') => void; + layout: "horizontal" | "vertical"; + onLayoutChange: (layout: "horizontal" | "vertical") => void; onExport: () => void; onImport: () => void; onCreate: () => void; @@ -15,26 +15,40 @@ interface ItemsToolbarProps { } export function ItemsToolbar({ - filters, onFilterChange, layout, onLayoutChange, - onExport, onImport, onCreate, isEditor, + filters, + onFilterChange, + layout, + onLayoutChange, + onExport, + onImport, + onCreate, + isEditor, }: ItemsToolbarProps) { const [projects, setProjects] = useState([]); useEffect(() => { - get('/api/projects').then(setProjects).catch(() => {}); + get("/api/projects") + .then(setProjects) + .catch(() => {}); }, []); - const scopeBtn = (scope: ItemFilters['searchScope'], label: string) => ( + const scopeBtn = (scope: ItemFilters["searchScope"], label: string) => ( {/* Export */} - + {/* Import (editor only) */} {isEditor && ( - + )} {/* Create (editor only) */} {isEditor && ( - )} @@ -133,20 +161,20 @@ export function ItemsToolbar({ } const selectStyle: React.CSSProperties = { - padding: '0.4rem 0.6rem', - backgroundColor: 'var(--ctp-surface0)', - border: '1px solid var(--ctp-surface1)', - borderRadius: '0.4rem', - color: 'var(--ctp-text)', - fontSize: '0.85rem', + padding: "var(--d-input-py) var(--d-input-px)", + backgroundColor: "var(--ctp-surface0)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.4rem", + color: "var(--ctp-text)", + fontSize: "var(--d-input-font)", }; const toolBtnStyle: React.CSSProperties = { - padding: '0.4rem 0.75rem', - backgroundColor: 'var(--ctp-surface1)', - border: 'none', - borderRadius: '0.4rem', - color: 'var(--ctp-text)', - fontSize: '0.85rem', - cursor: 'pointer', + padding: "var(--d-input-py) var(--d-input-px)", + backgroundColor: "var(--ctp-surface1)", + border: "none", + borderRadius: "0.4rem", + color: "var(--ctp-text)", + fontSize: "var(--d-input-font)", + cursor: "pointer", }; diff --git a/web/src/hooks/useDensity.ts b/web/src/hooks/useDensity.ts new file mode 100644 index 0000000..3c3a73d --- /dev/null +++ b/web/src/hooks/useDensity.ts @@ -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('silo-density', 'comfortable'); + + applyDensity(density); + + const toggle = useCallback(() => { + const next: Density = density === 'comfortable' ? 'compact' : 'comfortable'; + setDensity(next); + applyDensity(next); + }, [density, setDensity]); + + return [density, toggle]; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 5d5efd1..a497b34 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,11 +1,20 @@ -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import { AuthProvider } from './context/AuthContext'; -import { App } from './App'; -import './styles/global.css'; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import { App } from "./App"; +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( diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index eb98472..dc70813 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -6,6 +6,7 @@ import { AuditToolbar } from "../components/audit/AuditToolbar"; import { AuditTable } from "../components/audit/AuditTable"; import { AuditDetailPanel } from "../components/audit/AuditDetailPanel"; import { SplitPanel } from "../components/items/SplitPanel"; +import { PageFooter } from "../components/PageFooter"; type PaneMode = { type: "none" } | { type: "detail"; partNumber: string }; @@ -47,8 +48,8 @@ export function AuditPage() { style={{ display: "flex", flexDirection: "column", - height: "calc(100vh - 64px)", - paddingBottom: 28, + height: "100%", + paddingBottom: "var(--d-footer-h)", }} > {error && ( @@ -91,45 +92,18 @@ export function AuditPage() { storageKey="silo-audit-split" /> - {/* Pagination */} -
- - - Page {filters.page} · {items.length} items - - -
+ + {summary.total_items} items + Avg: {(summary.avg_score * 100).toFixed(1)}% + + } + page={filters.page} + pageSize={filters.pageSize} + itemCount={items.length} + onPageChange={(p) => updateFilters({ page: p })} + /> ); } - -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", -}; diff --git a/web/src/pages/ItemsPage.tsx b/web/src/pages/ItemsPage.tsx index 4f77ab5..2c4a8da 100644 --- a/web/src/pages/ItemsPage.tsx +++ b/web/src/pages/ItemsPage.tsx @@ -14,7 +14,7 @@ import { EditItemPane } from "../components/items/EditItemPane"; import { DeleteItemPane } from "../components/items/DeleteItemPane"; import { ImportItemsPane } from "../components/items/ImportItemsPane"; import { SplitPanel } from "../components/items/SplitPanel"; -import { FooterStats } from "../components/items/FooterStats"; +import { PageFooter } from "../components/PageFooter"; type PaneMode = | { type: "none" } @@ -170,8 +170,8 @@ export function ItemsPage() { style={{ display: "flex", flexDirection: "column", - height: "calc(100vh - 64px)", - paddingBottom: 28, + height: "100%", + paddingBottom: "var(--d-footer-h)", }} > {error && ( @@ -217,47 +217,40 @@ export function ItemsPage() { secondary={secondaryPane} /> - {/* Pagination */} -
- - - Page {filters.page} · {items.length} items - - -
- - + + + Total:{" "} + + {items.length} + + + + Parts:{" "} + + {items.filter((i) => i.item_type === "part").length} + + + + Assemblies:{" "} + + {items.filter((i) => i.item_type === "assembly").length} + + + + Documents:{" "} + + {items.filter((i) => i.item_type === "document").length} + + + + } + page={filters.page} + pageSize={filters.pageSize} + itemCount={items.length} + onPageChange={(p) => updateFilters({ page: p })} + /> ); } - -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", -}; diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css index 153240c..7b209fc 100644 --- a/web/src/styles/theme.css +++ b/web/src/styles/theme.css @@ -1,29 +1,92 @@ /* Catppuccin Mocha Theme */ :root { - --ctp-rosewater: #f5e0dc; - --ctp-flamingo: #f2cdcd; - --ctp-pink: #f5c2e7; - --ctp-mauve: #cba6f7; - --ctp-red: #f38ba8; - --ctp-maroon: #eba0ac; - --ctp-peach: #fab387; - --ctp-yellow: #f9e2af; - --ctp-green: #a6e3a1; - --ctp-teal: #94e2d5; - --ctp-sky: #89dceb; - --ctp-sapphire: #74c7ec; - --ctp-blue: #89b4fa; - --ctp-lavender: #b4befe; - --ctp-text: #cdd6f4; - --ctp-subtext1: #bac2de; - --ctp-subtext0: #a6adc8; - --ctp-overlay2: #9399b2; - --ctp-overlay1: #7f849c; - --ctp-overlay0: #6c7086; - --ctp-surface2: #585b70; - --ctp-surface1: #45475a; - --ctp-surface0: #313244; - --ctp-base: #1e1e2e; - --ctp-mantle: #181825; - --ctp-crust: #11111b; + --ctp-rosewater: #f5e0dc; + --ctp-flamingo: #f2cdcd; + --ctp-pink: #f5c2e7; + --ctp-mauve: #cba6f7; + --ctp-red: #f38ba8; + --ctp-maroon: #eba0ac; + --ctp-peach: #fab387; + --ctp-yellow: #f9e2af; + --ctp-green: #a6e3a1; + --ctp-teal: #94e2d5; + --ctp-sky: #89dceb; + --ctp-sapphire: #74c7ec; + --ctp-blue: #89b4fa; + --ctp-lavender: #b4befe; + --ctp-text: #cdd6f4; + --ctp-subtext1: #bac2de; + --ctp-subtext0: #a6adc8; + --ctp-overlay2: #9399b2; + --ctp-overlay1: #7f849c; + --ctp-overlay0: #6c7086; + --ctp-surface2: #585b70; + --ctp-surface1: #45475a; + --ctp-surface0: #313244; + --ctp-base: #1e1e2e; + --ctp-mantle: #181825; + --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; }
col.key !== 'actions' && onSort(col.key)} + onClick={() => col.key !== "actions" && onSort(col.key)} > {col.label} {sortKey === col.key && ( - {sortDir === 'asc' ? '▲' : '▼'} + + {sortDir === "asc" ? "▲" : "▼"} + )}
{ e.stopPropagation(); copyPN(item.part_number); }} + onClick={(e) => { + e.stopPropagation(); + copyPN(item.part_number); + }} title="Click to copy" style={{ fontFamily: "'JetBrains Mono', monospace", - color: 'var(--ctp-peach)', - cursor: 'copy', + color: "var(--ctp-peach)", + cursor: "copy", }} > {item.part_number} - + {item.item_type} {item.description}Rev {item.current_revision}{formatDate(item.created_at)} + {item.description} + + Rev {item.current_revision} + + — + + {formatDate(item.created_at)} + @@ -226,7 +318,14 @@ export function ItemTable({ })} {sortedItems.length === 0 && (
+ No items found