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"
port: 8080
base_url: "http://localhost:8080"
# read_only: false # Reject all write operations; toggle at runtime with SIGUSR1
database:
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
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

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.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
```
---

View File

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

View File

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

View File

@@ -599,7 +599,7 @@ func (s *Server) HandleExportBOMODS(w http.ResponseWriter, r *http.Request) {
}
}
source := getMetaString(e.Metadata, "source")
source := e.Source
if source == "" && childItem != nil {
st := childItem.SourcingType
if st == "manufactured" {
@@ -754,7 +754,7 @@ func (s *Server) HandleProjectSheetODS(w http.ResponseWriter, r *http.Request) {
if e.Quantity != nil {
qty = *e.Quantity
}
source := getMetaString(e.Metadata, "source")
source := e.Source
if source == "" && childItem != nil {
if childItem.SourcingType == "manufactured" {
source = "M"

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, React.CSSProperties> = {
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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
height: "100vh",
}}
>
<div className="spinner" />
</div>
);
}
return (
<>
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
<header
style={{
backgroundColor: 'var(--ctp-mantle)',
borderBottom: '1px solid var(--ctp-surface0)',
padding: '1rem 2rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: "var(--ctp-mantle)",
borderBottom: "1px solid var(--ctp-surface0)",
padding: "var(--d-header-py) var(--d-header-px)",
display: "flex",
alignItems: "center",
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) => (
<NavLink
key={link.to}
to={link.to}
end={link.to === '/'}
end={link.to === "/"}
style={({ isActive }) => ({
color: isActive ? 'var(--ctp-mauve)' : 'var(--ctp-subtext1)',
backgroundColor: isActive ? 'var(--ctp-surface1)' : 'transparent',
color: isActive ? "var(--ctp-mauve)" : "var(--ctp-subtext1)",
backgroundColor: isActive
? "var(--ctp-surface1)"
: "transparent",
fontWeight: 500,
padding: '0.5rem 1rem',
borderRadius: '0.5rem',
textDecoration: 'none',
transition: 'all 0.2s',
padding: "var(--d-nav-py) var(--d-nav-px)",
borderRadius: "var(--d-nav-radius)",
textDecoration: "none",
transition: "all 0.2s",
})}
>
{link.label}
@@ -62,32 +82,60 @@ export function AppShell() {
</nav>
{user && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ color: 'var(--ctp-subtext1)', fontSize: '0.9rem' }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "var(--d-user-gap)",
}}
>
<span
style={{
color: "var(--ctp-subtext1)",
fontSize: "var(--d-user-font)",
}}
>
{user.display_name}
</span>
<span
style={{
display: 'inline-block',
padding: '0.15rem 0.5rem',
borderRadius: '1rem',
fontSize: '0.75rem',
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 600,
...roleBadgeStyle[user.role],
}}
>
{user.role}
</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
onClick={logout}
style={{
padding: '0.35rem 0.75rem',
fontSize: '0.8rem',
borderRadius: '0.4rem',
cursor: 'pointer',
border: 'none',
background: 'var(--ctp-surface1)',
color: 'var(--ctp-subtext1)',
padding: "0.35rem 0.75rem",
fontSize: "0.8rem",
borderRadius: "0.4rem",
cursor: "pointer",
border: "none",
background: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
}}
>
Logout
@@ -96,9 +144,11 @@ export function AppShell() {
)}
</header>
<main style={{ padding: '1rem 1rem 0 1rem' }}>
<main
style={{ flex: 1, padding: "1rem 1rem 0 1rem", overflow: "hidden" }}
>
<Outlet />
</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) {
if (loading) {
return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}>
<div
style={{
padding: "2rem",
color: "var(--ctp-subtext0)",
textAlign: "center",
}}
>
Loading audit data...
</div>
);
@@ -31,7 +37,13 @@ export function AuditTable({
if (items.length === 0) {
return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}>
<div
style={{
padding: "2rem",
color: "var(--ctp-subtext0)",
textAlign: "center",
}}
>
No items found
</div>
);
@@ -39,16 +51,27 @@ export function AuditTable({
return (
<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>
<tr>
{["Score", "Part Number", "Description", "Category", "Sourcing", "Missing"].map(
(h) => (
{[
"Score",
"Part Number",
"Description",
"Category",
"Sourcing",
"Missing",
].map((h) => (
<th key={h} style={thStyle}>
{h}
</th>
),
)}
))}
</tr>
</thead>
<tbody>
@@ -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}
</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}
</td>
<td style={tdStyle}>{item.category_name || item.category}</td>
@@ -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)",
};

View File

@@ -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)",
}}
>
<select
@@ -95,8 +95,8 @@ export function AuditToolbar({
}
const selectStyle: React.CSSProperties = {
padding: "0.35rem 0.5rem",
fontSize: "0.8rem",
padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "var(--d-input-font)",
borderRadius: "0.4rem",
border: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-surface0)",
@@ -104,8 +104,8 @@ const selectStyle: React.CSSProperties = {
};
const btnStyle: React.CSSProperties = {
padding: "0.35rem 0.6rem",
fontSize: "0.8rem",
padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "var(--d-input-font)",
borderRadius: "0.4rem",
border: "none",
backgroundColor: "var(--ctp-surface1)",

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react';
import { get, post, put, del } from '../../api/client';
import type { BOMEntry } from '../../api/types';
import { useState, useEffect, useCallback } from "react";
import { get, post, put, del } from "../../api/client";
import type { BOMEntry } from "../../api/types";
interface BOMTabProps {
partNumber: string;
@@ -16,7 +16,14 @@ interface BOMFormData {
sourcing_link: string;
}
const emptyForm: BOMFormData = { child_part_number: '', quantity: '1', source: '', seller_description: '', unit_cost: '', sourcing_link: '' };
const emptyForm: BOMFormData = {
child_part_number: "",
quantity: "1",
source: "",
seller_description: "",
unit_cost: "",
sourcing_link: "",
};
export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const [entries, setEntries] = useState<BOMEntry[]>([]);
@@ -42,10 +49,10 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const formToRequest = () => ({
child_part_number: form.child_part_number,
rel_type: 'component' as const,
rel_type: "component" as const,
quantity: Number(form.quantity) || 1,
metadata: {
source: form.source,
metadata: {
seller_description: form.seller_description,
unit_cost: form.unit_cost,
sourcing_link: form.sourcing_link,
@@ -54,34 +61,42 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
const handleAdd = async () => {
try {
await post(`/api/items/${encodeURIComponent(partNumber)}/bom`, formToRequest());
await post(
`/api/items/${encodeURIComponent(partNumber)}/bom`,
formToRequest(),
);
setShowAdd(false);
setForm(emptyForm);
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to add BOM entry');
alert(e instanceof Error ? e.message : "Failed to add BOM entry");
}
};
const handleEdit = async (childPN: string) => {
try {
const { child_part_number: _, ...req } = formToRequest();
await put(`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`, req);
await put(
`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`,
req,
);
setEditIdx(null);
setForm(emptyForm);
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to update BOM entry');
alert(e instanceof Error ? e.message : "Failed to update BOM entry");
}
};
const handleDelete = async (childPN: string) => {
if (!confirm(`Remove ${childPN} from BOM?`)) return;
try {
await del(`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`);
await del(
`/api/items/${encodeURIComponent(partNumber)}/bom/${encodeURIComponent(childPN)}`,
);
load();
} catch (e) {
alert(e instanceof Error ? e.message : 'Failed to delete BOM entry');
alert(e instanceof Error ? e.message : "Failed to delete BOM entry");
}
};
@@ -91,71 +106,155 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
setForm({
child_part_number: e.child_part_number,
quantity: String(e.quantity ?? 1),
source: m.source ?? '',
seller_description: m.seller_description ?? '',
unit_cost: m.unit_cost ?? '',
sourcing_link: m.sourcing_link ?? '',
source: e.source ?? "",
seller_description: m.seller_description ?? "",
unit_cost: m.unit_cost ?? "",
sourcing_link: m.sourcing_link ?? "",
});
setEditIdx(idx);
setShowAdd(false);
};
const inputStyle: React.CSSProperties = {
padding: '0.2rem 0.4rem', fontSize: '0.8rem',
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem', color: 'var(--ctp-text)', width: '100%',
padding: "0.2rem 0.4rem",
fontSize: "0.8rem",
backgroundColor: "var(--ctp-base)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.3rem",
color: "var(--ctp-text)",
width: "100%",
};
const formRow = (isEditing: boolean, childPN?: string) => (
<tr style={{ backgroundColor: 'var(--ctp-surface0)' }}>
<tr style={{ backgroundColor: "var(--ctp-surface0)" }}>
<td style={tdStyle}>
<input value={form.child_part_number} onChange={(e) => setForm({ ...form, child_part_number: e.target.value })}
disabled={isEditing} placeholder="Part number" style={inputStyle} />
<input
value={form.child_part_number}
onChange={(e) =>
setForm({ ...form, child_part_number: e.target.value })
}
disabled={isEditing}
placeholder="Part number"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<input value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })} placeholder="Source" style={inputStyle} />
<input
value={form.source}
onChange={(e) => setForm({ ...form, source: e.target.value })}
placeholder="Source"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<input value={form.seller_description} onChange={(e) => setForm({ ...form, seller_description: e.target.value })} placeholder="Description" style={inputStyle} />
<input
value={form.seller_description}
onChange={(e) =>
setForm({ ...form, seller_description: e.target.value })
}
placeholder="Description"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<input value={form.unit_cost} onChange={(e) => setForm({ ...form, unit_cost: e.target.value })} type="number" step="0.01" placeholder="0.00" style={inputStyle} />
<input
value={form.unit_cost}
onChange={(e) => setForm({ ...form, unit_cost: e.target.value })}
type="number"
step="0.01"
placeholder="0.00"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<input value={form.quantity} onChange={(e) => setForm({ ...form, quantity: e.target.value })} type="number" step="1" placeholder="1" style={{ ...inputStyle, width: 50 }} />
<input
value={form.quantity}
onChange={(e) => setForm({ ...form, quantity: e.target.value })}
type="number"
step="1"
placeholder="1"
style={{ ...inputStyle, width: 50 }}
/>
</td>
<td style={tdStyle}></td>
<td style={tdStyle}>
<input value={form.sourcing_link} onChange={(e) => setForm({ ...form, sourcing_link: e.target.value })} placeholder="URL" style={inputStyle} />
<input
value={form.sourcing_link}
onChange={(e) => setForm({ ...form, sourcing_link: e.target.value })}
placeholder="URL"
style={inputStyle}
/>
</td>
<td style={tdStyle}>
<button onClick={() => isEditing ? void handleEdit(childPN!) : void handleAdd()} style={saveBtnStyle}>Save</button>
<button onClick={() => { isEditing ? setEditIdx(null) : setShowAdd(false); setForm(emptyForm); }} style={cancelBtnStyle}>Cancel</button>
<button
onClick={() =>
isEditing ? void handleEdit(childPN!) : void handleAdd()
}
style={saveBtnStyle}
>
Save
</button>
<button
onClick={() => {
isEditing ? setEditIdx(null) : setShowAdd(false);
setForm(emptyForm);
}}
style={cancelBtnStyle}
>
Cancel
</button>
</td>
</tr>
);
if (loading) return <div style={{ color: 'var(--ctp-subtext0)' }}>Loading BOM...</div>;
if (loading)
return <div style={{ color: "var(--ctp-subtext0)" }}>Loading BOM...</div>;
return (
<div>
{/* Toolbar */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.85rem', color: 'var(--ctp-subtext1)' }}>{entries.length} entries</span>
<div
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<span style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
{entries.length} entries
</span>
<span style={{ flex: 1 }} />
<button
onClick={() => { window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`; }}
onClick={() => {
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/bom/export.csv`;
}}
style={toolBtnStyle}
>
Export CSV
</button>
{isEditor && (
<button onClick={() => { setShowAdd(true); setEditIdx(null); setForm(emptyForm); }} style={toolBtnStyle}>+ Add</button>
<button
onClick={() => {
setShowAdd(true);
setEditIdx(null);
setForm(emptyForm);
}}
style={toolBtnStyle}
>
+ Add
</button>
)}
</div>
<div style={{ overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<div style={{ overflow: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "0.8rem",
}}
>
<thead>
<tr>
<th style={thStyle}>PN</th>
@@ -174,20 +273,71 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
if (editIdx === idx) return formRow(true, e.child_part_number);
const m = meta(e);
return (
<tr key={e.id} style={{ backgroundColor: idx % 2 === 0 ? 'var(--ctp-base)' : 'var(--ctp-surface0)' }}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>{e.child_part_number}</td>
<td style={tdStyle}>{m.source ?? ''}</td>
<td style={{ ...tdStyle, maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis' }}>{e.child_description || m.seller_description || ''}</td>
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : '—'}</td>
<td style={tdStyle}>{e.quantity ?? '—'}</td>
<td style={{ ...tdStyle, fontFamily: 'monospace' }}>{extCost(e) ? `$${extCost(e).toFixed(2)}` : '—'}</td>
<tr
key={e.id}
style={{
backgroundColor:
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
}}
>
<td
style={{
...tdStyle,
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
}}
>
{e.child_part_number}
</td>
<td style={tdStyle}>{e.source ?? ""}</td>
<td
style={{
...tdStyle,
maxWidth: 150,
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{e.child_description || m.seller_description || ""}
</td>
<td style={{ ...tdStyle, fontFamily: "monospace" }}>
{unitCost(e) ? `$${unitCost(e).toFixed(2)}` : "—"}
</td>
<td style={tdStyle}>{e.quantity ?? "—"}</td>
<td style={{ ...tdStyle, fontFamily: "monospace" }}>
{extCost(e) ? `$${extCost(e).toFixed(2)}` : "—"}
</td>
<td style={tdStyle}>
{m.sourcing_link ? <a href={m.sourcing_link} target="_blank" rel="noreferrer" style={{ color: 'var(--ctp-sapphire)', fontSize: '0.75rem' }}>Link</a> : '—'}
{m.sourcing_link ? (
<a
href={m.sourcing_link}
target="_blank"
rel="noreferrer"
style={{
color: "var(--ctp-sapphire)",
fontSize: "0.75rem",
}}
>
Link
</a>
) : (
"—"
)}
</td>
{isEditor && (
<td style={tdStyle}>
<button onClick={() => startEdit(idx)} style={actionBtnStyle}>Edit</button>
<button onClick={() => void handleDelete(e.child_part_number)} style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}>Del</button>
<button
onClick={() => startEdit(idx)}
style={actionBtnStyle}
>
Edit
</button>
<button
onClick={() => void handleDelete(e.child_part_number)}
style={{ ...actionBtnStyle, color: "var(--ctp-red)" }}
>
Del
</button>
</td>
)}
</tr>
@@ -196,9 +346,22 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
</tbody>
{totalCost > 0 && (
<tfoot>
<tr style={{ borderTop: '2px solid var(--ctp-surface1)' }}>
<td colSpan={5} style={{ ...tdStyle, textAlign: 'right', fontWeight: 600 }}>Total:</td>
<td style={{ ...tdStyle, fontFamily: 'monospace', fontWeight: 600 }}>${totalCost.toFixed(2)}</td>
<tr style={{ borderTop: "2px solid var(--ctp-surface1)" }}>
<td
colSpan={5}
style={{ ...tdStyle, textAlign: "right", fontWeight: 600 }}
>
Total:
</td>
<td
style={{
...tdStyle,
fontFamily: "monospace",
fontWeight: 600,
}}
>
${totalCost.toFixed(2)}
</td>
<td colSpan={isEditor ? 2 : 1} style={tdStyle} />
</tr>
</tfoot>
@@ -210,29 +373,59 @@ export function BOMTab({ partNumber, isEditor }: BOMTabProps) {
}
const thStyle: React.CSSProperties = {
padding: '0.3rem 0.5rem', textAlign: 'left', borderBottom: '1px solid var(--ctp-surface1)',
color: 'var(--ctp-subtext1)', fontWeight: 600, fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.05em', whiteSpace: 'nowrap',
padding: "0.3rem 0.5rem",
textAlign: "left",
borderBottom: "1px solid var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
fontWeight: 600,
fontSize: "0.7rem",
textTransform: "uppercase",
letterSpacing: "0.05em",
whiteSpace: "nowrap",
};
const tdStyle: React.CSSProperties = {
padding: '0.25rem 0.5rem', borderBottom: '1px solid var(--ctp-surface0)', whiteSpace: 'nowrap',
padding: "0.25rem 0.5rem",
borderBottom: "1px solid var(--ctp-surface0)",
whiteSpace: "nowrap",
};
const toolBtnStyle: React.CSSProperties = {
padding: '0.25rem 0.5rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-text)', cursor: 'pointer',
padding: "0.25rem 0.5rem",
fontSize: "0.8rem",
border: "none",
borderRadius: "0.3rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
cursor: "pointer",
};
const actionBtnStyle: React.CSSProperties = {
background: 'none', border: 'none', color: 'var(--ctp-subtext1)', cursor: 'pointer', fontSize: '0.75rem', padding: '0.1rem 0.3rem',
background: "none",
border: "none",
color: "var(--ctp-subtext1)",
cursor: "pointer",
fontSize: "0.75rem",
padding: "0.1rem 0.3rem",
};
const saveBtnStyle: React.CSSProperties = {
padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem',
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer', marginRight: '0.25rem',
padding: "0.2rem 0.4rem",
fontSize: "0.75rem",
border: "none",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-green)",
color: "var(--ctp-crust)",
cursor: "pointer",
marginRight: "0.25rem",
};
const cancelBtnStyle: React.CSSProperties = {
padding: '0.2rem 0.4rem', fontSize: '0.75rem', border: 'none', borderRadius: '0.25rem',
backgroundColor: 'var(--ctp-surface1)', color: 'var(--ctp-subtext1)', cursor: 'pointer',
padding: "0.2rem 0.4rem",
fontSize: "0.75rem",
border: "none",
borderRadius: "0.25rem",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-subtext1)",
cursor: "pointer",
};

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 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<string, { bg: string; color: string }> = {
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 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]);
},
[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 <div style={{ padding: '2rem', color: 'var(--ctp-subtext0)' }}>Loading items...</div>;
return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)" }}>
Loading items...
</div>
);
}
return (
<>
<div style={{ overflow: 'auto', height: '100%' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<div style={{ overflow: "auto", height: "100%" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead onContextMenu={handleHeaderContext}>
<tr>
{cols.map((col) => (
<th
key={col.key}
style={thStyle}
onClick={() => col.key !== 'actions' && onSort(col.key)}
onClick={() => col.key !== "actions" && onSort(col.key)}
>
{col.label}
{sortKey === col.key && (
<span style={{ marginLeft: 4 }}>{sortDir === 'asc' ? '▲' : '▼'}</span>
<span style={{ marginLeft: 4 }}>
{sortDir === "asc" ? "▲" : "▼"}
</span>
)}
</th>
))}
@@ -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 (
<tr
@@ -150,68 +200,110 @@ export function ItemTable({
onClick={() => 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 (
<td key={col.key} style={tdStyle}>
<span
onClick={(e) => { 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}
</span>
</td>
);
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 (
<td key={col.key} style={tdStyle}>
<span style={{
padding: '0.1rem 0.5rem', borderRadius: '1rem',
fontSize: '0.75rem', fontWeight: 500,
backgroundColor: tc.bg, color: tc.color,
}}>
<span
style={{
padding: "0.1rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 500,
backgroundColor: tc.bg,
color: tc.color,
}}
>
{item.item_type}
</span>
</td>
);
}
case 'description':
return <td key={col.key} style={{ ...tdStyle, maxWidth: 400 }}>{item.description}</td>;
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':
case "description":
return (
<td
key={col.key}
style={{ ...tdStyle, maxWidth: 400 }}
>
{item.description}
</td>
);
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 (
<td key={col.key} style={tdStyle}>
<button
onClick={(e) => { e.stopPropagation(); onEdit?.(item.part_number); }}
onClick={(e) => {
e.stopPropagation();
onEdit?.(item.part_number);
}}
style={actionBtnStyle}
>
Edit
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete?.(item.part_number); }}
style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}
onClick={(e) => {
e.stopPropagation();
onDelete?.(item.part_number);
}}
style={{
...actionBtnStyle,
color: "var(--ctp-red)",
}}
>
Del
</button>
@@ -226,7 +318,14 @@ export function ItemTable({
})}
{sortedItems.length === 0 && (
<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
</td>
</tr>
@@ -239,12 +338,14 @@ export function ItemTable({
x={ctxMenu.x}
y={ctxMenu.y}
onClose={() => setCtxMenu(null)}
items={ALL_COLUMNS.map((col): ContextMenuItem => ({
items={ALL_COLUMNS.map(
(col): ContextMenuItem => ({
label: col.label,
checked: visibleColumns.includes(col.key),
onToggle: () => toggleColumn(col.key),
disabled: col.key === 'part_number',
}))}
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",
};

View File

@@ -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<ItemFilters>) => 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<Project[]>([]);
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
onClick={() => onFilterChange({ searchScope: scope })}
style={{
padding: '0.3rem 0.6rem',
fontSize: '0.8rem',
border: 'none',
borderRadius: '0.3rem',
cursor: 'pointer',
backgroundColor: filters.searchScope === scope ? 'var(--ctp-mauve)' : 'var(--ctp-surface1)',
color: filters.searchScope === scope ? 'var(--ctp-crust)' : 'var(--ctp-subtext1)',
padding: "var(--d-input-py) var(--d-input-px)",
fontSize: "var(--d-input-font)",
border: "none",
borderRadius: "0.3rem",
cursor: "pointer",
backgroundColor:
filters.searchScope === scope
? "var(--ctp-mauve)"
: "var(--ctp-surface1)",
color:
filters.searchScope === scope
? "var(--ctp-crust)"
: "var(--ctp-subtext1)",
}}
>
{label}
@@ -42,15 +56,17 @@ export function ItemsToolbar({
);
return (
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.75rem',
alignItems: 'center',
padding: '0.75rem 0',
borderBottom: '1px solid var(--ctp-surface0)',
marginBottom: '0.5rem',
}}>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "var(--d-toolbar-gap)",
alignItems: "center",
padding: "var(--d-toolbar-py) 0",
borderBottom: "1px solid var(--ctp-surface0)",
marginBottom: "var(--d-toolbar-mb)",
}}
>
{/* Search */}
<input
type="text"
@@ -60,20 +76,20 @@ export function ItemsToolbar({
style={{
flex: 1,
minWidth: 200,
padding: '0.4rem 0.75rem',
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)",
}}
/>
{/* Search scope */}
<div style={{ display: 'flex', gap: '0.25rem' }}>
{scopeBtn('all', 'All')}
{scopeBtn('part_number', 'PN')}
{scopeBtn('description', 'Desc')}
<div style={{ display: "flex", gap: "0.25rem" }}>
{scopeBtn("all", "All")}
{scopeBtn("part_number", "PN")}
{scopeBtn("description", "Desc")}
</div>
{/* Type filter */}
@@ -97,34 +113,46 @@ export function ItemsToolbar({
>
<option value="">All Projects</option>
{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>
{/* Layout toggle */}
<button
onClick={() => onLayoutChange(layout === 'horizontal' ? 'vertical' : 'horizontal')}
title={`Switch to ${layout === 'horizontal' ? 'vertical' : 'horizontal'} layout`}
onClick={() =>
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
}
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
style={toolBtnStyle}
>
{layout === 'horizontal' ? '⬌' : '⬍'}
{layout === "horizontal" ? "⬌" : "⬍"}
</button>
{/* Export */}
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">Export</button>
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">
Export
</button>
{/* Import (editor only) */}
{isEditor && (
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">Import</button>
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">
Import
</button>
)}
{/* Create (editor only) */}
{isEditor && (
<button onClick={onCreate} style={{
<button
onClick={onCreate}
style={{
...toolBtnStyle,
backgroundColor: 'var(--ctp-mauve)',
color: 'var(--ctp-crust)',
}}>
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
}}
>
+ New
</button>
)}
@@ -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",
};

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 { 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(
<StrictMode>
<BrowserRouter>
<AuthProvider>

View File

@@ -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 */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "0.75rem",
padding: "0.4rem",
flexShrink: 0,
}}
>
<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>
<PageFooter
stats={
<>
<span>{summary.total_items} items</span>
<span>Avg: {(summary.avg_score * 100).toFixed(1)}%</span>
</>
}
page={filters.page}
pageSize={filters.pageSize}
itemCount={items.length}
onPageChange={(p) => updateFilters({ page: p })}
/>
</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 { 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 */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "0.75rem",
padding: "0.4rem",
flexShrink: 0,
}}
>
<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
<PageFooter
stats={
<>
<span>
Total:{" "}
<strong style={{ color: "var(--ctp-text)" }}>
{items.length}
</strong>
</span>
<button
onClick={() => updateFilters({ page: filters.page + 1 })}
disabled={items.length < filters.pageSize}
style={pageBtnStyle}
>
Next
</button>
</div>
<FooterStats items={items} />
<span>
Parts:{" "}
<strong style={{ color: "var(--ctp-blue)" }}>
{items.filter((i) => i.item_type === "part").length}
</strong>
</span>
<span>
Assemblies:{" "}
<strong style={{ color: "var(--ctp-green)" }}>
{items.filter((i) => i.item_type === "assembly").length}
</strong>
</span>
<span>
Documents:{" "}
<strong style={{ color: "var(--ctp-yellow)" }}>
{items.filter((i) => i.item_type === "document").length}
</strong>
</span>
</>
}
page={filters.page}
pageSize={filters.pageSize}
itemCount={items.length}
onPageChange={(p) => updateFilters({ page: p })}
/>
</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

@@ -27,3 +27,66 @@
--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;
}