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:
@@ -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,319 +124,397 @@ export function ItInventoryModule() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const rentedCount = allItems.filter((i) => i.status === "rented").length;
|
||||||
<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>
|
|
||||||
|
|
||||||
{viewMode === "list" && (
|
return (
|
||||||
<>
|
<Tabs defaultValue="list">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<TabsList>
|
||||||
<div className="relative min-w-[200px] flex-1">
|
<TabsTrigger value="list">Inventar</TabsTrigger>
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<TabsTrigger value="rack">
|
||||||
<Input
|
Rack 42U
|
||||||
placeholder="Caută..."
|
{allItems.filter((i) => i.rackPosition).length > 0 && (
|
||||||
value={filters.search}
|
<Badge
|
||||||
onChange={(e) => updateFilter("search", e.target.value)}
|
variant="secondary"
|
||||||
className="pl-9"
|
className="ml-1.5 text-[10px] px-1.5 py-0"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select
|
|
||||||
value={filters.type}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updateFilter("type", v as InventoryItemType | "all")
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[140px]">
|
{allItems.filter((i) => i.rackPosition).length}
|
||||||
<SelectValue />
|
</Badge>
|
||||||
</SelectTrigger>
|
)}
|
||||||
<SelectContent>
|
</TabsTrigger>
|
||||||
<SelectItem value="all">Toate tipurile</SelectItem>
|
</TabsList>
|
||||||
{(Object.keys(TYPE_LABELS) as InventoryItemType[]).map((t) => (
|
|
||||||
<SelectItem key={t} value={t}>
|
<TabsContent value="list">
|
||||||
{TYPE_LABELS[t]}
|
<div className="space-y-6">
|
||||||
</SelectItem>
|
{/* Stats */}
|
||||||
))}
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||||
</SelectContent>
|
<Card>
|
||||||
</Select>
|
<CardContent className="p-4">
|
||||||
<Select
|
<p className="text-xs text-muted-foreground">Total</p>
|
||||||
value={filters.status}
|
<p className="text-2xl font-bold">{allItems.length}</p>
|
||||||
onValueChange={(v) =>
|
</CardContent>
|
||||||
updateFilter("status", v as InventoryItemStatus | "all")
|
</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]">
|
<CardContent className="p-4">
|
||||||
<SelectValue />
|
<p className="text-xs text-muted-foreground">Închiriate</p>
|
||||||
</SelectTrigger>
|
<p
|
||||||
<SelectContent>
|
className={cn(
|
||||||
<SelectItem value="all">Toate</SelectItem>
|
"text-2xl font-bold",
|
||||||
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
|
rentedCount > 0 && "text-purple-600 dark:text-purple-400",
|
||||||
(s) => (
|
)}
|
||||||
<SelectItem key={s} value={s}>
|
>
|
||||||
{STATUS_LABELS[s]}
|
{rentedCount}
|
||||||
</SelectItem>
|
</p>
|
||||||
),
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
</SelectContent>
|
<Card>
|
||||||
</Select>
|
<CardContent className="p-4">
|
||||||
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
<p className="text-xs text-muted-foreground">Dezafectate</p>
|
||||||
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
<p className="text-2xl font-bold">
|
||||||
</Button>
|
{allItems.filter((i) => i.status === "decommissioned").length}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{viewMode === "list" && (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<>
|
||||||
Se încarcă...
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
</p>
|
<div className="relative min-w-[200px] flex-1">
|
||||||
) : items.length === 0 ? (
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<Input
|
||||||
Niciun echipament găsit.
|
placeholder="Caută..."
|
||||||
</p>
|
value={filters.search}
|
||||||
) : (
|
onChange={(e) => updateFilter("search", e.target.value)}
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
className="pl-9"
|
||||||
<table className="w-full text-sm">
|
/>
|
||||||
<thead>
|
</div>
|
||||||
<tr className="border-b bg-muted/40">
|
<Select
|
||||||
<th className="px-3 py-2 text-left font-medium">Nume</th>
|
value={filters.type}
|
||||||
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
onValueChange={(v) => updateFilter("type", v)}
|
||||||
<th className="px-3 py-2 text-left font-medium">
|
>
|
||||||
Vendor/Model
|
<SelectTrigger className="w-[160px]">
|
||||||
</th>
|
<SelectValue />
|
||||||
<th className="px-3 py-2 text-left font-medium">S/N</th>
|
</SelectTrigger>
|
||||||
<th className="px-3 py-2 text-left font-medium">IP</th>
|
<SelectContent>
|
||||||
<th className="px-3 py-2 text-left font-medium">
|
<SelectItem value="all">Toate tipurile</SelectItem>
|
||||||
Atribuit
|
{Array.from(allTypes.entries()).map(([key, label]) => (
|
||||||
</th>
|
<SelectItem key={key} value={key}>
|
||||||
<th className="px-3 py-2 text-left font-medium">Locație</th>
|
{label}
|
||||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
</SelectItem>
|
||||||
<th className="px-3 py-2 text-right font-medium">
|
))}
|
||||||
Acțiuni
|
</SelectContent>
|
||||||
</th>
|
</Select>
|
||||||
</tr>
|
<Select
|
||||||
</thead>
|
value={filters.status}
|
||||||
<tbody>
|
onValueChange={(v) =>
|
||||||
{items.map((item) => (
|
updateFilter("status", v as InventoryItemStatus | "all")
|
||||||
<tr
|
}
|
||||||
key={item.id}
|
>
|
||||||
className="border-b hover:bg-muted/20 transition-colors"
|
<SelectTrigger className="w-[140px]">
|
||||||
>
|
<SelectValue />
|
||||||
<td className="px-3 py-2 font-medium">{item.name}</td>
|
</SelectTrigger>
|
||||||
<td className="px-3 py-2">
|
<SelectContent>
|
||||||
<Badge variant="outline">
|
<SelectItem value="all">Toate</SelectItem>
|
||||||
{TYPE_LABELS[item.type]}
|
{(Object.keys(STATUS_LABELS) as InventoryItemStatus[]).map(
|
||||||
</Badge>
|
(s) => (
|
||||||
</td>
|
<SelectItem key={s} value={s}>
|
||||||
<td className="px-3 py-2 text-xs">
|
{STATUS_LABELS[s]}
|
||||||
{item.vendor && <span>{item.vendor}</span>}
|
</SelectItem>
|
||||||
{item.vendor && item.model && (
|
),
|
||||||
<span className="text-muted-foreground"> / </span>
|
)}
|
||||||
)}
|
</SelectContent>
|
||||||
{item.model && (
|
</Select>
|
||||||
<span className="text-muted-foreground">
|
<Button onClick={() => setViewMode("add")} className="shrink-0">
|
||||||
{item.model}
|
<Plus className="mr-1.5 h-4 w-4" /> Adaugă
|
||||||
</span>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-mono text-xs">
|
{loading ? (
|
||||||
{item.serialNumber}
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
</td>
|
Se încarcă...
|
||||||
<td className="px-3 py-2 font-mono text-xs">
|
</p>
|
||||||
{item.ipAddress}
|
) : items.length === 0 ? (
|
||||||
</td>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
<td className="px-3 py-2">{item.assignedTo}</td>
|
Niciun echipament găsit.
|
||||||
<td className="px-3 py-2 text-xs">
|
</p>
|
||||||
{item.rackLocation || item.location}
|
) : (
|
||||||
</td>
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
<td className="px-3 py-2">
|
<table className="w-full text-sm">
|
||||||
<Badge variant="secondary">
|
<thead>
|
||||||
{STATUS_LABELS[item.status]}
|
<tr className="border-b bg-muted/40">
|
||||||
</Badge>
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
</td>
|
Nume
|
||||||
<td className="px-3 py-2 text-right">
|
</th>
|
||||||
<div className="flex justify-end gap-1">
|
<th className="px-3 py-2 text-left font-medium">Tip</th>
|
||||||
<Button
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
variant="ghost"
|
Vendor/Model
|
||||||
size="icon"
|
</th>
|
||||||
className="h-7 w-7"
|
<th className="px-3 py-2 text-left font-medium">S/N</th>
|
||||||
onClick={() => {
|
<th className="px-3 py-2 text-left font-medium">IP</th>
|
||||||
setEditingItem(item);
|
<th className="px-3 py-2 text-left font-medium">
|
||||||
setViewMode("edit");
|
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" />
|
<td className="px-3 py-2 font-medium">
|
||||||
</Button>
|
{item.name}
|
||||||
<Button
|
</td>
|
||||||
variant="ghost"
|
<td className="px-3 py-2">
|
||||||
size="icon"
|
<Badge variant="outline">
|
||||||
className="h-7 w-7 text-destructive"
|
{getTypeLabel(item.type, customTypes)}
|
||||||
onClick={() => setDeletingId(item.id)}
|
</Badge>
|
||||||
>
|
</td>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<td className="px-3 py-2 text-xs">
|
||||||
</Button>
|
{item.vendor && <span>{item.vendor}</span>}
|
||||||
</div>
|
{item.vendor && item.model && (
|
||||||
</td>
|
<span className="text-muted-foreground">
|
||||||
</tr>
|
{" "}
|
||||||
))}
|
/{" "}
|
||||||
</tbody>
|
</span>
|
||||||
</table>
|
)}
|
||||||
</div>
|
{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") && (
|
{(viewMode === "add" || viewMode === "edit") && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{viewMode === "edit" ? "Editare echipament" : "Echipament nou"}
|
{viewMode === "edit"
|
||||||
</CardTitle>
|
? "Editare echipament"
|
||||||
</CardHeader>
|
: "Echipament nou"}
|
||||||
<CardContent>
|
</CardTitle>
|
||||||
<InventoryForm
|
</CardHeader>
|
||||||
initial={editingItem ?? undefined}
|
<CardContent>
|
||||||
onSubmit={handleSubmit}
|
<InventoryForm
|
||||||
onCancel={() => {
|
initial={editingItem ?? undefined}
|
||||||
setViewMode("list");
|
allTypes={allTypes}
|
||||||
setEditingItem(null);
|
onSubmit={handleSubmit}
|
||||||
}}
|
onCancel={() => {
|
||||||
/>
|
setViewMode("list");
|
||||||
</CardContent>
|
setEditingItem(null);
|
||||||
</Card>
|
}}
|
||||||
)}
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={deletingId !== null}
|
open={deletingId !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setDeletingId(null);
|
if (!open) setDeletingId(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Confirmare ștergere</DialogTitle>
|
<DialogTitle>Confirmare ștergere</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Ești sigur că vrei să ștergi acest echipament? Acțiunea este
|
Ești sigur că vrei să ștergi acest echipament? Acțiunea este
|
||||||
ireversibilă.
|
ireversibilă.
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeletingId(null)}>
|
<Button variant="outline" onClick={() => setDeletingId(null)}>
|
||||||
Anulează
|
Anulează
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
<Button variant="destructive" onClick={handleDeleteConfirm}>
|
||||||
Șterge
|
Șterge
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</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">
|
|
||||||
<div>
|
{/* Rack position fields — shown only for rack-mountable types */}
|
||||||
<Label>Data achiziție</Label>
|
{showRackFields && (
|
||||||
<Input
|
<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">
|
||||||
type="date"
|
<div>
|
||||||
value={purchaseDate}
|
<Label className="text-xs">
|
||||||
onChange={(e) => setPurchaseDate(e.target.value)}
|
Poziție Rack (U){" "}
|
||||||
className="mt-1"
|
<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>
|
||||||
<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>
|
<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ă
|
||||||
|
"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 { 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(
|
||||||
const now = new Date().toISOString();
|
async (data: Omit<InventoryItem, "id" | "createdAt" | "updatedAt">) => {
|
||||||
const item: InventoryItem = { ...data, id: uuid(), createdAt: now, updatedAt: now };
|
const now = new Date().toISOString();
|
||||||
await storage.set(`${PREFIX}${item.id}`, item);
|
const item: InventoryItem = {
|
||||||
await refresh();
|
...data,
|
||||||
return item;
|
id: uuid(),
|
||||||
}, [storage, refresh]);
|
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 updateItem = useCallback(
|
||||||
const existing = items.find((i) => i.id === id);
|
async (id: string, updates: Partial<InventoryItem>) => {
|
||||||
if (!existing) return;
|
const existing = items.find((i) => i.id === id);
|
||||||
const updated: InventoryItem = {
|
if (!existing) return;
|
||||||
...existing, ...updates,
|
const updated: InventoryItem = {
|
||||||
id: existing.id, createdAt: existing.createdAt,
|
...existing,
|
||||||
updatedAt: new Date().toISOString(),
|
...updates,
|
||||||
};
|
id: existing.id,
|
||||||
await storage.set(`${PREFIX}${id}`, updated);
|
createdAt: existing.createdAt,
|
||||||
await refresh();
|
updatedAt: new Date().toISOString(),
|
||||||
}, [storage, refresh, items]);
|
};
|
||||||
|
await storage.set(`${PREFIX}${id}`, updated);
|
||||||
|
await refresh();
|
||||||
|
},
|
||||||
|
[storage, refresh, items],
|
||||||
|
);
|
||||||
|
|
||||||
const removeItem = useCallback(async (id: string) => {
|
const removeItem = useCallback(
|
||||||
await storage.delete(`${PREFIX}${id}`);
|
async (id: string) => {
|
||||||
await refresh();
|
await storage.delete(`${PREFIX}${id}`);
|
||||||
}, [storage, refresh]);
|
await refresh();
|
||||||
|
},
|
||||||
|
[storage, refresh],
|
||||||
|
);
|
||||||
|
|
||||||
const updateFilter = useCallback(<K extends keyof InventoryFilters>(key: K, value: InventoryFilters[K]) => {
|
const updateFilter = useCallback(
|
||||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
<K extends keyof InventoryFilters>(key: K, value: InventoryFilters[K]) => {
|
||||||
}, []);
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user