feat(03-04): intake wizard UI — AIResultReview, ConfirmForm, IntakePage, router wiring
- 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)
This commit is contained in:
parent
709876d3a0
commit
5909025677
5 changed files with 331 additions and 3 deletions
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
web/src/components/layout/AppShell.tsx
Normal file
39
web/src/components/layout/AppShell.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="min-h-screen bg-canvas text-white">
|
||||||
|
{/* Top nav */}
|
||||||
|
<header className="h-12 border-b border-charcoal/80 flex items-center px-4 gap-4">
|
||||||
|
<Link to="/" className="font-display font-black text-volt text-lg tracking-tight">
|
||||||
|
HWLab
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-2 ml-auto">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-1.5 text-sm text-[#a0a0a0] hover:text-white transition-colors px-2 py-1"
|
||||||
|
>
|
||||||
|
<Package className="w-4 h-4" />
|
||||||
|
Inventory
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/intake"
|
||||||
|
className="flex items-center gap-1.5 text-sm text-[#a0a0a0] hover:text-white transition-colors px-2 py-1"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4" />
|
||||||
|
Add Item
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
|
import { createRouter, createRoute, createRootRoute, Outlet } from '@tanstack/react-router'
|
||||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
|
||||||
|
function Spinner() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-volt" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const IntakePage = lazy(() =>
|
||||||
|
import('./pages/IntakePage').then((m) => ({ default: m.IntakePage })),
|
||||||
|
)
|
||||||
|
|
||||||
// Root layout — wraps all routes with the app shell
|
// Root layout — wraps all routes with the app shell
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
|
|
@ -36,9 +50,9 @@ const intakeRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/intake',
|
path: '/intake',
|
||||||
component: () => (
|
component: () => (
|
||||||
<div className="min-h-screen bg-canvas flex items-center justify-center">
|
<Suspense fallback={<Spinner />}>
|
||||||
<p className="text-volt font-display font-black text-2xl">Intake wizard loading…</p>
|
<IntakePage />
|
||||||
</div>
|
</Suspense>
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue