feat(it-inventory): dynamic types, rented status, rack visualization, simplified form
- Rewrite types.ts: dynamic InventoryItemType (string-based), DEFAULT_EQUIPMENT_TYPES with server/switch/ups/patch-panel, RACK_MOUNTABLE_TYPES set, new 'rented' status with STATUS_LABELS export - Remove deprecated fields: assignedTo, assignedToContactId, purchaseDate, purchaseCost, warrantyExpiry - Add rack fields: rackPosition (1-42), rackSize (1-4U) on InventoryItem - New server-rack.tsx: 42U rack visualization with color-coded status slots, tooltips, occupied/empty rendering - Rewrite it-inventory-module.tsx: tabbed UI (Inventar + Rack 42U), 5 stat cards with purple pulse for rented count, inline custom type creation, conditional rack position fields for mountable types, simplified form - Fix search filter in use-inventory.ts: remove assignedTo reference, search rackLocation/location Task 3.08 complete
This commit is contained in:
@@ -27,36 +27,38 @@ import {
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/shared/components/ui/dialog";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/shared/components/ui/tabs";
|
||||
import type { CompanyId } from "@/core/auth/types";
|
||||
import type {
|
||||
InventoryItem,
|
||||
InventoryItemType,
|
||||
InventoryItemStatus,
|
||||
} from "../types";
|
||||
import {
|
||||
DEFAULT_TYPE_LABELS,
|
||||
STATUS_LABELS,
|
||||
RACK_MOUNTABLE_TYPES,
|
||||
} from "../types";
|
||||
import { useInventory } from "../hooks/use-inventory";
|
||||
import { useContacts } from "@/modules/address-book/hooks/use-contacts";
|
||||
|
||||
const TYPE_LABELS: Record<InventoryItemType, string> = {
|
||||
laptop: "Laptop",
|
||||
desktop: "Desktop",
|
||||
monitor: "Monitor",
|
||||
printer: "Imprimantă",
|
||||
phone: "Telefon",
|
||||
tablet: "Tabletă",
|
||||
network: "Rețea",
|
||||
peripheral: "Periferic",
|
||||
other: "Altele",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<InventoryItemStatus, string> = {
|
||||
active: "Activ",
|
||||
"in-repair": "În reparație",
|
||||
storage: "Depozitat",
|
||||
decommissioned: "Dezafectat",
|
||||
};
|
||||
import { ServerRack } from "./server-rack";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
type ViewMode = "list" | "add" | "edit";
|
||||
|
||||
/** Resolve type label from defaults or custom */
|
||||
function getTypeLabel(type: string, customTypes: Map<string, string>): string {
|
||||
return (
|
||||
DEFAULT_TYPE_LABELS[type] ??
|
||||
customTypes.get(type) ??
|
||||
type.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export function ItInventoryModule() {
|
||||
const {
|
||||
items,
|
||||
@@ -72,6 +74,37 @@ export function ItInventoryModule() {
|
||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Build dynamic type list from defaults + existing items
|
||||
const allTypes = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const [key, label] of Object.entries(DEFAULT_TYPE_LABELS)) {
|
||||
map.set(key, label);
|
||||
}
|
||||
for (const item of allItems) {
|
||||
if (!map.has(item.type)) {
|
||||
map.set(
|
||||
item.type,
|
||||
item.type.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allItems]);
|
||||
|
||||
// Custom types beyond defaults (for display)
|
||||
const customTypes = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const item of allItems) {
|
||||
if (!DEFAULT_TYPE_LABELS[item.type]) {
|
||||
map.set(
|
||||
item.type,
|
||||
item.type.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [allItems]);
|
||||
|
||||
const handleSubmit = async (
|
||||
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
|
||||
) => {
|
||||
@@ -91,319 +124,397 @@ export function ItInventoryModule() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
<p className="text-2xl font-bold">{allItems.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Active</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allItems.filter((i) => i.status === "active").length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">În reparație</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allItems.filter((i) => i.status === "in-repair").length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Dezafectate</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allItems.filter((i) => i.status === "decommissioned").length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
const rentedCount = allItems.filter((i) => i.status === "rented").length;
|
||||
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Caută..."
|
||||
value={filters.search}
|
||||
onChange={(e) => updateFilter("search", e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filters.type}
|
||||
onValueChange={(v) =>
|
||||
updateFilter("type", v as InventoryItemType | "all")
|
||||
}
|
||||
return (
|
||||
<Tabs defaultValue="list">
|
||||
<TabsList>
|
||||
<TabsTrigger value="list">Inventar</TabsTrigger>
|
||||
<TabsTrigger value="rack">
|
||||
Rack 42U
|
||||
{allItems.filter((i) => i.rackPosition).length > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-1.5 text-[10px] px-1.5 py-0"
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{TYPE_LABELS[t]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(v) =>
|
||||
updateFilter("status", v as InventoryItemStatus | "all")
|
||||
}
|
||||
{allItems.filter((i) => i.rackPosition).length}
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="list">
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
<p className="text-2xl font-bold">{allItems.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Active</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allItems.filter((i) => i.status === "active").length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">În reparație</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allItems.filter((i) => i.status === "in-repair").length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card
|
||||
className={cn(
|
||||
rentedCount > 0 && "ring-2 ring-purple-400/50 animate-pulse",
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
|
||||
(s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATUS_LABELS[s]}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Închiriate</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-2xl font-bold",
|
||||
rentedCount > 0 && "text-purple-600 dark:text-purple-400",
|
||||
)}
|
||||
>
|
||||
{rentedCount}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-xs text-muted-foreground">Dezafectate</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{allItems.filter((i) => i.status === "decommissioned").length}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Se încarcă...
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Niciun echipament găsit.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<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">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">IP</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">Status</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
Acțiuni
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<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">
|
||||
<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.ipAddress}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.assignedTo}</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 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setViewMode("edit");
|
||||
}}
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative min-w-[200px] flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Caută..."
|
||||
value={filters.search}
|
||||
onChange={(e) => updateFilter("search", e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={filters.type}
|
||||
onValueChange={(v) => updateFilter("type", v)}
|
||||
>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||
{Array.from(allTypes.entries()).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(v) =>
|
||||
updateFilter("status", v as InventoryItemStatus | "all")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Toate</SelectItem>
|
||||
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
|
||||
(s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATUS_LABELS[s]}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Se încarcă...
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Niciun echipament găsit.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<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">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">IP</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-right font-medium">
|
||||
Acțiuni
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const isRented = item.status === "rented";
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={cn(
|
||||
"border-b hover:bg-muted/20 transition-colors",
|
||||
isRented &&
|
||||
"bg-purple-50/50 dark:bg-purple-950/10",
|
||||
)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => setDeletingId(item.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="outline">
|
||||
{getTypeLabel(item.type, customTypes)}
|
||||
</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.ipAddress}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{item.rackPosition ? (
|
||||
<span>
|
||||
U{item.rackPosition}
|
||||
{item.rackSize && item.rackSize > 1
|
||||
? `–U${item.rackPosition + item.rackSize - 1}`
|
||||
: ""}
|
||||
{item.rackLocation
|
||||
? ` · ${item.rackLocation}`
|
||||
: ""}
|
||||
</span>
|
||||
) : (
|
||||
item.rackLocation || item.location || "—"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
isRented &&
|
||||
"bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200 animate-pulse",
|
||||
)}
|
||||
>
|
||||
{STATUS_LABELS[item.status] ?? item.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => {
|
||||
setEditingItem(item);
|
||||
setViewMode("edit");
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive"
|
||||
onClick={() => setDeletingId(item.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{items.length} din {allItems.length} echipamente afișate
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(viewMode === "add" || viewMode === "edit") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{viewMode === "edit" ? "Editare echipament" : "Echipament nou"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InventoryForm
|
||||
initial={editingItem ?? undefined}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setViewMode("list");
|
||||
setEditingItem(null);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{(viewMode === "add" || viewMode === "edit") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{viewMode === "edit"
|
||||
? "Editare echipament"
|
||||
: "Echipament nou"}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InventoryForm
|
||||
initial={editingItem ?? undefined}
|
||||
allTypes={allTypes}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => {
|
||||
setViewMode("list");
|
||||
setEditingItem(null);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</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 că vrei să ș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>
|
||||
{/* 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 că vrei să ș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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rack">
|
||||
<ServerRack items={allItems} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Inventory Form ──
|
||||
|
||||
function InventoryForm({
|
||||
initial,
|
||||
allTypes,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initial?: InventoryItem;
|
||||
allTypes: Map<string, string>;
|
||||
onSubmit: (
|
||||
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
|
||||
) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { allContacts } = useContacts();
|
||||
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [type, setType] = useState<InventoryItemType>(
|
||||
initial?.type ?? "laptop",
|
||||
);
|
||||
const [customType, setCustomType] = useState("");
|
||||
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? "");
|
||||
const [assignedTo, setAssignedTo] = useState(initial?.assignedTo ?? "");
|
||||
const [assignedToContactId, setAssignedToContactId] = useState(
|
||||
initial?.assignedToContactId ?? "",
|
||||
);
|
||||
const [assignedToFocused, setAssignedToFocused] = useState(false);
|
||||
const [company, setCompany] = useState<CompanyId>(
|
||||
initial?.company ?? "beletage",
|
||||
);
|
||||
const [location, setLocation] = useState(initial?.location ?? "");
|
||||
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? "");
|
||||
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 [rackPosition, setRackPosition] = useState<string>(
|
||||
initial?.rackPosition?.toString() ?? "",
|
||||
);
|
||||
const [rackSize, setRackSize] = useState<string>(
|
||||
initial?.rackSize?.toString() ?? "1",
|
||||
);
|
||||
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 ?? "");
|
||||
|
||||
// Contact suggestions for assignedTo autocomplete
|
||||
const assignedToSuggestions = useMemo(() => {
|
||||
if (!assignedTo || assignedTo.length < 2) return [];
|
||||
const q = assignedTo.toLowerCase();
|
||||
return allContacts
|
||||
.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.company.toLowerCase().includes(q),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}, [allContacts, assignedTo]);
|
||||
const showRackFields = RACK_MOUNTABLE_TYPES.has(type);
|
||||
|
||||
const handleAddCustomType = () => {
|
||||
const label = customType.trim();
|
||||
if (!label) return;
|
||||
const key = label.toLowerCase().replace(/\s+/g, "-");
|
||||
setType(key);
|
||||
setCustomType("");
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const rackPosNum = parseInt(rackPosition, 10);
|
||||
const rackSizeNum = parseInt(rackSize, 10);
|
||||
onSubmit({
|
||||
name,
|
||||
type,
|
||||
serialNumber,
|
||||
assignedTo,
|
||||
assignedToContactId,
|
||||
company,
|
||||
location,
|
||||
purchaseDate,
|
||||
status,
|
||||
ipAddress,
|
||||
macAddress,
|
||||
warrantyExpiry,
|
||||
purchaseCost,
|
||||
rackPosition:
|
||||
showRackFields && rackPosNum >= 1 ? rackPosNum : undefined,
|
||||
rackSize:
|
||||
showRackFields && rackSizeNum >= 1 ? rackSizeNum : undefined,
|
||||
rackLocation,
|
||||
vendor,
|
||||
model,
|
||||
@@ -427,22 +538,48 @@ function InventoryForm({
|
||||
<div>
|
||||
<Label>Tip</Label>
|
||||
<Select
|
||||
value={type}
|
||||
value={allTypes.has(type) ? type : "other"}
|
||||
onValueChange={(v) => setType(v as InventoryItemType)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{TYPE_LABELS[t]}
|
||||
{Array.from(allTypes.entries()).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Add custom type inline */}
|
||||
<div className="mt-1.5 flex gap-1">
|
||||
<Input
|
||||
value={customType}
|
||||
onChange={(e) => setCustomType(e.target.value)}
|
||||
placeholder="Tip nou..."
|
||||
className="text-xs h-7"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddCustomType();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleAddCustomType}
|
||||
disabled={!customType.trim()}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>Vendor</Label>
|
||||
@@ -470,6 +607,7 @@ function InventoryForm({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>Adresă IP</Label>
|
||||
@@ -489,47 +627,6 @@ function InventoryForm({
|
||||
placeholder="AA:BB:CC:DD:EE:FF"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Label>Atribuit</Label>
|
||||
<Input
|
||||
value={assignedTo}
|
||||
onChange={(e) => {
|
||||
setAssignedTo(e.target.value);
|
||||
setAssignedToContactId("");
|
||||
}}
|
||||
onFocus={() => setAssignedToFocused(true)}
|
||||
onBlur={() => setTimeout(() => setAssignedToFocused(false), 200)}
|
||||
className="mt-1"
|
||||
placeholder="Caută după nume..."
|
||||
/>
|
||||
{assignedToFocused && assignedToSuggestions.length > 0 && (
|
||||
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-1 shadow-md">
|
||||
{assignedToSuggestions.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className="w-full rounded px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||
onMouseDown={() => {
|
||||
setAssignedTo(
|
||||
c.company ? `${c.name} (${c.company})` : c.name,
|
||||
);
|
||||
setAssignedToContactId(c.id);
|
||||
setAssignedToFocused(false);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">{c.name}</span>
|
||||
{c.company && (
|
||||
<span className="ml-1 text-muted-foreground text-xs">
|
||||
{c.company}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<div>
|
||||
<Label>Companie</Label>
|
||||
<Select
|
||||
@@ -547,6 +644,9 @@ function InventoryForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<Label>Locație / Cameră</Label>
|
||||
<Input
|
||||
@@ -561,6 +661,7 @@ function InventoryForm({
|
||||
value={rackLocation}
|
||||
onChange={(e) => setRackLocation(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="Rack 1, Birou 2..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -584,35 +685,45 @@ function InventoryForm({
|
||||
</Select>
|
||||
</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"
|
||||
/>
|
||||
|
||||
{/* Rack position fields — shown only for rack-mountable types */}
|
||||
{showRackFields && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 p-3 rounded border border-dashed border-slate-400 dark:border-slate-600 bg-slate-50 dark:bg-slate-900/50">
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
Poziție Rack (U){" "}
|
||||
<span className="text-muted-foreground">(1–42)</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={42}
|
||||
value={rackPosition}
|
||||
onChange={(e) => setRackPosition(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="Ex: 12"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
Dimensiune{" "}
|
||||
<span className="text-muted-foreground">(unități rack)</span>
|
||||
</Label>
|
||||
<Select value={rackSize} onValueChange={setRackSize}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1U</SelectItem>
|
||||
<SelectItem value="2">2U</SelectItem>
|
||||
<SelectItem value="3">3U</SelectItem>
|
||||
<SelectItem value="4">4U</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</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
|
||||
@@ -622,6 +733,7 @@ function InventoryForm({
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Anulează
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/components/ui/tooltip";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import type { InventoryItem } from "../types";
|
||||
import { STATUS_LABELS } from "../types";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
|
||||
interface ServerRackProps {
|
||||
items: InventoryItem[];
|
||||
}
|
||||
|
||||
const TOTAL_UNITS = 42;
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: "bg-green-500/80 dark:bg-green-600/80",
|
||||
"in-repair": "bg-amber-500/80 dark:bg-amber-600/80",
|
||||
storage: "bg-slate-400/80 dark:bg-slate-500/80",
|
||||
rented: "bg-purple-500/80 dark:bg-purple-600/80 animate-pulse",
|
||||
decommissioned: "bg-red-400/60 dark:bg-red-500/60",
|
||||
};
|
||||
|
||||
interface RackSlot {
|
||||
unit: number;
|
||||
item?: InventoryItem;
|
||||
isStart: boolean;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function ServerRack({ items }: ServerRackProps) {
|
||||
const slots = useMemo(() => {
|
||||
// Build the rack map
|
||||
const occupied = new Map<number, { item: InventoryItem; size: number }>();
|
||||
const rackItems = items.filter(
|
||||
(i) =>
|
||||
i.rackPosition && i.rackPosition >= 1 && i.rackPosition <= TOTAL_UNITS,
|
||||
);
|
||||
|
||||
for (const item of rackItems) {
|
||||
const pos = item.rackPosition!;
|
||||
const size = item.rackSize ?? 1;
|
||||
occupied.set(pos, { item, size });
|
||||
}
|
||||
|
||||
const result: RackSlot[] = [];
|
||||
let u = 1;
|
||||
while (u <= TOTAL_UNITS) {
|
||||
const entry = occupied.get(u);
|
||||
if (entry) {
|
||||
result.push({
|
||||
unit: u,
|
||||
item: entry.item,
|
||||
isStart: true,
|
||||
height: entry.size,
|
||||
});
|
||||
u += entry.size;
|
||||
} else {
|
||||
result.push({ unit: u, isStart: false, height: 1 });
|
||||
u++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [items]);
|
||||
|
||||
const rackItems = items.filter((i) => i.rackPosition);
|
||||
|
||||
if (rackItems.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
Niciun echipament cu poziție rack configurată.
|
||||
<br />
|
||||
<span className="text-xs">
|
||||
Editează un echipament de tip Server/Switch/UPS și completează
|
||||
"Poziție Rack".
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="mx-auto max-w-md">
|
||||
{/* Rack frame */}
|
||||
<div className="rounded-lg border-2 border-slate-600 dark:border-slate-400 bg-slate-100 dark:bg-slate-900 overflow-hidden">
|
||||
{/* Top label */}
|
||||
<div className="bg-slate-700 dark:bg-slate-300 px-3 py-1.5 text-center">
|
||||
<span className="text-xs font-bold text-white dark:text-slate-900">
|
||||
SERVER RACK — 42U
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Units */}
|
||||
<div className="p-1 space-y-px">
|
||||
{slots.map((slot) => {
|
||||
if (!slot.isStart && slot.item) return null;
|
||||
|
||||
const heightPx = slot.height * 24;
|
||||
|
||||
if (slot.item) {
|
||||
const color =
|
||||
STATUS_COLORS[slot.item.status] ?? STATUS_COLORS.active;
|
||||
return (
|
||||
<Tooltip key={slot.unit}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded px-2 text-white text-xs font-medium cursor-default transition-all hover:brightness-110",
|
||||
color,
|
||||
)}
|
||||
style={{ height: `${heightPx}px` }}
|
||||
>
|
||||
<span className="text-[10px] opacity-70 shrink-0 w-5 text-right">
|
||||
U{slot.unit}
|
||||
</span>
|
||||
<span className="truncate flex-1">
|
||||
{slot.item.name}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-70 shrink-0">
|
||||
{slot.item.rackSize ?? 1}U
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{slot.item.name}</p>
|
||||
{slot.item.vendor && (
|
||||
<p className="text-xs">
|
||||
{slot.item.vendor}
|
||||
{slot.item.model ? ` / ${slot.item.model}` : ""}
|
||||
</p>
|
||||
)}
|
||||
{slot.item.ipAddress && (
|
||||
<p className="text-xs font-mono">
|
||||
IP: {slot.item.ipAddress}
|
||||
</p>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{STATUS_LABELS[slot.item.status] ?? slot.item.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty slot
|
||||
return (
|
||||
<div
|
||||
key={slot.unit}
|
||||
className="flex items-center px-2 rounded border border-dashed border-slate-300 dark:border-slate-700 text-[10px] text-muted-foreground"
|
||||
style={{ height: "24px" }}
|
||||
>
|
||||
<span className="w-5 text-right opacity-50">
|
||||
U{slot.unit}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom label */}
|
||||
<div className="bg-slate-700 dark:bg-slate-300 px-3 py-1 text-center">
|
||||
<span className="text-[10px] text-white dark:text-slate-900">
|
||||
{rackItems.length} echipamente montate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useStorage } from '@/core/storage';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useStorage } from "@/core/storage";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import type { InventoryItem, InventoryItemStatus } from "../types";
|
||||
|
||||
const PREFIX = 'item:';
|
||||
const PREFIX = "item:";
|
||||
|
||||
export interface InventoryFilters {
|
||||
search: string;
|
||||
type: InventoryItemType | 'all';
|
||||
status: InventoryItemStatus | 'all';
|
||||
type: string;
|
||||
status: InventoryItemStatus | "all";
|
||||
company: string;
|
||||
}
|
||||
|
||||
export function useInventory() {
|
||||
const storage = useStorage('it-inventory');
|
||||
const storage = useStorage("it-inventory");
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState<InventoryFilters>({
|
||||
search: '', type: 'all', status: 'all', company: 'all',
|
||||
search: "",
|
||||
type: "all",
|
||||
status: "all",
|
||||
company: "all",
|
||||
});
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
@@ -38,54 +41,88 @@ export function useInventory() {
|
||||
}, [storage]);
|
||||
|
||||
// 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' | 'updatedAt'>) => {
|
||||
const now = new Date().toISOString();
|
||||
const item: InventoryItem = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
||||
await storage.set(`${PREFIX}${item.id}`, item);
|
||||
await refresh();
|
||||
return item;
|
||||
}, [storage, refresh]);
|
||||
const addItem = useCallback(
|
||||
async (data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">) => {
|
||||
const now = new Date().toISOString();
|
||||
const item: InventoryItem = {
|
||||
...data,
|
||||
id: uuid(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await storage.set(`${PREFIX}${item.id}`, item);
|
||||
await refresh();
|
||||
return item;
|
||||
},
|
||||
[storage, refresh],
|
||||
);
|
||||
|
||||
const updateItem = useCallback(async (id: string, updates: Partial<InventoryItem>) => {
|
||||
const existing = items.find((i) => i.id === id);
|
||||
if (!existing) return;
|
||||
const updated: InventoryItem = {
|
||||
...existing, ...updates,
|
||||
id: existing.id, createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await storage.set(`${PREFIX}${id}`, updated);
|
||||
await refresh();
|
||||
}, [storage, refresh, items]);
|
||||
const updateItem = useCallback(
|
||||
async (id: string, updates: Partial<InventoryItem>) => {
|
||||
const existing = items.find((i) => i.id === id);
|
||||
if (!existing) return;
|
||||
const updated: InventoryItem = {
|
||||
...existing,
|
||||
...updates,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await storage.set(`${PREFIX}${id}`, updated);
|
||||
await refresh();
|
||||
},
|
||||
[storage, refresh, items],
|
||||
);
|
||||
|
||||
const removeItem = useCallback(async (id: string) => {
|
||||
await storage.delete(`${PREFIX}${id}`);
|
||||
await refresh();
|
||||
}, [storage, refresh]);
|
||||
const removeItem = useCallback(
|
||||
async (id: string) => {
|
||||
await storage.delete(`${PREFIX}${id}`);
|
||||
await refresh();
|
||||
},
|
||||
[storage, refresh],
|
||||
);
|
||||
|
||||
const updateFilter = useCallback(<K extends keyof InventoryFilters>(key: K, value: InventoryFilters[K]) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
const updateFilter = useCallback(
|
||||
<K extends keyof InventoryFilters>(key: K, value: InventoryFilters[K]) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
if (filters.type !== 'all' && item.type !== filters.type) return false;
|
||||
if (filters.status !== 'all' && item.status !== filters.status) return false;
|
||||
if (filters.company !== 'all' && item.company !== filters.company) return false;
|
||||
if (filters.type !== "all" && item.type !== filters.type) return false;
|
||||
if (filters.status !== "all" && item.status !== filters.status)
|
||||
return false;
|
||||
if (filters.company !== "all" && item.company !== filters.company)
|
||||
return false;
|
||||
if (filters.search) {
|
||||
const q = filters.search.toLowerCase();
|
||||
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)
|
||||
item.ipAddress.toLowerCase().includes(q) ||
|
||||
item.vendor.toLowerCase().includes(q) ||
|
||||
item.model.toLowerCase().includes(q) ||
|
||||
item.location.toLowerCase().includes(q) ||
|
||||
item.rackLocation.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return { items: filteredItems, allItems: items, loading, filters, updateFilter, addItem, updateItem, removeItem, refresh };
|
||||
return {
|
||||
items: filteredItems,
|
||||
allItems: items,
|
||||
loading,
|
||||
filters,
|
||||
updateFilter,
|
||||
addItem,
|
||||
updateItem,
|
||||
removeItem,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,43 +1,86 @@
|
||||
import type { Visibility } from "@/core/module-registry/types";
|
||||
import type { CompanyId } from "@/core/auth/types";
|
||||
|
||||
/** Default equipment types — user can add custom types dynamically */
|
||||
export const DEFAULT_EQUIPMENT_TYPES = [
|
||||
"laptop",
|
||||
"desktop",
|
||||
"monitor",
|
||||
"printer",
|
||||
"phone",
|
||||
"tablet",
|
||||
"network",
|
||||
"peripheral",
|
||||
"server",
|
||||
"switch",
|
||||
"ups",
|
||||
"patch-panel",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
/** Equipment type — string-based for dynamic types */
|
||||
export type InventoryItemType =
|
||||
| "laptop"
|
||||
| "desktop"
|
||||
| "monitor"
|
||||
| "printer"
|
||||
| "phone"
|
||||
| "tablet"
|
||||
| "network"
|
||||
| "peripheral"
|
||||
| "other";
|
||||
| (typeof DEFAULT_EQUIPMENT_TYPES)[number]
|
||||
| string;
|
||||
|
||||
/** Labels for default equipment types */
|
||||
export const DEFAULT_TYPE_LABELS: Record<string, string> = {
|
||||
laptop: "Laptop",
|
||||
desktop: "Desktop",
|
||||
monitor: "Monitor",
|
||||
printer: "Imprimantă",
|
||||
phone: "Telefon",
|
||||
tablet: "Tabletă",
|
||||
network: "Rețea",
|
||||
peripheral: "Periferic",
|
||||
server: "Server",
|
||||
switch: "Switch",
|
||||
ups: "UPS",
|
||||
"patch-panel": "Patch Panel",
|
||||
other: "Altele",
|
||||
};
|
||||
|
||||
/** Rack-mountable types that show rack position fields */
|
||||
export const RACK_MOUNTABLE_TYPES = new Set([
|
||||
"server",
|
||||
"switch",
|
||||
"ups",
|
||||
"patch-panel",
|
||||
"network",
|
||||
]);
|
||||
|
||||
export type InventoryItemStatus =
|
||||
| "active"
|
||||
| "in-repair"
|
||||
| "storage"
|
||||
| "rented"
|
||||
| "decommissioned";
|
||||
|
||||
export const STATUS_LABELS: Record<InventoryItemStatus, string> = {
|
||||
active: "Activ",
|
||||
"in-repair": "În reparație",
|
||||
storage: "Depozitat",
|
||||
rented: "Închiriat",
|
||||
decommissioned: "Dezafectat",
|
||||
};
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: InventoryItemType;
|
||||
serialNumber: string;
|
||||
assignedTo: string;
|
||||
assignedToContactId?: string;
|
||||
company: CompanyId;
|
||||
location: string;
|
||||
purchaseDate: string;
|
||||
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 */
|
||||
/** Rack unit start position (1-42) */
|
||||
rackPosition?: number;
|
||||
/** Size in rack units (1U, 2U, 4U) */
|
||||
rackSize?: number;
|
||||
/** Room / rack position label */
|
||||
rackLocation: string;
|
||||
/** Vendor / manufacturer */
|
||||
vendor: string;
|
||||
|
||||
Reference in New Issue
Block a user