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) ## Session — 2026-02-27 night (GitHub Copilot - Claude Opus 4.6)
### Context ### Context
@@ -27,36 +27,38 @@ import {
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from "@/shared/components/ui/dialog"; } from "@/shared/components/ui/dialog";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/shared/components/ui/tabs";
import type { CompanyId } from "@/core/auth/types"; import type { CompanyId } from "@/core/auth/types";
import type { import type {
InventoryItem, InventoryItem,
InventoryItemType, InventoryItemType,
InventoryItemStatus, InventoryItemStatus,
} from "../types"; } from "../types";
import {
DEFAULT_TYPE_LABELS,
STATUS_LABELS,
RACK_MOUNTABLE_TYPES,
} from "../types";
import { useInventory } from "../hooks/use-inventory"; import { useInventory } from "../hooks/use-inventory";
import { useContacts } from "@/modules/address-book/hooks/use-contacts"; import { ServerRack } from "./server-rack";
import { cn } from "@/shared/lib/utils";
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",
};
type ViewMode = "list" | "add" | "edit"; 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() { export function ItInventoryModule() {
const { const {
items, items,
@@ -72,6 +74,37 @@ export function ItInventoryModule() {
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null); const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [deletingId, setDeletingId] = useState<string | 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 ( const handleSubmit = async (
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">, data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
) => { ) => {
@@ -91,10 +124,29 @@ export function ItInventoryModule() {
} }
}; };
const rentedCount = allItems.filter((i) => i.status === "rented").length;
return ( 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"> <div className="space-y-6">
{/* Stats */} {/* 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> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Total</p> <p className="text-xs text-muted-foreground">Total</p>
@@ -117,6 +169,23 @@ export function ItInventoryModule() {
</p> </p>
</CardContent> </CardContent>
</Card> </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> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-xs text-muted-foreground">Dezafectate</p> <p className="text-xs text-muted-foreground">Dezafectate</p>
@@ -141,18 +210,16 @@ export function ItInventoryModule() {
</div> </div>
<Select <Select
value={filters.type} value={filters.type}
onValueChange={(v) => onValueChange={(v) => updateFilter("type", v)}
updateFilter("type", v as InventoryItemType | "all")
}
> >
<SelectTrigger className="w-[140px]"> <SelectTrigger className="w-[160px]">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">Toate tipurile</SelectItem> <SelectItem value="all">Toate tipurile</SelectItem>
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => ( {Array.from(allTypes.entries()).map(([key, label]) => (
<SelectItem key={t} value={t}> <SelectItem key={key} value={key}>
{TYPE_LABELS[t]} {label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -195,7 +262,9 @@ export function ItInventoryModule() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b bg-muted/40"> <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"> <th className="px-3 py-2 text-left font-medium">
Vendor/Model 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">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">IP</th>
<th className="px-3 py-2 text-left font-medium"> <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>
<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"> <th className="px-3 py-2 text-right font-medium">
Acțiuni Acțiuni
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{items.map((item) => ( {items.map((item) => {
const isRented = item.status === "rented";
return (
<tr <tr
key={item.id} 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"> <td className="px-3 py-2">
<Badge variant="outline"> <Badge variant="outline">
{TYPE_LABELS[item.type]} {getTypeLabel(item.type, customTypes)}
</Badge> </Badge>
</td> </td>
<td className="px-3 py-2 text-xs"> <td className="px-3 py-2 text-xs">
{item.vendor && <span>{item.vendor}</span>} {item.vendor && <span>{item.vendor}</span>}
{item.vendor && item.model && ( {item.vendor && item.model && (
<span className="text-muted-foreground"> / </span> <span className="text-muted-foreground">
{" "}
/{" "}
</span>
)} )}
{item.model && ( {item.model && (
<span className="text-muted-foreground"> <span className="text-muted-foreground">
@@ -241,13 +322,30 @@ export function ItInventoryModule() {
<td className="px-3 py-2 font-mono text-xs"> <td className="px-3 py-2 font-mono text-xs">
{item.ipAddress} {item.ipAddress}
</td> </td>
<td className="px-3 py-2">{item.assignedTo}</td>
<td className="px-3 py-2 text-xs"> <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>
<td className="px-3 py-2"> <td className="px-3 py-2">
<Badge variant="secondary"> <Badge
{STATUS_LABELS[item.status]} 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> </Badge>
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
@@ -274,11 +372,18 @@ export function ItInventoryModule() {
</div> </div>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </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> <Card>
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{viewMode === "edit" ? "Editare echipament" : "Echipament nou"} {viewMode === "edit"
? "Editare echipament"
: "Echipament nou"}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<InventoryForm <InventoryForm
initial={editingItem ?? undefined} initial={editingItem ?? undefined}
allTypes={allTypes}
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={() => { onCancel={() => {
setViewMode("list"); setViewMode("list");
@@ -328,82 +436,85 @@ export function ItInventoryModule() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
</TabsContent>
<TabsContent value="rack">
<ServerRack items={allItems} />
</TabsContent>
</Tabs>
); );
} }
// ── Inventory Form ──
function InventoryForm({ function InventoryForm({
initial, initial,
allTypes,
onSubmit, onSubmit,
onCancel, onCancel,
}: { }: {
initial?: InventoryItem; initial?: InventoryItem;
allTypes: Map<string, string>;
onSubmit: ( onSubmit: (
data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">, data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">,
) => void; ) => void;
onCancel: () => void; onCancel: () => void;
}) { }) {
const { allContacts } = useContacts();
const [name, setName] = useState(initial?.name ?? ""); const [name, setName] = useState(initial?.name ?? "");
const [type, setType] = useState<InventoryItemType>( const [type, setType] = useState<InventoryItemType>(
initial?.type ?? "laptop", initial?.type ?? "laptop",
); );
const [customType, setCustomType] = useState("");
const [serialNumber, setSerialNumber] = useState(initial?.serialNumber ?? ""); 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>( const [company, setCompany] = useState<CompanyId>(
initial?.company ?? "beletage", initial?.company ?? "beletage",
); );
const [location, setLocation] = useState(initial?.location ?? ""); const [location, setLocation] = useState(initial?.location ?? "");
const [purchaseDate, setPurchaseDate] = useState(initial?.purchaseDate ?? "");
const [status, setStatus] = useState<InventoryItemStatus>( const [status, setStatus] = useState<InventoryItemStatus>(
initial?.status ?? "active", initial?.status ?? "active",
); );
const [ipAddress, setIpAddress] = useState(initial?.ipAddress ?? ""); const [ipAddress, setIpAddress] = useState(initial?.ipAddress ?? "");
const [macAddress, setMacAddress] = useState(initial?.macAddress ?? ""); const [macAddress, setMacAddress] = useState(initial?.macAddress ?? "");
const [warrantyExpiry, setWarrantyExpiry] = useState( const [rackPosition, setRackPosition] = useState<string>(
initial?.warrantyExpiry ?? "", 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 [rackLocation, setRackLocation] = useState(initial?.rackLocation ?? "");
const [vendor, setVendor] = useState(initial?.vendor ?? ""); const [vendor, setVendor] = useState(initial?.vendor ?? "");
const [model, setModel] = useState(initial?.model ?? ""); const [model, setModel] = useState(initial?.model ?? "");
const [notes, setNotes] = useState(initial?.notes ?? ""); const [notes, setNotes] = useState(initial?.notes ?? "");
// Contact suggestions for assignedTo autocomplete const showRackFields = RACK_MOUNTABLE_TYPES.has(type);
const assignedToSuggestions = useMemo(() => {
if (!assignedTo || assignedTo.length < 2) return []; const handleAddCustomType = () => {
const q = assignedTo.toLowerCase(); const label = customType.trim();
return allContacts if (!label) return;
.filter( const key = label.toLowerCase().replace(/\s+/g, "-");
(c) => setType(key);
c.name.toLowerCase().includes(q) || setCustomType("");
c.company.toLowerCase().includes(q), };
)
.slice(0, 5);
}, [allContacts, assignedTo]);
return ( return (
<form <form
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
const rackPosNum = parseInt(rackPosition, 10);
const rackSizeNum = parseInt(rackSize, 10);
onSubmit({ onSubmit({
name, name,
type, type,
serialNumber, serialNumber,
assignedTo,
assignedToContactId,
company, company,
location, location,
purchaseDate,
status, status,
ipAddress, ipAddress,
macAddress, macAddress,
warrantyExpiry, rackPosition:
purchaseCost, showRackFields && rackPosNum >= 1 ? rackPosNum : undefined,
rackSize:
showRackFields && rackSizeNum >= 1 ? rackSizeNum : undefined,
rackLocation, rackLocation,
vendor, vendor,
model, model,
@@ -427,22 +538,48 @@ function InventoryForm({
<div> <div>
<Label>Tip</Label> <Label>Tip</Label>
<Select <Select
value={type} value={allTypes.has(type) ? type : "other"}
onValueChange={(v) => setType(v as InventoryItemType)} onValueChange={(v) => setType(v as InventoryItemType)}
> >
<SelectTrigger className="mt-1"> <SelectTrigger className="mt-1">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => ( {Array.from(allTypes.entries()).map(([key, label]) => (
<SelectItem key={t} value={t}> <SelectItem key={key} value={key}>
{TYPE_LABELS[t]} {label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </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>
</div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div> <div>
<Label>Vendor</Label> <Label>Vendor</Label>
@@ -470,6 +607,7 @@ function InventoryForm({
/> />
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<div> <div>
<Label>Adresă IP</Label> <Label>Adresă IP</Label>
@@ -489,47 +627,6 @@ function InventoryForm({
placeholder="AA:BB:CC:DD:EE:FF" placeholder="AA:BB:CC:DD:EE:FF"
/> />
</div> </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> <div>
<Label>Companie</Label> <Label>Companie</Label>
<Select <Select
@@ -547,6 +644,9 @@ function InventoryForm({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div> <div>
<Label>Locație / Cameră</Label> <Label>Locație / Cameră</Label>
<Input <Input
@@ -561,6 +661,7 @@ function InventoryForm({
value={rackLocation} value={rackLocation}
onChange={(e) => setRackLocation(e.target.value)} onChange={(e) => setRackLocation(e.target.value)}
className="mt-1" className="mt-1"
placeholder="Rack 1, Birou 2..."
/> />
</div> </div>
<div> <div>
@@ -584,35 +685,45 @@ function InventoryForm({
</Select> </Select>
</div> </div>
</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> <div>
<Label>Data achiziție</Label> <Label className="text-xs">
<Input Poziție Rack (U){" "}
type="date" <span className="text-muted-foreground">(142)</span>
value={purchaseDate} </Label>
onChange={(e) => setPurchaseDate(e.target.value)}
className="mt-1"
/>
</div>
<div>
<Label>Cost achiziție (RON)</Label>
<Input <Input
type="number" type="number"
value={purchaseCost} min={1}
onChange={(e) => setPurchaseCost(e.target.value)} max={42}
value={rackPosition}
onChange={(e) => setRackPosition(e.target.value)}
className="mt-1" className="mt-1"
placeholder="Ex: 12"
/> />
</div> </div>
<div> <div>
<Label>Expirare garanție</Label> <Label className="text-xs">
<Input Dimensiune{" "}
type="date" <span className="text-muted-foreground">(unități rack)</span>
value={warrantyExpiry} </Label>
onChange={(e) => setWarrantyExpiry(e.target.value)} <Select value={rackSize} onValueChange={setRackSize}>
className="mt-1" <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> </div>
)}
<div> <div>
<Label>Note</Label> <Label>Note</Label>
<Textarea <Textarea
@@ -622,6 +733,7 @@ function InventoryForm({
className="mt-1" className="mt-1"
/> />
</div> </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}> <Button type="button" variant="outline" onClick={onCancel}>
Anulează 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 { useState, useEffect, useCallback } from "react";
import { useStorage } from '@/core/storage'; import { useStorage } from "@/core/storage";
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from "uuid";
import type { InventoryItem, InventoryItemType, InventoryItemStatus } from '../types'; import type { InventoryItem, InventoryItemStatus } from "../types";
const PREFIX = 'item:'; const PREFIX = "item:";
export interface InventoryFilters { export interface InventoryFilters {
search: string; search: string;
type: InventoryItemType | 'all'; type: string;
status: InventoryItemStatus | 'all'; status: InventoryItemStatus | "all";
company: string; company: string;
} }
export function useInventory() { export function useInventory() {
const storage = useStorage('it-inventory'); const storage = useStorage("it-inventory");
const [items, setItems] = useState<InventoryItem[]>([]); const [items, setItems] = useState<InventoryItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<InventoryFilters>({ const [filters, setFilters] = useState<InventoryFilters>({
search: '', type: 'all', status: 'all', company: 'all', search: "",
type: "all",
status: "all",
company: "all",
}); });
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
@@ -38,54 +41,88 @@ export function useInventory() {
}, [storage]); }, [storage]);
// 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' | 'updatedAt'>) => { const addItem = useCallback(
async (data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">) => {
const now = new Date().toISOString(); 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 storage.set(`${PREFIX}${item.id}`, item);
await refresh(); await refresh();
return item; 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); const existing = items.find((i) => i.id === id);
if (!existing) return; if (!existing) return;
const updated: InventoryItem = { const updated: InventoryItem = {
...existing, ...updates, ...existing,
id: existing.id, createdAt: existing.createdAt, ...updates,
id: existing.id,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(), 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],
);
const removeItem = useCallback(async (id: string) => { const removeItem = useCallback(
async (id: string) => {
await storage.delete(`${PREFIX}${id}`); await storage.delete(`${PREFIX}${id}`);
await refresh(); 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 })); setFilters((prev) => ({ ...prev, [key]: value }));
}, []); },
[],
);
const filteredItems = items.filter((item) => { const filteredItems = items.filter((item) => {
if (filters.type !== 'all' && item.type !== filters.type) return false; if (filters.type !== "all" && item.type !== filters.type) return false;
if (filters.status !== 'all' && item.status !== filters.status) return false; if (filters.status !== "all" && item.status !== filters.status)
if (filters.company !== 'all' && item.company !== filters.company) return false; 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 ( return (
item.name.toLowerCase().includes(q) || item.name.toLowerCase().includes(q) ||
item.serialNumber.toLowerCase().includes(q) || item.serialNumber.toLowerCase().includes(q) ||
item.assignedTo.toLowerCase().includes(q) || item.ipAddress.toLowerCase().includes(q) ||
(item.ipAddress ?? '').toLowerCase().includes(q) || item.vendor.toLowerCase().includes(q) ||
(item.vendor ?? '').toLowerCase().includes(q) || item.model.toLowerCase().includes(q) ||
(item.model ?? '').toLowerCase().includes(q) item.location.toLowerCase().includes(q) ||
item.rackLocation.toLowerCase().includes(q)
); );
} }
return true; 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 { Visibility } from "@/core/module-registry/types";
import type { CompanyId } from "@/core/auth/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 = export type InventoryItemType =
| "laptop" | (typeof DEFAULT_EQUIPMENT_TYPES)[number]
| "desktop" | string;
| "monitor"
| "printer" /** Labels for default equipment types */
| "phone" export const DEFAULT_TYPE_LABELS: Record<string, string> = {
| "tablet" laptop: "Laptop",
| "network" desktop: "Desktop",
| "peripheral" monitor: "Monitor",
| "other"; 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 = export type InventoryItemStatus =
| "active" | "active"
| "in-repair" | "in-repair"
| "storage" | "storage"
| "rented"
| "decommissioned"; | "decommissioned";
export const STATUS_LABELS: Record<InventoryItemStatus, string> = {
active: "Activ",
"in-repair": "În reparație",
storage: "Depozitat",
rented: "Închiriat",
decommissioned: "Dezafectat",
};
export interface InventoryItem { export interface InventoryItem {
id: string; id: string;
name: string; name: string;
type: InventoryItemType; type: InventoryItemType;
serialNumber: string; serialNumber: string;
assignedTo: string;
assignedToContactId?: string;
company: CompanyId; company: CompanyId;
location: string; location: string;
purchaseDate: string;
status: InventoryItemStatus; status: InventoryItemStatus;
/** IP address */ /** IP address */
ipAddress: string; ipAddress: string;
/** MAC address */ /** MAC address */
macAddress: string; macAddress: string;
/** Warranty expiry date (YYYY-MM-DD) */ /** Rack unit start position (1-42) */
warrantyExpiry: string; rackPosition?: number;
/** Purchase cost (RON) */ /** Size in rack units (1U, 2U, 4U) */
purchaseCost: string; rackSize?: number;
/** Room / rack position */ /** Room / rack position label */
rackLocation: string; rackLocation: string;
/** Vendor / manufacturer */ /** Vendor / manufacturer */
vendor: string; vendor: string;