chore: merge 03-04 worktree (intake UI) — resolved router/api/AppShell conflicts
This commit is contained in:
commit
e2ac3b10aa
8 changed files with 582 additions and 1 deletions
151
.planning/phases/03-dashboard-intake-ui/03-04-SUMMARY.md
Normal file
151
.planning/phases/03-dashboard-intake-ui/03-04-SUMMARY.md
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
---
|
||||
phase: 03-dashboard-intake-ui
|
||||
plan: "04"
|
||||
subsystem: ui
|
||||
tags: [react, typescript, intake, zustand, react-dropzone, tanstack-router, clickhouse-design]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- web/src/store/ui.ts (Zustand pattern)
|
||||
- web/src/components/ui/button.tsx (Button variants)
|
||||
- web/src/components/ui/card.tsx (Card/CardContent)
|
||||
- web/src/components/ui/badge.tsx (Badge)
|
||||
- POST /api/intake (02-03)
|
||||
provides:
|
||||
- web/src/store/intake.ts (useIntakeStore, IntakeResult)
|
||||
- web/src/lib/api.ts (submitIntake, IntakeResponse)
|
||||
- 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/components/layout/AppShell.tsx
|
||||
- web/src/pages/IntakePage.tsx
|
||||
- /intake route (lazy-loaded)
|
||||
affects:
|
||||
- web/src/router.tsx (intakeRoute updated from stub to lazy IntakePage)
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- Zustand store per feature concern (intake.ts separate from ui.ts)
|
||||
- react-dropzone with capture="environment" for mobile camera
|
||||
- Lazy Suspense pattern for route-level code splitting
|
||||
- submitIntake() uses native fetch with FormData — no axios
|
||||
- Backend creates record immediately on POST (INTAKE-04); UI review is display-only
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- web/src/store/intake.ts
|
||||
- web/src/lib/api.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/components/layout/AppShell.tsx
|
||||
- web/src/pages/IntakePage.tsx
|
||||
modified:
|
||||
- web/src/router.tsx (intakeRoute lazy-loads IntakePage)
|
||||
|
||||
decisions:
|
||||
- "Backend creates NetBox record immediately on first POST /api/intake — review step is display-only; no second confirmation POST needed (carries INTAKE-04 from Phase 2)"
|
||||
- "AppShell created as layout component (not in plan) — IntakePage requires a page wrapper; auto-added as missing critical layout (Rule 2)"
|
||||
- "ConfirmForm exported but not used in IntakePage final flow — wired for possible future confirm-before-create variant; currently review step goes direct to handleDone()"
|
||||
|
||||
# Metrics
|
||||
duration: 15min
|
||||
completed: 2026-04-10
|
||||
---
|
||||
|
||||
# Phase 3 Plan 04: Intake Wizard UI Summary
|
||||
|
||||
**Three-step photo intake wizard (Upload → Submitting → Review) wired to POST /api/intake, using react-dropzone, Zustand store, and ClickHouse design tokens.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~15 min
|
||||
- **Completed:** 2026-04-10
|
||||
- **Tasks:** 2
|
||||
- **Files created:** 8
|
||||
- **Files modified:** 1
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- `useIntakeStore` Zustand store tracks wizard step (`upload | submitting | review | done | error`), up to 3 photos, AI result, and editable name
|
||||
- `submitIntake()` in `api.ts` posts multipart FormData to `/api/intake` and returns typed `IntakeResponse`
|
||||
- `DropZone` uses react-dropzone with `capture="environment"` for mobile camera, volt border glow on drag-active, slot counter, disabled state at 3 photos
|
||||
- `PhotoPreview` shows thumbnails in a flex grid with per-photo X remove button (hover reveal)
|
||||
- `AIResultReview` renders confidence meter (green ≥85%, yellow 60-84%, red <60%), manufacturer/model/category/tags, editable name input, and ai_notes card
|
||||
- `AppShell` minimal layout wrapper with HWLab top-nav and Inventory/Add Item links
|
||||
- `IntakePage` orchestrates the three steps: upload → POST → review → navigate to dashboard
|
||||
- Analyze button disabled during `step === 'submitting'` (T-03-13 DoS mitigation)
|
||||
- `/intake` route lazy-loaded with Suspense fallback spinner (code-split chunk: ~100 KB gzip 30 KB)
|
||||
- `npm run build` exits 0 with no TypeScript errors
|
||||
|
||||
## Task Commits
|
||||
|
||||
1. **Task 1: Intake store, api.ts, DropZone, PhotoPreview** — `709876d`
|
||||
2. **Task 2: AIResultReview, ConfirmForm, AppShell, IntakePage, router wiring** — `5909025`
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `web/src/store/intake.ts` — Zustand intake wizard state (step, photos, aiResult, editedName, error)
|
||||
- `web/src/lib/api.ts` — `submitIntake()` multipart POST + `IntakeResponse` type
|
||||
- `web/src/components/intake/DropZone.tsx` — react-dropzone with camera capture, volt hover, slot counter
|
||||
- `web/src/components/intake/PhotoPreview.tsx` — thumbnail grid with X remove per photo
|
||||
- `web/src/components/intake/AIResultReview.tsx` — confidence meter, classification fields, editable name, ai_notes
|
||||
- `web/src/components/intake/ConfirmForm.tsx` — confirm/reset action buttons with loading spinner
|
||||
- `web/src/components/layout/AppShell.tsx` — page layout wrapper with top nav
|
||||
- `web/src/pages/IntakePage.tsx` — intake wizard orchestrator (upload/submitting/review steps)
|
||||
- `web/src/router.tsx` — intakeRoute updated from stub to lazy IntakePage
|
||||
|
||||
## Decisions Made
|
||||
|
||||
- Backend creates NetBox record immediately on POST (INTAKE-04 from Phase 2) — no separate confirmation POST; UI review is display-only
|
||||
- Lazy Suspense code-splitting applied to /intake route (keeps main bundle small)
|
||||
- `capture="environment"` on file input for mobile rear-camera capture
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - Missing critical functionality] Created AppShell layout component**
|
||||
- **Found during:** Task 2 (IntakePage implementation)
|
||||
- **Issue:** IntakePage imports `@/components/layout/AppShell` but no such component existed. The plan listed IntakePage as the wizard orchestrator without noting the layout dependency.
|
||||
- **Fix:** Created `web/src/components/layout/AppShell.tsx` — minimal top-nav wrapper with HWLab logo (volt) and Inventory/Add Item nav links using TanStack Router `<Link>`
|
||||
- **Files modified:** `web/src/components/layout/AppShell.tsx` (created)
|
||||
- **Commit:** 5909025
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — all data paths are wired end-to-end. `submitIntake()` calls real `/api/intake`. AI result flows directly from API response into `AIResultReview`. No hardcoded mock data.
|
||||
|
||||
## Threat Surface Coverage
|
||||
|
||||
All three threats from the plan's threat register are addressed:
|
||||
|
||||
| Threat | Mitigation | Where |
|
||||
|--------|-----------|-------|
|
||||
| T-03-11: Tampering via FormData | Photos sent as bytes; hw_id assigned server-side; no client-controlled ID | api.ts:submitIntake |
|
||||
| T-03-12: AI notes XSS | JSX text rendering only; no dangerouslySetInnerHTML | AIResultReview.tsx |
|
||||
| T-03-13: Duplicate concurrent POSTs | Analyze button disabled during `step === 'submitting'` | IntakePage.tsx |
|
||||
|
||||
## Self-Check
|
||||
|
||||
Files created:
|
||||
- web/src/store/intake.ts: FOUND
|
||||
- web/src/lib/api.ts: FOUND
|
||||
- web/src/components/intake/DropZone.tsx: FOUND
|
||||
- web/src/components/intake/PhotoPreview.tsx: FOUND
|
||||
- web/src/components/intake/AIResultReview.tsx: FOUND
|
||||
- web/src/components/intake/ConfirmForm.tsx: FOUND
|
||||
- web/src/components/layout/AppShell.tsx: FOUND
|
||||
- web/src/pages/IntakePage.tsx: FOUND
|
||||
|
||||
Commits:
|
||||
- 709876d: feat(03-04): add intake Zustand store, api.ts submitIntake, DropZone, PhotoPreview
|
||||
- 5909025: feat(03-04): intake wizard UI — AIResultReview, ConfirmForm, IntakePage, router wiring
|
||||
|
||||
`npm run build`: PASSED (tsc -b + vite build, 0 errors, IntakePage code-split chunk ~100 KB)
|
||||
|
||||
## Self-Check: PASSED
|
||||
117
web/src/components/intake/AIResultReview.tsx
Normal file
117
web/src/components/intake/AIResultReview.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
37
web/src/components/intake/ConfirmForm.tsx
Normal file
37
web/src/components/intake/ConfirmForm.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
63
web/src/components/intake/DropZone.tsx
Normal file
63
web/src/components/intake/DropZone.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
34
web/src/components/intake/PhotoPreview.tsx
Normal file
34
web/src/components/intake/PhotoPreview.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -46,9 +46,10 @@ export interface IntakeResponse {
|
|||
ai_notes: string
|
||||
}
|
||||
|
||||
export async function submitIntake(photos: File[]): Promise<IntakeResponse> {
|
||||
export async function submitIntake(photos: File[], quickAdd = false): Promise<IntakeResponse> {
|
||||
const formData = new FormData()
|
||||
photos.forEach((file) => formData.append('photos', file))
|
||||
formData.append('quick_add', String(quickAdd))
|
||||
const res = await fetch(`${BASE}/intake`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
|
|
|
|||
121
web/src/pages/IntakePage.tsx
Normal file
121
web/src/pages/IntakePage.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { useNavigate } 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 / error */}
|
||||
{(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>
|
||||
)
|
||||
}
|
||||
57
web/src/store/intake.ts
Normal file
57
web/src/store/intake.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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),
|
||||
}))
|
||||
Loading…
Add table
Reference in a new issue