feat(03-04): add intake Zustand store, api.ts submitIntake, DropZone, PhotoPreview

- useIntakeStore: step tracking, photos (max 3), aiResult, editedName, error
- submitIntake(): multipart FormData POST /api/intake with IntakeResponse type
- DropZone: react-dropzone with camera capture, volt hover state, slot counter
- PhotoPreview: thumbnail grid with X remove button per photo
This commit is contained in:
Mikkel Georgsen 2026-04-10 06:20:35 +00:00
parent 86d0a949c5
commit 709876d3a0
4 changed files with 195 additions and 0 deletions

View 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>
)
}

View 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>
)
}

41
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,41 @@
export interface InventoryItem {
id: number
hw_id: string
name: string
manufacturer: string
model: string
category: string
status: string
tags: string[]
specs: Record<string, string>
netbox_url?: string
}
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 as { error?: string }).error ?? `HTTP ${res.status}`)
}
return res.json() as Promise<IntakeResponse>
}

57
web/src/store/intake.ts Normal file
View 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),
}))