Merge branch 'main' into issue-44-bom-source
This commit is contained in:
@@ -5,6 +5,7 @@ server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
base_url: "http://localhost:8080"
|
||||
# read_only: false # Reject all write operations; toggle at runtime with SIGUSR1
|
||||
|
||||
database:
|
||||
host: "psql.kindred.internal"
|
||||
|
||||
0
docs/BOM_MERGE.md
Normal file
0
docs/BOM_MERGE.md
Normal file
@@ -36,10 +36,10 @@ a blank field during a design review or procurement cycle.
|
||||
|
||||
## Design
|
||||
|
||||
The audit tool is a new page in the existing web UI (`/audit`), built with
|
||||
the same server-rendered Go templates + vanilla JS approach as the items and
|
||||
projects pages. It adds one new API endpoint for the completeness data and
|
||||
reuses existing endpoints for updates.
|
||||
The audit tool is a page in the web UI (`/audit`), built with the React SPA
|
||||
(same architecture as the items, projects, and schemas pages). It adds one
|
||||
new API endpoint for the completeness data and reuses existing endpoints for
|
||||
updates.
|
||||
|
||||
### Completeness Scoring
|
||||
|
||||
|
||||
@@ -30,12 +30,14 @@ YAML values support environment variable expansion using `${VAR_NAME}` syntax. E
|
||||
| `server.host` | string | `"0.0.0.0"` | Bind address |
|
||||
| `server.port` | int | `8080` | HTTP port |
|
||||
| `server.base_url` | string | — | External URL (e.g. `https://silo.example.com`). Used for OIDC callback URLs and session cookie domain. Required when OIDC is enabled. |
|
||||
| `server.read_only` | bool | `false` | Start in read-only mode. All write endpoints return 503. Can be toggled at runtime with `SIGUSR1`. |
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
port: 8080
|
||||
base_url: "https://silo.example.com"
|
||||
read_only: false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// HandleGetItemByUUID retrieves an item by its stable UUID (the items.id column).
|
||||
// Used by silo-mod to resolve FreeCAD document SiloUUID properties to part numbers.
|
||||
func (s *Server) HandleGetItemByUUID(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
uuid := chi.URLParam(r, "uuid")
|
||||
|
||||
item, err := s.items.GetByID(ctx, uuid)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("failed to get item by UUID")
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
|
||||
return
|
||||
}
|
||||
if item == nil || item.ArchivedAt != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Item not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, itemToResponse(item))
|
||||
}
|
||||
|
||||
// HandleGetItem retrieves an item by part number.
|
||||
// Supports query param: ?include=properties to include current revision properties.
|
||||
func (s *Server) HandleGetItem(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -119,6 +119,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
|
||||
r.Route("/items", func(r chi.Router) {
|
||||
r.Get("/", server.HandleListItems)
|
||||
r.Get("/search", server.HandleFuzzySearch)
|
||||
r.Get("/by-uuid/{uuid}", server.HandleGetItemByUUID)
|
||||
r.Get("/export.csv", server.HandleExportCSV)
|
||||
r.Get("/template.csv", server.HandleCSVTemplate)
|
||||
r.Get("/export.ods", server.HandleExportODS)
|
||||
|
||||
@@ -1,59 +1,79 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { useDensity } from "../hooks/useDensity";
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/', label: 'Items' },
|
||||
{ to: '/projects', label: 'Projects' },
|
||||
{ to: '/schemas', label: 'Schemas' },
|
||||
{ to: '/audit', label: 'Audit' },
|
||||
{ to: '/settings', label: 'Settings' },
|
||||
{ to: "/", label: "Items" },
|
||||
{ to: "/projects", label: "Projects" },
|
||||
{ to: "/schemas", label: "Schemas" },
|
||||
{ to: "/audit", label: "Audit" },
|
||||
{ to: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
const roleBadgeStyle: Record<string, React.CSSProperties> = {
|
||||
admin: { background: 'rgba(203,166,247,0.2)', color: 'var(--ctp-mauve)' },
|
||||
editor: { background: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' },
|
||||
viewer: { background: 'rgba(148,226,213,0.2)', color: 'var(--ctp-teal)' },
|
||||
admin: { background: "rgba(203,166,247,0.2)", color: "var(--ctp-mauve)" },
|
||||
editor: { background: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
|
||||
viewer: { background: "rgba(148,226,213,0.2)", color: "var(--ctp-teal)" },
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const { user, loading, logout } = useAuth();
|
||||
const [density, toggleDensity] = useDensity();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
|
||||
<header
|
||||
style={{
|
||||
backgroundColor: 'var(--ctp-mantle)',
|
||||
borderBottom: '1px solid var(--ctp-surface0)',
|
||||
padding: '1rem 2rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: "var(--ctp-mantle)",
|
||||
borderBottom: "1px solid var(--ctp-surface0)",
|
||||
padding: "var(--d-header-py) var(--d-header-px)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, color: 'var(--ctp-mauve)' }}>Silo</h1>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "var(--d-header-logo)",
|
||||
fontWeight: 600,
|
||||
color: "var(--ctp-mauve)",
|
||||
}}
|
||||
>
|
||||
Silo
|
||||
</h1>
|
||||
|
||||
<nav style={{ display: 'flex', gap: '1.5rem' }}>
|
||||
<nav style={{ display: "flex", gap: "var(--d-nav-gap)" }}>
|
||||
{navLinks.map((link) => (
|
||||
<NavLink
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
end={link.to === '/'}
|
||||
end={link.to === "/"}
|
||||
style={({ isActive }) => ({
|
||||
color: isActive ? 'var(--ctp-mauve)' : 'var(--ctp-subtext1)',
|
||||
backgroundColor: isActive ? 'var(--ctp-surface1)' : 'transparent',
|
||||
color: isActive ? "var(--ctp-mauve)" : "var(--ctp-subtext1)",
|
||||
backgroundColor: isActive
|
||||
? "var(--ctp-surface1)"
|
||||
: "transparent",
|
||||
fontWeight: 500,
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.5rem',
|
||||
textDecoration: 'none',
|
||||
transition: 'all 0.2s',
|
||||
padding: "var(--d-nav-py) var(--d-nav-px)",
|
||||
borderRadius: "var(--d-nav-radius)",
|
||||
textDecoration: "none",
|
||||
transition: "all 0.2s",
|
||||
})}
|
||||
>
|
||||
{link.label}
|
||||
@@ -62,32 +82,60 @@ export function AppShell() {
|
||||
</nav>
|
||||
|
||||
{user && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<span style={{ color: 'var(--ctp-subtext1)', fontSize: '0.9rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "var(--d-user-gap)",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "var(--d-user-font)",
|
||||
}}
|
||||
>
|
||||
{user.display_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.15rem 0.5rem',
|
||||
borderRadius: '1rem',
|
||||
fontSize: '0.75rem',
|
||||
display: "inline-block",
|
||||
padding: "0.15rem 0.5rem",
|
||||
borderRadius: "1rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
...roleBadgeStyle[user.role],
|
||||
}}
|
||||
>
|
||||
{user.role}
|
||||
</span>
|
||||
<button
|
||||
onClick={toggleDensity}
|
||||
title={`Switch to ${density === "comfortable" ? "compact" : "comfortable"} view`}
|
||||
style={{
|
||||
padding: "0.2rem 0.5rem",
|
||||
fontSize: "0.7rem",
|
||||
borderRadius: "0.3rem",
|
||||
cursor: "pointer",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
background: "var(--ctp-surface0)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{density === "comfortable" ? "COM" : "CMP"}
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
style={{
|
||||
padding: '0.35rem 0.75rem',
|
||||
fontSize: '0.8rem',
|
||||
borderRadius: '0.4rem',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: 'var(--ctp-surface1)',
|
||||
color: 'var(--ctp-subtext1)',
|
||||
padding: "0.35rem 0.75rem",
|
||||
fontSize: "0.8rem",
|
||||
borderRadius: "0.4rem",
|
||||
cursor: "pointer",
|
||||
border: "none",
|
||||
background: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
@@ -96,9 +144,11 @@ export function AppShell() {
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main style={{ padding: '1rem 1rem 0 1rem' }}>
|
||||
<main
|
||||
style={{ flex: 1, padding: "1rem 1rem 0 1rem", overflow: "hidden" }}
|
||||
>
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "2rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Loading audit data...
|
||||
</div>
|
||||
);
|
||||
@@ -31,7 +37,13 @@ export function AuditTable({
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "2rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
No items found
|
||||
</div>
|
||||
);
|
||||
@@ -39,16 +51,27 @@ export function AuditTable({
|
||||
|
||||
return (
|
||||
<div style={{ overflow: "auto", flex: 1 }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }}>
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
{["Score", "Part Number", "Description", "Category", "Sourcing", "Missing"].map(
|
||||
(h) => (
|
||||
<th key={h} style={thStyle}>
|
||||
{h}
|
||||
</th>
|
||||
),
|
||||
)}
|
||||
{[
|
||||
"Score",
|
||||
"Part Number",
|
||||
"Description",
|
||||
"Category",
|
||||
"Sourcing",
|
||||
"Missing",
|
||||
].map((h) => (
|
||||
<th key={h} style={thStyle}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -68,7 +91,8 @@ export function AuditTable({
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected)
|
||||
e.currentTarget.style.backgroundColor = "var(--ctp-surface0)";
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--ctp-surface0)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected)
|
||||
@@ -100,7 +124,15 @@ export function AuditTable({
|
||||
>
|
||||
{item.part_number}
|
||||
</td>
|
||||
<td style={{ ...tdStyle, maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
<td
|
||||
style={{
|
||||
...tdStyle,
|
||||
maxWidth: 300,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</td>
|
||||
<td style={tdStyle}>{item.category_name || item.category}</td>
|
||||
@@ -119,7 +151,8 @@ export function AuditTable({
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: "0.5rem 0.75rem",
|
||||
padding: "var(--d-th-py) var(--d-th-px)",
|
||||
fontSize: "var(--d-th-font)",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext0)",
|
||||
fontWeight: 500,
|
||||
@@ -130,7 +163,8 @@ const thStyle: React.CSSProperties = {
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: "0.4rem 0.75rem",
|
||||
padding: "var(--d-td-py) var(--d-td-px)",
|
||||
fontSize: "var(--d-td-font)",
|
||||
borderBottom: "1px solid var(--ctp-surface0)",
|
||||
color: "var(--ctp-text)",
|
||||
};
|
||||
|
||||
@@ -33,9 +33,9 @@ export function AuditToolbar({
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.5rem",
|
||||
gap: "var(--d-toolbar-gap)",
|
||||
alignItems: "center",
|
||||
marginBottom: "0.5rem",
|
||||
marginBottom: "var(--d-toolbar-mb)",
|
||||
}}
|
||||
>
|
||||
<select
|
||||
@@ -95,8 +95,8 @@ export function AuditToolbar({
|
||||
}
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.5rem",
|
||||
fontSize: "0.8rem",
|
||||
padding: "var(--d-input-py) var(--d-input-px)",
|
||||
fontSize: "var(--d-input-font)",
|
||||
borderRadius: "0.4rem",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
@@ -104,8 +104,8 @@ const selectStyle: React.CSSProperties = {
|
||||
};
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: "0.35rem 0.6rem",
|
||||
fontSize: "0.8rem",
|
||||
padding: "var(--d-input-py) var(--d-input-px)",
|
||||
fontSize: "var(--d-input-font)",
|
||||
borderRadius: "0.4rem",
|
||||
border: "none",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
|
||||
@@ -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 type { Item } from '../../api/types';
|
||||
import { ContextMenu, type ContextMenuItem } from '../ContextMenu';
|
||||
import { useState, useCallback } from "react";
|
||||
import type { Item } from "../../api/types";
|
||||
import { ContextMenu, type ContextMenuItem } from "../ContextMenu";
|
||||
|
||||
export interface ColumnDef {
|
||||
key: string;
|
||||
@@ -8,17 +8,29 @@ export interface ColumnDef {
|
||||
}
|
||||
|
||||
export const ALL_COLUMNS: ColumnDef[] = [
|
||||
{ key: 'part_number', label: 'Part Number' },
|
||||
{ key: 'item_type', label: 'Type' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'revision', label: 'Rev' },
|
||||
{ key: 'projects', label: 'Projects' },
|
||||
{ key: 'created', label: 'Created' },
|
||||
{ key: 'actions', label: 'Actions' },
|
||||
{ key: "part_number", label: "Part Number" },
|
||||
{ key: "item_type", label: "Type" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "revision", label: "Rev" },
|
||||
{ key: "projects", label: "Projects" },
|
||||
{ key: "created", label: "Created" },
|
||||
{ key: "actions", label: "Actions" },
|
||||
];
|
||||
|
||||
export const DEFAULT_COLUMNS_H = ['part_number', 'item_type', 'description', 'revision'];
|
||||
export const DEFAULT_COLUMNS_V = ['part_number', 'item_type', 'description', 'revision', 'created', 'actions'];
|
||||
export const DEFAULT_COLUMNS_H = [
|
||||
"part_number",
|
||||
"item_type",
|
||||
"description",
|
||||
"revision",
|
||||
];
|
||||
export const DEFAULT_COLUMNS_V = [
|
||||
"part_number",
|
||||
"item_type",
|
||||
"description",
|
||||
"revision",
|
||||
"created",
|
||||
"actions",
|
||||
];
|
||||
|
||||
interface ItemTableProps {
|
||||
items: Item[];
|
||||
@@ -30,21 +42,25 @@ interface ItemTableProps {
|
||||
onEdit?: (pn: string) => void;
|
||||
onDelete?: (pn: string) => void;
|
||||
sortKey: string;
|
||||
sortDir: 'asc' | 'desc';
|
||||
sortDir: "asc" | "desc";
|
||||
onSort: (key: string) => void;
|
||||
}
|
||||
|
||||
const typeColors: Record<string, { bg: string; color: string }> = {
|
||||
part: { bg: 'rgba(137,180,250,0.2)', color: 'var(--ctp-blue)' },
|
||||
assembly: { bg: 'rgba(166,227,161,0.2)', color: 'var(--ctp-green)' },
|
||||
document: { bg: 'rgba(249,226,175,0.2)', color: 'var(--ctp-yellow)' },
|
||||
tooling: { bg: 'rgba(243,139,168,0.2)', color: 'var(--ctp-red)' },
|
||||
part: { bg: "rgba(137,180,250,0.2)", color: "var(--ctp-blue)" },
|
||||
assembly: { bg: "rgba(166,227,161,0.2)", color: "var(--ctp-green)" },
|
||||
document: { bg: "rgba(249,226,175,0.2)", color: "var(--ctp-yellow)" },
|
||||
tooling: { bg: "rgba(243,139,168,0.2)", color: "var(--ctp-red)" },
|
||||
};
|
||||
|
||||
function formatDate(s: string) {
|
||||
if (!s) return '';
|
||||
if (!s) return "";
|
||||
const d = new Date(s);
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
return d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function copyPN(pn: string) {
|
||||
@@ -52,8 +68,17 @@ function copyPN(pn: string) {
|
||||
}
|
||||
|
||||
export function ItemTable({
|
||||
items, loading, selectedPN, onSelect, visibleColumns, onColumnsChange,
|
||||
onEdit, onDelete, sortKey, sortDir, onSort,
|
||||
items,
|
||||
loading,
|
||||
selectedPN,
|
||||
onSelect,
|
||||
visibleColumns,
|
||||
onColumnsChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSort,
|
||||
}: ItemTableProps) {
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
@@ -62,74 +87,99 @@ export function ItemTable({
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const toggleColumn = useCallback((key: string) => {
|
||||
if (key === 'part_number') return; // always visible
|
||||
const next = visibleColumns.includes(key)
|
||||
? visibleColumns.filter((c) => c !== key)
|
||||
: [...visibleColumns, key];
|
||||
if (next.length > 0) onColumnsChange(next);
|
||||
}, [visibleColumns, onColumnsChange]);
|
||||
const toggleColumn = useCallback(
|
||||
(key: string) => {
|
||||
if (key === "part_number") return; // always visible
|
||||
const next = visibleColumns.includes(key)
|
||||
? visibleColumns.filter((c) => c !== key)
|
||||
: [...visibleColumns, key];
|
||||
if (next.length > 0) onColumnsChange(next);
|
||||
},
|
||||
[visibleColumns, onColumnsChange],
|
||||
);
|
||||
|
||||
const cols = ALL_COLUMNS.filter((c) => visibleColumns.includes(c.key));
|
||||
|
||||
const sortedItems = [...items].sort((a, b) => {
|
||||
let av: string | number = '';
|
||||
let bv: string | number = '';
|
||||
let av: string | number = "";
|
||||
let bv: string | number = "";
|
||||
switch (sortKey) {
|
||||
case 'part_number': av = a.part_number; bv = b.part_number; break;
|
||||
case 'item_type': av = a.item_type; bv = b.item_type; break;
|
||||
case 'description': av = a.description; bv = b.description; break;
|
||||
case 'revision': av = a.current_revision; bv = b.current_revision; break;
|
||||
case 'created': av = a.created_at; bv = b.created_at; break;
|
||||
default: return 0;
|
||||
case "part_number":
|
||||
av = a.part_number;
|
||||
bv = b.part_number;
|
||||
break;
|
||||
case "item_type":
|
||||
av = a.item_type;
|
||||
bv = b.item_type;
|
||||
break;
|
||||
case "description":
|
||||
av = a.description;
|
||||
bv = b.description;
|
||||
break;
|
||||
case "revision":
|
||||
av = a.current_revision;
|
||||
bv = b.current_revision;
|
||||
break;
|
||||
case "created":
|
||||
av = a.created_at;
|
||||
bv = b.created_at;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
if (av < bv) return sortDir === 'asc' ? -1 : 1;
|
||||
if (av > bv) return sortDir === 'asc' ? 1 : -1;
|
||||
if (av < bv) return sortDir === "asc" ? -1 : 1;
|
||||
if (av > bv) return sortDir === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: '0.35rem 0.75rem',
|
||||
textAlign: 'left',
|
||||
borderBottom: '1px solid var(--ctp-surface1)',
|
||||
color: 'var(--ctp-subtext1)',
|
||||
padding: "var(--d-th-py) var(--d-th-px)",
|
||||
textAlign: "left",
|
||||
borderBottom: "1px solid var(--ctp-surface1)",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
fontSize: "var(--d-th-font)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
|
||||
const tdStyle: React.CSSProperties = {
|
||||
padding: '0.25rem 0.75rem',
|
||||
fontSize: '0.85rem',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
padding: "var(--d-td-py) var(--d-td-px)",
|
||||
fontSize: "var(--d-td-font)",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
maxWidth: 300,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ padding: '2rem', color: 'var(--ctp-subtext0)' }}>Loading items...</div>;
|
||||
return (
|
||||
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)" }}>
|
||||
Loading items...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ overflow: 'auto', height: '100%' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<div style={{ overflow: "auto", height: "100%" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead onContextMenu={handleHeaderContext}>
|
||||
<tr>
|
||||
{cols.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={thStyle}
|
||||
onClick={() => col.key !== 'actions' && onSort(col.key)}
|
||||
onClick={() => col.key !== "actions" && onSort(col.key)}
|
||||
>
|
||||
{col.label}
|
||||
{sortKey === col.key && (
|
||||
<span style={{ marginLeft: 4 }}>{sortDir === 'asc' ? '▲' : '▼'}</span>
|
||||
<span style={{ marginLeft: 4 }}>
|
||||
{sortDir === "asc" ? "▲" : "▼"}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
@@ -139,10 +189,10 @@ export function ItemTable({
|
||||
{sortedItems.map((item, idx) => {
|
||||
const isSelected = item.part_number === selectedPN;
|
||||
const rowBg = isSelected
|
||||
? 'var(--ctp-surface1)'
|
||||
? "var(--ctp-surface1)"
|
||||
: idx % 2 === 0
|
||||
? 'var(--ctp-base)'
|
||||
: 'var(--ctp-surface0)';
|
||||
? "var(--ctp-base)"
|
||||
: "var(--ctp-surface0)";
|
||||
|
||||
return (
|
||||
<tr
|
||||
@@ -150,68 +200,110 @@ export function ItemTable({
|
||||
onClick={() => onSelect(item.part_number)}
|
||||
style={{
|
||||
backgroundColor: rowBg,
|
||||
cursor: 'pointer',
|
||||
borderBottom: '1px solid var(--ctp-surface0)',
|
||||
cursor: "pointer",
|
||||
borderBottom: "1px solid var(--ctp-surface0)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--ctp-surface0)';
|
||||
if (!isSelected)
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--ctp-surface0)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = rowBg;
|
||||
if (!isSelected)
|
||||
e.currentTarget.style.backgroundColor = rowBg;
|
||||
}}
|
||||
>
|
||||
{cols.map((col) => {
|
||||
switch (col.key) {
|
||||
case 'part_number':
|
||||
case "part_number":
|
||||
return (
|
||||
<td key={col.key} style={tdStyle}>
|
||||
<span
|
||||
onClick={(e) => { e.stopPropagation(); copyPN(item.part_number); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyPN(item.part_number);
|
||||
}}
|
||||
title="Click to copy"
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
color: 'var(--ctp-peach)',
|
||||
cursor: 'copy',
|
||||
color: "var(--ctp-peach)",
|
||||
cursor: "copy",
|
||||
}}
|
||||
>
|
||||
{item.part_number}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
case 'item_type': {
|
||||
const tc = typeColors[item.item_type] ?? { bg: 'var(--ctp-surface1)', color: 'var(--ctp-text)' };
|
||||
case "item_type": {
|
||||
const tc = typeColors[item.item_type] ?? {
|
||||
bg: "var(--ctp-surface1)",
|
||||
color: "var(--ctp-text)",
|
||||
};
|
||||
return (
|
||||
<td key={col.key} style={tdStyle}>
|
||||
<span style={{
|
||||
padding: '0.1rem 0.5rem', borderRadius: '1rem',
|
||||
fontSize: '0.75rem', fontWeight: 500,
|
||||
backgroundColor: tc.bg, color: tc.color,
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
padding: "0.1rem 0.5rem",
|
||||
borderRadius: "1rem",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
backgroundColor: tc.bg,
|
||||
color: tc.color,
|
||||
}}
|
||||
>
|
||||
{item.item_type}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
case 'description':
|
||||
return <td key={col.key} style={{ ...tdStyle, maxWidth: 400 }}>{item.description}</td>;
|
||||
case 'revision':
|
||||
return <td key={col.key} style={tdStyle}>Rev {item.current_revision}</td>;
|
||||
case 'projects':
|
||||
return <td key={col.key} style={tdStyle}>—</td>;
|
||||
case 'created':
|
||||
return <td key={col.key} style={tdStyle}>{formatDate(item.created_at)}</td>;
|
||||
case 'actions':
|
||||
case "description":
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{ ...tdStyle, maxWidth: 400 }}
|
||||
>
|
||||
{item.description}
|
||||
</td>
|
||||
);
|
||||
case "revision":
|
||||
return (
|
||||
<td key={col.key} style={tdStyle}>
|
||||
Rev {item.current_revision}
|
||||
</td>
|
||||
);
|
||||
case "projects":
|
||||
return (
|
||||
<td key={col.key} style={tdStyle}>
|
||||
—
|
||||
</td>
|
||||
);
|
||||
case "created":
|
||||
return (
|
||||
<td key={col.key} style={tdStyle}>
|
||||
{formatDate(item.created_at)}
|
||||
</td>
|
||||
);
|
||||
case "actions":
|
||||
return (
|
||||
<td key={col.key} style={tdStyle}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit?.(item.part_number); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit?.(item.part_number);
|
||||
}}
|
||||
style={actionBtnStyle}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.(item.part_number); }}
|
||||
style={{ ...actionBtnStyle, color: 'var(--ctp-red)' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.(item.part_number);
|
||||
}}
|
||||
style={{
|
||||
...actionBtnStyle,
|
||||
color: "var(--ctp-red)",
|
||||
}}
|
||||
>
|
||||
Del
|
||||
</button>
|
||||
@@ -226,7 +318,14 @@ export function ItemTable({
|
||||
})}
|
||||
{sortedItems.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={cols.length} style={{ padding: '2rem', textAlign: 'center', color: 'var(--ctp-subtext0)' }}>
|
||||
<td
|
||||
colSpan={cols.length}
|
||||
style={{
|
||||
padding: "2rem",
|
||||
textAlign: "center",
|
||||
color: "var(--ctp-subtext0)",
|
||||
}}
|
||||
>
|
||||
No items found
|
||||
</td>
|
||||
</tr>
|
||||
@@ -239,12 +338,14 @@ export function ItemTable({
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
items={ALL_COLUMNS.map((col): ContextMenuItem => ({
|
||||
label: col.label,
|
||||
checked: visibleColumns.includes(col.key),
|
||||
onToggle: () => toggleColumn(col.key),
|
||||
disabled: col.key === 'part_number',
|
||||
}))}
|
||||
items={ALL_COLUMNS.map(
|
||||
(col): ContextMenuItem => ({
|
||||
label: col.label,
|
||||
checked: visibleColumns.includes(col.key),
|
||||
onToggle: () => toggleColumn(col.key),
|
||||
disabled: col.key === "part_number",
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -252,11 +353,11 @@ export function ItemTable({
|
||||
}
|
||||
|
||||
const actionBtnStyle: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'var(--ctp-subtext1)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
padding: '0.15rem 0.4rem',
|
||||
borderRadius: '0.25rem',
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--ctp-subtext1)",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.15rem 0.4rem",
|
||||
borderRadius: "0.25rem",
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { get } from '../../api/client';
|
||||
import type { Project } from '../../api/types';
|
||||
import type { ItemFilters } from '../../hooks/useItems';
|
||||
import { useEffect, useState } from "react";
|
||||
import { get } from "../../api/client";
|
||||
import type { Project } from "../../api/types";
|
||||
import type { ItemFilters } from "../../hooks/useItems";
|
||||
|
||||
interface ItemsToolbarProps {
|
||||
filters: ItemFilters;
|
||||
onFilterChange: (partial: Partial<ItemFilters>) => void;
|
||||
layout: 'horizontal' | 'vertical';
|
||||
onLayoutChange: (layout: 'horizontal' | 'vertical') => void;
|
||||
layout: "horizontal" | "vertical";
|
||||
onLayoutChange: (layout: "horizontal" | "vertical") => void;
|
||||
onExport: () => void;
|
||||
onImport: () => void;
|
||||
onCreate: () => void;
|
||||
@@ -15,26 +15,40 @@ interface ItemsToolbarProps {
|
||||
}
|
||||
|
||||
export function ItemsToolbar({
|
||||
filters, onFilterChange, layout, onLayoutChange,
|
||||
onExport, onImport, onCreate, isEditor,
|
||||
filters,
|
||||
onFilterChange,
|
||||
layout,
|
||||
onLayoutChange,
|
||||
onExport,
|
||||
onImport,
|
||||
onCreate,
|
||||
isEditor,
|
||||
}: ItemsToolbarProps) {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
get<Project[]>('/api/projects').then(setProjects).catch(() => {});
|
||||
get<Project[]>("/api/projects")
|
||||
.then(setProjects)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const scopeBtn = (scope: ItemFilters['searchScope'], label: string) => (
|
||||
const scopeBtn = (scope: ItemFilters["searchScope"], label: string) => (
|
||||
<button
|
||||
onClick={() => onFilterChange({ searchScope: scope })}
|
||||
style={{
|
||||
padding: '0.3rem 0.6rem',
|
||||
fontSize: '0.8rem',
|
||||
border: 'none',
|
||||
borderRadius: '0.3rem',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: filters.searchScope === scope ? 'var(--ctp-mauve)' : 'var(--ctp-surface1)',
|
||||
color: filters.searchScope === scope ? 'var(--ctp-crust)' : 'var(--ctp-subtext1)',
|
||||
padding: "var(--d-input-py) var(--d-input-px)",
|
||||
fontSize: "var(--d-input-font)",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
filters.searchScope === scope
|
||||
? "var(--ctp-mauve)"
|
||||
: "var(--ctp-surface1)",
|
||||
color:
|
||||
filters.searchScope === scope
|
||||
? "var(--ctp-crust)"
|
||||
: "var(--ctp-subtext1)",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -42,15 +56,17 @@ export function ItemsToolbar({
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '0.75rem',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem 0',
|
||||
borderBottom: '1px solid var(--ctp-surface0)',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "var(--d-toolbar-gap)",
|
||||
alignItems: "center",
|
||||
padding: "var(--d-toolbar-py) 0",
|
||||
borderBottom: "1px solid var(--ctp-surface0)",
|
||||
marginBottom: "var(--d-toolbar-mb)",
|
||||
}}
|
||||
>
|
||||
{/* Search */}
|
||||
<input
|
||||
type="text"
|
||||
@@ -60,20 +76,20 @@ export function ItemsToolbar({
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
padding: '0.4rem 0.75rem',
|
||||
backgroundColor: 'var(--ctp-surface0)',
|
||||
border: '1px solid var(--ctp-surface1)',
|
||||
borderRadius: '0.4rem',
|
||||
color: 'var(--ctp-text)',
|
||||
fontSize: '0.85rem',
|
||||
padding: "var(--d-input-py) var(--d-input-px)",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.4rem",
|
||||
color: "var(--ctp-text)",
|
||||
fontSize: "var(--d-input-font)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Search scope */}
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
{scopeBtn('all', 'All')}
|
||||
{scopeBtn('part_number', 'PN')}
|
||||
{scopeBtn('description', 'Desc')}
|
||||
<div style={{ display: "flex", gap: "0.25rem" }}>
|
||||
{scopeBtn("all", "All")}
|
||||
{scopeBtn("part_number", "PN")}
|
||||
{scopeBtn("description", "Desc")}
|
||||
</div>
|
||||
|
||||
{/* Type filter */}
|
||||
@@ -97,34 +113,46 @@ export function ItemsToolbar({
|
||||
>
|
||||
<option value="">All Projects</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.code} value={p.code}>{p.code}{p.name ? ` — ${p.name}` : ''}</option>
|
||||
<option key={p.code} value={p.code}>
|
||||
{p.code}
|
||||
{p.name ? ` — ${p.name}` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Layout toggle */}
|
||||
<button
|
||||
onClick={() => onLayoutChange(layout === 'horizontal' ? 'vertical' : 'horizontal')}
|
||||
title={`Switch to ${layout === 'horizontal' ? 'vertical' : 'horizontal'} layout`}
|
||||
onClick={() =>
|
||||
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
|
||||
}
|
||||
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
||||
style={toolBtnStyle}
|
||||
>
|
||||
{layout === 'horizontal' ? '⬌' : '⬍'}
|
||||
{layout === "horizontal" ? "⬌" : "⬍"}
|
||||
</button>
|
||||
|
||||
{/* Export */}
|
||||
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">Export</button>
|
||||
<button onClick={onExport} style={toolBtnStyle} title="Export CSV">
|
||||
Export
|
||||
</button>
|
||||
|
||||
{/* Import (editor only) */}
|
||||
{isEditor && (
|
||||
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">Import</button>
|
||||
<button onClick={onImport} style={toolBtnStyle} title="Import CSV">
|
||||
Import
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Create (editor only) */}
|
||||
{isEditor && (
|
||||
<button onClick={onCreate} style={{
|
||||
...toolBtnStyle,
|
||||
backgroundColor: 'var(--ctp-mauve)',
|
||||
color: 'var(--ctp-crust)',
|
||||
}}>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
style={{
|
||||
...toolBtnStyle,
|
||||
backgroundColor: "var(--ctp-mauve)",
|
||||
color: "var(--ctp-crust)",
|
||||
}}
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
)}
|
||||
@@ -133,20 +161,20 @@ export function ItemsToolbar({
|
||||
}
|
||||
|
||||
const selectStyle: React.CSSProperties = {
|
||||
padding: '0.4rem 0.6rem',
|
||||
backgroundColor: 'var(--ctp-surface0)',
|
||||
border: '1px solid var(--ctp-surface1)',
|
||||
borderRadius: '0.4rem',
|
||||
color: 'var(--ctp-text)',
|
||||
fontSize: '0.85rem',
|
||||
padding: "var(--d-input-py) var(--d-input-px)",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.4rem",
|
||||
color: "var(--ctp-text)",
|
||||
fontSize: "var(--d-input-font)",
|
||||
};
|
||||
|
||||
const toolBtnStyle: React.CSSProperties = {
|
||||
padding: '0.4rem 0.75rem',
|
||||
backgroundColor: 'var(--ctp-surface1)',
|
||||
border: 'none',
|
||||
borderRadius: '0.4rem',
|
||||
color: 'var(--ctp-text)',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
padding: "var(--d-input-py) var(--d-input-px)",
|
||||
backgroundColor: "var(--ctp-surface1)",
|
||||
border: "none",
|
||||
borderRadius: "0.4rem",
|
||||
color: "var(--ctp-text)",
|
||||
fontSize: "var(--d-input-font)",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
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 { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { App } from './App';
|
||||
import './styles/global.css';
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AuthProvider } from "./context/AuthContext";
|
||||
import { App } from "./App";
|
||||
import "./styles/global.css";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
// Apply saved density before first paint to prevent FOUC
|
||||
try {
|
||||
const saved = localStorage.getItem("silo-density");
|
||||
const density = saved ? JSON.parse(saved) : "comfortable";
|
||||
document.documentElement.setAttribute("data-density", density);
|
||||
} catch {
|
||||
document.documentElement.setAttribute("data-density", "comfortable");
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AuditToolbar } from "../components/audit/AuditToolbar";
|
||||
import { AuditTable } from "../components/audit/AuditTable";
|
||||
import { AuditDetailPanel } from "../components/audit/AuditDetailPanel";
|
||||
import { SplitPanel } from "../components/items/SplitPanel";
|
||||
import { PageFooter } from "../components/PageFooter";
|
||||
|
||||
type PaneMode = { type: "none" } | { type: "detail"; partNumber: string };
|
||||
|
||||
@@ -47,8 +48,8 @@ export function AuditPage() {
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "calc(100vh - 64px)",
|
||||
paddingBottom: 28,
|
||||
height: "100%",
|
||||
paddingBottom: "var(--d-footer-h)",
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
@@ -91,45 +92,18 @@ export function AuditPage() {
|
||||
storageKey="silo-audit-split"
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.4rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => updateFilters({ page: Math.max(1, filters.page - 1) })}
|
||||
disabled={filters.page <= 1}
|
||||
style={pageBtnStyle}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}>
|
||||
Page {filters.page} · {items.length} items
|
||||
</span>
|
||||
<button
|
||||
onClick={() => updateFilters({ page: filters.page + 1 })}
|
||||
disabled={items.length < filters.pageSize}
|
||||
style={pageBtnStyle}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<PageFooter
|
||||
stats={
|
||||
<>
|
||||
<span>{summary.total_items} items</span>
|
||||
<span>Avg: {(summary.avg_score * 100).toFixed(1)}%</span>
|
||||
</>
|
||||
}
|
||||
page={filters.page}
|
||||
pageSize={filters.pageSize}
|
||||
itemCount={items.length}
|
||||
onPageChange={(p) => updateFilters({ page: p })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageBtnStyle: React.CSSProperties = {
|
||||
padding: "0.25rem 0.6rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
color: "var(--ctp-text)",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { EditItemPane } from "../components/items/EditItemPane";
|
||||
import { DeleteItemPane } from "../components/items/DeleteItemPane";
|
||||
import { ImportItemsPane } from "../components/items/ImportItemsPane";
|
||||
import { SplitPanel } from "../components/items/SplitPanel";
|
||||
import { FooterStats } from "../components/items/FooterStats";
|
||||
import { PageFooter } from "../components/PageFooter";
|
||||
|
||||
type PaneMode =
|
||||
| { type: "none" }
|
||||
@@ -170,8 +170,8 @@ export function ItemsPage() {
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "calc(100vh - 64px)",
|
||||
paddingBottom: 28,
|
||||
height: "100%",
|
||||
paddingBottom: "var(--d-footer-h)",
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
@@ -217,47 +217,40 @@ export function ItemsPage() {
|
||||
secondary={secondaryPane}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
padding: "0.4rem",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => updateFilters({ page: Math.max(1, filters.page - 1) })}
|
||||
disabled={filters.page <= 1}
|
||||
style={pageBtnStyle}
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<span style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}>
|
||||
Page {filters.page} · {items.length} items
|
||||
</span>
|
||||
<button
|
||||
onClick={() => updateFilters({ page: filters.page + 1 })}
|
||||
disabled={items.length < filters.pageSize}
|
||||
style={pageBtnStyle}
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FooterStats items={items} />
|
||||
<PageFooter
|
||||
stats={
|
||||
<>
|
||||
<span>
|
||||
Total:{" "}
|
||||
<strong style={{ color: "var(--ctp-text)" }}>
|
||||
{items.length}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Parts:{" "}
|
||||
<strong style={{ color: "var(--ctp-blue)" }}>
|
||||
{items.filter((i) => i.item_type === "part").length}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Assemblies:{" "}
|
||||
<strong style={{ color: "var(--ctp-green)" }}>
|
||||
{items.filter((i) => i.item_type === "assembly").length}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
Documents:{" "}
|
||||
<strong style={{ color: "var(--ctp-yellow)" }}>
|
||||
{items.filter((i) => i.item_type === "document").length}
|
||||
</strong>
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
page={filters.page}
|
||||
pageSize={filters.pageSize}
|
||||
itemCount={items.length}
|
||||
onPageChange={(p) => updateFilters({ page: p })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pageBtnStyle: React.CSSProperties = {
|
||||
padding: "0.25rem 0.6rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
color: "var(--ctp-text)",
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
@@ -1,29 +1,92 @@
|
||||
/* Catppuccin Mocha Theme */
|
||||
:root {
|
||||
--ctp-rosewater: #f5e0dc;
|
||||
--ctp-flamingo: #f2cdcd;
|
||||
--ctp-pink: #f5c2e7;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-maroon: #eba0ac;
|
||||
--ctp-peach: #fab387;
|
||||
--ctp-yellow: #f9e2af;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-teal: #94e2d5;
|
||||
--ctp-sky: #89dceb;
|
||||
--ctp-sapphire: #74c7ec;
|
||||
--ctp-blue: #89b4fa;
|
||||
--ctp-lavender: #b4befe;
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-overlay2: #9399b2;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-surface2: #585b70;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
--ctp-rosewater: #f5e0dc;
|
||||
--ctp-flamingo: #f2cdcd;
|
||||
--ctp-pink: #f5c2e7;
|
||||
--ctp-mauve: #cba6f7;
|
||||
--ctp-red: #f38ba8;
|
||||
--ctp-maroon: #eba0ac;
|
||||
--ctp-peach: #fab387;
|
||||
--ctp-yellow: #f9e2af;
|
||||
--ctp-green: #a6e3a1;
|
||||
--ctp-teal: #94e2d5;
|
||||
--ctp-sky: #89dceb;
|
||||
--ctp-sapphire: #74c7ec;
|
||||
--ctp-blue: #89b4fa;
|
||||
--ctp-lavender: #b4befe;
|
||||
--ctp-text: #cdd6f4;
|
||||
--ctp-subtext1: #bac2de;
|
||||
--ctp-subtext0: #a6adc8;
|
||||
--ctp-overlay2: #9399b2;
|
||||
--ctp-overlay1: #7f849c;
|
||||
--ctp-overlay0: #6c7086;
|
||||
--ctp-surface2: #585b70;
|
||||
--ctp-surface1: #45475a;
|
||||
--ctp-surface0: #313244;
|
||||
--ctp-base: #1e1e2e;
|
||||
--ctp-mantle: #181825;
|
||||
--ctp-crust: #11111b;
|
||||
}
|
||||
|
||||
/* ── Density: comfortable (default) ── */
|
||||
[data-density="comfortable"],
|
||||
:root {
|
||||
--d-header-py: 0.625rem;
|
||||
--d-header-px: 2rem;
|
||||
--d-header-logo: 1.25rem;
|
||||
--d-nav-gap: 1rem;
|
||||
--d-nav-py: 0.35rem;
|
||||
--d-nav-px: 0.75rem;
|
||||
--d-nav-radius: 0.4rem;
|
||||
--d-user-gap: 0.6rem;
|
||||
--d-user-font: 0.85rem;
|
||||
|
||||
--d-th-py: 0.35rem;
|
||||
--d-th-px: 0.75rem;
|
||||
--d-th-font: 0.75rem;
|
||||
--d-td-py: 0.25rem;
|
||||
--d-td-px: 0.75rem;
|
||||
--d-td-font: 0.85rem;
|
||||
|
||||
--d-toolbar-gap: 0.5rem;
|
||||
--d-toolbar-py: 0.5rem;
|
||||
--d-toolbar-mb: 0.35rem;
|
||||
--d-input-py: 0.35rem;
|
||||
--d-input-px: 0.6rem;
|
||||
--d-input-font: 0.85rem;
|
||||
|
||||
--d-footer-h: 28px;
|
||||
--d-footer-font: 0.75rem;
|
||||
--d-footer-px: 2rem;
|
||||
}
|
||||
|
||||
/* ── Density: compact ── */
|
||||
[data-density="compact"] {
|
||||
--d-header-py: 0.35rem;
|
||||
--d-header-px: 1.25rem;
|
||||
--d-header-logo: 1.1rem;
|
||||
--d-nav-gap: 0.5rem;
|
||||
--d-nav-py: 0.2rem;
|
||||
--d-nav-px: 0.5rem;
|
||||
--d-nav-radius: 0.3rem;
|
||||
--d-user-gap: 0.35rem;
|
||||
--d-user-font: 0.8rem;
|
||||
|
||||
--d-th-py: 0.2rem;
|
||||
--d-th-px: 0.5rem;
|
||||
--d-th-font: 0.7rem;
|
||||
--d-td-py: 0.125rem;
|
||||
--d-td-px: 0.5rem;
|
||||
--d-td-font: 0.8rem;
|
||||
|
||||
--d-toolbar-gap: 0.35rem;
|
||||
--d-toolbar-py: 0.25rem;
|
||||
--d-toolbar-mb: 0.15rem;
|
||||
--d-input-py: 0.2rem;
|
||||
--d-input-px: 0.4rem;
|
||||
--d-input-font: 0.8rem;
|
||||
|
||||
--d-footer-h: 24px;
|
||||
--d-footer-font: 0.7rem;
|
||||
--d-footer-px: 1.25rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user