homelabby/.planning/phases/03-dashboard-intake-ui/03-04-PLAN.md

732 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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"
---
<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 &amp; 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 13 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 &lt;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>