docs(03): create phase 3 plans — dashboard, intake UI, PWA (5 plans, 2 waves)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
22c504247d
commit
1a9931f900
6 changed files with 3480 additions and 3 deletions
|
|
@ -69,8 +69,14 @@ Plans:
|
||||||
3. User can view full item detail including photos, specs, test data, and audit history
|
3. User can view full item detail including photos, specs, test data, and audit history
|
||||||
4. Intake flow accepts 1-3 photo uploads, shows AI classification result inline, and allows correction before creating the NetBox record
|
4. Intake flow accepts 1-3 photo uploads, shows AI classification result inline, and allows correction before creating the NetBox record
|
||||||
5. App is installable as a PWA on Android and the camera-based QR scanner resolves items by HW ID
|
5. App is installable as a PWA on Android and the camera-based QR scanner resolves items by HW ID
|
||||||
**Plans**: TBD
|
**Plans**: 5 plans
|
||||||
**UI hint**: yes
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 03-01-PLAN.md — Frontend scaffold: Vite 5 + React 18 + TypeScript + Tailwind + shadcn/ui, ClickHouse design tokens, TanStack Router/Query, Zustand
|
||||||
|
- [ ] 03-02-PLAN.md — Backend inventory endpoints: GET /api/inventory + GET /api/inventory/:id handlers + router wiring
|
||||||
|
- [ ] 03-03-PLAN.md — Dashboard + item detail views: grid/list toggle, filters, ItemCard/ItemRow, item detail with photos and custom fields
|
||||||
|
- [ ] 03-04-PLAN.md — Intake flow UI: DropZone, photo preview, AI result review, confidence meter, wired to POST /api/intake
|
||||||
|
- [ ] 03-05-PLAN.md — PWA setup: manifest.json, service worker, icons, camera QR scanner at /scan
|
||||||
|
|
||||||
### Phase 4: USB Manager & Label Printing
|
### Phase 4: USB Manager & Label Printing
|
||||||
**Goal**: USB peripherals are managed by a goroutine-per-device subsystem and any cataloged item can have a QR-coded label printed by the PRT Qutie without operator intervention after intake
|
**Goal**: USB peripherals are managed by a goroutine-per-device subsystem and any cataloged item can have a QR-coded label printed by the PRT Qutie without operator intervention after intake
|
||||||
|
|
@ -130,7 +136,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Foundation | 5/5 | Complete | 2026-04-10 |
|
| 1. Foundation | 5/5 | Complete | 2026-04-10 |
|
||||||
| 2. AI Pipeline | 4/4 | Complete | 2026-04-10 |
|
| 2. AI Pipeline | 4/4 | Complete | 2026-04-10 |
|
||||||
| 3. Dashboard & Intake UI | 0/TBD | Not started | - |
|
| 3. Dashboard & Intake UI | 0/5 | Not started | - |
|
||||||
| 4. USB Manager & Label Printing | 0/TBD | Not started | - |
|
| 4. USB Manager & Label Printing | 0/TBD | Not started | - |
|
||||||
| 5. Cable Test Integration | 0/TBD | Not started | - |
|
| 5. Cable Test Integration | 0/TBD | Not started | - |
|
||||||
| 6. Lab Advisor | 0/TBD | Not started | - |
|
| 6. Lab Advisor | 0/TBD | Not started | - |
|
||||||
|
|
|
||||||
819
.planning/phases/03-dashboard-intake-ui/03-01-PLAN.md
Normal file
819
.planning/phases/03-dashboard-intake-ui/03-01-PLAN.md
Normal file
|
|
@ -0,0 +1,819 @@
|
||||||
|
---
|
||||||
|
phase: 03-dashboard-intake-ui
|
||||||
|
plan: "01"
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- web/package.json
|
||||||
|
- web/tsconfig.json
|
||||||
|
- web/tsconfig.node.json
|
||||||
|
- web/vite.config.ts
|
||||||
|
- web/tailwind.config.ts
|
||||||
|
- web/postcss.config.cjs
|
||||||
|
- web/index.html
|
||||||
|
- web/src/main.tsx
|
||||||
|
- web/src/App.tsx
|
||||||
|
- web/src/router.tsx
|
||||||
|
- web/src/lib/queryClient.ts
|
||||||
|
- web/src/store/ui.ts
|
||||||
|
- web/src/styles/globals.css
|
||||||
|
- web/components.json
|
||||||
|
- web/src/components/ui/button.tsx
|
||||||
|
- web/src/components/ui/card.tsx
|
||||||
|
- web/src/components/ui/badge.tsx
|
||||||
|
- Makefile
|
||||||
|
autonomous: true
|
||||||
|
requirements: [UI-06]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Running `npm run dev` inside web/ serves the SPA at http://localhost:5173 with /api proxied to :8080"
|
||||||
|
- "Running `npm run build` inside web/ produces web/dist/index.html and web/dist/assets/ that Go can embed"
|
||||||
|
- "The page background is #000000 with neon volt (#faff69) as the accent color"
|
||||||
|
- "Inter font weight 900 is loaded and applied to display headings"
|
||||||
|
- "shadcn/ui Button, Card, and Badge components are available with ClickHouse tokens"
|
||||||
|
- "TanStack Router renders a /health-check route returning a plain text component"
|
||||||
|
- "TanStack Query QueryClientProvider wraps the app"
|
||||||
|
- "Zustand uiStore is defined and importable"
|
||||||
|
artifacts:
|
||||||
|
- path: "web/package.json"
|
||||||
|
provides: "npm project manifest with all dependencies pinned"
|
||||||
|
- path: "web/tailwind.config.ts"
|
||||||
|
provides: "ClickHouse design tokens as Tailwind CSS custom colors/fonts"
|
||||||
|
- path: "web/src/main.tsx"
|
||||||
|
provides: "React 18 entry point with Router + QueryClient providers"
|
||||||
|
- path: "web/src/router.tsx"
|
||||||
|
provides: "TanStack Router route tree with placeholder routes"
|
||||||
|
- path: "web/src/store/ui.ts"
|
||||||
|
provides: "Zustand UI store (view mode toggle, scanner state)"
|
||||||
|
- path: "web/components.json"
|
||||||
|
provides: "shadcn/ui CLI config"
|
||||||
|
- path: "web/src/components/ui/button.tsx"
|
||||||
|
provides: "shadcn/ui Button primitive"
|
||||||
|
- path: "web/src/components/ui/card.tsx"
|
||||||
|
provides: "shadcn/ui Card primitive"
|
||||||
|
- path: "web/src/components/ui/badge.tsx"
|
||||||
|
provides: "shadcn/ui Badge primitive"
|
||||||
|
key_links:
|
||||||
|
- from: "web/src/main.tsx"
|
||||||
|
to: "web/src/router.tsx"
|
||||||
|
via: "RouterProvider from @tanstack/react-router"
|
||||||
|
- from: "web/tailwind.config.ts"
|
||||||
|
to: "web/src/styles/globals.css"
|
||||||
|
via: "@tailwind directives + CSS custom properties"
|
||||||
|
- from: "web/vite.config.ts"
|
||||||
|
to: "http://localhost:8080"
|
||||||
|
via: "proxy: { '/api': 'http://localhost:8080' }"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Bootstrap the React+TypeScript frontend with the full toolchain and ClickHouse design system.
|
||||||
|
|
||||||
|
Purpose: Every subsequent UI plan depends on this scaffold existing. The design tokens, component library, routing shell, and build pipeline must all be correct before feature development begins.
|
||||||
|
Output: A buildable SPA in web/ with Vite 5, React 18, TypeScript 5, Tailwind 3, shadcn/ui, TanStack Router v1, TanStack Query v5, Zustand v4, and the ClickHouse visual identity.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
|
||||||
|
@assets.go
|
||||||
|
@web/dist/index.html
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Existing Go embed setup — web/dist is already embedded by assets.go at project root -->
|
||||||
|
<!-- web/dist/index.html is currently a stub placeholder — will be replaced by Vite build output -->
|
||||||
|
<!-- The Go server uses chi router and serves web/dist via go:embed -->
|
||||||
|
|
||||||
|
From assets.go:
|
||||||
|
```go
|
||||||
|
//go:embed web/dist
|
||||||
|
var StaticFiles embed.FS
|
||||||
|
```
|
||||||
|
|
||||||
|
From internal/api/router.go (SPA fallback handler):
|
||||||
|
```go
|
||||||
|
// Serves static files from embedded FS; falls back to index.html for client-side routing
|
||||||
|
r.Handle("/*", spaHandler{staticFS: staticFiles})
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Scaffold Vite+React project and apply ClickHouse design tokens</name>
|
||||||
|
<files>
|
||||||
|
web/package.json,
|
||||||
|
web/tsconfig.json,
|
||||||
|
web/tsconfig.node.json,
|
||||||
|
web/vite.config.ts,
|
||||||
|
web/tailwind.config.ts,
|
||||||
|
web/postcss.config.cjs,
|
||||||
|
web/index.html,
|
||||||
|
web/src/styles/globals.css,
|
||||||
|
web/components.json
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `web/dist/index.html` (current stub), `assets.go`, `internal/api/router.go`.
|
||||||
|
|
||||||
|
**1. Create web/package.json** with these exact dependencies:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "hwlab",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@tanstack/react-query": "^5.51.0",
|
||||||
|
"@tanstack/react-router": "^1.48.0",
|
||||||
|
"@tanstack/router-devtools": "^1.48.0",
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.417.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-dropzone": "^14.2.3",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"tailwind-merge": "^2.5.2",
|
||||||
|
"zustand": "^4.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||||
|
"@typescript-eslint/parser": "^7.15.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"eslint": "^9.7.0",
|
||||||
|
"postcss": "^8.4.40",
|
||||||
|
"tailwindcss": "^3.4.7",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create web/vite.config.ts:**
|
||||||
|
```typescript
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create web/tsconfig.json** (strict mode, path aliases):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create web/tsconfig.app.json:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Create web/tsconfig.node.json:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Create web/postcss.config.cjs:**
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Create web/tailwind.config.ts** with ClickHouse design tokens:
|
||||||
|
```typescript
|
||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// ClickHouse design system tokens
|
||||||
|
'volt': '#faff69', // neon yellow-green — primary CTA accent
|
||||||
|
'volt-pale': '#f4f692', // active/pressed state text
|
||||||
|
'forest': '#166534', // primary action button (Get Started)
|
||||||
|
'forest-dark':'#14572f', // forest border variant
|
||||||
|
'olive-dark': '#161600', // subtle brand text
|
||||||
|
'border-olive':'#4f5100', // ghost button borders
|
||||||
|
// Surfaces
|
||||||
|
'canvas': '#000000', // pure black background
|
||||||
|
'near-black': '#141414', // button bg, elevated surfaces
|
||||||
|
'hover-gray': '#3a3a3a', // button hover bg
|
||||||
|
'charcoal': '#414141', // primary border (used at 80% opacity in CSS)
|
||||||
|
'deep-charcoal':'#343434',// subtle dividers
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
display: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
body: ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
code: ['Inconsolata', 'monospace'],
|
||||||
|
},
|
||||||
|
fontWeight: {
|
||||||
|
black: '900',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
sharp: '4px',
|
||||||
|
card: '8px',
|
||||||
|
},
|
||||||
|
letterSpacing: {
|
||||||
|
widest: '1.4px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Create web/index.html** (replace the stub — this is the Vite entry point, not the final dist):
|
||||||
|
```html
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<title>HWLab</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inconsolata:wght@600&family=Inter:wght@400;500;600;700;900&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body class="bg-canvas text-white antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
```
|
||||||
|
|
||||||
|
**7. Create web/src/styles/globals.css** with CSS custom properties and Tailwind directives:
|
||||||
|
```css
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 0%;
|
||||||
|
--foreground: 0 0% 100%;
|
||||||
|
--card: 0 0% 8%;
|
||||||
|
--card-foreground: 0 0% 100%;
|
||||||
|
--border: 0 0% 25%;
|
||||||
|
--ring: 64 100% 71%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: rgba(65, 65, 65, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Charcoal border utility */
|
||||||
|
.border-charcoal-80 {
|
||||||
|
border-color: rgba(65, 65, 65, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ClickHouse card elevation */
|
||||||
|
.card-elevated {
|
||||||
|
box-shadow:
|
||||||
|
rgba(0,0,0,0.1) 0px 10px 15px -3px,
|
||||||
|
rgba(0,0,0,0.1) 0px 4px 6px -4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Neon accent glow (use sparingly) */
|
||||||
|
.ring-volt {
|
||||||
|
box-shadow: 0 0 0 2px #faff69;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uppercase tracked label */
|
||||||
|
.label-upper {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.4px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**8. Create web/components.json** (shadcn/ui CLI config — dark mode, no rsc, Tailwind aliases):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/styles/globals.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": false,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After creating config files: `cd web && npm install` to generate package-lock.json.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`npm run build` inside web/ completes without errors and produces web/dist/index.html and web/dist/assets/. The tailwind.config.ts contains all ClickHouse color tokens (volt, forest, canvas, charcoal, near-black). The vite.config.ts proxy routes /api to http://localhost:8080.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire React app entry point with Router, QueryClient, design primitives, and shadcn/ui components</name>
|
||||||
|
<files>
|
||||||
|
web/src/main.tsx,
|
||||||
|
web/src/App.tsx,
|
||||||
|
web/src/router.tsx,
|
||||||
|
web/src/lib/utils.ts,
|
||||||
|
web/src/lib/queryClient.ts,
|
||||||
|
web/src/store/ui.ts,
|
||||||
|
web/src/components/ui/button.tsx,
|
||||||
|
web/src/components/ui/card.tsx,
|
||||||
|
web/src/components/ui/badge.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `web/src/styles/globals.css`, `web/tailwind.config.ts` (just created in Task 1).
|
||||||
|
|
||||||
|
**1. Create web/src/lib/utils.ts** (shadcn/ui cn helper):
|
||||||
|
```typescript
|
||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create web/src/lib/queryClient.ts:**
|
||||||
|
```typescript
|
||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000, // 30s — inventory data doesn't change rapidly
|
||||||
|
retry: 2,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create web/src/store/ui.ts** (Zustand — UI state only, not server data):
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'list'
|
||||||
|
|
||||||
|
interface UIStore {
|
||||||
|
viewMode: ViewMode
|
||||||
|
setViewMode: (mode: ViewMode) => void
|
||||||
|
scannerActive: boolean
|
||||||
|
setScannerActive: (active: boolean) => void
|
||||||
|
intakeStep: number
|
||||||
|
setIntakeStep: (step: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIStore>((set) => ({
|
||||||
|
viewMode: 'grid',
|
||||||
|
setViewMode: (mode) => set({ viewMode: mode }),
|
||||||
|
scannerActive: false,
|
||||||
|
setScannerActive: (active) => set({ scannerActive: active }),
|
||||||
|
intakeStep: 0,
|
||||||
|
setIntakeStep: (step) => set({ intakeStep: step }),
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Create web/src/router.tsx** — TanStack Router v1 route tree with placeholder routes for all Phase 3 views:
|
||||||
|
```typescript
|
||||||
|
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
|
||||||
|
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||||
|
|
||||||
|
// Root layout — wraps all routes with the app shell
|
||||||
|
const rootRoute = createRootRoute({
|
||||||
|
component: () => (
|
||||||
|
<>
|
||||||
|
<Outlet />
|
||||||
|
{import.meta.env.DEV && <TanStackRouterDevtools />}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Routes — components are lazy-imported in Plan 03 and 04; stubs for now
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: () => (
|
||||||
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||||
|
<p className="text-volt font-display font-black text-2xl">HWLab — Dashboard loading…</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/item/$id',
|
||||||
|
component: () => (
|
||||||
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||||
|
<p className="text-volt font-display font-black text-2xl">Item detail loading…</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const intakeRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/intake',
|
||||||
|
component: () => (
|
||||||
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||||
|
<p className="text-volt font-display font-black text-2xl">Intake wizard loading…</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const scanRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/scan',
|
||||||
|
component: () => (
|
||||||
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||||
|
<p className="text-volt font-display font-black text-2xl">QR Scanner loading…</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const routeTree = rootRoute.addChildren([indexRoute, itemRoute, intakeRoute, scanRoute])
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
})
|
||||||
|
|
||||||
|
// TypeScript type augmentation for TanStack Router
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Create web/src/App.tsx:**
|
||||||
|
```typescript
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider } from '@tanstack/react-router'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import { queryClient } from '@/lib/queryClient'
|
||||||
|
import { router } from '@/router'
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: '#141414',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: '1px solid rgba(65,65,65,0.8)',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: { primary: '#faff69', secondary: '#000000' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Create web/src/main.tsx:**
|
||||||
|
```typescript
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import './styles/globals.css'
|
||||||
|
import { App } from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**7. Create shadcn/ui Button component at web/src/components/ui/button.tsx** (ClickHouse-themed variants):
|
||||||
|
```typescript
|
||||||
|
import * as React from 'react'
|
||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-volt disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
// Neon primary — #faff69 background, near-black text
|
||||||
|
default: 'bg-volt text-near-black rounded-sharp border border-volt hover:bg-near-black hover:text-white active:text-volt-pale',
|
||||||
|
// Forest green — primary action
|
||||||
|
forest: 'bg-forest text-white rounded-sharp border border-near-black hover:bg-near-black active:text-volt-pale',
|
||||||
|
// Dark solid
|
||||||
|
secondary: 'bg-near-black text-white rounded-sharp border border-near-black hover:bg-hover-gray active:text-volt-pale',
|
||||||
|
// Ghost / outlined
|
||||||
|
outline: 'bg-transparent text-white rounded-sharp border border-border-olive hover:bg-near-black active:text-volt-pale',
|
||||||
|
// Destructive
|
||||||
|
destructive: 'bg-red-900 text-white rounded-sharp hover:bg-red-800',
|
||||||
|
// Invisible — just text
|
||||||
|
ghost: 'bg-transparent text-white hover:text-volt',
|
||||||
|
link: 'text-volt underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 px-3 text-xs',
|
||||||
|
lg: 'h-11 px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
```
|
||||||
|
|
||||||
|
**8. Create web/src/components/ui/card.tsx** (ClickHouse charcoal card):
|
||||||
|
```typescript
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-card border border-charcoal/80 bg-near-black text-white shadow-sm transition-colors',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Card.displayName = 'Card'
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-4', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardHeader.displayName = 'CardHeader'
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h3 ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardTitle.displayName = 'CardTitle'
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('p-4 pt-0', className)} {...props} />,
|
||||||
|
)
|
||||||
|
CardContent.displayName = 'CardContent'
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-4 pt-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
)
|
||||||
|
CardFooter.displayName = 'CardFooter'
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardContent, CardFooter }
|
||||||
|
```
|
||||||
|
|
||||||
|
**9. Create web/src/components/ui/badge.tsx** (status badges with ClickHouse tokens):
|
||||||
|
```typescript
|
||||||
|
import * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-volt/40 bg-volt/10 text-volt',
|
||||||
|
indexed: 'border-green-500/40 bg-green-500/10 text-green-400',
|
||||||
|
draft: 'border-charcoal/80 bg-charcoal/20 text-gray-400',
|
||||||
|
needs_research: 'border-yellow-500/40 bg-yellow-500/10 text-yellow-400',
|
||||||
|
researched: 'border-blue-500/40 bg-blue-500/10 text-blue-400',
|
||||||
|
complete: 'border-forest/40 bg-forest/20 text-green-300',
|
||||||
|
destructive: 'border-red-500/40 bg-red-500/10 text-red-400',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
|
```
|
||||||
|
|
||||||
|
**10. Update Makefile** to add frontend build and dev targets:
|
||||||
|
- Read current Makefile first.
|
||||||
|
- Add these targets after existing ones:
|
||||||
|
```makefile
|
||||||
|
frontend:
|
||||||
|
cd web && npm run build
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd web && npm run dev
|
||||||
|
|
||||||
|
dev:
|
||||||
|
air -c .air.toml
|
||||||
|
```
|
||||||
|
(Note: `dev` already exists — if it does, keep the existing `dev` and add only `frontend` and `dev-frontend`.)
|
||||||
|
|
||||||
|
Finally run `cd web && npm run build` to verify the build succeeds and web/dist gets populated.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 && ls dist/assets/*.js 2>/dev/null | head -3</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`npm run build` succeeds (exit 0). `web/dist/assets/` contains at least one .js file. `web/src/store/ui.ts` exports `useUIStore` with `viewMode`, `scannerActive`, `intakeStep`. `web/src/router.tsx` exports `router` with routes for `/`, `/item/$id`, `/intake`, `/scan`. All shadcn/ui primitives (Button, Card, Badge) have ClickHouse variants.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → Go /api | All API requests cross this boundary; frontend must not forge hw_id or bypass intake |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-03-01 | Tampering | vite.config.ts proxy | accept | Dev proxy is localhost-only; no external exposure; production uses embedded SPA with no proxy |
|
||||||
|
| T-03-02 | Info Disclosure | google fonts CDN | accept | Only font metrics exposed; no auth tokens; acceptable for homelab context |
|
||||||
|
| T-03-03 | Spoofing | TanStack Router client-side routes | accept | No auth in Phase 3 — all routes are public; server enforces data access via NetBox token |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
From project root:
|
||||||
|
1. `cd web && npm run build` — exits 0, produces web/dist/index.html
|
||||||
|
2. `ls web/dist/assets/*.js` — at least one JS bundle present
|
||||||
|
3. `go build ./...` — Go binary still compiles (embed path unchanged)
|
||||||
|
4. `grep -r "volt" web/tailwind.config.ts` — confirms ClickHouse tokens present
|
||||||
|
5. `grep "proxy" web/vite.config.ts` — confirms /api proxy to :8080
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Vite 5 + React 18 + TypeScript 5 + Tailwind 3 project exists in web/
|
||||||
|
- `npm run build` produces web/dist/ ready for Go embed
|
||||||
|
- ClickHouse design tokens (volt, forest, canvas, charcoal, near-black) available as Tailwind classes
|
||||||
|
- Inter 900 weight loaded from Google Fonts
|
||||||
|
- TanStack Router renders placeholder routes for /, /item/$id, /intake, /scan
|
||||||
|
- TanStack Query QueryClientProvider wraps the app
|
||||||
|
- Zustand uiStore tracks viewMode, scannerActive, intakeStep
|
||||||
|
- shadcn/ui Button (neon/forest/secondary/outline variants), Card, Badge primitives exist
|
||||||
|
- Makefile has `frontend` and `dev-frontend` targets
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md` following the summary template.
|
||||||
|
</output>
|
||||||
346
.planning/phases/03-dashboard-intake-ui/03-02-PLAN.md
Normal file
346
.planning/phases/03-dashboard-intake-ui/03-02-PLAN.md
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
---
|
||||||
|
phase: 03-dashboard-intake-ui
|
||||||
|
plan: "02"
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- internal/api/handlers/inventory.go
|
||||||
|
- internal/api/handlers/inventory_test.go
|
||||||
|
- internal/api/router.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [UI-01, UI-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "GET /api/inventory returns a JSON array of inventory items with hw_id, name, catalog_status, and specs"
|
||||||
|
- "GET /api/inventory/:id returns a single item by NetBox device ID including all custom fields"
|
||||||
|
- "Both endpoints return proper HTTP status codes (200 on success, 404 for missing item, 502 on NetBox error)"
|
||||||
|
- "Unit tests pass without a live NetBox connection"
|
||||||
|
artifacts:
|
||||||
|
- path: "internal/api/handlers/inventory.go"
|
||||||
|
provides: "InventoryHandler with ListInventory and GetInventoryItem methods"
|
||||||
|
exports: ["InventoryHandler", "NewInventoryHandler", "InventoryNetBoxClient"]
|
||||||
|
- path: "internal/api/handlers/inventory_test.go"
|
||||||
|
provides: "Unit tests for list and detail endpoints with mock NetBox client"
|
||||||
|
- path: "internal/api/router.go"
|
||||||
|
provides: "GET /api/inventory and GET /api/inventory/:id routes registered"
|
||||||
|
key_links:
|
||||||
|
- from: "internal/api/handlers/inventory.go"
|
||||||
|
to: "internal/netbox.Client"
|
||||||
|
via: "InventoryNetBoxClient interface (ListDevices + GetDevice)"
|
||||||
|
- from: "internal/api/router.go"
|
||||||
|
to: "internal/api/handlers.InventoryHandler"
|
||||||
|
via: "r.Get('/inventory', ...) and r.Get('/inventory/{id}', ...)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add GET /api/inventory and GET /api/inventory/:id endpoints to the Go backend so the React dashboard can fetch real inventory data.
|
||||||
|
|
||||||
|
Purpose: The dashboard view (Plan 03) depends on these endpoints. They must exist and be testable before the frontend wires up TanStack Query hooks.
|
||||||
|
Output: Two handler methods behind an interface, registered on the chi router, with unit tests.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
|
||||||
|
@.planning/phases/02-ai-pipeline/02-03-SUMMARY.md
|
||||||
|
@internal/api/router.go
|
||||||
|
@internal/netbox/client.go
|
||||||
|
@internal/api/handlers/intake.go
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From internal/netbox/client.go — methods the inventory handler will use -->
|
||||||
|
```go
|
||||||
|
// ListDevices returns up to limit devices from NetBox.
|
||||||
|
func (c *Client) ListDevices(ctx context.Context, limit int) ([]Device, error)
|
||||||
|
|
||||||
|
// GetDevice retrieves a single device by its NetBox internal ID.
|
||||||
|
func (c *Client) GetDevice(ctx context.Context, id int) (*Device, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From internal/netbox/types.go — Device shape the handler returns as JSON -->
|
||||||
|
```go
|
||||||
|
type Device struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
AssetTag string // HW-XXXXX id
|
||||||
|
CustomFields CustomFields
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomFields struct {
|
||||||
|
HWID string
|
||||||
|
CatalogStatus string
|
||||||
|
ProductURL string
|
||||||
|
FirmwareVersion string
|
||||||
|
TestDate string
|
||||||
|
TestData string
|
||||||
|
AINotes string
|
||||||
|
PhotoURLs []string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From internal/api/router.go — current NewRouter signature to extend -->
|
||||||
|
```go
|
||||||
|
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Established pattern from intake.go: interface-for-testability -->
|
||||||
|
// Define a narrow interface (InventoryNetBoxClient) rather than taking *netbox.Client directly.
|
||||||
|
// This allows complete unit testing without a real NetBox.
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: InventoryHandler — list and detail endpoints with interface + unit tests</name>
|
||||||
|
<files>
|
||||||
|
internal/api/handlers/inventory.go,
|
||||||
|
internal/api/handlers/inventory_test.go
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- TestListInventoryEmpty: mock returns [], handler returns 200 with JSON `[]`
|
||||||
|
- TestListInventoryItems: mock returns 2 devices, handler returns 200 with JSON array of 2 items containing hw_id, name, catalog_status, asset_tag, photo_urls
|
||||||
|
- TestListInventoryNetBoxError: mock returns error, handler returns 502 with {"error": "..."}
|
||||||
|
- TestGetInventoryItemFound: mock returns device with id=42, GET /api/inventory/42 returns 200 with device JSON
|
||||||
|
- TestGetInventoryItemNotFound: mock returns nil device + error containing "404", handler returns 404
|
||||||
|
- TestGetInventoryItemInvalidID: GET /api/inventory/abc returns 400
|
||||||
|
- TestGetInventoryItemNetBoxError: mock returns server error, handler returns 502
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Read first: `internal/api/handlers/intake.go` (interface-for-testability pattern), `internal/netbox/types.go`, `internal/netbox/client.go`.
|
||||||
|
|
||||||
|
**RED phase — write tests first:**
|
||||||
|
|
||||||
|
Create `internal/api/handlers/inventory_test.go` with a `mockInventoryNetBoxClient` struct implementing `InventoryNetBoxClient`. Write all 7 tests listed in `<behavior>`. Run `go test ./internal/api/handlers/... -run TestInventory` — tests must FAIL (handler does not exist yet).
|
||||||
|
|
||||||
|
**GREEN phase — implement to pass tests:**
|
||||||
|
|
||||||
|
Create `internal/api/handlers/inventory.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"git.georgsen.dk/hwlab/internal/netbox"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InventoryNetBoxClient is the narrow interface the inventory handler needs.
|
||||||
|
// *netbox.Client satisfies this interface.
|
||||||
|
type InventoryNetBoxClient interface {
|
||||||
|
ListDevices(ctx context.Context, limit int) ([]netbox.Device, error)
|
||||||
|
GetDevice(ctx context.Context, id int) (*netbox.Device, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InventoryHandler handles GET /api/inventory and GET /api/inventory/{id}.
|
||||||
|
type InventoryHandler struct {
|
||||||
|
nb InventoryNetBoxClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewInventoryHandler creates an InventoryHandler.
|
||||||
|
func NewInventoryHandler(nb InventoryNetBoxClient) *InventoryHandler {
|
||||||
|
return &InventoryHandler{nb: nb}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InventoryItemResponse is the JSON shape the frontend consumes.
|
||||||
|
type InventoryItemResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
AssetTag string `json:"asset_tag"`
|
||||||
|
HWID string `json:"hw_id"`
|
||||||
|
CatalogStatus string `json:"catalog_status"`
|
||||||
|
ProductURL string `json:"product_url,omitempty"`
|
||||||
|
Firmware string `json:"firmware_version,omitempty"`
|
||||||
|
TestDate string `json:"test_date,omitempty"`
|
||||||
|
TestData string `json:"test_data,omitempty"`
|
||||||
|
AINotes string `json:"ai_notes,omitempty"`
|
||||||
|
PhotoURLs []string `json:"photo_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceToResponse(d netbox.Device) InventoryItemResponse {
|
||||||
|
urls := d.CustomFields.PhotoURLs
|
||||||
|
if urls == nil {
|
||||||
|
urls = []string{}
|
||||||
|
}
|
||||||
|
return InventoryItemResponse{
|
||||||
|
ID: d.ID,
|
||||||
|
Name: d.Name,
|
||||||
|
AssetTag: d.AssetTag,
|
||||||
|
HWID: d.CustomFields.HWID,
|
||||||
|
CatalogStatus: d.CustomFields.CatalogStatus,
|
||||||
|
ProductURL: d.CustomFields.ProductURL,
|
||||||
|
Firmware: d.CustomFields.FirmwareVersion,
|
||||||
|
TestDate: d.CustomFields.TestDate,
|
||||||
|
TestData: d.CustomFields.TestData,
|
||||||
|
AINotes: d.CustomFields.AINotes,
|
||||||
|
PhotoURLs: urls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInventory handles GET /api/inventory — returns up to 200 items.
|
||||||
|
func (h *InventoryHandler) ListInventory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
devices, err := h.nb.ListDevices(r.Context(), 200)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]InventoryItemResponse, 0, len(devices))
|
||||||
|
for _, d := range devices {
|
||||||
|
items = append(items, deviceToResponse(d))
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInventoryItem handles GET /api/inventory/{id}.
|
||||||
|
func (h *InventoryHandler) GetInventoryItem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rawID := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.Atoi(rawID)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "id must be an integer"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
device, err := h.nb.GetDevice(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if strings.Contains(err.Error(), "404") || strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": "item not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": fmt.Sprintf("netbox unavailable: %s", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(deviceToResponse(*device))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `go test ./internal/api/handlers/... -run TestInventory` — all 7 tests must PASS.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go test ./internal/api/handlers/... -run TestInventory -v 2>&1 | tail -20</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
All 7 TestInventory* tests pass. `internal/api/handlers/inventory.go` exports `InventoryHandler`, `NewInventoryHandler`, `InventoryNetBoxClient`, `InventoryItemResponse`. `go build ./...` succeeds.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Wire inventory routes into chi router and main.go</name>
|
||||||
|
<files>
|
||||||
|
internal/api/router.go,
|
||||||
|
cmd/hwlab/main.go
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `internal/api/router.go`, `cmd/hwlab/main.go`.
|
||||||
|
|
||||||
|
**1. Update internal/api/router.go** — extend `NewRouter` to accept an inventory handler:
|
||||||
|
|
||||||
|
Change signature from:
|
||||||
|
```go
|
||||||
|
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler) http.Handler
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```go
|
||||||
|
func NewRouter(staticFiles fs.FS, intakeHandler http.Handler, inventoryHandler *handlers.InventoryHandler) http.Handler
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the `/api` route group, add:
|
||||||
|
```go
|
||||||
|
r.Get("/inventory", inventoryHandler.ListInventory)
|
||||||
|
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
|
||||||
|
```
|
||||||
|
|
||||||
|
Full updated `/api` block:
|
||||||
|
```go
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Get("/health", handlers.Health)
|
||||||
|
r.Post("/intake", intakeHandler.ServeHTTP)
|
||||||
|
r.Get("/inventory", inventoryHandler.ListInventory)
|
||||||
|
r.Get("/inventory/{id}", inventoryHandler.GetInventoryItem)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Update cmd/hwlab/main.go** — construct InventoryHandler and pass to NewRouter:
|
||||||
|
|
||||||
|
After the existing netbox client construction (search for `netbox.NewClient`), add:
|
||||||
|
```go
|
||||||
|
inventoryHandler := handlers.NewInventoryHandler(nbClient)
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the `api.NewRouter(...)` call to include `inventoryHandler`:
|
||||||
|
```go
|
||||||
|
router := api.NewRouter(webFS, intakeHandler, inventoryHandler)
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `go build ./...` and `go test ./...` to confirm no regressions.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby && go build ./... 2>&1 && go test ./... 2>&1 | tail -15</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`go build ./...` exits 0. `go test ./...` — all existing tests pass (integration tests skip gracefully). `grep "inventory" internal/api/router.go` shows both GET routes. `grep "inventoryHandler" cmd/hwlab/main.go` confirms construction and wiring.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → GET /api/inventory | Untrusted caller requests inventory list — no auth in Phase 3 |
|
||||||
|
| Browser → GET /api/inventory/:id | URL parameter id is untrusted input |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-03-04 | Tampering | GET /api/inventory/{id} URL param | mitigate | `strconv.Atoi` rejects non-integer IDs; returns 400 — no raw string reaches NetBox API call |
|
||||||
|
| T-03-05 | Info Disclosure | GET /api/inventory error messages | accept | Error messages describe NetBox connectivity issues only; no credentials or internal paths exposed |
|
||||||
|
| T-03-06 | DoS | GET /api/inventory limit | mitigate | Hard-coded limit=200 in ListDevices call; caller cannot request unbounded result sets |
|
||||||
|
| T-03-07 | Elevation of Privilege | Inventory endpoints unauthenticated | accept | Phase 3 is homelab single-operator; no PII or critical data; Phase 4+ can add auth middleware if needed |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
From project root:
|
||||||
|
1. `go build ./...` — exits 0
|
||||||
|
2. `go test ./internal/api/handlers/... -run TestInventory -v` — 7 tests pass
|
||||||
|
3. `go test ./...` — all packages pass (integration tests skip)
|
||||||
|
4. `grep -n "inventory" internal/api/router.go` — shows both GET /inventory and GET /inventory/{id}
|
||||||
|
5. `grep "NewInventoryHandler" cmd/hwlab/main.go` — confirms wiring
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- GET /api/inventory returns JSON array using real NetBox ListDevices (integration test skips gracefully without live NetBox)
|
||||||
|
- GET /api/inventory/:id returns single item or 404/502 with proper status codes
|
||||||
|
- ID validation rejects non-integer inputs with 400
|
||||||
|
- 7 unit tests pass without live NetBox
|
||||||
|
- go build ./... succeeds — no regressions to Phase 1/2 code
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md` following the summary template.
|
||||||
|
</output>
|
||||||
884
.planning/phases/03-dashboard-intake-ui/03-03-PLAN.md
Normal file
884
.planning/phases/03-dashboard-intake-ui/03-03-PLAN.md
Normal file
|
|
@ -0,0 +1,884 @@
|
||||||
|
---
|
||||||
|
phase: 03-dashboard-intake-ui
|
||||||
|
plan: "03"
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["03-01", "03-02"]
|
||||||
|
files_modified:
|
||||||
|
- web/src/lib/api.ts
|
||||||
|
- web/src/hooks/useInventory.ts
|
||||||
|
- web/src/components/layout/AppShell.tsx
|
||||||
|
- web/src/components/layout/TopBar.tsx
|
||||||
|
- web/src/components/inventory/ItemCard.tsx
|
||||||
|
- web/src/components/inventory/ItemRow.tsx
|
||||||
|
- web/src/components/inventory/FilterBar.tsx
|
||||||
|
- web/src/components/inventory/StatusBadge.tsx
|
||||||
|
- web/src/pages/DashboardPage.tsx
|
||||||
|
- web/src/pages/ItemDetailPage.tsx
|
||||||
|
- web/src/router.tsx
|
||||||
|
autonomous: false
|
||||||
|
requirements: [UI-01, UI-02, UI-04, UI-05, PWA-02]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Visiting / shows the inventory dashboard with a grid of item cards on desktop"
|
||||||
|
- "Each card shows: photo (or placeholder icon), HW ID, item name, catalog_status badge, and key spec line from ai_notes"
|
||||||
|
- "Grid/list toggle switches the layout without a page reload"
|
||||||
|
- "Filter dropdowns for category, catalog_status, and location narrow the displayed items client-side"
|
||||||
|
- "Clicking a card navigates to /item/:id and shows the item detail page"
|
||||||
|
- "Item detail shows all custom fields, photo URLs as images, ai_notes, test_data (raw JSON)"
|
||||||
|
- "Quick actions on dashboard cards: 'View in NetBox' opens new tab to NetBox device URL"
|
||||||
|
- "Item detail page is readable on a 390px-wide mobile screen (PWA-02)"
|
||||||
|
artifacts:
|
||||||
|
- path: "web/src/lib/api.ts"
|
||||||
|
provides: "typed fetch wrappers for GET /api/inventory and GET /api/inventory/:id"
|
||||||
|
exports: ["InventoryItem", "fetchInventory", "fetchInventoryItem"]
|
||||||
|
- path: "web/src/hooks/useInventory.ts"
|
||||||
|
provides: "TanStack Query hooks wrapping api.ts"
|
||||||
|
exports: ["useInventory", "useInventoryItem"]
|
||||||
|
- path: "web/src/components/inventory/ItemCard.tsx"
|
||||||
|
provides: "Grid card component (photo, HW ID, name, status badge, quick actions)"
|
||||||
|
- path: "web/src/components/inventory/FilterBar.tsx"
|
||||||
|
provides: "Client-side filter controls (catalog_status, search by name)"
|
||||||
|
- path: "web/src/pages/DashboardPage.tsx"
|
||||||
|
provides: "/ route — inventory grid/list with filters"
|
||||||
|
- path: "web/src/pages/ItemDetailPage.tsx"
|
||||||
|
provides: "/item/$id route — full detail view, mobile responsive"
|
||||||
|
key_links:
|
||||||
|
- from: "web/src/pages/DashboardPage.tsx"
|
||||||
|
to: "web/src/hooks/useInventory.ts"
|
||||||
|
via: "useInventory() hook → TanStack Query → GET /api/inventory"
|
||||||
|
- from: "web/src/pages/ItemDetailPage.tsx"
|
||||||
|
to: "web/src/hooks/useInventory.ts"
|
||||||
|
via: "useInventoryItem(id) hook → TanStack Query → GET /api/inventory/:id"
|
||||||
|
- from: "web/src/router.tsx"
|
||||||
|
to: "web/src/pages/DashboardPage.tsx"
|
||||||
|
via: "indexRoute component replaced with lazy(() => import('./pages/DashboardPage'))"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the inventory dashboard and item detail views — the primary UI for browsing the homelab inventory.
|
||||||
|
|
||||||
|
Purpose: Users need to see, filter, and navigate their cataloged hardware. This is the core browsing experience wired to the real backend.
|
||||||
|
Output: Dashboard page with grid/list toggle and filters; item detail page with all custom fields; mobile-responsive layout following ClickHouse design system.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-02-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From Plan 02: InventoryItemResponse — the JSON shape from GET /api/inventory -->
|
||||||
|
```typescript
|
||||||
|
// TypeScript equivalent of internal/api/handlers.InventoryItemResponse
|
||||||
|
interface InventoryItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
asset_tag: string
|
||||||
|
hw_id: string
|
||||||
|
catalog_status: string // draft | indexed | needs_research | researched | complete
|
||||||
|
product_url?: string
|
||||||
|
firmware_version?: string
|
||||||
|
test_date?: string
|
||||||
|
test_data?: string // raw JSON string
|
||||||
|
ai_notes?: string
|
||||||
|
photo_urls: string[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From Plan 01: Zustand store for UI state -->
|
||||||
|
```typescript
|
||||||
|
import { useUIStore } from '@/store/ui'
|
||||||
|
const { viewMode, setViewMode } = useUIStore()
|
||||||
|
// viewMode: 'grid' | 'list'
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- From Plan 01: TanStack Router — route params -->
|
||||||
|
// /item/$id route param access:
|
||||||
|
// import { useParams } from '@tanstack/react-router'
|
||||||
|
// const { id } = useParams({ from: '/item/$id' })
|
||||||
|
|
||||||
|
<!-- From Plan 01: ClickHouse design tokens available as Tailwind classes -->
|
||||||
|
// bg-canvas (#000000), bg-near-black (#141414), text-volt (#faff69)
|
||||||
|
// bg-forest (#166534), border-charcoal/80 (rgba(65,65,65,0.8))
|
||||||
|
// rounded-card (8px), rounded-sharp (4px)
|
||||||
|
// font-display font-black (Inter 900)
|
||||||
|
// label-upper (uppercase tracked label)
|
||||||
|
|
||||||
|
<!-- From Plan 01: shadcn/ui components -->
|
||||||
|
// import { Button } from '@/components/ui/button'
|
||||||
|
// import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card'
|
||||||
|
// import { Badge } from '@/components/ui/badge'
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: API client, TanStack Query hooks, AppShell layout, and ItemCard/ItemRow components</name>
|
||||||
|
<files>
|
||||||
|
web/src/lib/api.ts,
|
||||||
|
web/src/hooks/useInventory.ts,
|
||||||
|
web/src/components/layout/AppShell.tsx,
|
||||||
|
web/src/components/layout/TopBar.tsx,
|
||||||
|
web/src/components/inventory/ItemCard.tsx,
|
||||||
|
web/src/components/inventory/ItemRow.tsx,
|
||||||
|
web/src/components/inventory/StatusBadge.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `web/src/store/ui.ts`, `web/src/components/ui/card.tsx`, `web/src/components/ui/badge.tsx`, `web/src/components/ui/button.tsx`.
|
||||||
|
|
||||||
|
**1. Create web/src/lib/api.ts** — typed fetch wrappers:
|
||||||
|
```typescript
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
asset_tag: string
|
||||||
|
hw_id: string
|
||||||
|
catalog_status: string
|
||||||
|
product_url?: string
|
||||||
|
firmware_version?: string
|
||||||
|
test_date?: string
|
||||||
|
test_data?: string
|
||||||
|
ai_notes?: string
|
||||||
|
photo_urls: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE = '/api'
|
||||||
|
|
||||||
|
async function fetchJSON<T>(url: string): Promise<T> {
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchInventory = (): Promise<InventoryItem[]> =>
|
||||||
|
fetchJSON<InventoryItem[]>(`${BASE}/inventory`)
|
||||||
|
|
||||||
|
export const fetchInventoryItem = (id: number): Promise<InventoryItem> =>
|
||||||
|
fetchJSON<InventoryItem>(`${BASE}/inventory/${id}`)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create web/src/hooks/useInventory.ts:**
|
||||||
|
```typescript
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchInventory, fetchInventoryItem } from '@/lib/api'
|
||||||
|
|
||||||
|
export function useInventory() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['inventory'],
|
||||||
|
queryFn: fetchInventory,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInventoryItem(id: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['inventory', id],
|
||||||
|
queryFn: () => fetchInventoryItem(id),
|
||||||
|
enabled: id > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create web/src/components/inventory/StatusBadge.tsx** — maps catalog_status to Badge variant:
|
||||||
|
```typescript
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
draft: 'Draft',
|
||||||
|
indexed: 'Indexed',
|
||||||
|
needs_research: 'Needs Research',
|
||||||
|
researched: 'Researched',
|
||||||
|
complete: 'Complete',
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeVariant = 'default' | 'indexed' | 'draft' | 'needs_research' | 'researched' | 'complete' | 'destructive'
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const variant = (status in STATUS_LABELS ? status : 'draft') as BadgeVariant
|
||||||
|
return <Badge variant={variant}>{STATUS_LABELS[status] ?? status}</Badge>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Create web/src/components/inventory/ItemCard.tsx** — grid card (ClickHouse style):
|
||||||
|
The card shows: photo or placeholder icon, HW ID (volt text, font-code), item name (white, font-semibold), StatusBadge, ai_notes preview (silver, truncated to 2 lines), quick action buttons (View in NetBox).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ExternalLink, Package } from 'lucide-react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { StatusBadge } from './StatusBadge'
|
||||||
|
import type { InventoryItem } from '@/lib/api'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface ItemCardProps {
|
||||||
|
item: InventoryItem
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemCard({ item, className }: ItemCardProps) {
|
||||||
|
const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to="/item/$id" params={{ id: String(item.id) }} className="block">
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
'hover:border-volt/60 transition-colors cursor-pointer h-full flex flex-col',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Photo */}
|
||||||
|
<div className="aspect-video bg-near-black rounded-t-card overflow-hidden flex items-center justify-center border-b border-charcoal/80">
|
||||||
|
{item.photo_urls.length > 0 ? (
|
||||||
|
<img
|
||||||
|
src={item.photo_urls[0]}
|
||||||
|
alt={item.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Package className="w-12 h-12 text-charcoal" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
{/* HW ID */}
|
||||||
|
<p className="font-code text-volt text-xs label-upper">{item.hw_id || item.asset_tag}</p>
|
||||||
|
<CardTitle className="text-white text-sm leading-tight line-clamp-2">{item.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pb-2 flex-1">
|
||||||
|
<StatusBadge status={item.catalog_status} />
|
||||||
|
{item.ai_notes && (
|
||||||
|
<p className="mt-2 text-xs text-[#a0a0a0] line-clamp-2">{item.ai_notes}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="pt-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-[#a0a0a0] hover:text-volt p-0 h-auto"
|
||||||
|
asChild
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="w-3 h-3 mr-1" />
|
||||||
|
NetBox
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Create web/src/components/inventory/ItemRow.tsx** — list-mode row:
|
||||||
|
Horizontal layout: status indicator bar (left, 4px, volt for indexed/complete, yellow for needs_research, gray for draft), HW ID, name, status badge, ai_notes preview, quick action. Uses `<tr>` or a flex div.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { StatusBadge } from './StatusBadge'
|
||||||
|
import type { InventoryItem } from '@/lib/api'
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
|
indexed: 'bg-green-500',
|
||||||
|
complete: 'bg-volt',
|
||||||
|
researched: 'bg-blue-500',
|
||||||
|
needs_research: 'bg-yellow-500',
|
||||||
|
draft: 'bg-charcoal',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemRow({ item }: { item: InventoryItem }) {
|
||||||
|
const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/`
|
||||||
|
const dot = STATUS_COLOR[item.catalog_status] ?? 'bg-charcoal'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/item/$id"
|
||||||
|
params={{ id: String(item.id) }}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 border-b border-charcoal/40 hover:bg-near-black/60 transition-colors group"
|
||||||
|
>
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className={`w-1 h-8 rounded-full flex-shrink-0 ${dot}`} />
|
||||||
|
|
||||||
|
{/* HW ID */}
|
||||||
|
<span className="font-code text-volt text-xs w-24 flex-shrink-0">{item.hw_id || item.asset_tag}</span>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<span className="text-white text-sm font-medium flex-1 truncate">{item.name}</span>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<StatusBadge status={item.catalog_status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes preview */}
|
||||||
|
{item.ai_notes && (
|
||||||
|
<span className="text-xs text-[#a0a0a0] truncate max-w-xs hidden lg:block">{item.ai_notes}</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick action */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-[#a0a0a0] hover:text-volt p-1 h-auto opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
asChild
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Create web/src/components/layout/TopBar.tsx** — app-wide navigation bar:
|
||||||
|
Left: "HWLab" in Inter Black volt text. Right: intake button (forest green), scan QR button (outline).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Plus, QrCode } from 'lucide-react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function TopBar() {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 flex items-center justify-between px-4 py-3 border-b border-charcoal/80 bg-canvas/95 backdrop-blur-sm">
|
||||||
|
<Link to="/" className="font-display font-black text-xl text-volt tracking-tight">
|
||||||
|
HWLab
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<Link to="/scan">
|
||||||
|
<QrCode className="w-4 h-4 mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">Scan</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button variant="forest" size="sm" asChild>
|
||||||
|
<Link to="/intake">
|
||||||
|
<Plus className="w-4 h-4 mr-1.5" />
|
||||||
|
<span className="hidden sm:inline">Add Item</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**7. Create web/src/components/layout/AppShell.tsx** — wraps TopBar + main content area:
|
||||||
|
```typescript
|
||||||
|
import { TopBar } from './TopBar'
|
||||||
|
|
||||||
|
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas flex flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<main className="flex-1 px-4 py-6 max-w-7xl mx-auto w-full">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`npm run build` succeeds. `web/src/lib/api.ts` exports `InventoryItem`, `fetchInventory`, `fetchInventoryItem`. `web/src/hooks/useInventory.ts` exports `useInventory`, `useInventoryItem`. All five component files exist with no TypeScript errors.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: DashboardPage, FilterBar, ItemDetailPage, and router wiring</name>
|
||||||
|
<files>
|
||||||
|
web/src/components/inventory/FilterBar.tsx,
|
||||||
|
web/src/pages/DashboardPage.tsx,
|
||||||
|
web/src/pages/ItemDetailPage.tsx,
|
||||||
|
web/src/router.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `web/src/router.tsx` (current stub routes), `web/src/store/ui.ts` (viewMode), `web/src/hooks/useInventory.ts`.
|
||||||
|
|
||||||
|
**1. Create web/src/components/inventory/FilterBar.tsx** — client-side filter controls:
|
||||||
|
Filter state is local component state (not Zustand — ephemeral UI). Filters: text search (name/hw_id), catalog_status select.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Search, LayoutGrid, List } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useUIStore } from '@/store/ui'
|
||||||
|
|
||||||
|
interface FilterBarProps {
|
||||||
|
search: string
|
||||||
|
onSearchChange: (v: string) => void
|
||||||
|
statusFilter: string
|
||||||
|
onStatusChange: (v: string) => void
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUSES = ['', 'draft', 'indexed', 'needs_research', 'researched', 'complete']
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
'': 'All Statuses',
|
||||||
|
draft: 'Draft',
|
||||||
|
indexed: 'Indexed',
|
||||||
|
needs_research: 'Needs Research',
|
||||||
|
researched: 'Researched',
|
||||||
|
complete: 'Complete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({ search, onSearchChange, statusFilter, onStatusChange, totalCount }: FilterBarProps) {
|
||||||
|
const { viewMode, setViewMode } = useUIStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative flex-1 min-w-48">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#a0a0a0]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search items…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white placeholder-[#585858] focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value)}
|
||||||
|
className="px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-sm text-white focus:outline-none focus:border-volt/60"
|
||||||
|
>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s} className="bg-near-black">
|
||||||
|
{STATUS_LABELS[s]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Item count */}
|
||||||
|
<span className="text-xs text-[#a0a0a0] label-upper mr-auto">
|
||||||
|
{totalCount} items
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* View toggle */}
|
||||||
|
<div className="flex rounded-sharp border border-charcoal/80 overflow-hidden">
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'grid' ? 'default' : 'secondary'}
|
||||||
|
size="icon"
|
||||||
|
className="rounded-none h-9 w-9"
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
title="Grid view"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={viewMode === 'list' ? 'default' : 'secondary'}
|
||||||
|
size="icon"
|
||||||
|
className="rounded-none h-9 w-9 border-l border-charcoal/80"
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
title="List view"
|
||||||
|
>
|
||||||
|
<List className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create web/src/pages/DashboardPage.tsx** — the main inventory view:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { FilterBar } from '@/components/inventory/FilterBar'
|
||||||
|
import { ItemCard } from '@/components/inventory/ItemCard'
|
||||||
|
import { ItemRow } from '@/components/inventory/ItemRow'
|
||||||
|
import { useInventory } from '@/hooks/useInventory'
|
||||||
|
import { useUIStore } from '@/store/ui'
|
||||||
|
import { Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
export function DashboardPage() {
|
||||||
|
const { data: items, isLoading, error } = useInventory()
|
||||||
|
const { viewMode } = useUIStore()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!items) return []
|
||||||
|
return items.filter((item) => {
|
||||||
|
const matchSearch =
|
||||||
|
!search ||
|
||||||
|
item.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(item.hw_id?.toLowerCase() ?? '').includes(search.toLowerCase()) ||
|
||||||
|
(item.asset_tag?.toLowerCase() ?? '').includes(search.toLowerCase())
|
||||||
|
const matchStatus = !statusFilter || item.catalog_status === statusFilter
|
||||||
|
return matchSearch && matchStatus
|
||||||
|
})
|
||||||
|
}, [items, search, statusFilter])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display font-black text-3xl text-white mb-1">Inventory</h1>
|
||||||
|
<p className="text-sm text-[#a0a0a0]">All cataloged hardware in your homelab</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterBar
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
onStatusChange={setStatusFilter}
|
||||||
|
totalCount={filtered.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||||
|
Loading inventory…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-3 p-4 border border-red-500/40 rounded-card bg-red-500/10 text-red-400">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span className="text-sm">{(error as Error).message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!isLoading && !error && filtered.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||||
|
<p className="font-display font-black text-4xl text-volt mb-2">0</p>
|
||||||
|
<p className="text-[#a0a0a0] text-sm">
|
||||||
|
{items && items.length > 0 ? 'No items match your filters' : 'No items cataloged yet — add your first item'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Grid view */}
|
||||||
|
{!isLoading && !error && filtered.length > 0 && viewMode === 'grid' && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<ItemCard key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* List view */}
|
||||||
|
{!isLoading && !error && filtered.length > 0 && viewMode === 'list' && (
|
||||||
|
<div className="border border-charcoal/80 rounded-card overflow-hidden">
|
||||||
|
{filtered.map((item) => (
|
||||||
|
<ItemRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create web/src/pages/ItemDetailPage.tsx** — full item detail, mobile-responsive:
|
||||||
|
|
||||||
|
Layout: TopBar via AppShell, back button, two-column on desktop (photos left, fields right), single column on mobile. Shows all custom fields, photos, raw test_data in a code block.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useParams, Link } from '@tanstack/react-router'
|
||||||
|
import { ArrowLeft, ExternalLink, Package } from 'lucide-react'
|
||||||
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { StatusBadge } from '@/components/inventory/StatusBadge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { useInventoryItem } from '@/hooks/useInventory'
|
||||||
|
import { Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
|
||||||
|
function FieldRow({ label, value }: { label: string; value?: string }) {
|
||||||
|
if (!value) return null
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 py-2 border-b border-charcoal/40 last:border-0">
|
||||||
|
<span className="label-upper text-[#a0a0a0] w-36 flex-shrink-0">{label}</span>
|
||||||
|
<span className="text-white text-sm flex-1 break-all">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ItemDetailPage() {
|
||||||
|
const { id } = useParams({ from: '/item/$id' })
|
||||||
|
const numericId = parseInt(id, 10)
|
||||||
|
const { data: item, isLoading, error } = useInventoryItem(numericId)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex items-center justify-center py-24 text-[#a0a0a0]">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mr-2" />
|
||||||
|
Loading item…
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !item) {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="flex items-center gap-3 p-4 border border-red-500/40 rounded-card bg-red-500/10 text-red-400">
|
||||||
|
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||||
|
<span className="text-sm">{(error as Error)?.message ?? 'Item not found'}</span>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const netboxUrl = `http://netbox.local/dcim/devices/${item.id}/`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
{/* Back nav */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/">
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-1.5" />
|
||||||
|
Inventory
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-wrap items-start gap-4 mb-6">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-code text-volt text-sm label-upper mb-1">{item.hw_id || item.asset_tag}</p>
|
||||||
|
<h1 className="font-display font-black text-2xl text-white leading-tight">{item.name}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<StatusBadge status={item.catalog_status} />
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={netboxUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
<ExternalLink className="w-3.5 h-3.5 mr-1.5" />
|
||||||
|
NetBox
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout: photos (left on lg+) / fields (right or below) */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Photos */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Photos</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{item.photo_urls.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{item.photo_urls.map((url, i) => (
|
||||||
|
<a key={i} href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`${item.name} photo ${i + 1}`}
|
||||||
|
className="w-full rounded-sharp object-cover aspect-square hover:opacity-80 transition-opacity"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-[#585858]">
|
||||||
|
<Package className="w-12 h-12 mb-2" />
|
||||||
|
<p className="text-sm">No photos</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Fields */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Details</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FieldRow label="HW ID" value={item.hw_id || item.asset_tag} />
|
||||||
|
<FieldRow label="Status" value={item.catalog_status} />
|
||||||
|
<FieldRow label="Firmware" value={item.firmware_version} />
|
||||||
|
<FieldRow label="Test Date" value={item.test_date} />
|
||||||
|
<FieldRow label="Product URL" value={item.product_url} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{item.ai_notes && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Notes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-white whitespace-pre-wrap">{item.ai_notes}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{item.test_data && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Test Data</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<pre className="font-code text-xs text-[#a0a0a0] bg-near-black p-3 rounded-sharp overflow-x-auto whitespace-pre-wrap break-words">
|
||||||
|
{(() => {
|
||||||
|
try { return JSON.stringify(JSON.parse(item.test_data!), null, 2) }
|
||||||
|
catch { return item.test_data }
|
||||||
|
})()}
|
||||||
|
</pre>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Update web/src/router.tsx** — replace stub components with real pages:
|
||||||
|
|
||||||
|
Read current `web/src/router.tsx`, then update `indexRoute` and `itemRoute` to use the real page components. Use lazy imports to keep bundle splitting:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
// ... existing imports ...
|
||||||
|
|
||||||
|
const DashboardPage = lazy(() => import('./pages/DashboardPage').then(m => ({ default: m.DashboardPage })))
|
||||||
|
const ItemDetailPage = lazy(() => import('./pages/ItemDetailPage').then(m => ({ default: m.ItemDetailPage })))
|
||||||
|
|
||||||
|
const Spinner = () => (
|
||||||
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||||
|
<div className="w-6 h-6 border-2 border-volt border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const indexRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/',
|
||||||
|
component: () => (
|
||||||
|
<Suspense fallback={<Spinner />}>
|
||||||
|
<DashboardPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/item/$id',
|
||||||
|
component: () => (
|
||||||
|
<Suspense fallback={<Spinner />}>
|
||||||
|
<ItemDetailPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep intakeRoute and scanRoute as stubs (Plan 04 and 05 replace them).
|
||||||
|
|
||||||
|
Run `npm run build` to confirm TypeScript compiles with no errors.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -15</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`npm run build` exits 0 with no TypeScript errors. `web/src/pages/DashboardPage.tsx` renders FilterBar (search + status dropdown + view toggle), grid/list conditional rendering, loading/error/empty states. `web/src/pages/ItemDetailPage.tsx` shows two-column layout with photo grid and field rows. `web/src/router.tsx` lazy-loads DashboardPage and ItemDetailPage.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
Dashboard and Item detail views wired to real backend inventory API.
|
||||||
|
|
||||||
|
- `/` — inventory grid (5-col on xl, responsive) with search, status filter, view toggle
|
||||||
|
- `/item/:id` — full detail with photos, custom fields, ai_notes, test_data, NetBox link
|
||||||
|
- ClickHouse design: black canvas, volt accents, charcoal card borders, Inter 900 display
|
||||||
|
- Grid/list toggle persists in Zustand (survives navigation within session)
|
||||||
|
- StatusBadge color-coded for all 5 catalog statuses
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Start Go backend: `cd /home/mikkel/homelabby && go run ./cmd/hwlab/...`
|
||||||
|
2. Start Vite dev server: `cd web && npm run dev`
|
||||||
|
3. Open http://localhost:5173
|
||||||
|
|
||||||
|
Check:
|
||||||
|
- [ ] Background is pure black (#000000)
|
||||||
|
- [ ] "HWLab" in the TopBar is volt (#faff69) Inter Black
|
||||||
|
- [ ] "Add Item" button is forest green, "Scan" is ghost/outlined
|
||||||
|
- [ ] With no NetBox items: empty state shows "0" in volt color, descriptive text
|
||||||
|
- [ ] With NetBox items (if live NetBox available): cards render with HW ID, name, status badge
|
||||||
|
- [ ] Grid/list toggle switches layout
|
||||||
|
- [ ] Search filters items client-side as you type
|
||||||
|
- [ ] Status dropdown filters correctly
|
||||||
|
- [ ] Clicking a card navigates to /item/:id
|
||||||
|
- [ ] Item detail shows two columns on desktop, single column on mobile (resize to 390px)
|
||||||
|
- [ ] "NetBox" link on detail page opens new tab
|
||||||
|
|
||||||
|
If NetBox is unavailable: verify the error state renders (red border, alert icon, error message) rather than crashing.
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe visual/functional issues to fix</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| React → GET /api/inventory | Frontend fetches inventory; all data comes from backend, not directly from NetBox |
|
||||||
|
| External links → NetBox | "View in NetBox" links open user-provided URLs — these use device IDs only |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-03-08 | Info Disclosure | photo_urls displayed as <img> | accept | URLs come from NetBox custom fields (trusted data store); homelab-internal only; no external URL injection path |
|
||||||
|
| T-03-09 | Tampering | test_data rendered in <pre> | mitigate | React escapes HTML in JSX; test_data is inside a <pre> tag as text content, not dangerouslySetInnerHTML — no XSS risk |
|
||||||
|
| T-03-10 | Info Disclosure | ai_notes rendered as text | accept | whitespace-pre-wrap rendering does not execute scripts; React's JSX rendering is safe |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `cd web && npm run build` — exits 0, no TypeScript errors
|
||||||
|
2. `go build ./...` — Go still compiles
|
||||||
|
3. Visual verification via checkpoint task
|
||||||
|
4. `grep "DashboardPage" web/src/router.tsx` — lazy import present
|
||||||
|
5. `grep "useInventory" web/src/pages/DashboardPage.tsx` — TanStack Query hook wired
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Dashboard renders grid/list of inventory items fetched from GET /api/inventory
|
||||||
|
- Filtering by name/hw_id and catalog_status works client-side without page reload
|
||||||
|
- Grid/list toggle works and persists in Zustand
|
||||||
|
- Item detail shows all custom fields, photos, and test data
|
||||||
|
- Mobile responsive: single column at 390px width (PWA-02)
|
||||||
|
- Quick action "View in NetBox" opens correct URL in new tab (UI-05)
|
||||||
|
- ClickHouse design system: black canvas, volt text, charcoal borders, Inter 900 display
|
||||||
|
- Human verification checkpoint passed
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-dashboard-intake-ui/03-03-SUMMARY.md` following the summary template.
|
||||||
|
</output>
|
||||||
732
.planning/phases/03-dashboard-intake-ui/03-04-PLAN.md
Normal file
732
.planning/phases/03-dashboard-intake-ui/03-04-PLAN.md
Normal file
|
|
@ -0,0 +1,732 @@
|
||||||
|
---
|
||||||
|
phase: 03-dashboard-intake-ui
|
||||||
|
plan: "04"
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["03-01"]
|
||||||
|
files_modified:
|
||||||
|
- web/src/lib/api.ts
|
||||||
|
- web/src/store/intake.ts
|
||||||
|
- web/src/components/intake/DropZone.tsx
|
||||||
|
- web/src/components/intake/PhotoPreview.tsx
|
||||||
|
- web/src/components/intake/AIResultReview.tsx
|
||||||
|
- web/src/components/intake/ConfirmForm.tsx
|
||||||
|
- web/src/pages/IntakePage.tsx
|
||||||
|
- web/src/router.tsx
|
||||||
|
autonomous: false
|
||||||
|
requirements: [UI-04, UI-05]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can drag-and-drop or click to upload 1-3 photos on the intake screen"
|
||||||
|
- "After upload, a loading state is shown while POST /api/intake is in flight"
|
||||||
|
- "AI result is displayed inline: model, manufacturer, category, suggested tags, ai_notes, confidence"
|
||||||
|
- "User can edit the name field before confirming"
|
||||||
|
- "Confirming on a high-confidence result (quick_add=true flag in request) creates the NetBox record"
|
||||||
|
- "After successful intake, a toast notification shows the assigned HW ID"
|
||||||
|
- "User is returned to dashboard after successful intake"
|
||||||
|
- "Errors from the backend are shown inline (not just console)"
|
||||||
|
artifacts:
|
||||||
|
- path: "web/src/store/intake.ts"
|
||||||
|
provides: "Zustand intake store — step tracking, photos, AI result, edit state"
|
||||||
|
exports: ["useIntakeStore", "IntakeResult"]
|
||||||
|
- path: "web/src/components/intake/DropZone.tsx"
|
||||||
|
provides: "react-dropzone file upload area, photo count indicator"
|
||||||
|
- path: "web/src/components/intake/AIResultReview.tsx"
|
||||||
|
provides: "AI result display with confidence meter and editable name field"
|
||||||
|
- path: "web/src/components/intake/ConfirmForm.tsx"
|
||||||
|
provides: "Final confirm/cancel actions"
|
||||||
|
- path: "web/src/pages/IntakePage.tsx"
|
||||||
|
provides: "/intake route — multi-step wizard orchestrator"
|
||||||
|
key_links:
|
||||||
|
- from: "web/src/pages/IntakePage.tsx"
|
||||||
|
to: "POST /api/intake"
|
||||||
|
via: "fetch in submitPhotos using FormData multipart upload"
|
||||||
|
- from: "web/src/components/intake/AIResultReview.tsx"
|
||||||
|
to: "web/src/store/intake.ts"
|
||||||
|
via: "useIntakeStore() — aiResult, editedName, step"
|
||||||
|
- from: "web/src/router.tsx"
|
||||||
|
to: "web/src/pages/IntakePage.tsx"
|
||||||
|
via: "intakeRoute component updated from stub to lazy IntakePage"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build the intake flow UI — photo upload, AI result review, and confirm-to-create workflow — wired to the POST /api/intake endpoint from Phase 2.
|
||||||
|
|
||||||
|
Purpose: Photo intake is the core value proposition of HWLab. The UI must make it effortless: drag photos, review AI output, correct if needed, confirm.
|
||||||
|
Output: A three-step wizard (Upload → Review → Done) using react-dropzone, Zustand state, and TanStack Query mutations.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
|
||||||
|
@.planning/phases/02-ai-pipeline/02-03-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- POST /api/intake — from Phase 2 Plan 03 -->
|
||||||
|
<!-- Request: multipart/form-data -->
|
||||||
|
<!-- photos[]: File (1-3 images) -->
|
||||||
|
<!-- quick_add: "true" | "false" (optional, string in FormData) -->
|
||||||
|
|
||||||
|
<!-- Response (201 success): -->
|
||||||
|
```typescript
|
||||||
|
interface IntakeResponse {
|
||||||
|
hw_id: string
|
||||||
|
model: string
|
||||||
|
manufacturer: string
|
||||||
|
category: string
|
||||||
|
specs: Record<string, string>
|
||||||
|
suggested_tags: string[]
|
||||||
|
ai_notes: string
|
||||||
|
confidence: number // 0.0 – 1.0
|
||||||
|
catalog_status: string // indexed | needs_research
|
||||||
|
netbox_id: number
|
||||||
|
queued: boolean // true if WAQ-enqueued (202)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
<!-- Response (202): queued=true, hw_id assigned but NetBox unavailable -->
|
||||||
|
<!-- Response (400): {"error": "..."} — wrong photo count or bad request -->
|
||||||
|
<!-- Response (503): {"error": "..."} — WAQ also unavailable -->
|
||||||
|
|
||||||
|
<!-- From Plan 01: Zustand pattern -->
|
||||||
|
// Create a separate store for intake (not in ui.ts — different concern)
|
||||||
|
// useIntakeStore with step, photos, aiResult, editedName, isSubmitting, error
|
||||||
|
|
||||||
|
<!-- From Plan 01: design tokens -->
|
||||||
|
// bg-canvas, bg-near-black, text-volt, border-charcoal/80, rounded-card, rounded-sharp
|
||||||
|
// Button variants: default (volt), forest, secondary, outline, ghost
|
||||||
|
|
||||||
|
<!-- react-dropzone usage: -->
|
||||||
|
// import { useDropzone } from 'react-dropzone'
|
||||||
|
// const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
// accept: { 'image/*': [] },
|
||||||
|
// maxFiles: 3,
|
||||||
|
// onDrop: acceptedFiles => { ... }
|
||||||
|
// })
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Intake store, DropZone component, and API mutation</name>
|
||||||
|
<files>
|
||||||
|
web/src/store/intake.ts,
|
||||||
|
web/src/lib/api.ts,
|
||||||
|
web/src/components/intake/DropZone.tsx,
|
||||||
|
web/src/components/intake/PhotoPreview.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `web/src/store/ui.ts` (Zustand pattern), `web/src/lib/api.ts` (current state), `web/src/components/ui/button.tsx`.
|
||||||
|
|
||||||
|
**1. Create web/src/store/intake.ts** — Zustand intake wizard state:
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
export interface IntakeResult {
|
||||||
|
hw_id: string
|
||||||
|
model: string
|
||||||
|
manufacturer: string
|
||||||
|
category: string
|
||||||
|
specs: Record<string, string>
|
||||||
|
suggested_tags: string[]
|
||||||
|
ai_notes: string
|
||||||
|
confidence: number
|
||||||
|
catalog_status: string
|
||||||
|
netbox_id: number
|
||||||
|
queued: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntakeStep = 'upload' | 'submitting' | 'review' | 'done' | 'error'
|
||||||
|
|
||||||
|
interface IntakeStore {
|
||||||
|
step: IntakeStep
|
||||||
|
photos: File[]
|
||||||
|
aiResult: IntakeResult | null
|
||||||
|
editedName: string
|
||||||
|
error: string | null
|
||||||
|
|
||||||
|
addPhoto: (file: File) => void
|
||||||
|
removePhoto: (index: number) => void
|
||||||
|
setStep: (step: IntakeStep) => void
|
||||||
|
setAIResult: (result: IntakeResult) => void
|
||||||
|
setEditedName: (name: string) => void
|
||||||
|
setError: (err: string | null) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
step: 'upload' as IntakeStep,
|
||||||
|
photos: [] as File[],
|
||||||
|
aiResult: null,
|
||||||
|
editedName: '',
|
||||||
|
error: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useIntakeStore = create<IntakeStore>((set) => ({
|
||||||
|
...initialState,
|
||||||
|
addPhoto: (file) =>
|
||||||
|
set((s) => ({
|
||||||
|
photos: s.photos.length < 3 ? [...s.photos, file] : s.photos,
|
||||||
|
})),
|
||||||
|
removePhoto: (index) =>
|
||||||
|
set((s) => ({ photos: s.photos.filter((_, i) => i !== index) })),
|
||||||
|
setStep: (step) => set({ step }),
|
||||||
|
setAIResult: (result) =>
|
||||||
|
set({ aiResult: result, editedName: result.model || result.manufacturer }),
|
||||||
|
setEditedName: (editedName) => set({ editedName }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Update web/src/lib/api.ts** — add intake mutation function:
|
||||||
|
Read the current file first, then add below the existing exports:
|
||||||
|
```typescript
|
||||||
|
export interface IntakeResponse {
|
||||||
|
hw_id: string
|
||||||
|
model: string
|
||||||
|
manufacturer: string
|
||||||
|
category: string
|
||||||
|
specs: Record<string, string>
|
||||||
|
suggested_tags: string[]
|
||||||
|
ai_notes: string
|
||||||
|
confidence: number
|
||||||
|
catalog_status: string
|
||||||
|
netbox_id: number
|
||||||
|
queued: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitIntake(photos: File[], quickAdd = false): Promise<IntakeResponse> {
|
||||||
|
const form = new FormData()
|
||||||
|
for (const photo of photos) {
|
||||||
|
form.append('photos[]', photo)
|
||||||
|
}
|
||||||
|
form.append('quick_add', String(quickAdd))
|
||||||
|
|
||||||
|
const res = await fetch('/api/intake', { method: 'POST', body: form })
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: res.statusText }))
|
||||||
|
throw new Error(body.error ?? `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
return res.json() as Promise<IntakeResponse>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create web/src/components/intake/PhotoPreview.tsx** — thumbnail grid with remove button:
|
||||||
|
```typescript
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface PhotoPreviewProps {
|
||||||
|
photos: File[]
|
||||||
|
onRemove: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PhotoPreview({ photos, onRemove }: PhotoPreviewProps) {
|
||||||
|
if (photos.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 flex-wrap mt-4">
|
||||||
|
{photos.map((file, i) => {
|
||||||
|
const url = URL.createObjectURL(file)
|
||||||
|
return (
|
||||||
|
<div key={i} className="relative w-24 h-24 rounded-sharp overflow-hidden border border-charcoal/80 group">
|
||||||
|
<img src={url} alt={`Photo ${i + 1}`} className="w-full h-full object-cover" />
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-1 right-1 h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => onRemove(i)}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Create web/src/components/intake/DropZone.tsx** — drag-drop upload area:
|
||||||
|
```typescript
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import { Upload, Camera } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface DropZoneProps {
|
||||||
|
photoCount: number
|
||||||
|
onDrop: (files: File[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX = 3
|
||||||
|
|
||||||
|
export function DropZone({ photoCount, onDrop }: DropZoneProps) {
|
||||||
|
const remaining = MAX - photoCount
|
||||||
|
const disabled = remaining === 0
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(accepted: File[]) => {
|
||||||
|
onDrop(accepted.slice(0, remaining))
|
||||||
|
},
|
||||||
|
[onDrop, remaining],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
accept: { 'image/*': [] },
|
||||||
|
maxFiles: remaining,
|
||||||
|
disabled,
|
||||||
|
onDrop: handleDrop,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={cn(
|
||||||
|
'relative flex flex-col items-center justify-center gap-3 rounded-card border-2 border-dashed p-12 text-center transition-colors',
|
||||||
|
isDragActive
|
||||||
|
? 'border-volt bg-volt/5 text-volt'
|
||||||
|
: disabled
|
||||||
|
? 'border-charcoal/40 text-charcoal cursor-not-allowed opacity-50'
|
||||||
|
: 'border-charcoal/80 text-[#a0a0a0] hover:border-volt/60 hover:text-white cursor-pointer',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} capture="environment" />
|
||||||
|
{isDragActive ? (
|
||||||
|
<Upload className="w-10 h-10 text-volt" />
|
||||||
|
) : (
|
||||||
|
<Camera className="w-10 h-10" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{isDragActive
|
||||||
|
? 'Drop photos here'
|
||||||
|
: disabled
|
||||||
|
? 'Maximum 3 photos reached'
|
||||||
|
: 'Drag photos or tap to shoot'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 opacity-70">
|
||||||
|
{disabled ? '' : `${remaining} of ${MAX} slots remaining · JPEG, PNG, WebP`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `npm run build` to confirm TypeScript compiles.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`npm run build` exits 0. `web/src/store/intake.ts` exports `useIntakeStore` and `IntakeResult`. `web/src/lib/api.ts` exports `submitIntake` and `IntakeResponse`. DropZone and PhotoPreview components exist with no TypeScript errors.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: AIResultReview, ConfirmForm, IntakePage, and router wiring</name>
|
||||||
|
<files>
|
||||||
|
web/src/components/intake/AIResultReview.tsx,
|
||||||
|
web/src/components/intake/ConfirmForm.tsx,
|
||||||
|
web/src/pages/IntakePage.tsx,
|
||||||
|
web/src/router.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `web/src/router.tsx` (current state — has stub intakeRoute), `web/src/store/intake.ts`, `web/src/components/ui/card.tsx`.
|
||||||
|
|
||||||
|
**1. Create web/src/components/intake/AIResultReview.tsx** — display AI extraction results with confidence meter and editable name:
|
||||||
|
```typescript
|
||||||
|
import { AlertTriangle, CheckCircle, Edit3 } from 'lucide-react'
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import type { IntakeResult } from '@/store/intake'
|
||||||
|
|
||||||
|
interface AIResultReviewProps {
|
||||||
|
result: IntakeResult
|
||||||
|
editedName: string
|
||||||
|
onNameChange: (name: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfidenceMeter({ value }: { value: number }) {
|
||||||
|
const pct = Math.round(value * 100)
|
||||||
|
const color =
|
||||||
|
pct >= 85 ? 'bg-green-500' :
|
||||||
|
pct >= 60 ? 'bg-yellow-500' :
|
||||||
|
'bg-red-500'
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-1.5 bg-near-black rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-[#a0a0a0]">{pct}%</span>
|
||||||
|
{pct >= 85 ? (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIResultReview({ result, editedName, onNameChange }: AIResultReviewProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Confidence */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Confidence</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ConfidenceMeter value={result.confidence} />
|
||||||
|
{result.confidence < 0.6 && (
|
||||||
|
<p className="mt-2 text-xs text-yellow-400">
|
||||||
|
Low confidence — item will be flagged for research
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Editable name */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm label-upper text-[#a0a0a0] flex items-center gap-2">
|
||||||
|
Item Name
|
||||||
|
<Edit3 className="w-3 h-3" />
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editedName}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-white text-sm focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30"
|
||||||
|
placeholder="Item name"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Classification */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">Classification</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Manufacturer</span>
|
||||||
|
<span className="text-white">{result.manufacturer || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Model</span>
|
||||||
|
<span className="text-white">{result.model || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Category</span>
|
||||||
|
<span className="text-white">{result.category || '—'}</span>
|
||||||
|
</div>
|
||||||
|
{result.suggested_tags.length > 0 && (
|
||||||
|
<div className="flex gap-3 flex-wrap items-start pt-1">
|
||||||
|
<span className="text-[#a0a0a0] w-28 flex-shrink-0">Tags</span>
|
||||||
|
<div className="flex gap-1.5 flex-wrap">
|
||||||
|
{result.suggested_tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="default" className="text-xs">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* AI notes */}
|
||||||
|
{result.ai_notes && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm label-upper text-[#a0a0a0]">AI Notes</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-white whitespace-pre-wrap">{result.ai_notes}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create web/src/components/intake/ConfirmForm.tsx** — final action buttons:
|
||||||
|
```typescript
|
||||||
|
import { CheckCircle, RotateCcw, Loader2 } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface ConfirmFormProps {
|
||||||
|
isSubmitting: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onReset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmForm({ isSubmitting, onConfirm, onReset }: ConfirmFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="forest"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating record…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Confirm & Create Record
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onReset} disabled={isSubmitting}>
|
||||||
|
<RotateCcw className="w-4 h-4 mr-1.5" />
|
||||||
|
Start Over
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Create web/src/pages/IntakePage.tsx** — the intake wizard orchestrator:
|
||||||
|
|
||||||
|
Three steps:
|
||||||
|
- `upload`: Show DropZone + PhotoPreview + "Analyze with AI" button (forest green)
|
||||||
|
- `submitting`: Uploading spinner overlay
|
||||||
|
- `review`: AIResultReview + ConfirmForm
|
||||||
|
- `done`: Success card with HW ID (volt) + "Add Another" / "Go to Dashboard" buttons
|
||||||
|
- `error`: Error card with retry
|
||||||
|
|
||||||
|
The page submits photos to POST /api/intake with `quick_add=false` initially (user reviews). The review ConfirmForm button calls `submitIntake` again with `quick_add=true` to trigger actual NetBox record creation (or relies on the already-created record from the initial call — see INTAKE-04 decision: handler always creates immediately). Check the Phase 2 decision: the backend creates the record on the first POST; there is no separate "confirm" POST. The UI review step is purely a display concern. The flow is: POST photos → show result → user edits name (display only, name was set by AI in NetBox already) → navigate to dashboard.
|
||||||
|
|
||||||
|
Adjust: since the backend creates on the first POST (INTAKE-04 decision from Phase 2 summary), the intake flow is:
|
||||||
|
1. Upload + submit (POST /api/intake) → NetBox record created, AI result returned
|
||||||
|
2. Review result (readonly display, name already in NetBox)
|
||||||
|
3. Done → toast with HW ID → navigate to dashboard
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useNavigate, Link } from '@tanstack/react-router'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { Loader2, CheckCircle2 } from 'lucide-react'
|
||||||
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { DropZone } from '@/components/intake/DropZone'
|
||||||
|
import { PhotoPreview } from '@/components/intake/PhotoPreview'
|
||||||
|
import { AIResultReview } from '@/components/intake/AIResultReview'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { useIntakeStore } from '@/store/intake'
|
||||||
|
import { submitIntake } from '@/lib/api'
|
||||||
|
|
||||||
|
export function IntakePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const {
|
||||||
|
step, photos, aiResult, editedName,
|
||||||
|
addPhoto, removePhoto, setStep, setAIResult, setEditedName, setError, reset,
|
||||||
|
} = useIntakeStore()
|
||||||
|
|
||||||
|
async function handleAnalyze() {
|
||||||
|
if (photos.length === 0) return
|
||||||
|
setStep('submitting')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await submitIntake(photos, false)
|
||||||
|
setAIResult(result)
|
||||||
|
setStep('review')
|
||||||
|
toast.success(`HW ID assigned: ${result.hw_id}`)
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message)
|
||||||
|
setStep('error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDone() {
|
||||||
|
const hwid = aiResult?.hw_id
|
||||||
|
reset()
|
||||||
|
if (hwid) {
|
||||||
|
toast.success(`${hwid} added to inventory`)
|
||||||
|
}
|
||||||
|
navigate({ to: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display font-black text-3xl text-white mb-1">Add Item</h1>
|
||||||
|
<p className="text-sm text-[#a0a0a0]">Upload 1–3 photos — AI extracts specs automatically</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step: upload */}
|
||||||
|
{(step === 'upload' || step === 'error') && (
|
||||||
|
<div className="max-w-xl space-y-4">
|
||||||
|
<DropZone photoCount={photos.length} onDrop={(files) => files.forEach(addPhoto)} />
|
||||||
|
<PhotoPreview photos={photos} onRemove={removePhoto} />
|
||||||
|
{step === 'error' && (
|
||||||
|
<Card className="border-red-500/40 bg-red-500/10">
|
||||||
|
<CardContent className="p-4 text-sm text-red-400">
|
||||||
|
Analysis failed — check that the Go backend is running and try again.
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="forest"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={photos.length === 0}
|
||||||
|
>
|
||||||
|
Analyze with AI
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: submitting */}
|
||||||
|
{step === 'submitting' && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-volt" />
|
||||||
|
<p className="text-white font-semibold">Analyzing photos…</p>
|
||||||
|
<p className="text-sm text-[#a0a0a0]">Gemma 4 is extracting specs</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step: review */}
|
||||||
|
{step === 'review' && aiResult && (
|
||||||
|
<div className="max-w-xl space-y-4">
|
||||||
|
<AIResultReview
|
||||||
|
result={aiResult}
|
||||||
|
editedName={editedName}
|
||||||
|
onNameChange={setEditedName}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="forest" className="flex-1" onClick={handleDone}>
|
||||||
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
||||||
|
Done — Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={reset}>
|
||||||
|
Add Another
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{aiResult.queued && (
|
||||||
|
<p className="text-xs text-yellow-400 text-center">
|
||||||
|
NetBox was unavailable — item is queued for sync
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. Update web/src/router.tsx** — replace stub intakeRoute with lazy IntakePage:
|
||||||
|
Read current router.tsx, then update `intakeRoute` component:
|
||||||
|
```typescript
|
||||||
|
const IntakePage = lazy(() => import('./pages/IntakePage').then(m => ({ default: m.IntakePage })))
|
||||||
|
|
||||||
|
const intakeRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/intake',
|
||||||
|
component: () => (
|
||||||
|
<Suspense fallback={<Spinner />}>
|
||||||
|
<IntakePage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `npm run build`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`npm run build` exits 0. `web/src/pages/IntakePage.tsx` exports `IntakePage`. `web/src/router.tsx` lazy-loads IntakePage for /intake route. All intake components exist with no TypeScript errors.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
Intake flow UI at /intake:
|
||||||
|
- DropZone with drag-drop, camera capture (capture="environment"), max 3 photos
|
||||||
|
- PhotoPreview thumbnails with remove button per photo
|
||||||
|
- "Analyze with AI" forest-green button (disabled until ≥1 photo added)
|
||||||
|
- Spinner while POST /api/intake is in flight
|
||||||
|
- AI result review: confidence meter (green ≥85%, yellow 60-84%, red <60%), manufacturer/model/category/tags, editable name, ai_notes
|
||||||
|
- "Done — Go to Dashboard" routes back to / with toast showing HW ID
|
||||||
|
- "Add Another" resets wizard state
|
||||||
|
- Error state on backend failure (red card, retry possible)
|
||||||
|
- Toast on success: "HW-XXXXX added to inventory"
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Start Go backend: `cd /home/mikkel/homelabby && go run ./cmd/hwlab/...`
|
||||||
|
2. Start Vite dev server: `cd web && npm run dev`
|
||||||
|
3. Open http://localhost:5173/intake
|
||||||
|
|
||||||
|
Check:
|
||||||
|
- [ ] DropZone renders with camera icon, "Drag photos or tap to shoot" text
|
||||||
|
- [ ] DropZone has volt border glow when dragging a file over it
|
||||||
|
- [ ] "Analyze with AI" button is forest green and disabled with no photos
|
||||||
|
- [ ] After dropping/selecting a photo: thumbnail appears with X remove button
|
||||||
|
- [ ] Maximum 3 photos: 4th drop is silently ignored, DropZone shows "Maximum 3 photos reached"
|
||||||
|
- [ ] Clicking "Analyze with AI" with photos shows spinner state
|
||||||
|
- [ ] With live Go backend + oMLX: AI result appears with confidence meter, tags, notes
|
||||||
|
- [ ] Without live AI: error state shows (red card), no crash
|
||||||
|
- [ ] "Done — Go to Dashboard" navigates to / and shows toast
|
||||||
|
- [ ] On mobile (390px): single column, DropZone usable, camera capture works
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → POST /api/intake | Multipart file upload crosses here — file content is untrusted |
|
||||||
|
| AI result → display | AI-extracted strings are displayed in UI |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-03-11 | Tampering | submitIntake FormData | accept | File bytes sent directly; no filename manipulation; backend validates count and MIME; server-side hw_id assignment (T-02-13 already mitigated in Phase 2) |
|
||||||
|
| T-03-12 | Info Disclosure | AIResultReview renders ai_notes | accept | React JSX text rendering is safe; no dangerouslySetInnerHTML used; ai_notes is plain text |
|
||||||
|
| T-03-13 | DoS | Multiple rapid intake submits | mitigate | "Analyze" button is disabled during `step === 'submitting'` — prevents duplicate concurrent POSTs from the same session |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `cd web && npm run build` — exits 0
|
||||||
|
2. `grep "IntakePage" web/src/router.tsx` — lazy import present
|
||||||
|
3. `grep "submitIntake" web/src/pages/IntakePage.tsx` — confirms API wiring
|
||||||
|
4. `grep "capture" web/src/components/intake/DropZone.tsx` — confirms camera capture attribute
|
||||||
|
5. Visual verification via checkpoint task
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Photo upload via drag-drop or file picker works with 1-3 photos
|
||||||
|
- Camera capture attribute present for mobile PWA use
|
||||||
|
- POST /api/intake called with correct multipart FormData
|
||||||
|
- AI result displays confidence, category, tags, ai_notes, manufacturer, model
|
||||||
|
- Success toast shows HW ID; user navigates to dashboard
|
||||||
|
- Error state renders on backend failure (no crash)
|
||||||
|
- Spinner shown during network request
|
||||||
|
- "Add Another" resets wizard to upload step
|
||||||
|
- Human verification checkpoint passed
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-dashboard-intake-ui/03-04-SUMMARY.md` following the summary template.
|
||||||
|
</output>
|
||||||
690
.planning/phases/03-dashboard-intake-ui/03-05-PLAN.md
Normal file
690
.planning/phases/03-dashboard-intake-ui/03-05-PLAN.md
Normal file
|
|
@ -0,0 +1,690 @@
|
||||||
|
---
|
||||||
|
phase: 03-dashboard-intake-ui
|
||||||
|
plan: "05"
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["03-01"]
|
||||||
|
files_modified:
|
||||||
|
- web/public/manifest.json
|
||||||
|
- web/public/sw.js
|
||||||
|
- web/public/icons/icon-192.png
|
||||||
|
- web/public/icons/icon-512.png
|
||||||
|
- web/src/hooks/usePWA.ts
|
||||||
|
- web/src/pages/ScanPage.tsx
|
||||||
|
- web/src/router.tsx
|
||||||
|
- web/index.html
|
||||||
|
autonomous: false
|
||||||
|
requirements: [PWA-01, PWA-02, PWA-03]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Chrome on Android shows the 'Add to Home Screen' banner for the app"
|
||||||
|
- "The installed PWA launches in standalone mode (no browser chrome)"
|
||||||
|
- "Visiting /scan activates the device rear camera and scans QR codes in real-time"
|
||||||
|
- "A scanned HW-XXXXX QR code navigates the user to /item/:id for that item"
|
||||||
|
- "Service worker caches the app shell so the UI loads offline (no network required for the shell)"
|
||||||
|
artifacts:
|
||||||
|
- path: "web/public/manifest.json"
|
||||||
|
provides: "PWA web app manifest (name, icons, display=standalone, theme_color)"
|
||||||
|
- path: "web/public/sw.js"
|
||||||
|
provides: "Service worker — app shell cache strategy for offline shell"
|
||||||
|
- path: "web/src/pages/ScanPage.tsx"
|
||||||
|
provides: "/scan route — camera QR scanner using @zxing/browser"
|
||||||
|
- path: "web/src/hooks/usePWA.ts"
|
||||||
|
provides: "Service worker registration hook"
|
||||||
|
key_links:
|
||||||
|
- from: "web/index.html"
|
||||||
|
to: "web/public/manifest.json"
|
||||||
|
via: "<link rel='manifest' href='/manifest.json'>"
|
||||||
|
- from: "web/src/hooks/usePWA.ts"
|
||||||
|
to: "web/public/sw.js"
|
||||||
|
via: "navigator.serviceWorker.register('/sw.js')"
|
||||||
|
- from: "web/src/pages/ScanPage.tsx"
|
||||||
|
to: "http://localhost:8080/api/inventory"
|
||||||
|
via: "Decoded QR text parsed as HW-XXXXX → navigate to /item/:id"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Set up the PWA manifest, service worker, and camera-based QR scanner so HWLab is installable on Android and items can be looked up by scanning their printed QR labels.
|
||||||
|
|
||||||
|
Purpose: PWA installability and QR scanning close the hardware inventory loop — print a label on intake, scan it later to pull up the item record instantly.
|
||||||
|
Output: Installable PWA with app shell offline caching and a /scan camera QR scanner that resolves HW IDs.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-CONTEXT.md
|
||||||
|
@.planning/phases/03-dashboard-intake-ui/03-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- @zxing/browser usage (already in package.json from Plan 01) -->
|
||||||
|
```typescript
|
||||||
|
import { BrowserQRCodeReader, IScannerControls } from '@zxing/browser'
|
||||||
|
|
||||||
|
const reader = new BrowserQRCodeReader()
|
||||||
|
// Get list of cameras:
|
||||||
|
const devices = await BrowserQRCodeReader.listVideoInputDevices()
|
||||||
|
// Start scanning — calls callback on each decoded result:
|
||||||
|
const controls: IScannerControls = await reader.decodeFromVideoDevice(
|
||||||
|
deviceId, // undefined = default (rear) camera
|
||||||
|
videoElement, // HTMLVideoElement ref
|
||||||
|
(result, _err, controls) => {
|
||||||
|
if (result) {
|
||||||
|
const text = result.getText()
|
||||||
|
// text will be the QR code content
|
||||||
|
controls.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// Stop: controls.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- QR code URL format (from Phase 1 PRD and LBL-01): -->
|
||||||
|
// QR codes encode: http://mac-mini.mg:8080/hw/XXXXX
|
||||||
|
// Or just the HW ID: HW-00001
|
||||||
|
// Parse: extract HW ID from URL path or direct match
|
||||||
|
// Lookup: GET /api/inventory returns hw_id field; match by hw_id to get numeric id
|
||||||
|
// Navigate: router.navigate({ to: '/item/$id', params: { id: String(numericId) } })
|
||||||
|
|
||||||
|
<!-- From Plan 01: router export -->
|
||||||
|
// import { router } from '@/router'
|
||||||
|
// router.navigate({ to: '/item/$id', params: { id: String(numericId) } })
|
||||||
|
|
||||||
|
<!-- From Plan 01: design tokens -->
|
||||||
|
// bg-canvas, text-volt, bg-near-black, border-charcoal/80
|
||||||
|
// Button variant="forest" for primary actions
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: PWA manifest, service worker, icons, and registration hook</name>
|
||||||
|
<files>
|
||||||
|
web/public/manifest.json,
|
||||||
|
web/public/sw.js,
|
||||||
|
web/public/icons/icon-192.png,
|
||||||
|
web/public/icons/icon-512.png,
|
||||||
|
web/src/hooks/usePWA.ts,
|
||||||
|
web/index.html
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `web/index.html` (current state from Plan 01).
|
||||||
|
|
||||||
|
**1. Create web/public/ directory structure:**
|
||||||
|
```
|
||||||
|
web/public/
|
||||||
|
manifest.json
|
||||||
|
sw.js
|
||||||
|
icons/
|
||||||
|
icon-192.png (placeholder — generate programmatically)
|
||||||
|
icon-512.png (placeholder — generate programmatically)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. Create web/public/manifest.json** (PWA-01: installable on Android):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "HWLab",
|
||||||
|
"short_name": "HWLab",
|
||||||
|
"description": "Self-hosted hardware inventory with AI photo intake",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#000000",
|
||||||
|
"theme_color": "#faff69",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["utilities", "productivity"],
|
||||||
|
"screenshots": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. Generate placeholder icons using Node.js (no ImageMagick required):**
|
||||||
|
Run this script to create minimal valid PNG icons programmatically. Create `web/scripts/gen-icons.cjs`:
|
||||||
|
```js
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Generates minimal PNG icons for the PWA manifest.
|
||||||
|
// Uses pure Node.js — no canvas or image library needed.
|
||||||
|
// Output: a valid 1x1 black PNG with correct PNG header and IDAT chunk.
|
||||||
|
// For production, replace with properly designed icons.
|
||||||
|
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const zlib = require('zlib')
|
||||||
|
|
||||||
|
function makePNG(size, bgHex, fgHex) {
|
||||||
|
// PNG signature
|
||||||
|
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||||
|
|
||||||
|
// IHDR chunk
|
||||||
|
const ihdrData = Buffer.alloc(13)
|
||||||
|
ihdrData.writeUInt32BE(size, 0)
|
||||||
|
ihdrData.writeUInt32BE(size, 4)
|
||||||
|
ihdrData[8] = 8 // bit depth
|
||||||
|
ihdrData[9] = 2 // color type RGB
|
||||||
|
ihdrData[10] = 0 // compression
|
||||||
|
ihdrData[11] = 0 // filter
|
||||||
|
ihdrData[12] = 0 // interlace
|
||||||
|
const ihdr = makeChunk('IHDR', ihdrData)
|
||||||
|
|
||||||
|
// IDAT chunk — raw RGB pixel data, filtered (filter byte 0 = none per row)
|
||||||
|
const bg = hexToRgb(bgHex)
|
||||||
|
const fg = hexToRgb(fgHex)
|
||||||
|
const rawRows = []
|
||||||
|
const margin = Math.floor(size * 0.2)
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
const row = [0] // filter byte
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
// Simple "H" monogram in the center
|
||||||
|
const inMargin = x >= margin && x < size - margin && y >= margin && y < size - margin
|
||||||
|
const isStroke =
|
||||||
|
inMargin &&
|
||||||
|
((x < margin + Math.floor(size * 0.08) || x > size - margin - Math.floor(size * 0.08)) ||
|
||||||
|
(Math.abs(y - size / 2) < Math.floor(size * 0.06)))
|
||||||
|
const c = isStroke ? fg : bg
|
||||||
|
row.push(c[0], c[1], c[2])
|
||||||
|
}
|
||||||
|
rawRows.push(Buffer.from(row))
|
||||||
|
}
|
||||||
|
const rawData = Buffer.concat(rawRows)
|
||||||
|
const compressed = zlib.deflateSync(rawData)
|
||||||
|
const idat = makeChunk('IDAT', compressed)
|
||||||
|
|
||||||
|
// IEND
|
||||||
|
const iend = makeChunk('IEND', Buffer.alloc(0))
|
||||||
|
|
||||||
|
return Buffer.concat([sig, ihdr, idat, iend])
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChunk(type, data) {
|
||||||
|
const len = Buffer.alloc(4)
|
||||||
|
len.writeUInt32BE(data.length)
|
||||||
|
const typeBytes = Buffer.from(type)
|
||||||
|
const crc = crc32(Buffer.concat([typeBytes, data]))
|
||||||
|
const crcBuf = Buffer.alloc(4)
|
||||||
|
crcBuf.writeInt32BE(crc)
|
||||||
|
return Buffer.concat([len, typeBytes, data, crcBuf])
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const n = parseInt(hex.replace('#', ''), 16)
|
||||||
|
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff]
|
||||||
|
}
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
let crc = 0xffffffff
|
||||||
|
const table = makeCRCTable()
|
||||||
|
for (const b of buf) crc = (crc >>> 8) ^ table[(crc ^ b) & 0xff]
|
||||||
|
return (crc ^ 0xffffffff) | 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeCRCTable() {
|
||||||
|
const t = new Int32Array(256)
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i
|
||||||
|
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
||||||
|
t[i] = c
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconsDir = path.join(__dirname, '..', 'public', 'icons')
|
||||||
|
fs.mkdirSync(iconsDir, { recursive: true })
|
||||||
|
fs.writeFileSync(path.join(iconsDir, 'icon-192.png'), makePNG(192, '#000000', '#faff69'))
|
||||||
|
fs.writeFileSync(path.join(iconsDir, 'icon-512.png'), makePNG(512, '#000000', '#faff69'))
|
||||||
|
console.log('Icons generated: icon-192.png, icon-512.png')
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `node web/scripts/gen-icons.cjs`
|
||||||
|
|
||||||
|
**4. Create web/public/sw.js** — app shell cache strategy:
|
||||||
|
```javascript
|
||||||
|
const CACHE_NAME = 'hwlab-shell-v1'
|
||||||
|
|
||||||
|
// App shell assets to cache on install
|
||||||
|
const SHELL_ASSETS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
]
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(SHELL_ASSETS))
|
||||||
|
)
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
// Remove old caches
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.clients.claim()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const url = new URL(event.request.url)
|
||||||
|
|
||||||
|
// API requests: network only — never cache
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation requests: serve from cache, fall back to network (app shell)
|
||||||
|
if (event.request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
caches.match('/').then((cached) => cached ?? fetch(event.request))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets: cache-first
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then(
|
||||||
|
(cached) => cached ?? fetch(event.request).then((response) => {
|
||||||
|
if (response.ok && event.request.method === 'GET') {
|
||||||
|
const clone = response.clone()
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone))
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Create web/src/hooks/usePWA.ts** — service worker registration:
|
||||||
|
```typescript
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
|
||||||
|
export function usePWA() {
|
||||||
|
useEffect(() => {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register('/sw.js')
|
||||||
|
.then((reg) => {
|
||||||
|
console.log('[PWA] Service worker registered:', reg.scope)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn('[PWA] Service worker registration failed:', err)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Update web/src/App.tsx** — call usePWA() hook:
|
||||||
|
Read current `web/src/App.tsx`, then add:
|
||||||
|
```typescript
|
||||||
|
import { usePWA } from '@/hooks/usePWA'
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
usePWA() // Register service worker
|
||||||
|
// ... rest unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**7. Verify web/index.html already has the manifest link** (it was added in Plan 01):
|
||||||
|
`grep "manifest" web/index.html` — if missing, add `<link rel="manifest" href="/manifest.json" />` inside `<head>`.
|
||||||
|
|
||||||
|
Add `<meta name="theme-color" content="#faff69" />` inside `<head>` if not already present.
|
||||||
|
|
||||||
|
Run `npm run build`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -5 && ls public/icons/ && node -e "const b = require('fs').readFileSync('public/icons/icon-192.png'); console.log('PNG sig:', b.slice(0,4).toString('hex') === '89504e47' ? 'VALID' : 'INVALID')"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`npm run build` exits 0. `web/public/icons/icon-192.png` and `icon-512.png` exist as valid PNG files (PNG signature 89504E47). `web/public/manifest.json` exists with `"display": "standalone"` and `"theme_color": "#faff69"`. `web/public/sw.js` exists. `web/src/hooks/usePWA.ts` exports `usePWA`.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: QR scanner page with @zxing/browser and router wiring</name>
|
||||||
|
<files>
|
||||||
|
web/src/pages/ScanPage.tsx,
|
||||||
|
web/src/router.tsx
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
Read first: `web/src/router.tsx` (current state — has stub scanRoute), `web/src/lib/api.ts` (fetchInventory for HW ID lookup).
|
||||||
|
|
||||||
|
**Create web/src/pages/ScanPage.tsx** — camera QR code scanner:
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Request camera access via `BrowserQRCodeReader.listVideoInputDevices()`
|
||||||
|
2. Stream rear camera to `<video>` element
|
||||||
|
3. On QR decode: parse text to extract HW ID or item URL
|
||||||
|
4. Lookup item ID from inventory: `GET /api/inventory`, find by `hw_id` match
|
||||||
|
5. Navigate to `/item/:id`
|
||||||
|
|
||||||
|
QR text parsing:
|
||||||
|
- If text matches `/hw/(HW-\d+)/i` or just `HW-\d+` → extract HW ID
|
||||||
|
- Look up numeric NetBox ID by matching hw_id in the inventory list
|
||||||
|
- Navigate to `/item/$id`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import { BrowserQRCodeReader, type IScannerControls } from '@zxing/browser'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { AppShell } from '@/components/layout/AppShell'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Camera, Loader2, QrCode, AlertCircle } from 'lucide-react'
|
||||||
|
import { fetchInventory } from '@/lib/api'
|
||||||
|
import { useUIStore } from '@/store/ui'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
|
||||||
|
function extractHWID(text: string): string | null {
|
||||||
|
// Match full URL: http://mac-mini.mg:8080/hw/HW-00001
|
||||||
|
const urlMatch = text.match(/\/hw\/(HW-\d{5,})/i)
|
||||||
|
if (urlMatch) return urlMatch[1].toUpperCase()
|
||||||
|
// Match bare HW ID: HW-00001
|
||||||
|
const bareMatch = text.match(/^(HW-\d{5,})$/i)
|
||||||
|
if (bareMatch) return bareMatch[1].toUpperCase()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScanState = 'idle' | 'requesting' | 'scanning' | 'resolving' | 'error'
|
||||||
|
|
||||||
|
export function ScanPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const controlsRef = useRef<IScannerControls | null>(null)
|
||||||
|
const readerRef = useRef<BrowserQRCodeReader | null>(null)
|
||||||
|
const { setScannerActive } = useUIStore()
|
||||||
|
|
||||||
|
const [scanState, setScanState] = useState<ScanState>('idle')
|
||||||
|
const [errorMsg, setErrorMsg] = useState<string | null>(null)
|
||||||
|
const [lastScanned, setLastScanned] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const stopScanner = useCallback(() => {
|
||||||
|
controlsRef.current?.stop()
|
||||||
|
controlsRef.current = null
|
||||||
|
setScannerActive(false)
|
||||||
|
}, [setScannerActive])
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => () => stopScanner(), [stopScanner])
|
||||||
|
|
||||||
|
const startScanner = useCallback(async () => {
|
||||||
|
setScanState('requesting')
|
||||||
|
setErrorMsg(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reader = new BrowserQRCodeReader()
|
||||||
|
readerRef.current = reader
|
||||||
|
|
||||||
|
const devices = await BrowserQRCodeReader.listVideoInputDevices()
|
||||||
|
if (devices.length === 0) {
|
||||||
|
throw new Error('No camera found on this device')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer rear camera (contains "back" or "environment" in label)
|
||||||
|
const rear = devices.find((d) =>
|
||||||
|
/back|rear|environment/i.test(d.label)
|
||||||
|
)
|
||||||
|
const deviceId = rear?.deviceId ?? devices[devices.length - 1].deviceId
|
||||||
|
|
||||||
|
setScanState('scanning')
|
||||||
|
setScannerActive(true)
|
||||||
|
|
||||||
|
const controls = await reader.decodeFromVideoDevice(
|
||||||
|
deviceId,
|
||||||
|
videoRef.current!,
|
||||||
|
async (result, _err) => {
|
||||||
|
if (!result) return
|
||||||
|
const text = result.getText()
|
||||||
|
const hwid = extractHWID(text)
|
||||||
|
|
||||||
|
if (!hwid) {
|
||||||
|
// Not an HWLab QR code — ignore
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce: skip if same code scanned recently
|
||||||
|
if (hwid === lastScanned) return
|
||||||
|
setLastScanned(hwid)
|
||||||
|
|
||||||
|
controls.stop()
|
||||||
|
setScanState('resolving')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await fetchInventory()
|
||||||
|
const item = items.find(
|
||||||
|
(i) => i.hw_id?.toUpperCase() === hwid || i.asset_tag?.toUpperCase() === hwid
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
toast.error(`${hwid} not found in inventory`)
|
||||||
|
setScanState('scanning')
|
||||||
|
// Restart scanner
|
||||||
|
startScanner()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stopScanner()
|
||||||
|
navigate({ to: '/item/$id', params: { id: String(item.id) } })
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Failed to look up item')
|
||||||
|
setScanState('scanning')
|
||||||
|
startScanner()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
controlsRef.current = controls
|
||||||
|
} catch (e) {
|
||||||
|
setErrorMsg((e as Error).message)
|
||||||
|
setScanState('error')
|
||||||
|
setScannerActive(false)
|
||||||
|
}
|
||||||
|
}, [lastScanned, navigate, setScannerActive, stopScanner])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="font-display font-black text-3xl text-white mb-1">Scan QR Code</h1>
|
||||||
|
<p className="text-sm text-[#a0a0a0]">Point camera at an HWLab label to open the item</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-sm mx-auto space-y-4">
|
||||||
|
{/* Camera viewfinder */}
|
||||||
|
<div className="relative aspect-square bg-near-black rounded-card overflow-hidden border border-charcoal/80">
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
playsInline
|
||||||
|
muted
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Scan reticle overlay */}
|
||||||
|
{scanState === 'scanning' && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<div className="w-48 h-48 border-2 border-volt rounded-sharp opacity-80">
|
||||||
|
{/* Corner brackets */}
|
||||||
|
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-volt" />
|
||||||
|
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-volt" />
|
||||||
|
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-volt" />
|
||||||
|
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-volt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Idle state overlay */}
|
||||||
|
{scanState === 'idle' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-[#a0a0a0]">
|
||||||
|
<QrCode className="w-16 h-16" />
|
||||||
|
<p className="text-sm">Camera not started</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{(scanState === 'requesting' || scanState === 'resolving') && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-canvas/80">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin text-volt" />
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{scanState === 'requesting' ? 'Starting camera…' : 'Looking up item…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{scanState === 'error' && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<AlertCircle className="w-10 h-10 text-red-400" />
|
||||||
|
<p className="text-sm text-red-400">{errorMsg}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{scanState === 'idle' || scanState === 'error' ? (
|
||||||
|
<Button variant="forest" className="w-full" onClick={startScanner}>
|
||||||
|
<Camera className="w-4 h-4 mr-2" />
|
||||||
|
{scanState === 'error' ? 'Try Again' : 'Start Camera'}
|
||||||
|
</Button>
|
||||||
|
) : scanState === 'scanning' ? (
|
||||||
|
<Button variant="secondary" className="w-full" onClick={stopScanner}>
|
||||||
|
Stop Scanner
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-[#585858]">
|
||||||
|
Scans QR codes printed on HWLab labels (HW-XXXXX format)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update web/src/router.tsx** — replace stub scanRoute with lazy ScanPage:
|
||||||
|
Read current `web/src/router.tsx`, then update `scanRoute`:
|
||||||
|
```typescript
|
||||||
|
const ScanPage = lazy(() => import('./pages/ScanPage').then(m => ({ default: m.ScanPage })))
|
||||||
|
|
||||||
|
const scanRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/scan',
|
||||||
|
component: () => (
|
||||||
|
<Suspense fallback={<Spinner />}>
|
||||||
|
<ScanPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `npm run build`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
`npm run build` exits 0 with no TypeScript errors. `web/src/pages/ScanPage.tsx` exports `ScanPage`. `web/src/router.tsx` lazy-loads ScanPage. `extractHWID` function handles both URL and bare HW-ID formats.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
PWA setup and QR scanner:
|
||||||
|
- web/public/manifest.json — installable PWA config (name, theme_color=#faff69, display=standalone, icons)
|
||||||
|
- web/public/sw.js — app shell cache (API calls always network-only, navigation uses cache-first)
|
||||||
|
- web/public/icons/ — 192px and 512px PNG icons (black canvas, volt "H" monogram)
|
||||||
|
- web/src/pages/ScanPage.tsx — /scan route with live camera, volt reticle overlay, HW ID parsing
|
||||||
|
- Service worker registered on app load via usePWA() hook
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
**PWA installability (Chrome on Android or Chrome DevTools > Application):**
|
||||||
|
1. Build: `cd web && npm run build`
|
||||||
|
2. Start Go backend (which serves the built SPA): `go run ./cmd/hwlab/...`
|
||||||
|
3. Open http://mac-mini.mg:8080 in Chrome
|
||||||
|
4. Open DevTools > Application > Manifest
|
||||||
|
- [ ] Manifest loads with correct name, theme_color #faff69
|
||||||
|
- [ ] Icons show as 192px and 512px (may be the simple monogram placeholder)
|
||||||
|
- [ ] No manifest errors in DevTools
|
||||||
|
5. DevTools > Application > Service Workers
|
||||||
|
- [ ] sw.js registered and status is "Activated and is running"
|
||||||
|
6. On Android Chrome: address bar shows install banner or + icon to "Add to Home screen"
|
||||||
|
|
||||||
|
**QR scanner (http://localhost:5173/scan or installed PWA):**
|
||||||
|
1. Navigate to /scan
|
||||||
|
- [ ] "Scan QR Code" heading, gray QR code icon, "Start Camera" forest green button
|
||||||
|
2. Click "Start Camera"
|
||||||
|
- [ ] Camera permission dialog appears
|
||||||
|
- [ ] After permission: live video feed shows in viewfinder, volt reticle overlay appears
|
||||||
|
3. Point camera at any QR code (or print test): `http://mac-mini.mg:8080/hw/HW-00001`
|
||||||
|
- [ ] Toast appears on match if item exists in NetBox, navigates to /item/:id
|
||||||
|
- [ ] Toast "HW-00001 not found in inventory" if item doesn't exist yet
|
||||||
|
4. Test "Stop Scanner" button stops camera and returns to idle state
|
||||||
|
|
||||||
|
**Mobile layout (390px):**
|
||||||
|
- [ ] /scan viewfinder is square and fills the screen width
|
||||||
|
- [ ] "Start Camera" button is full-width, tap-friendly
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Camera → QR decode | Raw camera frames decoded by @zxing — output is untrusted text |
|
||||||
|
| QR text → navigation | Decoded QR text is parsed before any navigation or API call |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-03-14 | Tampering | QR text → extractHWID | mitigate | `extractHWID` regex only matches `HW-\d{5,}` pattern; arbitrary text cannot trigger navigation; lookup uses numeric NetBox ID from server-returned data, not from QR content directly |
|
||||||
|
| T-03-15 | Info Disclosure | Camera stream | accept | Camera stream is local to the device; no frames are sent to the server; @zxing decodes frames locally |
|
||||||
|
| T-03-16 | Tampering | Service worker cache | accept | SW caches only app shell (static assets); API responses are explicitly excluded from cache (network-only path); no risk of stale auth or inventory data |
|
||||||
|
| T-03-17 | DoS | Rapid QR scan triggers | mitigate | `lastScanned` state debounces repeated scans of the same QR code; scanner stops before resolving item |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
1. `cd web && npm run build` — exits 0
|
||||||
|
2. `ls web/public/manifest.json web/public/sw.js web/public/icons/icon-192.png web/public/icons/icon-512.png` — all exist
|
||||||
|
3. `node -e "require('fs').readFileSync('web/public/manifest.json'); console.log('manifest valid JSON')"` — parses without error
|
||||||
|
4. `grep "ScanPage" web/src/router.tsx` — lazy import present
|
||||||
|
5. `grep "extractHWID" web/src/pages/ScanPage.tsx` — HW ID parsing function present
|
||||||
|
6. Visual/functional verification via checkpoint task
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- manifest.json loads in Chrome DevTools with no errors, display=standalone, theme_color=#faff69
|
||||||
|
- Service worker registered and active (DevTools > Application > Service Workers)
|
||||||
|
- PWA "Add to Home Screen" installable on Android Chrome (PWA-01)
|
||||||
|
- /scan activates rear camera and scans QR codes in real-time
|
||||||
|
- Decoded HW-XXXXX QR navigates to /item/:id (PWA-03)
|
||||||
|
- QR text parsing handles both URL format (http://.../hw/HW-00001) and bare HW-ID
|
||||||
|
- Camera permission denied handled gracefully (error state, not crash)
|
||||||
|
- Human verification checkpoint passed
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/03-dashboard-intake-ui/03-05-SUMMARY.md` following the summary template.
|
||||||
|
</output>
|
||||||
Loading…
Add table
Reference in a new issue