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