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 ( +
+ {`Photo + +
+ ) + })} +
+ ) +} 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), +}))