diff --git a/web/src/components/intake/DropZone.tsx b/web/src/components/intake/DropZone.tsx
new file mode 100644
index 0000000..2c0a6a5
--- /dev/null
+++ b/web/src/components/intake/DropZone.tsx
@@ -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 (
+
+
+ {isDragActive ? (
+
+ ) : (
+
+ )}
+
+
+ {isDragActive
+ ? 'Drop photos here'
+ : disabled
+ ? 'Maximum 3 photos reached'
+ : 'Drag photos or tap to shoot'}
+
+
+ {disabled ? '' : `${remaining} of ${MAX} slots remaining ยท JPEG, PNG, WebP`}
+
+
+
+ )
+}
diff --git a/web/src/components/intake/PhotoPreview.tsx b/web/src/components/intake/PhotoPreview.tsx
new file mode 100644
index 0000000..0cb6df8
--- /dev/null
+++ b/web/src/components/intake/PhotoPreview.tsx
@@ -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 (
+
+ {photos.map((file, i) => {
+ const url = URL.createObjectURL(file)
+ return (
+
+

+
+
+ )
+ })}
+
+ )
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..70517ea
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -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
+ netbox_url?: string
+}
+
+export interface IntakeResponse {
+ hw_id: string
+ model: string
+ manufacturer: string
+ category: string
+ specs: Record
+ 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 {
+ 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
+}
diff --git a/web/src/store/intake.ts b/web/src/store/intake.ts
new file mode 100644
index 0000000..4e70a4e
--- /dev/null
+++ b/web/src/store/intake.ts
@@ -0,0 +1,57 @@
+import { create } from 'zustand'
+
+export interface IntakeResult {
+ hw_id: string
+ model: string
+ manufacturer: string
+ category: string
+ specs: Record
+ 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((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),
+}))