feat(gear): connect CRUD operations and add category filter

This commit is contained in:
senke 2026-02-19 23:41:46 +01:00
parent bd810a931a
commit d803d2bcfe
6 changed files with 426 additions and 21 deletions

View file

@ -15,6 +15,8 @@ import {
FileCheck,
Download,
Plus,
Pencil,
Trash2,
} from 'lucide-react';
import type { GearItem } from '@/types';
import { getWarrantyStatus } from './gearUtils';
@ -23,6 +25,8 @@ import { cn } from '@/lib/utils';
export interface GearDetailModalProps {
item: GearItem;
onClose: () => void;
onEdit?: (item: GearItem) => void;
onDelete?: (item: GearItem) => void;
onSellOnMarketplace?: (item: GearItem) => void;
onLogMaintenance?: (item: GearItem) => void;
onContactSupport?: (item: GearItem) => void;
@ -33,6 +37,8 @@ export interface GearDetailModalProps {
export function GearDetailModal({
item,
onClose,
onEdit,
onDelete,
onSellOnMarketplace,
onLogMaintenance,
onContactSupport,
@ -68,7 +74,28 @@ export function GearDetailModal({
<h3 className="text-xl text-primary font-medium mb-4">
{item.brand} {item.model}
</h3>
<div className="flex gap-4">
<div className="flex flex-wrap gap-2">
{onEdit && (
<Button
variant="secondary"
size="sm"
icon={<Pencil className="w-4 h-4" />}
onClick={() => onEdit(item)}
>
EDIT
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4" />}
onClick={() => onDelete(item)}
className="text-destructive hover:text-destructive"
>
DELETE
</Button>
)}
<Button
variant="glass"
size="sm"

View file

@ -0,0 +1,265 @@
/**
* GearFormModal Add/Edit gear item
* v0.102: Connects to gearService.create/update
*/
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { gearService } from '@/services/gearService';
import type { GearItem } from '@/types';
import { cn } from '@/lib/utils';
const gearFormSchema = z.object({
name: z.string().min(1, 'Name is required').max(200),
category: z.string().min(1, 'Category is required').max(100),
brand: z.string().min(1, 'Brand is required').max(200),
model: z.string().max(200).optional().or(z.literal('')),
serialNumber: z.string().max(100).optional().or(z.literal('')),
status: z.enum(['Active', 'Maintenance', 'Sold', 'Wishlist']),
condition: z.enum(['Mint', 'Good', 'Fair', 'Poor']),
purchaseDate: z.string().optional().or(z.literal('')),
purchasePrice: z.coerce.number().min(0),
currency: z.enum(['USD', 'EUR', 'GBP']),
vendor: z.string().max(200).optional().or(z.literal('')),
notes: z.string().max(2000).optional().or(z.literal('')),
});
type GearFormData = z.infer<typeof gearFormSchema>;
export interface GearFormModalProps {
item?: GearItem | null;
onClose: () => void;
onSuccess: () => void;
className?: string;
}
export function GearFormModal({
item,
onClose,
onSuccess,
className,
}: GearFormModalProps) {
const isEdit = !!item;
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<GearFormData>({
resolver: zodResolver(gearFormSchema),
defaultValues: {
name: item?.name ?? '',
category: item?.category ?? 'Synth',
brand: item?.brand ?? '',
model: item?.model ?? '',
serialNumber: item?.serialNumber ?? '',
status: item?.status ?? 'Active',
condition: item?.condition ?? 'Good',
purchaseDate: item?.purchaseDate ?? '',
purchasePrice: item?.purchasePrice ?? 0,
currency: item?.currency ?? 'USD',
vendor: item?.vendor ?? '',
notes: item?.notes ?? '',
},
});
const onSubmit = async (data: GearFormData) => {
try {
const payload: Partial<GearItem> = {
name: data.name,
category: data.category,
brand: data.brand,
model: data.model || undefined,
serialNumber: data.serialNumber || undefined,
status: data.status,
condition: data.condition,
purchaseDate: data.purchaseDate || undefined,
purchasePrice: data.purchasePrice,
currency: data.currency,
vendor: data.vendor || undefined,
notes: data.notes || undefined,
};
if (isEdit && item) {
await gearService.update(item.id, payload);
} else {
await gearService.create(payload);
}
onSuccess();
onClose();
} catch (err) {
throw err;
}
};
return (
<div className={cn('fixed inset-0 z-50 flex items-center justify-center p-4', className)}>
<div
className="absolute inset-0 bg-background/90 backdrop-blur-sm"
onClick={onClose}
aria-hidden
/>
<div className="relative w-full max-w-2xl bg-card border border-border rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col">
<div className="p-6 border-b border-border bg-muted/30 flex justify-between items-center shrink-0">
<h2 className="text-xl font-heading font-bold">
{isEdit ? 'Edit Gear' : 'Add New Gear'}
</h2>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground p-1"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex-1 overflow-y-auto p-6 space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<Label htmlFor="name" className="text-xs font-mono uppercase tracking-wider">
Name *
</Label>
<Input
id="name"
{...register('name')}
error={errors.name?.message}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="category" className="text-xs font-mono uppercase tracking-wider">
Category *
</Label>
<select
id="category"
{...register('category')}
className="mt-1 flex h-11 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
>
<option value="Synth">Synth</option>
<option value="Interface">Interface</option>
<option value="Microphone">Microphone</option>
<option value="Monitor">Monitor</option>
<option value="Controller">Controller</option>
<option value="Other">Other</option>
</select>
</div>
<div>
<Label htmlFor="brand" className="text-xs font-mono uppercase tracking-wider">
Brand *
</Label>
<Input id="brand" {...register('brand')} error={errors.brand?.message} className="mt-1" />
</div>
<div>
<Label htmlFor="model" className="text-xs font-mono uppercase tracking-wider">
Model
</Label>
<Input id="model" {...register('model')} className="mt-1" />
</div>
<div>
<Label htmlFor="serialNumber" className="text-xs font-mono uppercase tracking-wider">
Serial Number
</Label>
<Input id="serialNumber" {...register('serialNumber')} className="mt-1" />
</div>
<div>
<Label htmlFor="status" className="text-xs font-mono uppercase tracking-wider">
Status
</Label>
<select
id="status"
{...register('status')}
className="mt-1 flex h-11 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
>
<option value="Active">Active</option>
<option value="Maintenance">Maintenance</option>
<option value="Sold">Sold</option>
<option value="Wishlist">Wishlist</option>
</select>
</div>
<div>
<Label htmlFor="condition" className="text-xs font-mono uppercase tracking-wider">
Condition
</Label>
<select
id="condition"
{...register('condition')}
className="mt-1 flex h-11 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
>
<option value="Mint">Mint</option>
<option value="Good">Good</option>
<option value="Fair">Fair</option>
<option value="Poor">Poor</option>
</select>
</div>
<div>
<Label htmlFor="purchaseDate" className="text-xs font-mono uppercase tracking-wider">
Purchase Date
</Label>
<Input
id="purchaseDate"
type="date"
{...register('purchaseDate')}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="purchasePrice" className="text-xs font-mono uppercase tracking-wider">
Purchase Price
</Label>
<Input
id="purchasePrice"
type="number"
step="0.01"
{...register('purchasePrice')}
error={errors.purchasePrice?.message}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="currency" className="text-xs font-mono uppercase tracking-wider">
Currency
</Label>
<select
id="currency"
{...register('currency')}
className="mt-1 flex h-11 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm"
>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="vendor" className="text-xs font-mono uppercase tracking-wider">
Vendor
</Label>
<Input id="vendor" {...register('vendor')} className="mt-1" />
</div>
<div className="md:col-span-2">
<Label htmlFor="notes" className="text-xs font-mono uppercase tracking-wider">
Notes
</Label>
<Textarea id="notes" {...register('notes')} rows={3} className="mt-1" />
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-border">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : isEdit ? 'Save' : 'Add Gear'}
</Button>
</div>
</form>
</div>
</div>
);
}

View file

@ -4,6 +4,7 @@ export { GearInventoryGrid } from './GearInventoryGrid';
export { GearInventoryGridSkeleton } from './GearInventoryGridSkeleton';
export { GearCard } from './GearCard';
export { GearDetailModal } from './GearDetailModal';
export { GearFormModal } from './GearFormModal';
export { getWarrantyStatus } from './gearUtils';
export type { GearViewHeaderProps } from './GearViewHeader';
export type { GearFiltersProps } from './GearFilters';

View file

@ -1,12 +1,15 @@
import React from 'react';
import React, { useState } from 'react';
import toast from '@/utils/toast';
import {
GearViewHeader,
GearFilters,
GearInventoryGrid,
GearDetailModal,
GearFormModal,
} from '../../components/gear';
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog';
import type { GearItem } from '@/types';
import { gearService } from '@/services/gearService';
import { useGearView } from './useGearView';
import { GearViewSkeleton } from './GearViewSkeleton';
import { GearViewToolbar } from './GearViewToolbar';
@ -25,6 +28,14 @@ export const GearView: React.FC<GearViewProps> = ({
isLoading: isLoadingProp,
error: errorProp,
}) => {
const [formModalItem, setFormModalItem] = useState<GearItem | null | 'add'>(
null,
);
const [deleteConfirmItem, setDeleteConfirmItem] = useState<GearItem | null>(
null,
);
const [isDeleting, setIsDeleting] = useState(false);
const {
filter,
setFilter,
@ -37,6 +48,7 @@ export const GearView: React.FC<GearViewProps> = ({
filteredInventory,
isLoading,
error,
refetch,
} = useGearView({
itemsOverride,
isLoading: isLoadingProp,
@ -48,6 +60,36 @@ export const GearView: React.FC<GearViewProps> = ({
setSelectedItem(null);
};
const handleOpenAddForm = () => setFormModalItem('add');
const handleOpenEditForm = (item: GearItem) => {
setSelectedItem(null);
setFormModalItem(item);
};
const handleCloseFormModal = () => setFormModalItem(null);
const handleFormSuccess = () => {
void refetch();
};
const handleDeleteClick = (item: GearItem) => {
setSelectedItem(null);
setDeleteConfirmItem(item);
};
const handleDeleteConfirm = async () => {
if (!deleteConfirmItem) return;
setIsDeleting(true);
try {
await gearService.delete(deleteConfirmItem.id);
toast.success('Gear item deleted');
setSelectedItem(null);
setDeleteConfirmItem(null);
void refetch();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete');
} finally {
setIsDeleting(false);
}
};
if (isLoading) {
return <GearViewSkeleton />;
}
@ -56,7 +98,7 @@ export const GearView: React.FC<GearViewProps> = ({
<div className="space-y-8 animate-fadeIn relative max-w-layout-content mx-auto px-4 md:px-6">
<GearViewHeader
onExport={() => toast('Exporting Inventory CSV...')}
onRegister={() => toast('Opens Registration Form')}
onRegister={handleOpenAddForm}
error={error}
/>
{error ? (
@ -65,7 +107,7 @@ export const GearView: React.FC<GearViewProps> = ({
viewMode={viewMode}
error={error}
onItemSelect={setSelectedItem}
onAddNew={() => toast('Opens Registration Form')}
onAddNew={handleOpenAddForm}
/>
) : (
<>
@ -80,18 +122,41 @@ export const GearView: React.FC<GearViewProps> = ({
items={filteredInventory}
viewMode={viewMode}
onItemSelect={setSelectedItem}
onAddNew={() => toast('Opens Registration Form')}
onAddNew={handleOpenAddForm}
/>
{selectedItem && (
<GearDetailModal
item={selectedItem}
onClose={() => setSelectedItem(null)}
onEdit={handleOpenEditForm}
onDelete={handleDeleteClick}
onSellOnMarketplace={handleListOnMarketplace}
onLogMaintenance={() => toast('Maintenance Log Updated')}
onContactSupport={(item) => toast(`Contacting ${item.supportContact}`)}
onUploadDocument={() => toast('Upload document')}
/>
)}
{formModalItem !== null && (
<GearFormModal
item={formModalItem === 'add' ? undefined : formModalItem}
onClose={handleCloseFormModal}
onSuccess={handleFormSuccess}
/>
)}
<ConfirmationDialog
open={!!deleteConfirmItem}
onClose={() => setDeleteConfirmItem(null)}
onConfirm={handleDeleteConfirm}
title="Delete gear item"
description={
deleteConfirmItem
? `Are you sure you want to delete "${deleteConfirmItem.name}"? This cannot be undone.`
: ''
}
confirmLabel="Delete"
variant="destructive"
isLoading={isDeleting}
/>
</>
)}
</div>

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import type { GearItem } from '@/types';
import {
type GearViewMode,
@ -27,6 +27,7 @@ export interface UseGearViewReturn {
filteredInventory: GearItem[];
isLoading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export function useGearView(props: UseGearViewProps = {}): UseGearViewReturn {
@ -39,24 +40,25 @@ export function useGearView(props: UseGearViewProps = {}): UseGearViewReturn {
const [fetchLoading, setFetchLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const fetchItems = useCallback(async () => {
setFetchLoading(true);
setFetchError(null);
try {
const items = await gearService.list();
setFetchedItems(items);
} catch (err) {
setFetchError(err instanceof Error ? err.message : 'Failed to load gear');
setFetchedItems([]);
} finally {
setFetchLoading(false);
}
}, []);
// When itemsOverride is undefined, fetch from API
useEffect(() => {
if (itemsOverride !== undefined) return;
setFetchLoading(true);
setFetchError(null);
gearService
.list()
.then((items) => {
setFetchedItems(items);
})
.catch((err) => {
setFetchError(err instanceof Error ? err.message : 'Failed to load gear');
setFetchedItems([]);
})
.finally(() => {
setFetchLoading(false);
});
}, [itemsOverride]);
void fetchItems();
}, [itemsOverride, fetchItems]);
const sourceItems =
itemsOverride !== undefined ? (itemsOverride ?? []) : fetchedItems;
@ -87,5 +89,6 @@ export function useGearView(props: UseGearViewProps = {}): UseGearViewReturn {
filteredInventory,
isLoading,
error,
refetch: fetchItems,
};
}

View file

@ -252,6 +252,50 @@ export const handlersMisc = [
});
}),
http.post('*/api/v1/inventory/gear', async ({ request }) => {
const body = (await request.json()) as Record<string, unknown>;
const item = {
id: `gear-${Date.now()}`,
name: body.name ?? 'New Gear',
category: body.category ?? 'Synth',
brand: body.brand ?? '',
model: body.model ?? '',
serialNumber: body.serialNumber ?? '',
purchaseDate: body.purchaseDate ?? '',
purchasePrice: body.purchasePrice ?? 0,
currency: body.currency ?? 'USD',
status: body.status ?? 'Active',
condition: body.condition ?? 'Good',
vendor: body.vendor ?? '',
image: 'https://picsum.photos/id/100/400/400',
};
return HttpResponse.json({ success: true, data: { item } }, { status: 201 });
}),
http.put('*/api/v1/inventory/gear/:id', async ({ request, params }) => {
const body = (await request.json()) as Record<string, unknown>;
const item = {
id: params.id,
name: body.name ?? 'Updated',
category: body.category ?? 'Synth',
brand: body.brand ?? '',
model: body.model ?? '',
serialNumber: body.serialNumber ?? '',
purchaseDate: body.purchaseDate ?? '',
purchasePrice: body.purchasePrice ?? 0,
currency: body.currency ?? 'USD',
status: body.status ?? 'Active',
condition: body.condition ?? 'Good',
vendor: body.vendor ?? '',
image: 'https://picsum.photos/id/100/400/400',
};
return HttpResponse.json({ success: true, data: { item } });
}),
http.delete('*/api/v1/inventory/gear/:id', () => {
return HttpResponse.json({ success: true, message: 'gear item deleted' });
}),
http.get('*/api/v1/live/streams', ({ request }) => {
const url = new URL(request.url);
const isLive = url.searchParams.get('is_live');