- Mark Phase 4 (remove Go templates) as complete - Update architecture: SPA serves at / via NotFound handler - Update overview to past tense (migration is done) - Update file/line counts (~40 files, ~7,600 lines) - Mark backend changes 1-3 and 5 as implemented - Reorganize remaining work section Closes #30
729 lines
32 KiB
Markdown
729 lines
32 KiB
Markdown
# Silo Frontend Specification
|
||
|
||
Current as of 2026-02-08. Documents the React + Vite + TypeScript frontend (migration from Go templates is complete).
|
||
|
||
## Overview
|
||
|
||
The Silo web UI has been migrated from server-rendered Go templates to a React single-page application. The Go templates (~7,000 lines across 7 files) have been removed. The Go API server serves JSON at `/api/*` and the React SPA at `/`.
|
||
|
||
**Stack**: React 19, React Router 7, Vite 6, TypeScript 5.7
|
||
**Theme**: Catppuccin Mocha (dark) via CSS custom properties
|
||
**Styling**: Inline React styles using `React.CSSProperties` — no CSS modules, no Tailwind, no styled-components
|
||
**State**: Local `useState` + custom hooks. No global state library (no Redux, Zustand, etc.)
|
||
**Dependencies**: Minimal — only `react`, `react-dom`, `react-router-dom`. No axios, no tanstack-query.
|
||
|
||
## Migration Status
|
||
|
||
| Phase | Issue | Title | Status |
|
||
|-------|-------|-------|--------|
|
||
| 1 | #7 | Scaffold React + Vite + TS, shared layout, auth, API client | Code complete |
|
||
| 2 | #8 | Migrate Items page with UI improvements | Code complete |
|
||
| 3 | #9 | Migrate Projects, Schemas, Settings, Login pages | Code complete |
|
||
| 4 | #10 | Remove Go templates, Docker integration, cleanup | Complete |
|
||
|
||
## Architecture
|
||
|
||
```
|
||
Browser
|
||
└── React SPA (served at /)
|
||
├── Vite dev server (development) → proxies /api/* to Go backend
|
||
└── Static files in web/dist/ (production) → served by Go binary
|
||
|
||
Go Server (silod)
|
||
├── /api/* JSON REST API
|
||
├── /login, /logout Session auth endpoints (form POST)
|
||
├── /auth/oidc OIDC redirect flow
|
||
└── /* React SPA (NotFound handler serves index.html for client-side routing)
|
||
```
|
||
|
||
### Auth Flow
|
||
|
||
1. React app loads, `AuthProvider` calls `GET /api/auth/me`
|
||
2. If 401 → render `LoginPage` (React form)
|
||
3. Login form POSTs `application/x-www-form-urlencoded` to `/login` (Go handler sets session cookie)
|
||
4. On success, `AuthProvider.refresh()` re-fetches `/api/auth/me`, user state populates, app renders
|
||
5. OIDC: link to `/auth/oidc` triggers Go-served redirect flow, callback sets session, user returns to app
|
||
6. API client auto-redirects to `/login` on any 401 response
|
||
|
||
### Public API Endpoint
|
||
|
||
`GET /api/auth/config` — returns `{ oidc_enabled: bool, local_enabled: bool }` so the login page can conditionally show the OIDC button without hardcoding.
|
||
|
||
## File Structure
|
||
|
||
```
|
||
web/
|
||
├── index.html
|
||
├── package.json
|
||
├── tsconfig.json
|
||
├── tsconfig.node.json
|
||
├── vite.config.ts
|
||
└── src/
|
||
├── main.tsx Entry point, renders AuthProvider + BrowserRouter + App
|
||
├── App.tsx Route definitions, auth guard
|
||
├── api/
|
||
│ ├── client.ts fetch wrapper: get, post, put, del + ApiError class
|
||
│ └── types.ts All TypeScript interfaces (272 lines)
|
||
├── context/
|
||
│ └── AuthContext.tsx AuthProvider with login/logout/refresh methods
|
||
├── hooks/
|
||
│ ├── useAuth.ts Context consumer hook
|
||
│ ├── useItems.ts Items fetching with search, filters, pagination, debounce
|
||
│ └── useLocalStorage.ts Typed localStorage persistence hook
|
||
├── styles/
|
||
│ ├── theme.css Catppuccin Mocha CSS custom properties
|
||
│ └── global.css Base element styles
|
||
├── components/
|
||
│ ├── AppShell.tsx Header nav + user info + <Outlet/>
|
||
│ ├── ContextMenu.tsx Reusable right-click positioned menu
|
||
│ └── items/ Items page components (16 files)
|
||
│ ├── ItemsToolbar.tsx Search, filters, layout toggle, action buttons
|
||
│ ├── ItemTable.tsx Sortable table, column config, compact rows
|
||
│ ├── ItemDetail.tsx 5-tab detail panel (Main, Properties, Revisions, BOM, Where Used)
|
||
│ ├── MainTab.tsx Metadata display, project tags editor, file info
|
||
│ ├── PropertiesTab.tsx Form/JSON dual-mode property editor
|
||
│ ├── RevisionsTab.tsx Revision list, compare diff, status, rollback
|
||
│ ├── BOMTab.tsx BOM table with inline CRUD, cost calculations
|
||
│ ├── WhereUsedTab.tsx Parent assemblies table
|
||
│ ├── SplitPanel.tsx Resizable horizontal/vertical layout container
|
||
│ ├── FooterStats.tsx Fixed bottom bar with item counts
|
||
│ ├── CreateItemPane.tsx In-pane create form with schema category properties
|
||
│ ├── EditItemPane.tsx In-pane edit form
|
||
│ ├── DeleteItemPane.tsx In-pane delete confirmation
|
||
│ └── ImportItemsPane.tsx CSV upload with dry-run/import flow
|
||
└── pages/
|
||
├── LoginPage.tsx Username/password form + OIDC button
|
||
├── ItemsPage.tsx Orchestrator: toolbar, split panel, table, detail/CRUD panes
|
||
├── ProjectsPage.tsx Project CRUD with sortable table, in-pane forms
|
||
├── SchemasPage.tsx Schema browser with collapsible segments, enum value CRUD
|
||
├── SettingsPage.tsx Account info, API token management
|
||
└── AuditPage.tsx Audit completeness (placeholder, expanded in Issue #5)
|
||
```
|
||
|
||
**Total**: ~40 source files, ~7,600 lines of TypeScript/TSX.
|
||
|
||
## Design System
|
||
|
||
### Theme
|
||
|
||
Catppuccin Mocha dark theme. All colors referenced via CSS custom properties:
|
||
|
||
| Token | Use |
|
||
|-------|-----|
|
||
| `--ctp-base` | Page background, input backgrounds |
|
||
| `--ctp-mantle` | Header background |
|
||
| `--ctp-surface0` | Card backgrounds, even table rows |
|
||
| `--ctp-surface1` | Borders, dividers, hover states |
|
||
| `--ctp-surface2` | Secondary button backgrounds |
|
||
| `--ctp-text` | Primary text |
|
||
| `--ctp-subtext0/1` | Secondary/muted text, labels |
|
||
| `--ctp-overlay0` | Placeholder text |
|
||
| `--ctp-mauve` | Brand accent, primary buttons, nav active |
|
||
| `--ctp-blue` | Editor role badge, edit headers |
|
||
| `--ctp-green` | Success banners, create headers |
|
||
| `--ctp-red` | Errors, delete actions, danger buttons |
|
||
| `--ctp-peach` | Part numbers, project codes, token prefixes |
|
||
| `--ctp-teal` | Viewer role badge |
|
||
| `--ctp-sapphire` | Links, collapsible toggles |
|
||
| `--ctp-crust` | Dark text on colored backgrounds |
|
||
|
||
### Typography
|
||
|
||
- Body: system font stack (Inter, -apple-system, etc.)
|
||
- Monospace: JetBrains Mono (part numbers, codes, tokens)
|
||
- Table cells: 0.85rem
|
||
- Labels: 0.85rem, weight 500
|
||
- Table headers: 0.8rem, uppercase, letter-spacing 0.05em
|
||
|
||
### Component Patterns
|
||
|
||
**Tables**: Inline styles, compact rows (28-32px), alternating `base`/`surface0` backgrounds, sortable headers with arrow indicators, right-click column config (Items page).
|
||
|
||
**Forms**: In-pane forms (Infor ERP-style) — not modal overlays. Create/Edit/Delete forms render in the detail pane area with a colored header bar (green=create, blue=edit, red=delete). Cancel returns to previous view.
|
||
|
||
**Cards**: `surface0` background, `0.75rem` border radius, `1.5rem` padding.
|
||
|
||
**Buttons**: Primary (`mauve` bg, `crust` text), secondary (`surface1` bg), danger (`red` bg or translucent red bg with red text).
|
||
|
||
**Errors**: Red text with translucent red background banner, `0.4rem` border radius.
|
||
|
||
**Role badges**: Colored pill badges — admin=mauve, editor=blue, viewer=teal.
|
||
|
||
## Page Specifications
|
||
|
||
### Items Page (completed in #8)
|
||
|
||
The most complex page. Master-detail layout with resizable split panel.
|
||
|
||
**Toolbar**: Debounced search (300ms) with scope toggle (All/PN/Description), type and project filter dropdowns, layout toggle (horizontal/vertical), export/import/create buttons.
|
||
|
||
**Table**: 7 configurable columns (part_number, item_type, description, revision, projects, created, actions). Visibility stored per layout mode in localStorage. Right-click header opens ContextMenu with checkboxes. Compact rows, zebra striping, click to select.
|
||
|
||
**Detail panel**: 5 tabs — Main (metadata + project tags + file info), Properties (form/JSON editor, save creates revision), Revisions (compare, status management, rollback), BOM (inline CRUD, cost calculations, CSV export), Where Used (parent assemblies).
|
||
|
||
**CRUD panes**: In-pane forms for Create (schema category properties, project tags), Edit (basic fields), Delete (confirmation), Import (CSV upload with dry-run).
|
||
|
||
**Footer**: Fixed 28px bottom bar showing Total | Parts | Assemblies | Documents counts, reactive to filters.
|
||
|
||
**State**: `PaneMode` discriminated union manages which pane is shown. `useItems` hook handles fetching, search, filters, pagination. `useLocalStorage` persists layout and column preferences.
|
||
|
||
### Projects Page (completed in #9)
|
||
|
||
Sortable table with columns: Code, Name, Description, Items (count fetched per project), Created, Actions.
|
||
|
||
**CRUD**: In-pane forms above the table. Create requires code (2-10 chars, auto-uppercase), name, description. Edit allows name and description changes. Delete shows confirmation with project code.
|
||
|
||
**Navigation**: Click project code navigates to Items page with `?project=CODE` filter.
|
||
|
||
**Permissions**: Create/Edit/Delete buttons only visible to editor/admin roles.
|
||
|
||
### Schemas Page (completed in #9)
|
||
|
||
Schema cards with collapsible segment details. Each schema shows name, description, format string, version, and example part numbers.
|
||
|
||
**Segments**: Expandable list showing segment name, type badge, description. Enum segments include a values table with code and description columns.
|
||
|
||
**Enum CRUD**: Inline table operations — add row at bottom, edit replaces the row, delete highlights the row with confirmation. All operations call `POST/PUT/DELETE /api/schemas/{name}/segments/{segment}/values/{code}`.
|
||
|
||
### Settings Page (completed in #9)
|
||
|
||
Two cards:
|
||
|
||
**Account**: Read-only grid showing username, display name, email, auth source, role (with colored badge). Data from `useAuth()` context.
|
||
|
||
**API Tokens**: Create form (name input + button), one-time token display in green banner with copy-to-clipboard, token list table (name, prefix, created, last used, expires, revoke). Revoke has inline confirm step. Uses `GET/POST/DELETE /api/auth/tokens`.
|
||
|
||
### Login Page (completed in #9)
|
||
|
||
Standalone centered card (no AppShell). Username/password form, OIDC button shown conditionally based on `GET /api/auth/config`. Error messages in red banner. Submit calls `AuthContext.login()` which POSTs form data to `/login` then re-fetches the user.
|
||
|
||
### Audit Page (placeholder)
|
||
|
||
Basic table showing audit completeness data from `GET /api/audit/completeness`. Will be expanded as part of Issue #5 (Component Audit UI with completeness scoring and inline editing).
|
||
|
||
## API Client
|
||
|
||
`web/src/api/client.ts` — thin wrapper around `fetch`:
|
||
|
||
- Always sends `credentials: 'include'` for session cookies
|
||
- Always sets `Content-Type: application/json`
|
||
- 401 responses redirect to `/login`
|
||
- Non-OK responses parsed as `{ error, message }` and thrown as `ApiError`
|
||
- 204 responses return `undefined`
|
||
- Exports: `get<T>()`, `post<T>()`, `put<T>()`, `del()`
|
||
|
||
## Type Definitions
|
||
|
||
`web/src/api/types.ts` — 272 lines covering all API response and request shapes:
|
||
|
||
**Core models**: User, Item, Project, Schema, SchemaSegment, Revision, BOMEntry
|
||
**Audit**: AuditFieldResult, AuditItemResult, AuditSummary, AuditCompletenessResponse
|
||
**Search**: FuzzyResult (extends Item with score)
|
||
**BOM**: WhereUsedEntry, AddBOMEntryRequest, UpdateBOMEntryRequest
|
||
**Items**: CreateItemRequest, UpdateItemRequest, CreateRevisionRequest
|
||
**Projects**: CreateProjectRequest, UpdateProjectRequest
|
||
**Schemas**: CreateSchemaValueRequest, UpdateSchemaValueRequest, PropertyDef, PropertySchema
|
||
**Auth**: AuthConfig, ApiToken, ApiTokenCreated
|
||
**Revisions**: RevisionComparison
|
||
**Import**: CSVImportResult, CSVImportError
|
||
**Errors**: ErrorResponse
|
||
|
||
## Completed Work
|
||
|
||
### Issue #10: Remove Go Templates + Docker Integration -- COMPLETE
|
||
|
||
Completed in commit `50923cf`. All Go templates deleted, `web.go` handler removed, SPA serves at `/` via `NotFound` handler with `index.html` fallback. `build/package/Dockerfile` added.
|
||
|
||
### Remaining Work
|
||
|
||
### Issue #5: Component Audit UI (future)
|
||
|
||
The Audit page will be expanded with completeness scoring, inline editing, tier filtering, and category breakdowns. This will be built natively in React using the patterns established in the migration.
|
||
|
||
## Development
|
||
|
||
```bash
|
||
# Install dependencies
|
||
cd web && npm install
|
||
|
||
# Dev server (proxies /api/* to Go backend on :8080)
|
||
npm run dev
|
||
|
||
# Type check
|
||
npx tsc --noEmit
|
||
|
||
# Production build
|
||
npm run build
|
||
```
|
||
|
||
Vite dev server runs on port 5173 with proxy config in `vite.config.ts` forwarding `/api/*`, `/login`, `/logout`, `/auth/*` to the Go backend.
|
||
|
||
## Conventions
|
||
|
||
- **No modals for CRUD** — use in-pane forms (Infor ERP-style pattern)
|
||
- **No shared component library extraction** until a pattern repeats 3+ times
|
||
- **Inline styles only** — all styling via `React.CSSProperties` objects, using Catppuccin CSS variables
|
||
- **No class components** — functional components with hooks only
|
||
- **Permission checks**: derive `isEditor` from `user.role` in each page, conditionally render write actions
|
||
- **Error handling**: try/catch with error state, display in red banners inline
|
||
- **Data fetching**: `useEffect` + API client on mount, loading/error/data states
|
||
- **Persistence**: `useLocalStorage` hook for user preferences (layout mode, column visibility)
|
||
|
||
## New Frontend Tasks
|
||
|
||
# CreateItemPane Redesign Specification
|
||
|
||
**Date**: 2026-02-06
|
||
**Scope**: Replace existing `CreateItemPane.tsx` with a two-column layout, multi-stage category picker, file attachment via MinIO, and full use of screen real estate.
|
||
**Parent**: Items page (`ItemsPage.tsx`) — renders in the detail pane area per existing in-pane CRUD pattern.
|
||
|
||
---
|
||
|
||
## Layout
|
||
|
||
The pane uses a CSS Grid two-column layout instead of the current single-column form:
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────┬──────────────┐
|
||
│ Header: "New Item" [green bar] Cancel │ Create │ │
|
||
├──────────────────────────────────────────────────────┤ │
|
||
│ │ Auto- │
|
||
│ ── Identity ────────────────────────────────────── │ assigned │
|
||
│ [Part Number *] [Type * v] │ metadata │
|
||
│ [Description ] │ │
|
||
│ Category * [Domain │ Group │ Subtype ] │──────────────│
|
||
│ Mechanical│ Structural│ Bracket │ │ │
|
||
│ Electrical│ Bearings │ Plate │ │ Attachments │
|
||
│ ... │ ... │ ... │ │ ┌─ ─ ─ ─ ┐ │
|
||
│ ── Sourcing ────────────────────────────────────── │ │ Drop │ │
|
||
│ [Sourcing Type v] [Standard Cost $ ] │ │ zone │ │
|
||
│ [Unit of Measure v] [Sourcing Link ] │ └─ ─ ─ ─ ┘ │
|
||
│ │ file.FCStd │
|
||
│ ── Details ─────────────────────────────────────── │ drawing.pdf │
|
||
│ [Long Description ] │ │
|
||
│ [Projects: [tag][tag] type to search... ] │──────────────│
|
||
│ │ Thumbnail │
|
||
│ │ [preview] │
|
||
└──────────────────────────────────────────────────────┴──────────────┘
|
||
```
|
||
|
||
Grid definition: `grid-template-columns: 1fr 320px`. The left column scrolls independently if content overflows. The right sidebar is a flex column with sections separated by `--ctp-surface1` borders.
|
||
|
||
## File Location
|
||
|
||
`web/src/components/items/CreateItemPane.tsx` (replaces existing file)
|
||
|
||
New supporting files:
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `web/src/components/items/CategoryPicker.tsx` | Multi-stage category selector |
|
||
| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs |
|
||
| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects |
|
||
| `web/src/hooks/useCategories.ts` | Fetches category tree from schema data |
|
||
| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow |
|
||
|
||
## Component Breakdown
|
||
|
||
### CreateItemPane
|
||
|
||
Top-level orchestrator. Manages form state, submission, and layout.
|
||
|
||
**Props** (unchanged interface):
|
||
|
||
```typescript
|
||
interface CreateItemPaneProps {
|
||
onCreated: (item: Item) => void;
|
||
onCancel: () => void;
|
||
}
|
||
```
|
||
|
||
**State**:
|
||
|
||
```typescript
|
||
const [form, setForm] = useState<CreateItemForm>({
|
||
part_number: '',
|
||
item_type: 'part',
|
||
description: '',
|
||
category_path: [], // e.g. ['Mechanical', 'Structural', 'Bracket']
|
||
sourcing_type: 'manufactured',
|
||
standard_cost: '',
|
||
unit_of_measure: 'ea',
|
||
sourcing_link: '',
|
||
long_description: '',
|
||
project_ids: [],
|
||
});
|
||
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
|
||
const [thumbnail, setThumbnail] = useState<PendingAttachment | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
```
|
||
|
||
**Submission flow**:
|
||
|
||
1. Validate required fields (part_number, item_type, category_path length === 3).
|
||
2. `POST /api/items` with form data → returns created `Item` with UUID.
|
||
3. For each attachment in `attachments[]`, call the file association endpoint: `POST /api/items/{id}/files` with the MinIO object key returned from upload.
|
||
4. If thumbnail exists, `PUT /api/items/{id}/thumbnail` with the object key.
|
||
5. Call `onCreated(item)`.
|
||
|
||
If step 2 fails, show error banner. If file association fails, show warning but still navigate (item was created, files can be re-attached).
|
||
|
||
**Header bar**: Green (`--ctp-green` background, `--ctp-crust` text) per existing create-pane convention. "New Item" title on left, Cancel (ghost button) and Create Item (primary button, `--ctp-green` bg) on right.
|
||
|
||
### CategoryPicker
|
||
|
||
Three-column scrollable list for hierarchical category selection.
|
||
|
||
**Props**:
|
||
|
||
```typescript
|
||
interface CategoryPickerProps {
|
||
value: string[]; // current selection path, e.g. ['Mechanical', 'Structural']
|
||
onChange: (path: string[]) => void;
|
||
categories: CategoryNode[]; // top-level nodes
|
||
}
|
||
|
||
interface CategoryNode {
|
||
name: string;
|
||
children?: CategoryNode[];
|
||
}
|
||
```
|
||
|
||
**Rendering**: Three side-by-side `<div>` columns inside a container with `border: 1px solid var(--ctp-surface1)` and `border-radius: 0.4rem`. Each column has:
|
||
|
||
- A sticky header row (10px uppercase, `--ctp-overlay0` text, `--ctp-mantle` background) labeling the tier. Labels come from the schema definition if available, otherwise "Level 1", "Level 2", "Level 3".
|
||
- A scrollable list of options. Each option is a `<div>` row, 28px height, `0.85rem` font. Hover: `--ctp-surface0` background. Selected: translucent mauve background (`rgba(203, 166, 247, 0.12)`), `--ctp-mauve` text, weight 600.
|
||
- If a node has children, show a `›` chevron on the right side of the row.
|
||
|
||
Column 1 always shows all top-level nodes. Column 2 shows children of the selected Column 1 node. Column 3 shows children of the selected Column 2 node. If nothing is selected in a column, the next column shows an empty state with muted text: "Select a [tier name]".
|
||
|
||
Below the picker, render a breadcrumb trail: `Mechanical › Structural › Bracket` in `--ctp-mauve` with `›` separators in `--ctp-overlay0`. Only show segments that are selected.
|
||
|
||
**Data source**: Categories are derived from schemas. The `useCategories` hook calls `GET /api/schemas` and transforms the response into a `CategoryNode[]` tree. The exact mapping depends on how schemas define category hierarchies — if schemas don't currently support hierarchical categories, this requires a backend addition (see Backend Changes section).
|
||
|
||
**Max height**: 180px per column with `overflow-y: auto`.
|
||
|
||
### FileDropZone
|
||
|
||
Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow.
|
||
|
||
**Props**:
|
||
|
||
```typescript
|
||
interface FileDropZoneProps {
|
||
files: PendingAttachment[];
|
||
onFilesAdded: (files: PendingAttachment[]) => void;
|
||
onFileRemoved: (index: number) => void;
|
||
accept?: string; // e.g. '.FCStd,.step,.stl,.pdf,.png,.jpg'
|
||
}
|
||
|
||
interface PendingAttachment {
|
||
file: File;
|
||
objectKey: string; // MinIO key after upload
|
||
uploadProgress: number; // 0-100
|
||
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
|
||
error?: string;
|
||
}
|
||
```
|
||
|
||
**Drop zone UI**: Dashed `2px` border using `--ctp-surface1`, `border-radius: 0.5rem`, centered content with a paperclip icon (Unicode 📎 or inline SVG), "Drop files here or **browse**" text, and accepted formats in `--ctp-overlay0` at 10px.
|
||
|
||
States:
|
||
- **Default**: dashed border `--ctp-surface1`
|
||
- **Drag over**: dashed border `--ctp-mauve`, background `rgba(203, 166, 247, 0.05)`
|
||
- **Uploading**: show progress per file in the file list
|
||
|
||
Clicking the zone opens a hidden `<input type="file" multiple>`.
|
||
|
||
**File list**: Rendered below the drop zone. Each file shows:
|
||
- Type icon: colored 28×28 rounded square. Color mapping: `.FCStd`/`.step`/`.stl` → `--ctp-blue` ("CAD"), `.pdf` → `--ctp-red` ("PDF"), `.png`/`.jpg` → `--ctp-green` ("IMG"), other → `--ctp-overlay1` ("FILE").
|
||
- File name (truncated with ellipsis).
|
||
- File size + type label in `--ctp-overlay0` at 10px.
|
||
- Upload progress bar (thin 2px bar under the file item, `--ctp-mauve` fill) when uploading.
|
||
- Remove button (`×`) on the right, `--ctp-overlay0` → `--ctp-red` on hover.
|
||
|
||
**Upload flow** (managed by `useFileUpload` hook):
|
||
|
||
1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`.
|
||
2. Backend returns `{ object_key, upload_url, expires_at }`.
|
||
3. `PUT` the file directly to the presigned MinIO URL using `XMLHttpRequest` (for progress tracking).
|
||
4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`.
|
||
5. The `object_key` is later sent to the item creation endpoint to associate the file.
|
||
|
||
If the presigned URL endpoint doesn't exist yet, see Backend Changes.
|
||
|
||
### TagInput
|
||
|
||
Reusable multi-select input for projects (and potentially other tag-like fields).
|
||
|
||
**Props**:
|
||
|
||
```typescript
|
||
interface TagInputProps {
|
||
value: string[]; // selected project IDs
|
||
onChange: (ids: string[]) => void;
|
||
placeholder?: string;
|
||
searchFn: (query: string) => Promise<{ id: string; label: string }[]>;
|
||
}
|
||
```
|
||
|
||
**Rendering**: Container styled like a form input (`--ctp-crust` bg, `--ctp-surface1` border, `border-radius: 0.4rem`). Inside:
|
||
- Selected tags as inline pills: `rgba(203, 166, 247, 0.15)` bg, `--ctp-mauve` text, 11px font, with `×` remove button.
|
||
- A bare `<input>` (no border/bg) that grows to fill remaining width, `min-width: 80px`.
|
||
|
||
**Behavior**: On typing, debounce 200ms, call `searchFn(query)`. Show a dropdown below the input with matching results. Click or Enter selects. Already-selected items are excluded from results. Escape or blur closes the dropdown.
|
||
|
||
The dropdown is an absolutely-positioned `<div>` below the input container, `--ctp-crust` background, `--ctp-surface1` border, `border-radius: 0.4rem`, `max-height: 160px`, `overflow-y: auto`. Each row is 28px, hover `--ctp-surface0`.
|
||
|
||
**For projects**: `searchFn` calls `GET /api/projects?q={query}` and maps to `{ id: project.id, label: project.code + ' — ' + project.name }`.
|
||
|
||
### useCategories Hook
|
||
|
||
```typescript
|
||
function useCategories(): {
|
||
categories: CategoryNode[];
|
||
loading: boolean;
|
||
error: string | null;
|
||
}
|
||
```
|
||
|
||
Fetches `GET /api/schemas` on mount and transforms into a category tree. Caches in a module-level variable so repeated renders don't refetch. If the API doesn't currently support hierarchical categories, this returns a flat list as a single-tier picker until the backend is extended.
|
||
|
||
### useFileUpload Hook
|
||
|
||
```typescript
|
||
function useFileUpload(): {
|
||
upload: (file: File) => Promise<PendingAttachment>;
|
||
uploading: boolean;
|
||
}
|
||
```
|
||
|
||
Encapsulates the presigned URL flow. Returns a function that takes a `File`, gets a presigned URL, uploads via XHR with progress tracking, and returns the completed `PendingAttachment`. The component manages the array of attachments in its own state.
|
||
|
||
## Styling
|
||
|
||
All styling via inline `React.CSSProperties` objects, per project convention. Reference Catppuccin tokens through `var(--ctp-*)` strings. No CSS modules, no Tailwind, no class names.
|
||
|
||
Common style patterns to extract as `const` objects at the top of each file:
|
||
|
||
```typescript
|
||
const styles = {
|
||
container: {
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 320px',
|
||
height: '100%',
|
||
overflow: 'hidden',
|
||
} as React.CSSProperties,
|
||
|
||
formArea: {
|
||
padding: '1.5rem 2rem',
|
||
overflowY: 'auto',
|
||
} as React.CSSProperties,
|
||
|
||
formGrid: {
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr 1fr',
|
||
gap: '1.25rem 1.5rem',
|
||
maxWidth: '800px',
|
||
} as React.CSSProperties,
|
||
|
||
sidebar: {
|
||
background: 'var(--ctp-mantle)',
|
||
borderLeft: '1px solid var(--ctp-surface0)',
|
||
display: 'flex',
|
||
flexDirection: 'column' as const,
|
||
overflowY: 'auto',
|
||
} as React.CSSProperties,
|
||
|
||
// ... etc
|
||
};
|
||
```
|
||
|
||
## Form Sections
|
||
|
||
The form is visually divided by section headers. Each header is a flex row containing a label (11px uppercase, `--ctp-overlay0`) and a `flex: 1` horizontal line (`1px solid --ctp-surface0`). Sections span `grid-column: 1 / -1`.
|
||
|
||
| Section | Fields |
|
||
|---------|--------|
|
||
| Identity | Part Number*, Type*, Description, Category* |
|
||
| Sourcing | Sourcing Type, Standard Cost, Unit of Measure, Sourcing Link |
|
||
| Details | Long Description, Projects |
|
||
|
||
## Sidebar Sections
|
||
|
||
The right sidebar is divided into three sections with `borderBottom: 1px solid var(--ctp-surface0)`:
|
||
|
||
**Auto-assigned metadata**: Read-only key-value rows showing:
|
||
- UUID: "On create" in `--ctp-teal` italic
|
||
- Revision: "A" (hardcoded initial)
|
||
- Created By: current user's display name from `useAuth()`
|
||
|
||
**Attachments**: `FileDropZone` component. Takes `flex: 1` to fill available space.
|
||
|
||
**Thumbnail**: A 4:3 aspect ratio placeholder box (`--ctp-crust` bg, `--ctp-surface0` border) with centered text "Generated from CAD file or upload manually". Clicking opens file picker filtered to images. If a thumbnail is uploaded, show it as an `<img>` with `object-fit: cover`.
|
||
|
||
## Backend Changes
|
||
|
||
Items 1-3 and 5 below are implemented (migration `011_item_files.sql`, `internal/api/file_handlers.go`). Item 4 (hierarchical categories) remains open.
|
||
|
||
### 1. Presigned Upload URL -- IMPLEMENTED
|
||
|
||
```
|
||
POST /api/uploads/presign
|
||
Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 }
|
||
Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." }
|
||
```
|
||
|
||
The Go handler generates a presigned PUT URL via the MinIO SDK. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix.
|
||
|
||
### 2. File Association -- IMPLEMENTED
|
||
|
||
```
|
||
POST /api/items/{id}/files
|
||
Request: { "object_key": "uploads/tmp/{uuid}/bracket.FCStd", "filename": "bracket.FCStd", "content_type": "...", "size": 2400000 }
|
||
Response: { "file_id": "uuid", "filename": "...", "size": ..., "created_at": "..." }
|
||
```
|
||
|
||
Moves the object from the temp prefix to `items/{item_id}/files/{file_id}` and creates a row in a new `item_files` table.
|
||
|
||
### 3. Thumbnail -- IMPLEMENTED
|
||
|
||
```
|
||
PUT /api/items/{id}/thumbnail
|
||
Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" }
|
||
Response: 204
|
||
```
|
||
|
||
Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column.
|
||
|
||
### 4. Hierarchical Categories -- NOT IMPLEMENTED
|
||
|
||
If schemas don't currently support a hierarchical category tree, one of these approaches:
|
||
|
||
**Option A — Schema-driven**: Add a `category_tree` JSON column to the `schemas` table that defines the hierarchy. The `GET /api/schemas` response already returns schemas; the frontend transforms this into the picker tree.
|
||
|
||
**Option B — Dedicated table**:
|
||
|
||
```sql
|
||
CREATE TABLE categories (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
name TEXT NOT NULL,
|
||
parent_id UUID REFERENCES categories(id),
|
||
sort_order INT NOT NULL DEFAULT 0,
|
||
UNIQUE(parent_id, name)
|
||
);
|
||
```
|
||
|
||
With endpoints:
|
||
```
|
||
GET /api/categories → flat list with parent_id, frontend builds tree
|
||
POST /api/categories → { name, parent_id? }
|
||
PUT /api/categories/{id} → { name, sort_order }
|
||
DELETE /api/categories/{id} → cascade check
|
||
```
|
||
|
||
**Recommendation**: Option B is more flexible and keeps categories as a first-class entity. The three-tier picker doesn't need to be limited to exactly three levels — it can render as many columns as the deepest category path, but three is the practical default (Domain → Group → Subtype).
|
||
|
||
### 5. Database Schema Addition -- IMPLEMENTED
|
||
|
||
```sql
|
||
CREATE TABLE item_files (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||
filename TEXT NOT NULL,
|
||
content_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
||
size BIGINT NOT NULL DEFAULT 0,
|
||
object_key TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE INDEX idx_item_files_item ON item_files(item_id);
|
||
|
||
ALTER TABLE items ADD COLUMN thumbnail_key TEXT;
|
||
ALTER TABLE items ADD COLUMN category_id UUID REFERENCES categories(id);
|
||
ALTER TABLE items ADD COLUMN sourcing_type TEXT NOT NULL DEFAULT 'manufactured';
|
||
ALTER TABLE items ADD COLUMN sourcing_link TEXT;
|
||
ALTER TABLE items ADD COLUMN standard_cost NUMERIC(12,2);
|
||
ALTER TABLE items ADD COLUMN unit_of_measure TEXT NOT NULL DEFAULT 'ea';
|
||
ALTER TABLE items ADD COLUMN long_description TEXT;
|
||
```
|
||
|
||
## Implementation Order
|
||
|
||
1. **TagInput component** — reusable, no backend changes needed, uses existing projects API.
|
||
2. **CategoryPicker component** — start with flat/mock data, wire to real API after backend adds categories.
|
||
3. **FileDropZone + useFileUpload** — requires presigned URL backend endpoint first.
|
||
4. **CreateItemPane rewrite** — compose the above into the two-column layout.
|
||
5. **Backend: categories table + endpoints** — unblocks real category data.
|
||
6. **Backend: presigned uploads + item_files** — unblocks file attachments.
|
||
7. **Backend: items table migration** — adds new columns (sourcing_type, standard_cost, etc.).
|
||
|
||
Steps 1-2 can start immediately. Steps 5-7 can run in parallel once specified. Step 4 ties it all together.
|
||
|
||
## Types to Add
|
||
|
||
Add to `web/src/api/types.ts`:
|
||
|
||
```typescript
|
||
// Categories
|
||
interface Category {
|
||
id: string;
|
||
name: string;
|
||
parent_id: string | null;
|
||
sort_order: number;
|
||
}
|
||
|
||
interface CategoryNode {
|
||
name: string;
|
||
id: string;
|
||
children?: CategoryNode[];
|
||
}
|
||
|
||
// File uploads
|
||
interface PresignRequest {
|
||
filename: string;
|
||
content_type: string;
|
||
size: number;
|
||
}
|
||
|
||
interface PresignResponse {
|
||
object_key: string;
|
||
upload_url: string;
|
||
expires_at: string;
|
||
}
|
||
|
||
interface ItemFile {
|
||
id: string;
|
||
item_id: string;
|
||
filename: string;
|
||
content_type: string;
|
||
size: number;
|
||
object_key: string;
|
||
created_at: string;
|
||
}
|
||
|
||
// Extended create request
|
||
interface CreateItemRequest {
|
||
part_number: string;
|
||
item_type: 'part' | 'assembly' | 'document';
|
||
description?: string;
|
||
category_id?: string;
|
||
sourcing_type?: 'manufactured' | 'purchased' | 'phantom';
|
||
standard_cost?: number;
|
||
unit_of_measure?: string;
|
||
sourcing_link?: string;
|
||
long_description?: string;
|
||
project_ids?: string[];
|
||
}
|
||
|
||
// Pending upload (frontend only, not an API type)
|
||
interface PendingAttachment {
|
||
file: File;
|
||
objectKey: string;
|
||
uploadProgress: number;
|
||
uploadStatus: 'pending' | 'uploading' | 'complete' | 'error';
|
||
error?: string;
|
||
}
|
||
```
|