Merge branch 'main' into issue-45-bom-merge

This commit is contained in:
2026-02-09 01:21:38 +00:00
19 changed files with 713 additions and 665 deletions

View File

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

0
docs/BOM_MERGE.md Normal file
View File

View File

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

View File

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

View File

@@ -1,283 +0,0 @@
# Repository Status Report
**Generated:** 2026-01-31
**Branch:** main
**Last Build:** `go build ./...` and `go vet ./...` pass clean
---
## Codebase Summary
| Category | Lines | Files |
|----------|-------|-------|
| Go source | ~6,644 | 20 |
| HTML templates | ~4,923 | 4 |
| SQL migrations | ~464 | 8 |
| **Total** | **~12,231** | **32** |
---
## Architecture
```
cmd/
silo/ CLI client (313 lines)
silod/ API server (126 lines)
internal/
api/ HTTP handlers, routes, middleware, templates (3,491 Go + 4,923 HTML)
config/ YAML config loading (132 lines)
db/ PostgreSQL repositories (1,634 lines)
migration/ Property migration framework (211 lines)
odoo/ Odoo ERP integration stubs (201 lines)
partnum/ Part number generator (180 lines)
schema/ YAML schema parser (235 lines)
storage/ MinIO S3 client (121 lines)
migrations/ Database DDL (8 files)
```
### Key Dependencies
- `go-chi/chi/v5` -- HTTP router
- `jackc/pgx/v5` -- PostgreSQL driver
- `minio/minio-go/v7` -- S3-compatible storage
- `rs/zerolog` -- Structured logging
- `sahilm/fuzzy` -- Fuzzy text matching
- `gopkg.in/yaml.v3` -- YAML parsing
---
## API Surface (38 Routes)
### Web UI
| Method | Path | Handler |
|--------|------|---------|
| GET | `/` | Items page |
| GET | `/projects` | Projects page |
| GET | `/schemas` | Schemas page |
### Items
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/items` | List with filtering and pagination |
| POST | `/api/items` | Create item |
| GET | `/api/items/search` | Fuzzy search |
| GET | `/api/items/export.csv` | CSV export |
| POST | `/api/items/import` | CSV import |
| GET | `/api/items/template.csv` | CSV template |
| GET | `/api/items/{partNumber}` | Get item |
| PUT | `/api/items/{partNumber}` | Update item |
| DELETE | `/api/items/{partNumber}` | Archive item |
### Revisions
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/items/{pn}/revisions` | List revisions |
| POST | `/api/items/{pn}/revisions` | Create revision |
| GET | `/api/items/{pn}/revisions/compare` | Compare two revisions |
| GET | `/api/items/{pn}/revisions/{rev}` | Get revision |
| PATCH | `/api/items/{pn}/revisions/{rev}` | Update status/label |
| POST | `/api/items/{pn}/revisions/{rev}/rollback` | Rollback |
### Files
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/items/{pn}/file` | Upload file |
| GET | `/api/items/{pn}/file` | Download latest |
| GET | `/api/items/{pn}/file/{rev}` | Download at revision |
### BOM
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/items/{pn}/bom` | List children |
| POST | `/api/items/{pn}/bom` | Add child |
| GET | `/api/items/{pn}/bom/expanded` | Multi-level BOM |
| GET | `/api/items/{pn}/bom/where-used` | Where-used lookup |
| GET | `/api/items/{pn}/bom/export.csv` | BOM CSV export |
| POST | `/api/items/{pn}/bom/import` | BOM CSV import |
| PUT | `/api/items/{pn}/bom/{child}` | Update quantity/ref |
| DELETE | `/api/items/{pn}/bom/{child}` | Remove child |
### Projects
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/projects` | List projects |
| POST | `/api/projects` | Create project |
| GET | `/api/projects/{code}` | Get project |
| PUT | `/api/projects/{code}` | Update project |
| DELETE | `/api/projects/{code}` | Delete project |
| GET | `/api/projects/{code}/items` | Items in project |
### Item-Project Associations
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/items/{pn}/projects` | Item's projects |
| POST | `/api/items/{pn}/projects` | Tag item to project |
| DELETE | `/api/items/{pn}/projects/{code}` | Remove tag |
### Schemas
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/schemas` | List schemas |
| GET | `/api/schemas/{name}` | Get schema |
| GET | `/api/schemas/{name}/properties` | Property definitions |
| POST | `/api/schemas/{name}/segments/{seg}/values` | Add enum value |
| PUT | `/api/schemas/{name}/segments/{seg}/values/{code}` | Update enum value |
| DELETE | `/api/schemas/{name}/segments/{seg}/values/{code}` | Delete enum value |
### Odoo Integration (Stubs)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/integrations/odoo/config` | Get config |
| PUT | `/api/integrations/odoo/config` | Update config |
| GET | `/api/integrations/odoo/sync-log` | Sync history |
| POST | `/api/integrations/odoo/test-connection` | Test connection |
| POST | `/api/integrations/odoo/sync/push/{pn}` | Push to Odoo |
| POST | `/api/integrations/odoo/sync/pull/{id}` | Pull from Odoo |
### Other
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | Health probe |
| GET | `/ready` | Readiness probe (DB + MinIO) |
| POST | `/api/generate-part-number` | Generate next PN |
---
## Database Migrations
| # | File | Purpose |
|---|------|---------|
| 1 | `001_initial.sql` | Core schema: items, revisions, relationships, locations, inventory, sequences, projects, schemas |
| 2 | `002_sequence_by_name.sql` | Sequence naming changes |
| 3 | `003_remove_material.sql` | Schema cleanup |
| 4 | `004_cad_sync_state.sql` | CAD synchronization tracking |
| 5 | `005_property_schema_version.sql` | Property versioning framework |
| 6 | `006_project_tags.sql` | Many-to-many project-item relationships |
| 7 | `007_revision_status.sql` | Revision status and labels |
| 8 | `008_odoo_integration.sql` | Integrations + sync_log tables |
---
## Web UI Architecture
The web UI uses server-rendered Go templates with vanilla JavaScript (no framework).
### Items Page (`items.html`, 3718 lines)
Infor CloudSuite-style split-panel layout:
- **Horizontal mode** (default): item list on left, tabbed detail panel on right
- **Vertical mode**: tabbed detail panel on top, item list below
- **Detail tabs**: Main, Properties, Revisions, BOM, Where Used
- **Ctrl+F** opens in-page filter overlay with fuzzy search
- **Column config**: gear icon popover, separate settings per layout mode
- **Search scope**: All / Part Number / Description toggle pills
### Projects Page (`projects.html`, 345 lines)
- Full CRUD for project codes
- Item count per project
- Click project code to filter items page
### Schemas Page (`schemas.html`, 689 lines)
- Schema browsing and enum value management
### Base Template (`base.html`, 171 lines)
- 3-tab navigation: Items, Projects, Schemas
- Catppuccin Mocha dark theme
---
## Feature Stubs (Not Yet Implemented)
These are scaffolded but contain placeholder implementations.
### Odoo ERP Integration
All functions in `internal/odoo/` return "not yet implemented" errors:
| File | Function | Status |
|------|----------|--------|
| `client.go:30` | `Authenticate()` | Stub |
| `client.go:41` | `SearchRead()` | Stub |
| `client.go:51` | `Create()` | Stub |
| `client.go:60` | `Write()` | Stub |
| `client.go:70` | `TestConnection()` | Stub |
| `sync.go:27` | `PushItem()` | Stub (logs, returns nil) |
| `sync.go:36` | `PullProduct()` | Stub (logs, returns nil) |
API handlers at `odoo_handlers.go`:
- `HandleTestOdooConnection` (line 134) -- returns stub message
- `HandleOdooPush` (line 149) -- returns stub message
- `HandleOdooPull` (line 167) -- returns stub message
Config and sync-log CRUD handlers are functional.
### Part Number Date Segments
`internal/partnum/generator.go:102` -- `formatDate()` returns error. Date-based segments in schemas will fail at generation time.
### Location and Inventory APIs
Tables exist from migration 001 (`locations`, `inventory`) but no API handlers or repository methods are implemented. The database layer has no `LocationRepository` or `InventoryRepository`.
---
## Potential Issues
### Critical
1. **No authentication or authorization.** All API endpoints are publicly accessible. Single-user only. Adding LDAP/FreeIPA integration is required before multi-user deployment.
2. **No file locking.** Concurrent edits to the same item or file upload can cause data races. A pessimistic locking mechanism is needed for CAD file workflows.
3. **No unit tests.** Zero test coverage across the entire Go codebase. Regressions cannot be caught automatically.
### High
4. **Large monolithic template.** `items.html` is 3,718 lines with inline CSS and JavaScript. Changes risk unintended side effects. Consider extracting JavaScript into separate files or adopting a build step.
5. **No input validation middleware.** API handlers validate some fields inline but there is no systematic validation layer. Malformed requests may produce unclear errors or unexpected behavior.
6. **No rate limiting.** API has no request rate controls. A misbehaving client or script could overwhelm the server.
7. **Odoo handlers reference DB repositories not wired up.** `odoo_handlers.go` calls `s.db.IntegrationRepository()` but the `DB` struct in `db/db.go` does not expose an `IntegrationRepository` method. These handlers will panic if reached with a real database operation. Currently safe because config is stored in-memory and stubs short-circuit before DB calls.
### Medium
8. **No pagination on fuzzy search.** `HandleFuzzySearch` loads all items matching type/project filters into memory before fuzzy matching. Large datasets will cause high memory usage.
9. **CSV import lacks transaction rollback on partial failure.** If import fails mid-batch, already-imported items remain.
10. **No CSRF protection.** Web UI forms submit via `fetch()` but there are no CSRF tokens. Acceptable for single-user but a risk if authentication is added.
11. **MinIO connection not validated at startup.** The `/ready` endpoint checks MinIO, but the server starts regardless. A misconfigured MinIO will only fail on file operations.
12. **Property migration framework exists but has no registered migrations.** `internal/migration/properties.go` defines the framework but no concrete migrations use it yet.
---
## Recent Git History
```
8e44ed2 2026-01-29 Fix SIGSEGV: defer document open after dialog close
e2b3f12 2026-01-29 Fix API URL: only auto-append /api for bare hostnames
559f615 2026-01-29 Fix API URL handling and SSL certificate verification
f08ecc1 2026-01-29 feat(workbench): fix icon loading and add settings dialog
53b5edb 2026-01-29 update documentation and specs
5ee88a6 2026-01-26 update deploy.sh
93add05 2026-01-26 improve csv import handling
2d44b2a 2026-01-26 add free-ipa setup
```
---
## Deployment
### Supported Methods
- **Docker Compose** -- `deployments/docker-compose.yaml` (dev), `docker-compose.prod.yaml` (prod)
- **systemd** -- `deployments/systemd/silod.service`
- **Manual** -- `go build ./cmd/silod && ./silod -config config.yaml`
### Infrastructure Requirements
- PostgreSQL 15+ with `pg_trgm` and `uuid-ossp` extensions
- MinIO or S3-compatible storage
- Go 1.23+ for building

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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