Merge branch 'main' into issue-44-bom-source
This commit is contained in:
@@ -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
0
docs/BOM_MERGE.md
Normal 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
69
web/src/components/PageFooter.tsx
Normal file
69
web/src/components/PageFooter.tsx
Normal 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',
|
||||||
|
};
|
||||||
@@ -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)",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|||||||
22
web/src/hooks/useDensity.ts
Normal file
22
web/src/hooks/useDensity.ts
Normal 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];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user