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:
parent
86d0a949c5
commit
709876d3a0
4 changed files with 195 additions and 0 deletions
63
web/src/components/intake/DropZone.tsx
Normal file
63
web/src/components/intake/DropZone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
web/src/components/intake/PhotoPreview.tsx
Normal file
34
web/src/components/intake/PhotoPreview.tsx
Normal 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
41
web/src/lib/api.ts
Normal 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
57
web/src/store/intake.ts
Normal 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),
|
||||
}))
|
||||
Loading…
Add table
Reference in a new issue