--- 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: true 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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 ```typescript interface IntakeResponse { hw_id: string model: string manufacturer: string category: string specs: Record 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) } ``` // Create a separate store for intake (not in ui.ts — different concern) // useIntakeStore with step, photos, aiResult, editedName, isSubmitting, error // bg-canvas, bg-near-black, text-volt, border-charcoal/80, rounded-card, rounded-sharp // Button variants: default (volt), forest, secondary, outline, ghost // import { useDropzone } from 'react-dropzone' // const { getRootProps, getInputProps, isDragActive } = useDropzone({ // accept: { 'image/*': [] }, // maxFiles: 3, // onDrop: acceptedFiles => { ... } // }) Task 1: Intake store, DropZone component, and API mutation web/src/store/intake.ts, web/src/lib/api.ts, web/src/components/intake/DropZone.tsx, web/src/components/intake/PhotoPreview.tsx 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 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((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 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 { 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 } ``` **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 (
{photos.map((file, i) => { const url = URL.createObjectURL(file) return (
{`Photo
) })}
) } ``` **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 (
{isDragActive ? ( ) : ( )}

{isDragActive ? 'Drop photos here' : disabled ? 'Maximum 3 photos reached' : 'Drag photos or tap to shoot'}

{disabled ? '' : `${remaining} of ${MAX} slots remaining · JPEG, PNG, WebP`}

) } ``` Run `npm run build` to confirm TypeScript compiles.
cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 `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.
Task 2: AIResultReview, ConfirmForm, IntakePage, and router wiring web/src/components/intake/AIResultReview.tsx, web/src/components/intake/ConfirmForm.tsx, web/src/pages/IntakePage.tsx, web/src/router.tsx 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 (
{pct}% {pct >= 85 ? ( ) : ( )}
) } export function AIResultReview({ result, editedName, onNameChange }: AIResultReviewProps) { return (
{/* Confidence */} AI Confidence {result.confidence < 0.6 && (

Low confidence — item will be flagged for research

)}
{/* Editable name */} Item Name 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" /> {/* Classification */} Classification
Manufacturer {result.manufacturer || '—'}
Model {result.model || '—'}
Category {result.category || '—'}
{result.suggested_tags.length > 0 && (
Tags
{result.suggested_tags.map((tag) => ( {tag} ))}
)}
{/* AI notes */} {result.ai_notes && ( AI Notes

{result.ai_notes}

)}
) } ``` **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 (
) } ``` **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 ( {/* Header */}

Add Item

Upload 1–3 photos — AI extracts specs automatically

{/* Step: upload */} {(step === 'upload' || step === 'error') && (
files.forEach(addPhoto)} /> {step === 'error' && ( Analysis failed — check that the Go backend is running and try again. )}
)} {/* Step: submitting */} {step === 'submitting' && (

Analyzing photos…

Gemma 4 is extracting specs

)} {/* Step: review */} {step === 'review' && aiResult && (
{aiResult.queued && (

NetBox was unavailable — item is queued for sync

)}
)}
) } ``` **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: () => ( }> ), }) ``` Run `npm run build`. cd /home/mikkel/homelabby/web && npm run build 2>&1 | tail -10 `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. 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" 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 Type "approved" or describe issues to fix ## 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 | 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 - 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 After completion, create `.planning/phases/03-dashboard-intake-ui/03-04-SUMMARY.md` following the summary template.