diff --git a/web/src/components/intake/AIResultReview.tsx b/web/src/components/intake/AIResultReview.tsx
new file mode 100644
index 0000000..44faf34
--- /dev/null
+++ b/web/src/components/intake/AIResultReview.tsx
@@ -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 (
+
+
+
{pct}%
+ {pct >= 85 ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export function AIResultReview({ result, editedName, onNameChange }: AIResultReviewProps) {
+ return (
+
+ {/* Confidence */}
+
+
+ AI Confidence
+
+
+
+ {result.confidence < 0.6 && (
+
+ Low confidence — item will be flagged for research
+
+ )}
+
+
+
+ {/* Editable name */}
+
+
+
+ Item Name
+
+
+
+
+ onNameChange(e.target.value)}
+ className="w-full px-3 py-2 bg-near-black border border-charcoal/80 rounded-sharp text-white text-sm focus:outline-none focus:border-volt/60 focus:ring-1 focus:ring-volt/30"
+ placeholder="Item name"
+ />
+
+
+
+ {/* Classification */}
+
+
+ Classification
+
+
+
+ Manufacturer
+ {result.manufacturer || '—'}
+
+
+ Model
+ {result.model || '—'}
+
+
+ Category
+ {result.category || '—'}
+
+ {result.suggested_tags.length > 0 && (
+
+
Tags
+
+ {result.suggested_tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* AI notes */}
+ {result.ai_notes && (
+
+
+ AI Notes
+
+
+ {result.ai_notes}
+
+
+ )}
+
+ )
+}
diff --git a/web/src/components/intake/ConfirmForm.tsx b/web/src/components/intake/ConfirmForm.tsx
new file mode 100644
index 0000000..558c64a
--- /dev/null
+++ b/web/src/components/intake/ConfirmForm.tsx
@@ -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 (
+
+
+
+
+ )
+}
diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx
new file mode 100644
index 0000000..b856d47
--- /dev/null
+++ b/web/src/components/layout/AppShell.tsx
@@ -0,0 +1,39 @@
+import { type ReactNode } from 'react'
+import { Link } from '@tanstack/react-router'
+import { PlusCircle, Package } from 'lucide-react'
+
+interface AppShellProps {
+ children: ReactNode
+}
+
+export function AppShell({ children }: AppShellProps) {
+ return (
+
+ {/* Top nav */}
+
+
+ HWLab
+
+
+
+
+ {/* Page content */}
+
{children}
+
+ )
+}
diff --git a/web/src/pages/IntakePage.tsx b/web/src/pages/IntakePage.tsx
new file mode 100644
index 0000000..34888c0
--- /dev/null
+++ b/web/src/pages/IntakePage.tsx
@@ -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 (
+
+ {/* Header */}
+
+
Add Item
+
+ Upload 1–3 photos — AI extracts specs automatically
+
+
+
+ {/* Step: upload / error */}
+ {(step === 'upload' || step === 'error') && (
+
+
files.forEach(addPhoto)} />
+
+ {step === 'error' && (
+
+
+ Analysis failed — check that the Go backend is running and try again.
+
+
+ )}
+
+
+ )}
+
+ {/* Step: submitting */}
+ {step === 'submitting' && (
+
+
+
Analyzing photos…
+
Gemma 4 is extracting specs
+
+ )}
+
+ {/* Step: review */}
+ {step === 'review' && aiResult && (
+
+
+
+
+
+
+ {aiResult.queued && (
+
+ NetBox was unavailable — item is queued for sync
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/web/src/router.tsx b/web/src/router.tsx
index 5113fc7..123aca1 100644
--- a/web/src/router.tsx
+++ b/web/src/router.tsx
@@ -1,5 +1,19 @@
+import { lazy, Suspense } from 'react'
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
+import { Loader2 } from 'lucide-react'
+
+function Spinner() {
+ return (
+
+
+
+ )
+}
+
+const IntakePage = lazy(() =>
+ import('./pages/IntakePage').then((m) => ({ default: m.IntakePage })),
+)
// Root layout — wraps all routes with the app shell
const rootRoute = createRootRoute({
@@ -36,9 +50,9 @@ const intakeRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/intake',
component: () => (
-
-
Intake wizard loading…
-
+ }>
+
+
),
})