"use client" import { useCallback, useEffect, useMemo, useState } from "react" import type { ReactNode } from "react" import { useMutation, useQuery } from "convex/react" import { format, formatDistanceToNowStrict } from "date-fns" import { ptBR } from "date-fns/locale" import { toast } from "sonner" import { ClipboardCopy, ServerCog, Cpu, MemoryStick, Monitor, HardDrive, Pencil, ShieldCheck, ShieldAlert, Shield, ShieldOff, ShieldQuestion, Lock, Cloud, RefreshCcw, CheckSquare, RotateCcw, AlertTriangle, Key, Eye, EyeOff, MonitorSmartphone, Globe, Apple, Terminal, Power, PlayCircle, Download, Plus, Smartphone, Tablet, } from "lucide-react" import { api } from "@/convex/_generated/api" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" import { Progress } from "@/components/ui/progress" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Checkbox } from "@/components/ui/checkbox" import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { Separator } from "@/components/ui/separator" import { ChartContainer } from "@/components/ui/chart" import { cn } from "@/lib/utils" import { DEVICE_INVENTORY_COLUMN_METADATA, type DeviceInventoryColumnConfig } from "@/lib/device-inventory-columns" import { RadialBarChart, RadialBar, PolarAngleAxis } from "recharts" import Link from "next/link" import { useRouter } from "next/navigation" import { useAuth } from "@/lib/auth-client" import type { Id } from "@/convex/_generated/dataModel" import { TicketStatusBadge } from "@/components/tickets/status-badge" import type { TicketPriority, TicketStatus } from "@/lib/schemas/ticket" import { DeviceCustomFieldManager } from "@/components/admin/devices/device-custom-field-manager" import { DatePicker } from "@/components/ui/date-picker" import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox" type DeviceMetrics = Record | null type DeviceLabel = { id?: number | string name?: string } type DeviceSoftware = { name?: string version?: string source?: string } type NormalizedSoftwareEntry = { name: string version?: string publisher?: string installDate?: Date | null source?: string } type DeviceExportTemplate = { id: string name: string description?: string columns: DeviceInventoryColumnConfig[] companyId: string | null isDefault: boolean isActive?: boolean } const BASE_DEVICE_COLUMN_KEYS = DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => meta.key) const DEFAULT_DEVICE_COLUMN_CONFIG: DeviceInventoryColumnConfig[] = DEVICE_INVENTORY_COLUMN_METADATA.filter((meta) => meta.default !== false).map( (meta) => ({ key: meta.key }) ) function orderColumnConfig( config: DeviceInventoryColumnConfig[], customOrder: string[] ): DeviceInventoryColumnConfig[] { const order = new Map() BASE_DEVICE_COLUMN_KEYS.forEach((key, index) => order.set(key, index)) customOrder.forEach((key, idx) => { if (!order.has(key)) { order.set(key, BASE_DEVICE_COLUMN_KEYS.length + idx) } }) const seen = new Set() return config .filter(({ key }) => { if (!key || seen.has(key)) return false seen.add(key) return order.has(key) }) .sort((a, b) => (order.get(a.key)! - order.get(b.key)!)) } function areColumnConfigsEqual(a: DeviceInventoryColumnConfig[], b: DeviceInventoryColumnConfig[]): boolean { if (a.length !== b.length) return false return a.every((col, idx) => col.key === b[idx]?.key && (col.label ?? "") === (b[idx]?.label ?? "")) } function formatDeviceCustomFieldDisplay(entry?: { value?: unknown; displayValue?: string }): string { if (!entry) return "—" if (typeof entry.displayValue === "string" && entry.displayValue.trim().length > 0) { return entry.displayValue } const raw = entry.value if (raw === null || raw === undefined) return "—" if (Array.isArray(raw)) { const values = raw .map((item) => (item === null || item === undefined ? "" : String(item).trim())) .filter((item) => item.length > 0) return values.length > 0 ? values.join(", ") : "—" } if (typeof raw === "boolean") { return raw ? "Sim" : "Não" } if (typeof raw === "number") { return Number.isFinite(raw) ? String(raw) : "—" } const asString = String(raw).trim() return asString.length > 0 ? asString : "—" } type DeviceAlertEntry = { id: string kind: string message: string severity: string createdAt: number } type DeviceTicketSummary = { id: string reference: number subject: string status: TicketStatus priority: TicketPriority updatedAt: number createdAt: number device: { id: string | null; hostname: string | null } | null assignee: { name: string | null; email: string | null } | null } type DeviceOpenTicketsSummary = { totalOpen: number hasMore: boolean tickets: DeviceTicketSummary[] } type DetailLineProps = { label: string value?: string | number | null classNameValue?: string layout?: "spread" | "compact" } type GpuAdapter = { name?: string vendor?: string driver?: string memoryBytes?: number } type LinuxLsblkEntry = { name?: string mountPoint?: string mountpoint?: string fs?: string fstype?: string sizeBytes?: number size?: number } type LinuxSmartEntry = { smart_status?: { passed?: boolean } model_name?: string model_family?: string serial_number?: string device?: { name?: string } } type LinuxExtended = { lsblk?: LinuxLsblkEntry[] lspci?: string lsusb?: string pciList?: Array<{ text: string }> usbList?: Array<{ text: string }> smart?: LinuxSmartEntry[] } type WindowsCpuInfo = { Name?: string Manufacturer?: string SocketDesignation?: string NumberOfCores?: number NumberOfLogicalProcessors?: number L2CacheSize?: number L3CacheSize?: number MaxClockSpeed?: number } type WindowsMemoryModule = { BankLabel?: string Capacity?: number Manufacturer?: string PartNumber?: string SerialNumber?: string ConfiguredClockSpeed?: number Speed?: number ConfiguredVoltage?: number } type WindowsVideoController = { Name?: string AdapterRAM?: number DriverVersion?: string PNPDeviceID?: string } type WindowsDiskEntry = { Model?: string SerialNumber?: string Size?: number InterfaceType?: string MediaType?: string } type WindowsExtended = { software?: DeviceSoftware[] services?: Array<{ name?: string; status?: string; displayName?: string }> defender?: Record hotfix?: Array> cpu?: WindowsCpuInfo | WindowsCpuInfo[] baseboard?: Record | Array> bios?: Record | Array> memoryModules?: WindowsMemoryModule[] videoControllers?: WindowsVideoController[] disks?: WindowsDiskEntry[] osInfo?: WindowsOsInfo bitLocker?: Array> | Record bitlocker?: Array> | Record tpm?: Record secureBoot?: Record deviceGuard?: Array> | Record firewallProfiles?: Array> | Record windowsUpdate?: Record computerSystem?: Record azureAdStatus?: Record } type MacExtended = { systemProfiler?: Record packages?: string[] launchctl?: string } type NetworkInterface = { name?: string; mac?: string; ip?: string } type DeviceInventory = { hardware?: { vendor?: string model?: string serial?: string cpuType?: string physicalCores?: number logicalCores?: number memoryBytes?: number memory?: number primaryGpu?: GpuAdapter gpus?: GpuAdapter[] } network?: { primaryIp?: string; publicIp?: string; macAddresses?: string[] } | NetworkInterface[] software?: DeviceSoftware[] labels?: DeviceLabel[] fleet?: { id?: number | string teamId?: number | string detailUpdatedAt?: string osqueryVersion?: string } disks?: Array<{ name?: string; mountPoint?: string; fs?: string; interface?: string | null; serial?: string | null; totalBytes?: number; availableBytes?: number }> extended?: { linux?: LinuxExtended; windows?: WindowsExtended; macos?: MacExtended } services?: Array<{ name?: string; status?: string; displayName?: string }> collaborator?: { email?: string; name?: string; role?: string } } type DeviceRemoteAccessEntry = { id: string | null clientId: string provider: string | null identifier: string | null username: string | null password: string | null url: string | null notes: string | null lastVerifiedAt: number | null metadata: Record | null } export type DeviceRemoteAccess = { provider: string | null identifier: string | null username: string | null password: string | null url: string | null notes: string | null lastVerifiedAt: number | null metadata: Record | null } function collectInitials(name: string): string { const words = name.split(/\s+/).filter(Boolean) if (words.length === 0) return "?" if (words.length === 1) return words[0].slice(0, 2).toUpperCase() return (words[0][0] + words[1][0]).toUpperCase() } function toRecord(value: unknown): Record | null { if (!value || typeof value !== "object") return null return value as Record } function readString(record: Record, ...keys: string[]): string | undefined { for (const key of keys) { const raw = record[key] if (typeof raw === "string" && raw.trim().length > 0) { return raw } } return undefined } function readNumber(record: Record, ...keys: string[]): number | undefined { for (const key of keys) { const raw = record[key] if (typeof raw === "number" && Number.isFinite(raw)) { return raw } if (typeof raw === "string") { const trimmed = raw.trim() if (!trimmed) continue const parsed = Number(trimmed) if (!Number.isNaN(parsed)) return parsed const digits = trimmed.replace(/[^0-9.]/g, "") if (digits) { const fallback = Number(digits) if (!Number.isNaN(fallback)) return fallback } } } return undefined } function createRemoteAccessClientId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID() } return `ra-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}` } function normalizeDeviceRemoteAccessEntry(raw: unknown): DeviceRemoteAccessEntry | null { if (!raw) return null if (typeof raw === "string") { const trimmed = raw.trim() if (!trimmed) return null const isUrl = /^https?:\/\//i.test(trimmed) return { id: null, clientId: createRemoteAccessClientId(), provider: null, identifier: isUrl ? null : trimmed, username: null, password: null, url: isUrl ? trimmed : null, notes: null, lastVerifiedAt: null, metadata: null, } } const record = toRecord(raw) if (!record) return null const provider = readString(record, "provider", "tool", "vendor", "name") ?? null const identifier = readString(record, "identifier", "code", "id", "accessId") ?? readString(record, "value", "label") const username = readString(record, "username", "user", "login", "email", "account") ?? null const password = readString(record, "password", "pass", "secret", "pin") ?? null const url = readString(record, "url", "link", "remoteUrl", "console", "viewer") ?? null const notes = readString(record, "notes", "note", "description", "obs") ?? null const timestampCandidate = readNumber(record, "lastVerifiedAt", "verifiedAt", "checkedAt", "updatedAt") ?? parseDateish(record["lastVerifiedAt"] ?? record["verifiedAt"] ?? record["checkedAt"] ?? record["updatedAt"]) const lastVerifiedAt = timestampCandidate instanceof Date ? timestampCandidate.getTime() : timestampCandidate ?? null const id = readString(record, "id") ?? null return { id, clientId: id ?? createRemoteAccessClientId(), provider, identifier: identifier ?? url ?? null, username, password, url, notes, lastVerifiedAt, metadata: record, } } export function normalizeDeviceRemoteAccess(raw: unknown): DeviceRemoteAccess | null { const entry = normalizeDeviceRemoteAccessEntry(raw) if (!entry) return null const { provider, identifier, username, password, url, notes, lastVerifiedAt, metadata } = entry return { provider, identifier, username, password, url, notes, lastVerifiedAt, metadata } } export function normalizeDeviceRemoteAccessList(raw: unknown): DeviceRemoteAccessEntry[] { if (!raw) return [] const source = Array.isArray(raw) ? raw : [raw] const seen = new Set() const entries: DeviceRemoteAccessEntry[] = [] for (const item of source) { const entry = normalizeDeviceRemoteAccessEntry(item) if (!entry) continue let clientId = entry.clientId while (seen.has(clientId)) { clientId = createRemoteAccessClientId() } seen.add(clientId) entries.push(clientId === entry.clientId ? entry : { ...entry, clientId }) } return entries } function readText(record: Record, ...keys: string[]): string | undefined { const stringValue = readString(record, ...keys) if (stringValue) return stringValue const numberValue = readNumber(record, ...keys) if (typeof numberValue === "number") return String(numberValue) return undefined } function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) { if (!entry) return false const provider = (entry.provider ?? entry.metadata?.provider ?? "").toString().toLowerCase() if (provider.includes("rustdesk")) return true const url = (entry.url ?? entry.metadata?.url ?? "").toString().toLowerCase() return url.includes("rustdesk") } function buildRustDeskUri(entry: DeviceRemoteAccessEntry) { const identifier = (entry.identifier ?? "").replace(/\s+/g, "") if (!identifier) return null const params = new URLSearchParams() if (entry.password) { params.set("password", entry.password) } const query = params.toString() return `rustdesk://${encodeURIComponent(identifier)}${query ? `?${query}` : ""}` } function parseWindowsInstallDate(value: unknown): Date | null { if (!value) return null if (value instanceof Date) return value if (typeof value === "number" && Number.isFinite(value)) { if (value > 10_000_000_000) { return new Date(value) } if (value > 1_000_000_000) { return new Date(value * 1000) } return new Date(value * 1000) } if (typeof value !== "string") return null const trimmed = value.trim() if (!trimmed) return null const wmiMatch = trimmed.match(/Date\((\d+)\)/) if (wmiMatch) { const timestamp = Number(wmiMatch[1]) return Number.isFinite(timestamp) ? new Date(timestamp) : null } const isoValue = Date.parse(trimmed) if (!Number.isNaN(isoValue)) return new Date(isoValue) const digitsOnly = trimmed.replace(/[^0-9]/g, "") if (digitsOnly.length >= 8) { const yyyy = Number(digitsOnly.slice(0, 4)) const mm = Number(digitsOnly.slice(4, 6)) const dd = Number(digitsOnly.slice(6, 8)) const hh = Number(digitsOnly.slice(8, 10) || "0") const mi = Number(digitsOnly.slice(10, 12) || "0") const ss = Number(digitsOnly.slice(12, 14) || "0") if (yyyy > 1900 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { const parsed = new Date(Date.UTC(yyyy, mm - 1, dd, hh, mi, ss)) if (!Number.isNaN(parsed.getTime())) return parsed } const dd2 = Number(digitsOnly.slice(0, 2)) const mm2 = Number(digitsOnly.slice(2, 4)) const yyyy2 = Number(digitsOnly.slice(4, 8)) if (yyyy2 > 1900 && mm2 >= 1 && mm2 <= 12 && dd2 >= 1 && dd2 <= 31) { const parsed = new Date(Date.UTC(yyyy2, mm2 - 1, dd2)) if (!Number.isNaN(parsed.getTime())) return parsed } } const localMatch = trimmed.match( /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?)?$/ ) if (localMatch) { const dd = Number(localMatch[1]) const mm = Number(localMatch[2]) const yyyy = Number(localMatch[3]) const hh = Number(localMatch[4] || "0") const mi = Number(localMatch[5] || "0") const ss = Number(localMatch[6] || "0") if (yyyy > 1900 && mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) { const parsed = new Date(Date.UTC(yyyy, mm - 1, dd, hh, mi, ss)) if (!Number.isNaN(parsed.getTime())) return parsed } } return null } type WindowsOsInfo = { productName?: string caption?: string editionId?: string displayVersion?: string releaseId?: string version?: string currentBuild?: string currentBuildNumber?: string licenseStatus?: number isActivated?: boolean licenseStatusText?: string productId?: string partialProductKey?: string computerName?: string registeredOwner?: string installDate?: Date | null experience?: string } function formatOsVersionDisplay(osName: string | null | undefined, osVersion: string | null | undefined) { const name = (osName ?? "").trim() const version = (osVersion ?? "").trim() if (!version) return "" // If Windows and version redundantly starts with the same major (e.g., "11 (26100)"), drop leading major const winMatch = name.match(/^windows\s+(\d+)\b/i) if (winMatch) { const major = winMatch[1] const re = new RegExp(`^\\s*${major}(?:\\b|\\.|-_|\\s)+(.*)$`, "i") const m = version.match(re) if (m) { const rest = (m[1] ?? "").trim() return rest } } return version } function parseWindowsOsInfo(raw: unknown): WindowsOsInfo | null { if (!raw) return null const parseRecord = (value: Record) => { const read = (k: string, ...alts: string[]) => readString(value, k, ...alts) const readNum = (...keys: string[]) => readNumber(value, ...keys) const readFlexible = (...keys: string[]) => readText(value, ...keys) const family = read("Family") const edition = read("Edition") const captionRaw = readFlexible("Caption", "caption") const captionNormalized = captionRaw ? captionRaw.replace(/^Microsoft\s+/i, "").trim() : undefined let productName = readFlexible("ProductName", "productName", "Name", "name") ?? (family && edition ? `${family} ${edition}` : family ?? edition ?? undefined) if (captionNormalized && /windows/i.test(captionNormalized)) { productName = captionNormalized } const editionId = readFlexible("EditionID", "editionId", "Edition", "edition", "SkuEdition", "skuEdition", "CompositionEditionID", "compositionEditionId") ?? undefined const displayVersion = readFlexible("DisplayVersion", "displayVersion") const versionValue = readFlexible("Version", "version", "ReleaseId", "releaseId") ?? displayVersion const baseBuild = readFlexible("CurrentBuildNumber", "currentBuildNumber", "CurrentBuild", "currentBuild", "OSBuild", "osBuild", "BuildNumber", "buildNumber") ?? undefined const ubrRaw = readFlexible("UBR") const licenseStatus = readNum("LicenseStatus", "licenseStatus") const licenseStatusTextRaw = readFlexible( "LicenseStatusDescription", "licenseStatusDescription", "StatusDescription", "statusDescription", "Status", "status" ) const licenseStatusText = licenseStatusTextRaw ? licenseStatusTextRaw.trim() : undefined const currentBuildNumber = baseBuild && ubrRaw && /^\d+$/.test(ubrRaw) ? `${baseBuild}.${ubrRaw}` : baseBuild ?? readFlexible("BuildNumber", "buildNumber") const currentBuild = baseBuild const isActivatedRaw = value["IsActivated"] ?? value["isActivated"] const isLicensedRaw = value["IsLicensed"] ?? value["isLicensed"] const isActivated = typeof isActivatedRaw === "boolean" ? isActivatedRaw : typeof isActivatedRaw === "number" ? isActivatedRaw === 1 : typeof isActivatedRaw === "string" ? isActivatedRaw.toLowerCase() === "true" : typeof isLicensedRaw === "boolean" ? isLicensedRaw : typeof isLicensedRaw === "number" ? isLicensedRaw === 1 : typeof isLicensedRaw === "string" ? ["1", "true", "licensed", "license", "activated"].includes(isLicensedRaw.toLowerCase()) : licenseStatus === 1 || Boolean(licenseStatusText && /licensed|activated|licenciado/i.test(licenseStatusText)) const installDate = parseWindowsInstallDate(value["InstallDate"]) ?? parseWindowsInstallDate(value["InstallationDate"]) ?? parseWindowsInstallDate(value["InstallDateTime"]) ?? parseWindowsInstallDate(value["InstalledOn"]) const experience = (() => { const exp = readFlexible("Experience", "experience") if (exp) return exp const pack = readFlexible("FeatureExperiencePack", "featureExperiencePack") if (pack) { const trimmed = pack.trim() if (trimmed) return `Pacote de experiência ${trimmed}` } return currentBuildNumber ? `OS Build ${currentBuildNumber}` : undefined })() const productId = readFlexible("ProductID", "productID", "ProductId", "productId", "ProductKeyId", "productKeyId") const partialProductKey = readFlexible("PartialProductKey", "partialProductKey") const computerName = readFlexible("DeviceName", "deviceName", "ComputerName", "computerName", "CSName", "csName", "HostName", "hostName") const registeredOwner = readFlexible("RegisteredOwner", "registeredOwner", "RegisteredOrganization", "registeredOrganization") return { productName, editionId, displayVersion, version: versionValue, releaseId: read("ReleaseId", "releaseId"), currentBuild, currentBuildNumber, licenseStatus, isActivated, licenseStatusText, productId, partialProductKey, computerName, registeredOwner, installDate, experience, caption: captionNormalized ?? captionRaw ?? undefined, } } if (Array.isArray(raw)) { for (const entry of raw) { const record = toRecord(entry) if (record) { return parseRecord(record) } } return null } if (typeof raw === "string") { return { productName: raw } } const record = toRecord(raw) if (!record) return null return parseRecord(record) } function normalizeWindowsSoftwareEntry(value: unknown): NormalizedSoftwareEntry | null { const record = toRecord(value) if (!record) return null const name = readString(record, "DisplayName", "displayName", "Name", "name") ?? readText(record, "Title", "title") ?? "" if (!name) return null const version = readString(record, "DisplayVersion", "displayVersion", "Version", "version") ?? undefined const publisher = readString(record, "Publisher", "publisher", "Vendor", "vendor") ?? undefined const installDate = parseWindowsInstallDate(record["InstallDate"]) ?? parseWindowsInstallDate(record["InstallDateUTC"]) ?? parseWindowsInstallDate(record["InstallDateTime"]) ?? parseWindowsInstallDate(record["InstallDateFromRegistry"]) ?? parseWindowsInstallDate(record["InstallDateFromRegistryUTC"]) ?? parseWindowsInstallDate(record["InstalledDate"]) ?? parseWindowsInstallDate(record["InstalledOn"]) ?? null const source = readString(record, "ParentDisplayName", "ParentKeyName", "SystemComponent") ?? undefined return { name, version, publisher, installDate, source, } } function parseBytesLike(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) return value if (typeof value === "string") { const trimmed = value.trim() if (!trimmed) return undefined const normalized = trimmed.replace(",", ".") const match = normalized.match(/^([\d.]+)\s*(ti|tb|tib|gb|gib|mb|mib|kb|kib|b)?$/i) if (match) { const amount = Number(match[1]) if (Number.isNaN(amount)) return undefined const unit = match[2]?.toLowerCase() const base = 1024 const unitMap: Record = { b: 1, kb: base, kib: base, mb: base ** 2, mib: base ** 2, gb: base ** 3, gib: base ** 3, tb: base ** 4, tib: base ** 4, ti: base ** 4, } if (unit) { const multiplier = unitMap[unit] if (multiplier) { return amount * multiplier } } return amount } const digits = normalized.replace(/[^0-9.]/g, "") if (digits) { const fallback = Number(digits) if (!Number.isNaN(fallback)) return fallback } } return undefined } function deriveVendor(record: Record): string | undefined { const direct = readString(record, "vendor", "Vendor", "AdapterCompatibility") if (direct) return direct const pnp = readString(record, "PNPDeviceID") if (!pnp) return undefined const match = pnp.match(/VEN_([0-9A-F]{4})/i) if (match) { const vendorCode = match[1].toUpperCase() const vendorMap: Record = { "10DE": "NVIDIA", "1002": "AMD", "1022": "AMD", "8086": "Intel", "8087": "Intel", "1AF4": "Red Hat", } return vendorMap[vendorCode] ?? `VEN_${vendorCode}` } const segments = pnp.split("\\") const last = segments.pop() return last && last.trim().length > 0 ? last : pnp } function normalizeGpuSource(value: unknown): GpuAdapter | null { if (typeof value === "string") { const name = value.trim() return name ? { name } : null } const record = toRecord(value) if (!record) return null const name = readString(record, "name", "Name", "_name", "AdapterCompatibility") const vendor = deriveVendor(record) const driver = readString(record, "driver", "DriverVersion", "driverVersion") const memoryBytes = readNumber(record, "memoryBytes", "MemoryBytes", "AdapterRAM", "VRAM", "vramBytes") ?? parseBytesLike(record["AdapterRAM"] ?? record["VRAM"] ?? record["vram"]) if (!name && !vendor && !driver && memoryBytes === undefined) { return null } return { name, vendor, driver, memoryBytes } } function uniqueBy(items: T[], keyFn: (item: T) => string): T[] { const seen = new Set() const result: T[] = [] items.forEach((item) => { const key = keyFn(item) if (key && !seen.has(key)) { seen.add(key) result.push(item) } }) return result } export type DevicesQueryItem = { id: string tenantId: string hostname: string displayName: string | null deviceType: string | null devicePlatform: string | null managementMode: string | null companyId: string | null companySlug: string | null companyName: string | null osName: string | null osVersion: string | null architecture: string | null macAddresses: string[] serialNumbers: string[] authUserId: string | null authEmail: string | null persona: string | null assignedUserId: string | null assignedUserEmail: string | null assignedUserName: string | null assignedUserRole: string | null status: string | null isActive: boolean lastHeartbeatAt: number | null heartbeatAgeMs: number | null registeredBy: string | null createdAt: number updatedAt: number token: { expiresAt: number lastUsedAt: number | null usageCount: number } | null metrics: DeviceMetrics inventory: DeviceInventory | null postureAlerts?: Array> | null lastPostureAt?: number | null linkedUsers?: Array<{ id: string; email: string; name: string }> remoteAccessEntries: DeviceRemoteAccessEntry[] customFields?: Array<{ fieldId?: string; fieldKey: string; label: string; type?: string; value: unknown; displayValue?: string }> } export function normalizeDeviceItem(raw: Record): DevicesQueryItem { const { remoteAccess, ...rest } = raw as Record & { remoteAccess?: unknown } return { ...(rest as DevicesQueryItem), remoteAccessEntries: normalizeDeviceRemoteAccessList(remoteAccess), } } function useDevicesQuery(tenantId: string): { devices: DevicesQueryItem[]; isLoading: boolean } { const result = useQuery(api.devices.listByTenant, { tenantId, includeMetadata: true, }) as Array> | undefined const devices = useMemo(() => (result ?? []).map((item) => normalizeDeviceItem(item)), [result]) return { devices, isLoading: result === undefined, } } const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000 const DEFAULT_STALE_THRESHOLD_MS = DEFAULT_OFFLINE_THRESHOLD_MS * 12 function parseThreshold(raw: string | undefined, fallback: number) { if (!raw) return fallback const parsed = Number(raw) if (!Number.isFinite(parsed) || parsed <= 0) return fallback return parsed } const MACHINE_OFFLINE_THRESHOLD_MS = parseThreshold(process.env.NEXT_PUBLIC_MACHINE_OFFLINE_THRESHOLD_MS, DEFAULT_OFFLINE_THRESHOLD_MS) const MACHINE_STALE_THRESHOLD_MS = parseThreshold(process.env.NEXT_PUBLIC_MACHINE_STALE_THRESHOLD_MS, DEFAULT_STALE_THRESHOLD_MS) const statusLabels: Record = { online: "Online", offline: "Offline", stale: "Sem sinal", maintenance: "Em manutenção", blocked: "Bloqueado", deactivated: "Desativado", unknown: "Desconhecida", } const DEVICE_TYPE_LABELS: Record = { desktop: "Desktop", mobile: "Celular", tablet: "Tablet", } const DEVICE_TYPE_FILTER_OPTIONS = [ { value: "all", label: "Todos os tipos" }, { value: "desktop", label: DEVICE_TYPE_LABELS.desktop }, { value: "mobile", label: DEVICE_TYPE_LABELS.mobile }, { value: "tablet", label: DEVICE_TYPE_LABELS.tablet }, ] function formatDeviceTypeLabel(value?: string | null): string { if (!value) return "Desconhecido" const normalized = value.toLowerCase() return DEVICE_TYPE_LABELS[normalized] ?? value.charAt(0).toUpperCase() + value.slice(1) } const TICKET_PRIORITY_META: Record = { LOW: { label: "Baixa", badgeClass: "border border-slate-200 bg-slate-100 text-slate-600" }, MEDIUM: { label: "Média", badgeClass: "border border-sky-200 bg-sky-100 text-sky-700" }, HIGH: { label: "Alta", badgeClass: "border border-amber-200 bg-amber-50 text-amber-700" }, URGENT: { label: "Urgente", badgeClass: "border border-rose-200 bg-rose-50 text-rose-700" }, } function getTicketPriorityMeta(priority: TicketPriority | string | null | undefined) { if (!priority) { return { label: "Sem prioridade", badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600" } } const normalized = priority.toUpperCase() return ( TICKET_PRIORITY_META[normalized] ?? { label: priority.charAt(0).toUpperCase() + priority.slice(1).toLowerCase(), badgeClass: "border border-slate-200 bg-slate-100 text-neutral-600", } ) } const statusClasses: Record = { online: "border-emerald-200 text-emerald-600", offline: "border-rose-200 text-rose-600", stale: "border-amber-200 text-amber-600", maintenance: "border-amber-300 text-amber-700", blocked: "border-orange-200 text-orange-600", deactivated: "border-slate-200 bg-slate-50 text-slate-500", unknown: "border-slate-200 text-slate-600", } const REMOTE_ACCESS_PROVIDERS = [ { value: "TEAMVIEWER", label: "TeamViewer" }, { value: "ANYDESK", label: "AnyDesk" }, { value: "SUPREMO", label: "Supremo" }, { value: "RUSTDESK", label: "RustDesk" }, { value: "QUICKSUPPORT", label: "TeamViewer QS" }, { value: "CHROME_REMOTE_DESKTOP", label: "Chrome Remote Desktop" }, { value: "DW_SERVICE", label: "DWService" }, { value: "OTHER", label: "Outro" }, ] as const type RemoteAccessProviderValue = (typeof REMOTE_ACCESS_PROVIDERS)[number]["value"] const POSTURE_ALERT_LABELS: Record = { CPU_HIGH: "CPU alta", SERVICE_DOWN: "Serviço indisponível", SMART_FAIL: "Falha SMART", } function formatPostureAlertKind(raw?: string | null): string { if (!raw) return "Alerta" const normalized = raw.toUpperCase() if (POSTURE_ALERT_LABELS[normalized]) { return POSTURE_ALERT_LABELS[normalized] } return raw .toLowerCase() .replace(/_/g, " ") .replace(/\b\w/g, (char) => char.toUpperCase()) } function postureSeverityClass(severity?: string | null) { return (severity ?? "warning").toLowerCase() === "critical" ? "border-rose-500/20 bg-rose-500/10" : "border-amber-500/20 bg-amber-500/10" } function formatRelativeTime(date?: Date | null) { if (!date) return "Nunca" try { return formatDistanceToNowStrict(date, { addSuffix: true, locale: ptBR }) } catch { return "—" } } function formatInstallDate(date?: Date | null) { if (!date) return null return `${formatAbsoluteDateTime(date)} (${formatRelativeTime(date)})` } function formatDate(date?: Date | null) { if (!date) return "—" return format(date, "dd/MM/yyyy HH:mm") } function formatAbsoluteDateTime(date?: Date | null) { if (!date) return "—" return new Intl.DateTimeFormat("pt-BR", { dateStyle: "long", timeStyle: "short", }).format(date) } function parseDateish(value: unknown): Date | null { if (!value) return null if (value instanceof Date && !Number.isNaN(value.getTime())) return value if (typeof value === "number" && Number.isFinite(value)) { const numeric = value > 1e12 ? value : value > 1e9 ? value * 1000 : value const date = new Date(numeric) return Number.isNaN(date.getTime()) ? null : date } if (typeof value === "string") { const trimmed = value.trim() if (!trimmed) return null const numericValue = Number(trimmed) if (Number.isFinite(numericValue)) { const asMs = trimmed.length >= 13 ? numericValue : numericValue * 1000 const date = new Date(asMs) if (!Number.isNaN(date.getTime())) return date } const date = new Date(trimmed) if (!Number.isNaN(date.getTime())) return date } return null } function getMetricsTimestamp(metrics: DeviceMetrics): Date | null { if (!metrics || typeof metrics !== "object") return null const data = metrics as Record const candidates = [ data["collectedAt"], data["collected_at"], data["collected_at_iso"], data["collected_at_ms"], data["timestamp"], data["updatedAt"], data["updated_at"], data["createdAt"], data["created_at"], ] for (const candidate of candidates) { const parsed = parseDateish(candidate) if (parsed) return parsed } return null } function formatBytes(bytes?: number | null) { if (!bytes || Number.isNaN(bytes)) return "—" const units = ["B", "KB", "MB", "GB", "TB"] let value = bytes let unitIndex = 0 while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024 unitIndex += 1 } return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}` } function formatPercent(value?: number | null) { if (value === null || value === undefined || Number.isNaN(value)) return "—" const normalized = value > 1 ? value : value * 100 return `${normalized.toFixed(0)}%` } const BADGE_POSITIVE = "gap-1 border-emerald-500/20 bg-emerald-500/15 text-emerald-700" const BADGE_WARNING = "gap-1 border-amber-500/20 bg-amber-100 text-amber-700" const BADGE_NEUTRAL = "gap-1 border-slate-300 bg-slate-100 text-slate-600" function parseBooleanLike(value: unknown): boolean | undefined { if (typeof value === "boolean") return value if (typeof value === "number") { if (value === 1) return true if (value === 0) return false } if (typeof value === "string") { const normalized = value.trim().toLowerCase() if (["yes", "sim", "true", "enabled", "on", "ativo", "active", "y", "1"].includes(normalized)) return true if (["no", "não", "nao", "false", "disabled", "off", "inativo", "not joined", "n", "0"].includes(normalized)) return false } return undefined } function toNumberArray(value: unknown): number[] { if (!value) return [] if (Array.isArray(value)) { return value .map((item) => { if (typeof item === "number") return item if (typeof item === "string") { const parsed = Number(item) return Number.isNaN(parsed) ? null : parsed } return null }) .filter((item): item is number => item !== null) } if (typeof value === "number") return [value] if (typeof value === "string" && value.trim().length > 0) { return value .replace(/[^\d,]/g, " ") .split(/[,\s]+/) .map((part) => Number(part)) .filter((num) => !Number.isNaN(num)) } return [] } function describeDomainRole(role?: number | null): string | null { if (role === null || role === undefined) return null const map: Record = { 0: "Estação isolada", 1: "Estação em domínio", 2: "Servidor isolado", 3: "Servidor em domínio", 4: "Controlador de domínio (backup)", 5: "Controlador de domínio (primário)", } return map[role] ?? `Função ${role}` } function describePcSystemType(code?: number | null): string | null { if (code === null || code === undefined) return null const map: Record = { 0: "Não especificado", 1: "Desktop", 2: "Portátil / Laptop", 3: "Workstation", 4: "Servidor corporativo", 5: "Servidor SOHO", 8: "Tablet / Slate", 9: "Conversível", 10: "Sistema baseado em detecção", } return map[code] ?? `Tipo ${code}` } function describeDeviceGuardService(code: number): string { const map: Record = { 1: "Credential Guard", 2: "HVCI (Kernel Integrity)", 3: "Secure Boot com DMA", 4: "Hypervisor com Device Guard", 5: "Aplicação protegida", } return map[code] ?? `Serviço ${code}` } function describeVbsStatus(code?: number | null): string | null { switch (code) { case 0: return "VBS desabilitado" case 1: return "VBS habilitado (inativo)" case 2: return "VBS ativo (sem serviços)" case 3: return "VBS ativo com proteções" default: return code != null ? `VBS status ${code}` : null } } function describeAuOption(value?: number | null): string | null { switch (value ?? -1) { case 1: return "Não configurado" case 2: return "Notificar antes de baixar" case 3: return "Baixar automático e notificar" case 4: return "Baixar e instalar automaticamente" case 5: return "Administrador local define" default: return value != null ? `Opção ${value}` : null } } function describeScheduledDay(value?: number | null): string | null { if (value === null || value === undefined) return null if (value === 0) return "Todos os dias" const map = ["Domingo", "Segunda", "Terça", "Quarta", "Quinta", "Sexta", "Sábado"] return map[value - 1] ?? `Dia ${value}` } function getStatusVariant(status?: string | null) { if (!status) return { label: statusLabels.unknown, className: statusClasses.unknown } const normalized = status.toLowerCase() return { label: statusLabels[normalized] ?? status, className: statusClasses[normalized] ?? statusClasses.unknown, } } function resolveDeviceStatus(device: { status?: string | null; lastHeartbeatAt?: number | null; isActive?: boolean | null }): string { if (device.isActive === false) return "deactivated" const manualStatus = (device.status ?? "").toLowerCase() if (["maintenance", "blocked"].includes(manualStatus)) { return manualStatus } const heartbeat = device.lastHeartbeatAt if (typeof heartbeat === "number" && Number.isFinite(heartbeat) && heartbeat > 0) { const age = Date.now() - heartbeat if (age <= MACHINE_OFFLINE_THRESHOLD_MS) return "online" if (age <= MACHINE_STALE_THRESHOLD_MS) return "offline" return "stale" } return device.status ?? "unknown" } function OsIcon({ osName }: { osName?: string | null }) { const name = (osName ?? "").toLowerCase() if (name.includes("mac") || name.includes("darwin") || name.includes("macos")) return if (name.includes("linux")) return // fallback para Windows/outros como monitor genérico return } export function AdminDevicesOverview({ tenantId, initialCompanyFilterSlug = "all" }: { tenantId: string; initialCompanyFilterSlug?: string }) { const { devices, isLoading } = useDevicesQuery(tenantId) const [q, setQ] = useState("") const [statusFilter, setStatusFilter] = useState("all") const [deviceTypeFilter, setDeviceTypeFilter] = useState("all") const [companyFilterSlug, setCompanyFilterSlug] = useState(initialCompanyFilterSlug) const [companySearch, setCompanySearch] = useState("") const [isCompanyPopoverOpen, setIsCompanyPopoverOpen] = useState(false) const [onlyAlerts, setOnlyAlerts] = useState(false) const [isExportDialogOpen, setIsExportDialogOpen] = useState(false) const [exportSelection, setExportSelection] = useState([]) const [isExporting, setIsExporting] = useState(false) const [exportProgress, setExportProgress] = useState(0) const [exportError, setExportError] = useState(null) const [columnConfig, setColumnConfig] = useState(DEFAULT_DEVICE_COLUMN_CONFIG) const [selectedTemplateId, setSelectedTemplateId] = useState(null) const [hasManualColumns, setHasManualColumns] = useState(false) const [isTemplateDialogOpen, setIsTemplateDialogOpen] = useState(false) const [templateName, setTemplateName] = useState("") const [templateForCompany, setTemplateForCompany] = useState(false) const [templateAsDefault, setTemplateAsDefault] = useState(false) const [isSavingTemplate, setIsSavingTemplate] = useState(false) const [isCreateDeviceOpen, setIsCreateDeviceOpen] = useState(false) const [createDeviceLoading, setCreateDeviceLoading] = useState(false) const [newDeviceName, setNewDeviceName] = useState("") const [newDeviceIdentifier, setNewDeviceIdentifier] = useState("") const [newDeviceType, setNewDeviceType] = useState("mobile") const [newDevicePlatform, setNewDevicePlatform] = useState("") const [newDeviceCompanySlug, setNewDeviceCompanySlug] = useState( initialCompanyFilterSlug !== "all" ? initialCompanyFilterSlug : null ) const [newDeviceSerials, setNewDeviceSerials] = useState("") const [newDeviceNotes, setNewDeviceNotes] = useState("") const [newDeviceActive, setNewDeviceActive] = useState(true) const { convexUserId } = useAuth() const companies = useQuery( api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) as Array<{ id: string; name: string; slug?: string }> | undefined const companyNameBySlug = useMemo(() => { const map = new Map() devices.forEach((m) => { if (m.companySlug && m.companyName) { map.set(m.companySlug, m.companyName) } }) ;(companies ?? []).forEach((c) => { if (c.slug) { map.set(c.slug, c.name) } }) return map }, [devices, companies]) const selectedCompany = useMemo(() => { if (companyFilterSlug === "all") return null return (companies ?? []).find((company) => (company.slug ?? company.id) === companyFilterSlug) ?? null }, [companies, companyFilterSlug]) const companyOptions = useMemo(() => { if (companies && companies.length > 0) { return companies .map((c) => ({ slug: c.slug ?? c.id, name: c.name })) .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) } const fallback = new Map() devices.forEach((m) => { if (m.companySlug) { fallback.set(m.companySlug, m.companyName ?? m.companySlug) } }) return Array.from(fallback.entries()) .map(([slug, name]) => ({ slug, name })) .sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) }, [companies, devices]) const companyComboboxOptions = useMemo(() => { return companyOptions.map((company) => ({ value: company.slug, label: company.name, })) }, [companyOptions]) const deviceFields = useQuery( api.deviceFields.listForTenant, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) as Array<{ id: string; key: string; label: string }> | undefined const exportTemplates = useQuery( api.deviceExportTemplates.listForTenant, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) as DeviceExportTemplate[] | undefined const customFieldOptions = useMemo(() => { const map = new Map() ;(deviceFields ?? []).forEach((field) => { map.set(field.key, { key: field.key, label: field.label }) }) devices.forEach((device) => { ;(device.customFields ?? []).forEach((field) => { if (!map.has(field.fieldKey)) { map.set(field.fieldKey, { key: field.fieldKey, label: field.label }) } }) }) return Array.from(map.values()).sort((a, b) => a.label.localeCompare(b.label, "pt-BR")) }, [deviceFields, devices]) const customColumnOrder = useMemo(() => customFieldOptions.map((field) => `custom:${field.key}`), [customFieldOptions]) useEffect(() => { setColumnConfig((prev) => orderColumnConfig(prev, customColumnOrder)) }, [customColumnOrder]) const customColumnOptions = useMemo( () => customFieldOptions.map((field) => ({ key: `custom:${field.key}`, label: field.label })), [customFieldOptions] ) const baseColumnOptions = useMemo( () => DEVICE_INVENTORY_COLUMN_METADATA.map((meta) => ({ key: meta.key, label: meta.label })), [] ) useEffect(() => { if (!exportTemplates || exportTemplates.length === 0) return if (hasManualColumns || selectedTemplateId) return const companyTemplate = selectedCompany ? exportTemplates.find((tpl) => tpl.companyId === selectedCompany.id && tpl.isDefault) : null const fallbackTemplate = exportTemplates.find((tpl) => tpl.isDefault && !tpl.companyId) const templateToApply = companyTemplate ?? fallbackTemplate if (!templateToApply) return const normalized = orderColumnConfig( (templateToApply.columns ?? []).map((col) => ({ key: col.key, label: col.label ?? undefined })), customColumnOrder ) if (normalized.length === 0 || areColumnConfigsEqual(normalized, columnConfig)) return setColumnConfig(normalized) setSelectedTemplateId(templateToApply.id) setHasManualColumns(false) }, [exportTemplates, selectedCompany, customColumnOrder, hasManualColumns, selectedTemplateId, columnConfig]) const createTemplate = useMutation(api.deviceExportTemplates.create) const saveDeviceProfileMutation = useMutation(api.devices.saveDeviceProfile) useEffect(() => { if (!selectedCompany && templateForCompany) { setTemplateForCompany(false) } }, [selectedCompany, templateForCompany]) const templateOptions = useMemo(() => { return (exportTemplates ?? []).slice().sort((a, b) => a.name.localeCompare(b.name, "pt-BR")) }, [exportTemplates]) const selectedTemplate = useMemo( () => templateOptions.find((tpl) => tpl.id === selectedTemplateId) ?? null, [templateOptions, selectedTemplateId] ) const handleTemplateSelection = useCallback( (value: string) => { if (value === "custom") { setSelectedTemplateId(null) setHasManualColumns(true) return } const template = templateOptions.find((tpl) => tpl.id === value) if (!template) { toast.error("Template não encontrado.") return } const normalized = orderColumnConfig( (template.columns ?? []).map((col) => ({ key: col.key, label: col.label ?? undefined })), customColumnOrder ) if (normalized.length === 0) { toast.error("Template sem colunas válidas.") return } setColumnConfig(normalized) setSelectedTemplateId(template.id) setHasManualColumns(false) }, [templateOptions, customColumnOrder] ) const handleToggleColumn = useCallback( (key: string, checked: boolean, label?: string) => { setColumnConfig((prev) => { const filtered = prev.filter((col) => col.key !== key) if (checked) { return orderColumnConfig([...filtered, { key, label }], customColumnOrder) } return filtered }) setSelectedTemplateId(null) setHasManualColumns(true) }, [customColumnOrder] ) const handleResetColumns = useCallback(() => { setColumnConfig(orderColumnConfig([...DEFAULT_DEVICE_COLUMN_CONFIG], customColumnOrder)) setSelectedTemplateId(null) setHasManualColumns(false) }, [customColumnOrder]) const handleSelectAllColumns = useCallback(() => { const allColumns: DeviceInventoryColumnConfig[] = [ ...baseColumnOptions.map((col) => ({ key: col.key })), ...customColumnOptions.map((col) => ({ key: col.key, label: col.label })), ] setColumnConfig(orderColumnConfig(allColumns, customColumnOrder)) setSelectedTemplateId(null) setHasManualColumns(true) }, [baseColumnOptions, customColumnOptions, customColumnOrder]) const handleOpenTemplateDialog = useCallback(() => { setTemplateName("") setTemplateForCompany(Boolean(selectedCompany)) setTemplateAsDefault(false) setIsTemplateDialogOpen(true) }, [selectedCompany]) const handleSaveTemplate = useCallback(async () => { if (!convexUserId) { toast.error("Sincronize a sessão para salvar templates.") return } const normalized = orderColumnConfig(columnConfig, customColumnOrder) if (normalized.length === 0) { toast.error("Selecione ao menos uma coluna para salvar o template.") return } const name = templateName.trim() if (name.length < 3) { toast.error("Informe um nome para o template (mínimo 3 caracteres).") return } try { setIsSavingTemplate(true) const response = await createTemplate({ tenantId, actorId: convexUserId as Id<"users">, name, columns: normalized, companyId: templateForCompany && selectedCompany ? (selectedCompany.id as Id<"companies">) : undefined, isDefault: templateAsDefault, isActive: true, }) setIsTemplateDialogOpen(false) setTemplateName("") setTemplateForCompany(false) setTemplateAsDefault(false) const newTemplateId = typeof response === "string" ? response : response ? String(response) : null if (newTemplateId) { setSelectedTemplateId(newTemplateId) setHasManualColumns(false) } toast.success("Template salvo com sucesso.") } catch (error) { console.error("Failed to save template", error) toast.error("Não foi possível salvar o template.") } finally { setIsSavingTemplate(false) } }, [columnConfig, createTemplate, customColumnOrder, convexUserId, selectedCompany, templateAsDefault, templateForCompany, templateName, tenantId]) const handleOpenCreateDevice = useCallback(() => { const initialCompany = selectedCompany ? selectedCompany.slug ?? selectedCompany.id : companyFilterSlug !== "all" ? companyFilterSlug : null setNewDeviceName("") setNewDeviceIdentifier("") setNewDeviceType("mobile") setNewDevicePlatform("") setNewDeviceSerials("") setNewDeviceNotes("") setNewDeviceActive(true) setNewDeviceCompanySlug(initialCompany) setIsCreateDeviceOpen(true) }, [selectedCompany, companyFilterSlug]) const handleCreateDevice = useCallback(async () => { if (!convexUserId) { toast.error("Sincronize a sessão antes de criar dispositivos.") return } const name = newDeviceName.trim() if (name.length < 3) { toast.error("Informe um nome com ao menos 3 caracteres.") return } const identifier = (newDeviceIdentifier.trim() || name).trim() const platform = newDevicePlatform.trim() const serials = newDeviceSerials .split(/\r?\n|,|;/) .map((value) => value.trim()) .filter(Boolean) const targetCompany = newDeviceCompanySlug ? (companies ?? []).find((company) => (company.slug ?? company.id) === newDeviceCompanySlug) ?? null : null try { setCreateDeviceLoading(true) await saveDeviceProfileMutation({ tenantId, actorId: convexUserId as Id<"users">, displayName: name, hostname: identifier, deviceType: newDeviceType, devicePlatform: platform || undefined, osName: platform || undefined, serialNumbers: serials.length > 0 ? serials : undefined, companyId: targetCompany ? (targetCompany.id as Id<"companies">) : undefined, companySlug: targetCompany?.slug ?? undefined, status: "unknown", isActive: newDeviceActive, profile: newDeviceNotes.trim() ? { notes: newDeviceNotes.trim() } : undefined, }) toast.success("Dispositivo criado com sucesso.") setIsCreateDeviceOpen(false) setNewDeviceName("") setNewDeviceIdentifier("") setNewDevicePlatform("") setNewDeviceSerials("") setNewDeviceNotes("") setNewDeviceActive(true) if (targetCompany?.slug) { setCompanyFilterSlug(targetCompany.slug) } } catch (error) { console.error("Failed to create device", error) toast.error("Não foi possível criar o dispositivo.") } finally { setCreateDeviceLoading(false) } }, [companies, convexUserId, newDeviceActive, newDeviceCompanySlug, newDeviceIdentifier, newDeviceName, newDeviceNotes, newDevicePlatform, newDeviceSerials, newDeviceType, saveDeviceProfileMutation, tenantId]) const filteredDevices = useMemo(() => { const text = q.trim().toLowerCase() return devices.filter((m) => { if (onlyAlerts && !(Array.isArray(m.postureAlerts) && m.postureAlerts.length > 0)) return false if (statusFilter !== "all") { const s = resolveDeviceStatus(m).toLowerCase() if (s !== statusFilter) return false } if (deviceTypeFilter !== "all") { const type = (m.deviceType ?? "desktop").toLowerCase() if (type !== deviceTypeFilter) return false } if (companyFilterSlug !== "all" && (m.companySlug ?? "") !== companyFilterSlug) return false if (!text) return true const hay = [ m.hostname, m.authEmail ?? "", (m.macAddresses ?? []).join(" "), (m.serialNumbers ?? []).join(" "), ] .join(" ") .toLowerCase() return hay.includes(text) }) }, [devices, q, statusFilter, companyFilterSlug, onlyAlerts, deviceTypeFilter]) const handleOpenExportDialog = useCallback(() => { if (filteredDevices.length === 0) { toast.info("Não há dispositivos para exportar com os filtros atuais.") return } setExportSelection(filteredDevices.map((m) => m.id)) setExportProgress(0) setExportError(null) setIsExporting(false) setIsExportDialogOpen(true) }, [filteredDevices]) const handleExportDialogOpenChange = useCallback((open: boolean) => { if (!open && isExporting) return setIsExportDialogOpen(open) }, [isExporting]) useEffect(() => { if (!isExportDialogOpen) { setExportSelection([]) setExportProgress(0) setExportError(null) setIsExporting(false) } }, [isExportDialogOpen]) useEffect(() => { if (!isExportDialogOpen) return const allowed = new Set(filteredDevices.map((m) => m.id)) setExportSelection((prev) => { const next = prev.filter((id) => allowed.has(id)) return next.length === prev.length ? prev : next }) }, [filteredDevices, isExportDialogOpen]) const handleToggleDeviceSelection = useCallback((deviceId: string, checked: boolean) => { setExportSelection((prev) => { if (checked) { if (prev.includes(deviceId)) return prev return [...prev, deviceId] } return prev.filter((id) => id !== deviceId) }) }, []) const handleSelectAllDevices = useCallback((checked: boolean) => { if (checked) { setExportSelection(filteredDevices.map((m) => m.id)) } else { setExportSelection([]) } }, [filteredDevices]) const handleConfirmExport = useCallback(async () => { const orderedSelection = filteredDevices.map((m) => m.id).filter((id) => exportSelection.includes(id)) if (orderedSelection.length === 0) { toast.info("Selecione ao menos um dispositivo para exportar.") return } const normalizedColumns = orderColumnConfig(columnConfig, customColumnOrder) if (normalizedColumns.length === 0) { toast.info("Selecione ao menos uma coluna para exportar.") return } setIsExporting(true) setExportError(null) setExportProgress(5) try { const params = new URLSearchParams() if (companyFilterSlug !== "all") { params.set("companyId", companyFilterSlug) } orderedSelection.forEach((id) => params.append("machineId", id)) params.set("columns", JSON.stringify(normalizedColumns)) if (selectedTemplateId) { params.set("templateId", selectedTemplateId) } const qs = params.toString() const url = `/api/reports/machines-inventory.xlsx${qs ? `?${qs}` : ""}` const response = await fetch(url) if (!response.ok) { throw new Error(`Export failed with status ${response.status}`) } const contentLengthHeader = response.headers.get("Content-Length") const totalBytes = contentLengthHeader ? parseInt(contentLengthHeader, 10) : Number.NaN const hasLength = Number.isFinite(totalBytes) && totalBytes > 0 const disposition = response.headers.get("Content-Disposition") const filenameMatch = disposition?.match(/filename="?([^";]+)"?/i) const filename = filenameMatch?.[1] ?? `devices-inventory-${new Date().toISOString().slice(0, 10)}.xlsx` let blob: Blob if (!response.body || typeof response.body.getReader !== "function") { blob = await response.blob() setExportProgress(100) } else { const reader = response.body.getReader() const chunks: ArrayBuffer[] = [] let received = 0 while (true) { const { value, done } = await reader.read() if (done) break if (value) { chunks.push(value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength)) received += value.length if (hasLength) { const percent = Math.min(99, Math.round((received / totalBytes) * 100)) setExportProgress(percent) } else { setExportProgress((prev) => (prev >= 95 ? prev : prev + 5)) } } } blob = new Blob(chunks, { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }) setExportProgress(100) } const downloadUrl = window.URL.createObjectURL(blob) const link = document.createElement("a") link.href = downloadUrl link.download = filename document.body.appendChild(link) link.click() link.remove() window.URL.revokeObjectURL(downloadUrl) toast.success(`Exportação gerada para ${orderedSelection.length} dispositivo${orderedSelection.length === 1 ? "" : "s"}.`) setIsExporting(false) setIsExportDialogOpen(false) } catch (error) { console.error("Failed to export devices inventory", error) setIsExporting(false) setExportProgress(0) setExportError("Não foi possível gerar o arquivo. Tente novamente.") } }, [companyFilterSlug, exportSelection, filteredDevices, columnConfig, customColumnOrder, selectedTemplateId]) const exportableCount = filteredDevices.length const selectedCount = exportSelection.length const selectAllState: boolean | "indeterminate" = exportableCount === 0 ? false : selectedCount === exportableCount ? true : selectedCount > 0 ? "indeterminate" : false return (
Dispositivos registrados Sincronizadas via agente local ou Fleet. Atualiza em tempo real.
setCompanySearch(e.target.value)} placeholder="Buscar empresa..." />
{companyOptions .filter((c) => c.name.toLowerCase().includes(companySearch.toLowerCase())) .map((c) => ( ))}
{isLoading ? ( ) : devices.length === 0 ? ( ) : ( )}
Exportar inventário Revise os dispositivos antes de gerar o XLSX. {filteredDevices.length === 0 ? (
Nenhum dispositivo disponível para exportar com os filtros atuais.
) : (
{selectedCount} de {filteredDevices.length} selecionadas
    {filteredDevices.map((device) => { const statusKey = resolveDeviceStatus(device) const statusLabel = statusLabels[statusKey] ?? statusKey const isChecked = exportSelection.includes(device.id) const osParts = [device.osName ?? "", device.osVersion ?? ""].filter(Boolean) const osLabel = osParts.join(" ") return (
  • ) })}
)} {filteredDevices.length > 0 ? (

Template de exportação

Salve combinações de colunas para reutilizar mais tarde.

{selectedTemplate ? (

Aplicando {selectedTemplate.name} {selectedTemplate.companyId ? " (empresa)" : ""} {selectedTemplate.isDefault ? " • padrão" : ""}

) : null}

Colunas

{baseColumnOptions.map((column) => { const checked = columnConfig.some((col) => col.key === column.key) return ( ) })}
{customColumnOptions.length > 0 ? (

Campos personalizados

{customColumnOptions.map((column) => { const checked = columnConfig.some((col) => col.key === column.key) return ( ) })}
) : null}

{columnConfig.length} coluna{columnConfig.length === 1 ? "" : "s"} selecionada{columnConfig.length === 1 ? "" : "s"}.

) : null} {isExporting ? (
Gerando planilha... {Math.min(100, Math.max(0, Math.round(exportProgress)))}%
) : null} {exportError ?

{exportError}

: null}
setIsTemplateDialogOpen(open)}> Salvar template Guarde esta seleção de colunas para reutilizar em futuras exportações.
setTemplateName(event.target.value)} placeholder="Inventário padrão" disabled={isSavingTemplate} />

Escopo

{columnConfig.length} coluna{columnConfig.length === 1 ? "" : "s"} será{columnConfig.length === 1 ? "" : "o"} salva{columnConfig.length === 1 ? "" : "s"} neste template.

(!createDeviceLoading ? setIsCreateDeviceOpen(open) : null)}> Novo dispositivo Cadastre equipamentos sem agente instalado, como celulares e tablets.
setNewDeviceName(event.target.value)} placeholder="iPhone da Ana" disabled={createDeviceLoading} />
setNewDeviceIdentifier(event.target.value)} placeholder="ana-iphone" disabled={createDeviceLoading} />

Caso vazio, usaremos o nome como identificador.

setNewDevicePlatform(event.target.value)} placeholder="iOS 18, Android 15, Windows 11" disabled={createDeviceLoading} />