From 5909025677db5a12a3c5ac5f6046a737f7be4b77 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Fri, 10 Apr 2026 06:21:53 +0000 Subject: [PATCH] =?UTF-8?q?feat(03-04):=20intake=20wizard=20UI=20=E2=80=94?= =?UTF-8?q?=20AIResultReview,=20ConfirmForm,=20IntakePage,=20router=20wiri?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AIResultReview: confidence meter (green/yellow/red), manufacturer/model/category/tags, editable name input, ai_notes card - ConfirmForm: confirm/start-over action buttons with submitting spinner state - AppShell: top-nav layout wrapper with HWLab logo and Inventory/Add Item nav links - IntakePage: three-step wizard (upload → submitting → review) wired to submitIntake() - router.tsx: lazy-loads IntakePage for /intake, Suspense+Spinner fallback - Analyze button disabled during submitting (T-03-13 DoS mitigation) --- web/src/components/intake/AIResultReview.tsx | 117 ++++++++++++++++++ web/src/components/intake/ConfirmForm.tsx | 37 ++++++ web/src/components/layout/AppShell.tsx | 39 ++++++ web/src/pages/IntakePage.tsx | 121 +++++++++++++++++++ web/src/router.tsx | 20 ++- 5 files changed, 331 insertions(+), 3 deletions(-) create mode 100644 web/src/components/intake/AIResultReview.tsx create mode 100644 web/src/components/intake/ConfirmForm.tsx create mode 100644 web/src/components/layout/AppShell.tsx create mode 100644 web/src/pages/IntakePage.tsx 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…

-
+ }> + + ), })