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:
AI Assistant
2026-02-27 22:04:47 +02:00
parent 8042df481f
commit 346e40d788
5 changed files with 811 additions and 410 deletions
+32
View File
@@ -4,6 +4,38 @@
---
## Session — 2026-02-27 late night (GitHub Copilot - Claude Opus 4.6)
### Context
Continued Phase 3. Fixed critical Registratura bugs reported by user + started next task.
### Completed
- **Registratura Bug Fixes (6 issues):**
1. **File upload loading feedback** — Added `uploadingCount` state tracking FileReader progress. Shows spinner + "Se încarcă X fișiere…" next to Atașamente label. Submit button shows "Se încarcă fișiere…" and is disabled while files load.
2. **Duplicate registration numbers** — Root cause: `generateRegistryNumber` counted entries instead of parsing max existing number. Fix: parse actual number from regex `PREFIX-NNNN/YYYY`, find max, +1. Also: `addEntry` now fetches fresh entries from storage before generating number (eliminates race condition from stale state).
3. **Form submission lock** — Added `isSubmitting` state. Submit button disabled + shows Loader2 spinner during save. Prevents double-click creating multiple entries.
4. **Unified close/resolve flow** — Added `ClosureResolution` type (finalizat/aprobat-tacit/respins/retras/altele) to `ClosureInfo`. CloseGuardDialog now has resolution selector matching deadline resolve options. ClosureBanner shows resolution badge.
5. **Backdating support** — Date field renamed "Data document" with tooltip explaining retroactive registration. Added `registrationDate` field on RegistryEntry (auto = today). Registry table shows "(înr. DATE)" when registrationDate differs from document date. Numbers remain sequential regardless of document date.
6. **"Actualizează" button feedback** — Submit button now shows loading spinner when saving, disabled during upload. `onSubmit` prop accepts `Promise<void>` for proper async tracking.
### Files Modified
- `src/modules/registratura/types.ts` — Added `ClosureResolution` type, `registrationDate` field on RegistryEntry, `resolution` field on ClosureInfo
- `src/modules/registratura/services/registry-service.ts` — Rewrote `generateRegistryNumber` to parse max existing number via regex
- `src/modules/registratura/hooks/use-registry.ts``addEntry` fetches fresh entries before generating number
- `src/modules/registratura/components/registry-entry-form.tsx` — Upload progress tracking, submission lock, date tooltip, async submit
- `src/modules/registratura/components/registry-table.tsx` — "Data doc." header, shows registrationDate when different
- `src/modules/registratura/components/close-guard-dialog.tsx` — Resolution selector added
- `src/modules/registratura/components/closure-banner.tsx` — Resolution badge display
### Commits
- `8042df4` fix(registratura): prevent duplicate numbers, add upload progress, submission lock, unified close/resolve, backdating support
---
## Session — 2026-02-27 night (GitHub Copilot - Claude Opus 4.6)
### Context
@@ -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,10 +124,29 @@ export function ItInventoryModule() {
}
};
const rentedCount = allItems.filter((i) => i.status === "rented").length;
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"
>
{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-4">
<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>
@@ -117,6 +169,23 @@ export function ItInventoryModule() {
</p>
</CardContent>
</Card>
<Card
className={cn(
rentedCount > 0 && "ring-2 ring-purple-400/50 animate-pulse",
)}
>
<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>
@@ -141,18 +210,16 @@ export function ItInventoryModule() {
</div>
<Select
value={filters.type}
onValueChange={(v) =>
updateFilter("type", v as InventoryItemType | "all")
}
onValueChange={(v) => updateFilter("type", v)}
>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="w-[160px]">
<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]}
{Array.from(allTypes.entries()).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
@@ -195,7 +262,9 @@ export function ItInventoryModule() {
<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">
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
@@ -203,31 +272,43 @@ export function ItInventoryModule() {
<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
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">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) => (
{items.map((item) => {
const isRented = item.status === "rented";
return (
<tr
key={item.id}
className="border-b hover:bg-muted/20 transition-colors"
className={cn(
"border-b hover:bg-muted/20 transition-colors",
isRented &&
"bg-purple-50/50 dark:bg-purple-950/10",
)}
>
<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]}
{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>
<span className="text-muted-foreground">
{" "}
/{" "}
</span>
)}
{item.model && (
<span className="text-muted-foreground">
@@ -241,13 +322,30 @@ export function ItInventoryModule() {
<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}
{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">
{STATUS_LABELS[item.status]}
<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">
@@ -274,11 +372,18 @@ export function ItInventoryModule() {
</div>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
)}
{!loading && (
<p className="text-xs text-muted-foreground">
{items.length} din {allItems.length} echipamente afișate
</p>
)}
</>
)}
@@ -286,12 +391,15 @@ export function ItInventoryModule() {
<Card>
<CardHeader>
<CardTitle>
{viewMode === "edit" ? "Editare echipament" : "Echipament nou"}
{viewMode === "edit"
? "Editare echipament"
: "Echipament nou"}
</CardTitle>
</CardHeader>
<CardContent>
<InventoryForm
initial={editingItem ?? undefined}
allTypes={allTypes}
onSubmit={handleSubmit}
onCancel={() => {
setViewMode("list");
@@ -328,82 +436,85 @@ export function ItInventoryModule() {
</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">
{/* 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>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>
<Label className="text-xs">
Poziție Rack (U){" "}
<span className="text-muted-foreground">(142)</span>
</Label>
<Input
type="number"
value={purchaseCost}
onChange={(e) => setPurchaseCost(e.target.value)}
min={1}
max={42}
value={rackPosition}
onChange={(e) => setRackPosition(e.target.value)}
className="mt-1"
placeholder="Ex: 12"
/>
</div>
<div>
<Label>Expirare garanție</Label>
<Input
type="date"
value={warrantyExpiry}
onChange={(e) => setWarrantyExpiry(e.target.value)}
className="mt-1"
/>
<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>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ă
&quot;Poziție Rack&quot;.
</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>
);
}
+67 -30
View File
@@ -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 addItem = useCallback(
async (data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">) => {
const now = new Date().toISOString();
const item: InventoryItem = { ...data, id: uuid(), createdAt: now, updatedAt: now };
const item: InventoryItem = {
...data,
id: uuid(),
createdAt: now,
updatedAt: now,
};
await storage.set(`${PREFIX}${item.id}`, item);
await refresh();
return item;
}, [storage, refresh]);
},
[storage, refresh],
);
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);
if (!existing) return;
const updated: InventoryItem = {
...existing, ...updates,
id: existing.id, createdAt: existing.createdAt,
...existing,
...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
await storage.set(`${PREFIX}${id}`, updated);
await refresh();
}, [storage, refresh, items]);
},
[storage, refresh, items],
);
const removeItem = useCallback(async (id: string) => {
const removeItem = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`);
await refresh();
}, [storage, refresh]);
},
[storage, refresh],
);
const updateFilter = useCallback(<K extends keyof InventoryFilters>(key: K, value: InventoryFilters[K]) => {
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,
};
}
+60 -17
View File
@@ -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;