feat(it-inventory): add IP/MAC/warranty/cost/rack/vendor/model fields and delete confirmation

- New fields: ipAddress, macAddress, warrantyExpiry, purchaseCost, rackLocation, vendor, model
- Delete confirmation dialog
- Expanded table columns for vendor/model and IP
- Search includes IP, vendor, and model
- Form layout with organized field groups

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Marius Tarau
2026-02-18 06:35:33 +02:00
parent 93cf6feae2
commit f7e6cbbc65
3 changed files with 96 additions and 19 deletions

View File

@@ -9,6 +9,7 @@ import { Textarea } from '@/shared/components/ui/textarea';
import { Badge } from '@/shared/components/ui/badge'; import { Badge } from '@/shared/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/shared/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/shared/components/ui/dialog';
import type { CompanyId } from '@/core/auth/types'; import type { CompanyId } from '@/core/auth/types';
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types'; import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
import { useInventory } from '../hooks/use-inventory'; import { useInventory } from '../hooks/use-inventory';
@@ -28,8 +29,9 @@ export function ItInventoryModule() {
const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory(); const { items, allItems, loading, filters, updateFilter, addItem, updateItem, removeItem } = useInventory();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null); const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => { const handleSubmit = async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
if (viewMode === 'edit' && editingItem) { if (viewMode === 'edit' && editingItem) {
await updateItem(editingItem.id, data); await updateItem(editingItem.id, data);
} else { } else {
@@ -39,6 +41,13 @@ export function ItInventoryModule() {
setEditingItem(null); setEditingItem(null);
}; };
const handleDeleteConfirm = async () => {
if (deletingId) {
await removeItem(deletingId);
setDeletingId(null);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Stats */} {/* Stats */}
@@ -51,7 +60,6 @@ export function ItInventoryModule() {
{viewMode === 'list' && ( {viewMode === 'list' && (
<> <>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-[200px] flex-1"> <div className="relative min-w-[200px] flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -80,7 +88,6 @@ export function ItInventoryModule() {
</Button> </Button>
</div> </div>
{/* Table */}
{loading ? ( {loading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p> <p className="py-8 text-center text-sm text-muted-foreground">Se încarcă...</p>
) : items.length === 0 ? ( ) : items.length === 0 ? (
@@ -91,7 +98,9 @@ export function ItInventoryModule() {
<thead><tr className="border-b bg-muted/40"> <thead><tr className="border-b bg-muted/40">
<th className="px-3 py-2 text-left font-medium">Nume</th> <th className="px-3 py-2 text-left font-medium">Nume</th>
<th className="px-3 py-2 text-left font-medium">Tip</th> <th className="px-3 py-2 text-left font-medium">Tip</th>
<th className="px-3 py-2 text-left font-medium">Vendor/Model</th>
<th className="px-3 py-2 text-left font-medium">S/N</th> <th className="px-3 py-2 text-left font-medium">S/N</th>
<th className="px-3 py-2 text-left font-medium">IP</th>
<th className="px-3 py-2 text-left font-medium">Atribuit</th> <th className="px-3 py-2 text-left font-medium">Atribuit</th>
<th className="px-3 py-2 text-left font-medium">Locație</th> <th className="px-3 py-2 text-left font-medium">Locație</th>
<th className="px-3 py-2 text-left font-medium">Status</th> <th className="px-3 py-2 text-left font-medium">Status</th>
@@ -102,16 +111,22 @@ export function ItInventoryModule() {
<tr key={item.id} className="border-b hover:bg-muted/20 transition-colors"> <tr key={item.id} className="border-b hover:bg-muted/20 transition-colors">
<td className="px-3 py-2 font-medium">{item.name}</td> <td className="px-3 py-2 font-medium">{item.name}</td>
<td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td> <td className="px-3 py-2"><Badge variant="outline">{TYPE_LABELS[item.type]}</Badge></td>
<td className="px-3 py-2 text-xs">
{item.vendor && <span>{item.vendor}</span>}
{item.vendor && item.model && <span className="text-muted-foreground"> / </span>}
{item.model && <span className="text-muted-foreground">{item.model}</span>}
</td>
<td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td> <td className="px-3 py-2 font-mono text-xs">{item.serialNumber}</td>
<td className="px-3 py-2 font-mono text-xs">{item.ipAddress}</td>
<td className="px-3 py-2">{item.assignedTo}</td> <td className="px-3 py-2">{item.assignedTo}</td>
<td className="px-3 py-2">{item.location}</td> <td className="px-3 py-2 text-xs">{item.rackLocation || item.location}</td>
<td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td> <td className="px-3 py-2"><Badge variant="secondary">{STATUS_LABELS[item.status]}</Badge></td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}> <Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditingItem(item); setViewMode('edit'); }}>
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => removeItem(item.id)}> <Button variant="ghost" size="icon" className="h-7 w-7 text-destructive" onClick={() => setDeletingId(item.id)}>
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
@@ -137,13 +152,25 @@ export function ItInventoryModule() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Delete confirmation */}
<Dialog open={deletingId !== null} onOpenChange={(open) => { if (!open) setDeletingId(null); }}>
<DialogContent>
<DialogHeader><DialogTitle>Confirmare ștergere</DialogTitle></DialogHeader>
<p className="text-sm">Ești sigur vrei ștergi acest echipament? Acțiunea este ireversibilă.</p>
<DialogFooter>
<Button variant="outline" onClick={() => setDeletingId(null)}>Anulează</Button>
<Button variant="destructive" onClick={handleDeleteConfirm}>Șterge</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
function InventoryForm({ initial, onSubmit, onCancel }: { function InventoryForm({ initial, onSubmit, onCancel }: {
initial?: InventoryItem; initial?: InventoryItem;
onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt'>) => void; onSubmit: (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const [name, setName] = useState(initial?.name ?? ''); const [name, setName] = useState(initial?.name ?? '');
@@ -154,12 +181,26 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
const [location, setLocation] = useState(initial?.location ?? ''); const [location, setLocation] = useState(initial?.location ?? '');
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? ''); const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? '');
const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active'); const [status, setStatus] = useState<InventoryItemStatus>(initial?.status ?? 'active');
const [ipAddress, setIpAddress] = useState(initial?.ipAddress ?? '');
const [macAddress, setMacAddress] = useState(initial?.macAddress ?? '');
const [warrantyExpiry, setWarrantyExpiry] = useState(initial?.warrantyExpiry ?? '');
const [purchaseCost, setPurchaseCost] = useState(initial?.purchaseCost ?? '');
const [rackLocation, setRackLocation] = useState(initial?.rackLocation ?? '');
const [vendor, setVendor] = useState(initial?.vendor ?? '');
const [model, setModel] = useState(initial?.model ?? '');
const [notes, setNotes] = useState(initial?.notes ?? ''); const [notes, setNotes] = useState(initial?.notes ?? '');
return ( return (
<form onSubmit={(e) => { e.preventDefault(); onSubmit({ name, type, serialNumber, assignedTo, company, location, purchaseDate, status, notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all' }); }} className="space-y-4"> <form onSubmit={(e) => {
e.preventDefault();
onSubmit({
name, type, serialNumber, assignedTo, company, location, purchaseDate, status,
ipAddress, macAddress, warrantyExpiry, purchaseCost, rackLocation, vendor, model,
notes, tags: initial?.tags ?? [], visibility: initial?.visibility ?? 'all',
});
}} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div><Label>Nume echipament</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div> <div><Label>Nume echipament *</Label><Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" required /></div>
<div><Label>Tip</Label> <div><Label>Tip</Label>
<Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}> <Select value={type} onValueChange={(v) => setType(v as InventoryItemType)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
@@ -167,11 +208,17 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
</Select> </Select>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Vendor</Label><Input value={vendor} onChange={(e) => setVendor(e.target.value)} className="mt-1" placeholder="Dell, HP, Lenovo..." /></div>
<div><Label>Model</Label><Input value={model} onChange={(e) => setModel(e.target.value)} className="mt-1" /></div>
<div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div> <div><Label>Număr serie</Label><Input value={serialNumber} onChange={(e) => setSerialNumber(e.target.value)} className="mt-1" /></div>
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div><Label>Adresă IP</Label><Input value={ipAddress} onChange={(e) => setIpAddress(e.target.value)} className="mt-1" placeholder="192.168.1.x" /></div>
<div><Label>Adresă MAC</Label><Input value={macAddress} onChange={(e) => setMacAddress(e.target.value)} className="mt-1" placeholder="AA:BB:CC:DD:EE:FF" /></div>
<div><Label>Atribuit</Label><Input value={assignedTo} onChange={(e) => setAssignedTo(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-4 sm:grid-cols-4">
<div><Label>Companie</Label> <div><Label>Companie</Label>
<Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}> <Select value={company} onValueChange={(v) => setCompany(v as CompanyId)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
@@ -183,18 +230,21 @@ function InventoryForm({ initial, onSubmit, onCancel }: {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div><Label>Locație</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div> <div><Label>Locație / Cameră</Label><Input value={location} onChange={(e) => setLocation(e.target.value)} className="mt-1" /></div>
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div> <div><Label>Rack / Poziție</Label><Input value={rackLocation} onChange={(e) => setRackLocation(e.target.value)} className="mt-1" /></div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div><Label>Status</Label> <div><Label>Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}> <Select value={status} onValueChange={(v) => setStatus(v as InventoryItemStatus)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger> <SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent> <SelectContent>{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map((s) => (<SelectItem key={s} value={s}>{STATUS_LABELS[s]}</SelectItem>))}</SelectContent>
</Select> </Select>
</div> </div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3">
<div><Label>Data achiziție</Label><Input type="date" value={purchaseDate} onChange={(e) => setPurchaseDate(e.target.value)} className="mt-1" /></div>
<div><Label>Cost achiziție (RON)</Label><Input type="number" value={purchaseCost} onChange={(e) => setPurchaseCost(e.target.value)} className="mt-1" /></div>
<div><Label>Expirare garanție</Label><Input type="date" value={warrantyExpiry} onChange={(e) => setWarrantyExpiry(e.target.value)} className="mt-1" /></div>
</div>
<div><Label>Note</Label><Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} className="mt-1" /></div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={onCancel}>Anulează</Button> <Button type="button" variant="outline" onClick={onCancel}>Anulează</Button>
<Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button> <Button type="submit">{initial ? 'Actualizează' : 'Adaugă'}</Button>

View File

@@ -40,8 +40,9 @@ export function useInventory() {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { refresh(); }, [refresh]);
const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt'>) => { const addItem = useCallback(async (data: Omit<InventoryItem, 'id' | 'createdAt' | 'updatedAt'>) => {
const item: InventoryItem = { ...data, id: uuid(), createdAt: new Date().toISOString() }; const now = new Date().toISOString();
const item: InventoryItem = { ...data, id: uuid(), createdAt: now, updatedAt: now };
await storage.set(`${PREFIX}${item.id}`, item); await storage.set(`${PREFIX}${item.id}`, item);
await refresh(); await refresh();
return item; return item;
@@ -50,7 +51,11 @@ export function useInventory() {
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => { const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
const existing = items.find((i) => i.id === id); const existing = items.find((i) => i.id === id);
if (!existing) return; if (!existing) return;
const updated = { ...existing, ...updates, id: existing.id, createdAt: existing.createdAt }; const updated: InventoryItem = {
...existing, ...updates,
id: existing.id, createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated); await storage.set(`${PREFIX}${id}`, updated);
await refresh(); await refresh();
}, [storage, refresh, items]); }, [storage, refresh, items]);
@@ -70,7 +75,14 @@ export function useInventory() {
if (filters.company !== 'all' && item.company !== filters.company) return false; if (filters.company !== 'all' && item.company !== filters.company) return false;
if (filters.search) { if (filters.search) {
const q = filters.search.toLowerCase(); const q = filters.search.toLowerCase();
return item.name.toLowerCase().includes(q) || item.serialNumber.toLowerCase().includes(q) || item.assignedTo.toLowerCase().includes(q); return (
item.name.toLowerCase().includes(q) ||
item.serialNumber.toLowerCase().includes(q) ||
item.assignedTo.toLowerCase().includes(q) ||
item.ipAddress.toLowerCase().includes(q) ||
item.vendor.toLowerCase().includes(q) ||
item.model.toLowerCase().includes(q)
);
} }
return true; return true;
}); });

View File

@@ -28,8 +28,23 @@ export interface InventoryItem {
location: string; location: string;
purchaseDate: string; purchaseDate: string;
status: InventoryItemStatus; status: InventoryItemStatus;
/** IP address */
ipAddress: string;
/** MAC address */
macAddress: string;
/** Warranty expiry date (YYYY-MM-DD) */
warrantyExpiry: string;
/** Purchase cost (RON) */
purchaseCost: string;
/** Room / rack position */
rackLocation: string;
/** Vendor / manufacturer */
vendor: string;
/** Model name/number */
model: string;
tags: string[]; tags: string[];
notes: string; notes: string;
visibility: Visibility; visibility: Visibility;
createdAt: string; createdAt: string;
updatedAt: string;
} }