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

28 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements must_haves
03-dashboard-intake-ui 04 execute 2
03-01
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
true
UI-04
UI-05
truths artifacts key_links
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)
path provides exports
web/src/store/intake.ts Zustand intake store — step tracking, photos, AI result, edit state
useIntakeStore
IntakeResult
path provides
web/src/components/intake/DropZone.tsx react-dropzone file upload area, photo count indicator
path provides
web/src/components/intake/AIResultReview.tsx AI result display with confidence meter and editable name field
path provides
web/src/components/intake/ConfirmForm.tsx Final confirm/cancel actions
path provides
web/src/pages/IntakePage.tsx /intake route — multi-step wizard orchestrator
from to via
web/src/pages/IntakePage.tsx POST /api/intake fetch in submitPhotos using FormData multipart upload
from to via
web/src/components/intake/AIResultReview.tsx web/src/store/intake.ts useIntakeStore() — aiResult, editedName, step
from to via
web/src/router.tsx web/src/pages/IntakePage.tsx 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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
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)
}

// 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<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.
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 (
    <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`.
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

<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>
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

<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>
After completion, create `.planning/phases/03-dashboard-intake-ui/03-04-SUMMARY.md` following the summary template.