732 lines
28 KiB
Markdown
732 lines
28 KiB
Markdown
---
|
||
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 & 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>
|