diff --git a/convex/bootstrap.ts b/convex/bootstrap.ts index 6292237..e6031bf 100644 --- a/convex/bootstrap.ts +++ b/convex/bootstrap.ts @@ -18,6 +18,10 @@ export const ensureDefaults = mutation({ await ctx.db.patch(queue._id, { name: "Laboratório", slug: "laboratorio" }); return (await ctx.db.get(queue._id)) ?? queue; } + if (queue.name === "Field Services" || queue.slug === "field-services") { + await ctx.db.patch(queue._id, { name: "Visitas", slug: "visitas" }); + return (await ctx.db.get(queue._id)) ?? queue; + } return queue; }) ); @@ -25,7 +29,7 @@ export const ensureDefaults = mutation({ const queues = [ { name: "Chamados", slug: "chamados" }, { name: "Laboratório", slug: "laboratorio" }, - { name: "Field Services", slug: "field-services" }, + { name: "Visitas", slug: "visitas" }, ]; for (const q of queues) { await ctx.db.insert("queues", { tenantId, name: q.name, slug: q.slug, teamId: undefined }); @@ -33,4 +37,3 @@ export const ensureDefaults = mutation({ } }, }); - diff --git a/convex/machines.ts b/convex/machines.ts index af2dd97..9f95303 100644 --- a/convex/machines.ts +++ b/convex/machines.ts @@ -1367,6 +1367,118 @@ export const toggleActive = mutation({ }, }) +type RemoteAccessEntry = { + id: string + provider: string + identifier: string + url: string | null + notes: string | null + lastVerifiedAt: number | null + metadata: Record | null +} + +function createRemoteAccessId() { + return `ra_${Math.random().toString(36).slice(2, 8)}${Date.now().toString(36)}` +} + +function coerceString(value: unknown): string | null { + if (typeof value === "string") { + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null + } + return null +} + +function coerceNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value + } + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) return null + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : null + } + return null +} + +function normalizeRemoteAccessEntry(raw: unknown): RemoteAccessEntry | 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: createRemoteAccessId(), + provider: "Remoto", + identifier: isUrl ? trimmed : trimmed, + url: isUrl ? trimmed : null, + notes: null, + lastVerifiedAt: null, + metadata: null, + } + } + if (typeof raw !== "object") return null + const record = raw as Record + const provider = + coerceString(record.provider) ?? + coerceString(record.tool) ?? + coerceString(record.vendor) ?? + coerceString(record.name) ?? + "Remoto" + const identifier = + coerceString(record.identifier) ?? + coerceString(record.code) ?? + coerceString(record.id) ?? + coerceString(record.accessId) + const url = + coerceString(record.url) ?? + coerceString(record.link) ?? + coerceString(record.remoteUrl) ?? + coerceString(record.console) ?? + coerceString(record.viewer) ?? + null + const resolvedIdentifier = identifier ?? url ?? "Acesso remoto" + const notes = coerceString(record.notes) ?? coerceString(record.note) ?? coerceString(record.description) ?? coerceString(record.obs) ?? null + const timestamp = + coerceNumber(record.lastVerifiedAt) ?? + coerceNumber(record.verifiedAt) ?? + coerceNumber(record.checkedAt) ?? + coerceNumber(record.updatedAt) ?? + null + const id = coerceString(record.id) ?? createRemoteAccessId() + const metadata = + record.metadata && typeof record.metadata === "object" && !Array.isArray(record.metadata) + ? (record.metadata as Record) + : null + return { + id, + provider, + identifier: resolvedIdentifier, + url, + notes, + lastVerifiedAt: timestamp, + metadata, + } +} + +function normalizeRemoteAccessList(raw: unknown): RemoteAccessEntry[] { + const source = Array.isArray(raw) ? raw : raw ? [raw] : [] + const seen = new Set() + const entries: RemoteAccessEntry[] = [] + for (const item of source) { + const entry = normalizeRemoteAccessEntry(item) + if (!entry) continue + let nextId = entry.id + while (seen.has(nextId)) { + nextId = createRemoteAccessId() + } + seen.add(nextId) + entries.push(nextId === entry.id ? entry : { ...entry, id: nextId }) + } + return entries +} + export const updateRemoteAccess = mutation({ args: { machineId: v.id("machines"), @@ -1375,9 +1487,11 @@ export const updateRemoteAccess = mutation({ identifier: v.optional(v.string()), url: v.optional(v.string()), notes: v.optional(v.string()), + action: v.optional(v.string()), + entryId: v.optional(v.string()), clear: v.optional(v.boolean()), }, - handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, clear }) => { + handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, action, entryId, clear }) => { const machine = await ctx.db.get(machineId) if (!machine) { throw new ConvexError("Máquina não encontrada") @@ -1393,11 +1507,49 @@ export const updateRemoteAccess = mutation({ throw new ConvexError("Somente administradores e agentes podem ajustar o acesso remoto.") } - if (clear) { + const actionMode = (() => { + if (clear) return "clear" as const + const normalized = (action ?? "").toLowerCase() + if (normalized === "clear") return "clear" as const + if (normalized === "delete" || normalized === "remove") return "delete" as const + return "upsert" as const + })() + + const existingEntries = normalizeRemoteAccessList(machine.remoteAccess) + + if (actionMode === "clear") { await ctx.db.patch(machineId, { remoteAccess: null, updatedAt: Date.now() }) return { remoteAccess: null } } + if (actionMode === "delete") { + const trimmedEntryId = coerceString(entryId) + const trimmedProvider = coerceString(provider) + const trimmedIdentifier = coerceString(identifier) + let target: RemoteAccessEntry | undefined + if (trimmedEntryId) { + target = existingEntries.find((entry) => entry.id === trimmedEntryId) + } + if (!target && trimmedProvider && trimmedIdentifier) { + target = existingEntries.find( + (entry) => entry.provider === trimmedProvider && entry.identifier === trimmedIdentifier + ) + } + if (!target && trimmedIdentifier) { + target = existingEntries.find((entry) => entry.identifier === trimmedIdentifier) + } + if (!target && trimmedProvider) { + target = existingEntries.find((entry) => entry.provider === trimmedProvider) + } + if (!target) { + throw new ConvexError("Entrada de acesso remoto não encontrada.") + } + const nextEntries = existingEntries.filter((entry) => entry.id !== target!.id) + const nextValue = nextEntries.length > 0 ? nextEntries : null + await ctx.db.patch(machineId, { remoteAccess: nextValue, updatedAt: Date.now() }) + return { remoteAccess: nextValue } + } + const trimmedProvider = (provider ?? "").trim() const trimmedIdentifier = (identifier ?? "").trim() if (!trimmedProvider || !trimmedIdentifier) { @@ -1422,8 +1574,15 @@ export const updateRemoteAccess = mutation({ const cleanedNotes = notes?.trim() ? notes.trim() : null const lastVerifiedAt = Date.now() + const targetEntryId = + coerceString(entryId) ?? + existingEntries.find( + (entry) => entry.provider === trimmedProvider && entry.identifier === trimmedIdentifier + )?.id ?? + createRemoteAccessId() - const remoteAccess = { + const updatedEntry: RemoteAccessEntry = { + id: targetEntryId, provider: trimmedProvider, identifier: trimmedIdentifier, url: normalizedUrl, @@ -1438,12 +1597,21 @@ export const updateRemoteAccess = mutation({ }, } + const existingIndex = existingEntries.findIndex((entry) => entry.id === targetEntryId) + let nextEntries: RemoteAccessEntry[] + if (existingIndex >= 0) { + nextEntries = [...existingEntries] + nextEntries[existingIndex] = updatedEntry + } else { + nextEntries = [...existingEntries, updatedEntry] + } + await ctx.db.patch(machineId, { - remoteAccess, + remoteAccess: nextEntries, updatedAt: Date.now(), }) - return { remoteAccess } + return { remoteAccess: nextEntries } }, }) diff --git a/src/app/admin/machines/[id]/page.tsx b/src/app/admin/machines/[id]/page.tsx index 507cf11..5ca5bd5 100644 --- a/src/app/admin/machines/[id]/page.tsx +++ b/src/app/admin/machines/[id]/page.tsx @@ -7,8 +7,8 @@ import { MachineBreadcrumbs } from "@/components/admin/machines/machine-breadcru export const runtime = "nodejs" export const dynamic = "force-dynamic" -export default function AdminMachineDetailsPage({ params }: { params: { id: string } }) { - const { id } = params +export default async function AdminMachineDetailsPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params return ( }>
diff --git a/src/app/api/admin/machines/remote-access/route.ts b/src/app/api/admin/machines/remote-access/route.ts index 8aeaa2d..a30d83d 100644 --- a/src/app/api/admin/machines/remote-access/route.ts +++ b/src/app/api/admin/machines/remote-access/route.ts @@ -4,6 +4,7 @@ import { ConvexHttpClient } from "convex/browser" import { assertStaffSession } from "@/lib/auth-server" import type { Id } from "@/convex/_generated/dataModel" +import { api } from "@/convex/_generated/api" export const runtime = "nodejs" @@ -13,7 +14,8 @@ const schema = z.object({ identifier: z.string().optional(), url: z.string().optional(), notes: z.string().optional(), - action: z.enum(["save", "clear"]).optional(), + entryId: z.string().optional(), + action: z.enum(["save", "upsert", "clear", "delete", "remove"]).optional(), }) export async function POST(request: Request) { @@ -21,9 +23,6 @@ export async function POST(request: Request) { if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) } - if (!session.user.id) { - return NextResponse.json({ error: "Sessão inválida" }, { status: 400 }) - } let parsed: z.infer try { @@ -41,49 +40,103 @@ export async function POST(request: Request) { const client = new ConvexHttpClient(convexUrl) try { - const action = parsed.action ?? "save" + const tenantId = + session.user.tenantId ?? + process.env.SYNC_TENANT_ID ?? + process.env.SEED_USER_TENANT ?? + "tenant-atlas" + const ensured = (await client.mutation(api.users.ensureUser, { + tenantId, + email: session.user.email, + name: session.user.name ?? session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + })) as { _id?: Id<"users"> } | null - if (action === "clear") { - const result = (await (client as unknown as { mutation: (name: string, args: Record) => Promise }).mutation("machines:updateRemoteAccess", { - machineId: parsed.machineId as Id<"machines">, - actorId: session.user.id as Id<"users">, - clear: true, - })) as { remoteAccess?: unknown } | null - return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null }) + const actorId = ensured?._id as Id<"users"> | undefined + if (!actorId) { + return NextResponse.json( + { error: "Usuário não encontrado no Convex para executar esta ação." }, + { status: 403 } + ) } + const actionRaw = (parsed.action ?? "save").toLowerCase() + const normalizedAction = + actionRaw === "clear" + ? "clear" + : actionRaw === "delete" || actionRaw === "remove" + ? "delete" + : "upsert" + const provider = (parsed.provider ?? "").trim() const identifier = (parsed.identifier ?? "").trim() - if (!provider || !identifier) { - return NextResponse.json({ error: "Informe o provedor e o identificador do acesso remoto." }, { status: 400 }) - } + const notes = (parsed.notes ?? "").trim() let normalizedUrl: string | undefined - const rawUrl = (parsed.url ?? "").trim() - if (rawUrl.length > 0) { - const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}` - try { - new URL(candidate) - normalizedUrl = candidate - } catch { - return NextResponse.json({ error: "URL inválida. Informe um endereço iniciado com http:// ou https://." }, { status: 422 }) + if (normalizedAction === "upsert") { + if (!provider || !identifier) { + return NextResponse.json({ error: "Informe o provedor e o identificador do acesso remoto." }, { status: 400 }) + } + const rawUrl = (parsed.url ?? "").trim() + if (rawUrl.length > 0) { + const candidate = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}` + try { + new URL(candidate) + normalizedUrl = candidate + } catch { + return NextResponse.json({ error: "URL inválida. Informe um endereço iniciado com http:// ou https://." }, { status: 422 }) + } } } - const notes = (parsed.notes ?? "").trim() - - const result = (await (client as unknown as { mutation: (name: string, args: Record) => Promise }).mutation("machines:updateRemoteAccess", { + const mutationArgs: Record = { machineId: parsed.machineId as Id<"machines">, - actorId: session.user.id as Id<"users">, - provider, - identifier, - url: normalizedUrl, - notes: notes.length ? notes : undefined, - })) as { remoteAccess?: unknown } | null + actorId, + action: normalizedAction, + } + + if (parsed.entryId) { + mutationArgs.entryId = parsed.entryId + } + + if (normalizedAction === "clear") { + mutationArgs.clear = true + } else { + if (provider) mutationArgs.provider = provider + if (identifier) mutationArgs.identifier = identifier + if (normalizedUrl !== undefined) mutationArgs.url = normalizedUrl + if (notes.length) mutationArgs.notes = notes + } + + const result = (await (client as unknown as { mutation: (name: string, args: Record) => Promise }).mutation( + "machines:updateRemoteAccess", + mutationArgs + )) as { remoteAccess?: unknown } | null return NextResponse.json({ ok: true, remoteAccess: result?.remoteAccess ?? null }) } catch (error) { console.error("[machines.remote-access]", error) - return NextResponse.json({ error: "Falha ao atualizar acesso remoto" }, { status: 500 }) + const detail = error instanceof Error ? error.message : null + const isOutdatedConvex = + typeof detail === "string" && detail.includes("extra field `action`") + + if (isOutdatedConvex) { + return NextResponse.json( + { + error: "Backend do Convex desatualizado", + detail: "Recompile/deploy as funções Convex (ex.: `pnpm convex:dev` em desenvolvimento ou `npx convex deploy`) para aplicar o suporte a múltiplos acessos remotos.", + }, + { status: 409 } + ) + } + + return NextResponse.json( + { + error: "Falha ao atualizar acesso remoto", + ...(process.env.NODE_ENV !== "production" && detail ? { detail } : {}), + }, + { status: 500 } + ) } } diff --git a/src/components/admin/machines/admin-machines-overview.tsx b/src/components/admin/machines/admin-machines-overview.tsx index 7e9b984..cf4f089 100644 --- a/src/components/admin/machines/admin-machines-overview.tsx +++ b/src/components/admin/machines/admin-machines-overview.tsx @@ -34,7 +34,7 @@ import { import { api } from "@/convex/_generated/api" import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" +import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Textarea } from "@/components/ui/textarea" import { Spinner } from "@/components/ui/spinner" @@ -238,7 +238,9 @@ type MachineInventory = { collaborator?: { email?: string; name?: string; role?: string } } -type MachineRemoteAccess = { +type MachineRemoteAccessEntry = { + id: string | null + clientId: string provider: string | null identifier: string | null url: string | null @@ -290,15 +292,24 @@ function readNumber(record: Record, ...keys: string[]): number return undefined } -export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess | null { +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 normalizeMachineRemoteAccessEntry(raw: unknown): MachineRemoteAccessEntry | 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, + identifier: trimmed, url: isUrl ? trimmed : null, notes: null, lastVerifiedAt: null, @@ -308,16 +319,21 @@ export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess 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") ?? null + const identifier = + readString(record, "identifier", "code", "id", "accessId") ?? + readString(record, "value", "label") 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: identifier ?? url ?? null, url, notes, lastVerifiedAt, @@ -325,6 +341,58 @@ export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess } } +export function normalizeMachineRemoteAccessList(raw: unknown): MachineRemoteAccessEntry[] { + if (!raw) return [] + const source = Array.isArray(raw) ? raw : [raw] + const seen = new Set() + const entries: MachineRemoteAccessEntry[] = [] + for (const item of source) { + const entry = normalizeMachineRemoteAccessEntry(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 +} + +const REMOTE_ACCESS_METADATA_IGNORED_KEYS = new Set([ + "provider", + "tool", + "vendor", + "name", + "identifier", + "code", + "id", + "accessId", + "url", + "link", + "remoteUrl", + "console", + "viewer", + "notes", + "note", + "description", + "obs", + "lastVerifiedAt", + "verifiedAt", + "checkedAt", + "updatedAt", +]) + +function extractRemoteAccessMetadataEntries(metadata: Record | null | undefined) { + if (!metadata) return [] as Array<[string, unknown]> + return Object.entries(metadata).filter(([key, value]) => { + if (REMOTE_ACCESS_METADATA_IGNORED_KEYS.has(key)) return false + if (value === null || value === undefined) return false + if (typeof value === "string" && value.trim().length === 0) return false + return true + }) +} + function formatRemoteAccessMetadataKey(key: string) { return key .replace(/[_.-]+/g, " ") @@ -740,14 +808,14 @@ export type MachinesQueryItem = { postureAlerts?: Array> | null lastPostureAt?: number | null linkedUsers?: Array<{ id: string; email: string; name: string }> - remoteAccess: MachineRemoteAccess | null + remoteAccessEntries: MachineRemoteAccessEntry[] } export function normalizeMachineItem(raw: Record): MachinesQueryItem { - const base = raw as MachinesQueryItem + const { remoteAccess, ...rest } = raw as Record & { remoteAccess?: unknown } return { - ...base, - remoteAccess: normalizeMachineRemoteAccess(raw["remoteAccess"]) ?? null, + ...(rest as MachinesQueryItem), + remoteAccessEntries: normalizeMachineRemoteAccessList(remoteAccess), } } @@ -794,13 +862,13 @@ const TICKET_STATUS_LABELS: Record = { } const statusClasses: Record = { - online: "border-emerald-500/20 bg-emerald-500/15 text-emerald-600", - offline: "border-rose-500/20 bg-rose-500/15 text-rose-600", - stale: "border-slate-400/30 bg-slate-200 text-slate-700", - maintenance: "border-amber-500/20 bg-amber-500/15 text-amber-600", - blocked: "border-orange-500/20 bg-orange-500/15 text-orange-600", - deactivated: "border-slate-400/40 bg-slate-100 text-slate-600", - unknown: "border-slate-300 bg-slate-200 text-slate-700", + 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 = [ @@ -1257,6 +1325,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) { ? "bg-emerald-500" : s === "offline" ? "bg-rose-500" + : s === "stale" + ? "bg-amber-500" : s === "maintenance" ? "bg-amber-500" : s === "blocked" @@ -1269,6 +1339,8 @@ function MachineStatusBadge({ status }: { status?: string | null }) { ? "bg-emerald-400/30" : s === "offline" ? "bg-rose-400/30" + : s === "stale" + ? "bg-amber-400/30" : s === "maintenance" ? "bg-amber-400/30" : s === "blocked" @@ -1279,11 +1351,22 @@ function MachineStatusBadge({ status }: { status?: string | null }) { const isOnline = s === "online" return ( - - - + + + {isOnline ? ( - + ) : null} {label} @@ -1331,8 +1414,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) { const normalizedViewerRole = (viewerRole ?? "").toLowerCase() const canManageRemoteAccess = normalizedViewerRole === "admin" || normalizedViewerRole === "agent" const effectiveStatus = machine ? resolveMachineStatus(machine) : "unknown" - const isActive = machine?.isActive ?? true - const isDeactivated = !isActive || effectiveStatus === "deactivated" + const [isActiveLocal, setIsActiveLocal] = useState(machine?.isActive ?? true) + const isDeactivated = !isActiveLocal || effectiveStatus === "deactivated" const alertsHistory = useQuery( machine ? api.machines.listAlerts : "skip", machine ? { machineId: machine.id as Id<"machines">, limit: 50 } : ("skip" as const) @@ -1763,54 +1846,33 @@ export function MachineDetails({ machine }: MachineDetailsProps) { return null }, [machine?.assignedUserEmail, machine?.assignedUserName, machine?.persona, machine?.assignedUserRole, metadata]) - const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador" + const primaryLinkedUser: Collaborator | null = useMemo(() => { + const firstLinked = machine?.linkedUsers?.find((user) => typeof user?.email === "string" && user.email.trim().length > 0) + if (firstLinked) { + return { + email: firstLinked.email, + name: firstLinked.name ?? undefined, + role: collaborator?.role ?? machine?.persona ?? undefined, + } + } + if (collaborator?.email) { + return collaborator + } + if (machine?.authEmail) { + return { + email: machine.authEmail ?? undefined, + name: undefined, + role: machine?.persona ?? undefined, + } + } + return null + }, [collaborator, machine?.authEmail, machine?.linkedUsers, machine?.persona]) - const remoteAccess = machine?.remoteAccess ?? null - const remoteAccessMetadataEntries = useMemo(() => { - if (!remoteAccess?.metadata) return [] as Array<[string, unknown]> - const knownKeys = new Set([ - "provider", - "tool", - "vendor", - "name", - "identifier", - "code", - "id", - "accessId", - "url", - "link", - "remoteUrl", - "console", - "viewer", - "notes", - "note", - "description", - "obs", - "lastVerifiedAt", - "verifiedAt", - "checkedAt", - "updatedAt", - ]) - return Object.entries(remoteAccess.metadata) - .filter(([key, value]) => { - if (knownKeys.has(key)) return false - if (value === null || value === undefined) return false - if (typeof value === "string" && value.trim().length === 0) return false - return true - }) - }, [remoteAccess]) - const remoteAccessLastVerifiedDate = useMemo(() => { - if (!remoteAccess?.lastVerifiedAt) return null - const date = new Date(remoteAccess.lastVerifiedAt) - return Number.isNaN(date.getTime()) ? null : date - }, [remoteAccess?.lastVerifiedAt]) - const hasRemoteAccess = Boolean( - remoteAccess?.identifier || - remoteAccess?.url || - remoteAccess?.notes || - remoteAccess?.provider || - remoteAccessMetadataEntries.length > 0 - ) + const personaRole = (primaryLinkedUser?.role ?? collaborator?.role ?? machine?.persona ?? "").toLowerCase() + const personaLabel = personaRole === "manager" ? "Gestor" : "Colaborador" + + const remoteAccessEntries = useMemo(() => machine?.remoteAccessEntries ?? [], [machine?.remoteAccessEntries]) + const hasRemoteAccess = remoteAccessEntries.length > 0 const summaryChips = useMemo(() => { const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = [] @@ -1848,16 +1910,8 @@ export function MachineDetails({ machine }: MachineDetailsProps) { tone: windowsActivationStatus ? undefined : "warning", }) } - if (primaryGpu?.name) { - chips.push({ - key: "gpu", - label: "GPU principal", - value: `${primaryGpu.name}${typeof primaryGpu.memoryBytes === "number" ? ` · ${formatBytes(primaryGpu.memoryBytes)}` : ""}`, - icon: , - }) - } - if (collaborator?.email) { - const collaboratorValue = collaborator.name ? `${collaborator.name} · ${collaborator.email}` : collaborator.email + if (primaryLinkedUser?.email) { + const collaboratorValue = primaryLinkedUser.name ? `${primaryLinkedUser.name} · ${primaryLinkedUser.email}` : primaryLinkedUser.email chips.push({ key: "collaborator", label: personaLabel, @@ -1865,9 +1919,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) { icon: , }) } - if (remoteAccess && (remoteAccess.identifier || remoteAccess.url)) { - const value = remoteAccess.identifier ?? remoteAccess.url ?? "—" - const label = remoteAccess.provider ? `Acesso (${remoteAccess.provider})` : "Acesso remoto" + const primaryRemoteAccess = remoteAccessEntries[0] + if (primaryRemoteAccess && (primaryRemoteAccess.identifier || primaryRemoteAccess.url)) { + const value = primaryRemoteAccess.identifier ?? primaryRemoteAccess.url ?? "—" + const label = primaryRemoteAccess.provider ? `Acesso (${primaryRemoteAccess.provider})` : "Acesso remoto" chips.push({ key: "remote-access", label, @@ -1876,7 +1931,19 @@ export function MachineDetails({ machine }: MachineDetailsProps) { }) } return chips - }, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName, remoteAccess]) + }, [ + osNameDisplay, + machine?.osVersion, + machine?.architecture, + windowsVersionLabel, + windowsBuildLabel, + windowsActivationStatus, + primaryLinkedUser?.email, + primaryLinkedUser?.name, + personaLabel, + machine?.osName, + remoteAccessEntries, + ]) const companyName = machine?.companyName ?? machine?.companySlug ?? null @@ -1888,13 +1955,12 @@ export function MachineDetails({ machine }: MachineDetailsProps) { const [deleteDialog, setDeleteDialog] = useState(false) const [deleting, setDeleting] = useState(false) const [accessDialog, setAccessDialog] = useState(false) - const [accessEmail, setAccessEmail] = useState(collaborator?.email ?? "") - const [accessName, setAccessName] = useState(collaborator?.name ?? "") - const [accessRole, setAccessRole] = useState<"collaborator" | "manager">( - (machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator" - ) + const [accessEmail, setAccessEmail] = useState(primaryLinkedUser?.email ?? "") + const [accessName, setAccessName] = useState(primaryLinkedUser?.name ?? "") + const [accessRole, setAccessRole] = useState<"collaborator" | "manager">(personaRole === "manager" ? "manager" : "collaborator") const [savingAccess, setSavingAccess] = useState(false) const [remoteAccessDialog, setRemoteAccessDialog] = useState(false) + const [editingRemoteAccessClientId, setEditingRemoteAccessClientId] = useState(null) const [remoteAccessProviderOption, setRemoteAccessProviderOption] = useState( REMOTE_ACCESS_PROVIDERS[0].value, ) @@ -1903,6 +1969,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) { const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("") const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("") const [remoteAccessSaving, setRemoteAccessSaving] = useState(false) + const editingRemoteAccess = useMemo( + () => remoteAccessEntries.find((entry) => entry.clientId === editingRemoteAccessClientId) ?? null, + [editingRemoteAccessClientId, remoteAccessEntries] + ) const [togglingActive, setTogglingActive] = useState(false) const [showAllWindowsSoftware, setShowAllWindowsSoftware] = useState(false) const jsonText = useMemo(() => { @@ -1942,14 +2012,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) { // removed copy/export inventory JSON buttons as requested useEffect(() => { - setAccessEmail(collaborator?.email ?? "") - setAccessName(collaborator?.name ?? "") - setAccessRole((machine?.persona === "manager" || collaborator?.role === "manager") ? "manager" : "collaborator") - }, [machine?.id, machine?.persona, collaborator?.email, collaborator?.name, collaborator?.role]) + setAccessEmail(primaryLinkedUser?.email ?? "") + setAccessName(primaryLinkedUser?.name ?? "") + setAccessRole(personaRole === "manager" ? "manager" : "collaborator") + }, [machine?.id, primaryLinkedUser?.email, primaryLinkedUser?.name, personaRole]) + + useEffect(() => { + setIsActiveLocal(machine?.isActive ?? true) + }, [machine?.isActive]) useEffect(() => { if (!remoteAccessDialog) return - const providerName = remoteAccess?.provider ?? "" + const providerName = editingRemoteAccess?.provider ?? "" const matched = REMOTE_ACCESS_PROVIDERS.find( (option) => option.value !== "OTHER" && option.label.toLowerCase() === providerName.toLowerCase(), ) @@ -1960,21 +2034,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) { setRemoteAccessProviderOption(providerName ? "OTHER" : REMOTE_ACCESS_PROVIDERS[0].value) setRemoteAccessCustomProvider(providerName ?? "") } - setRemoteAccessIdentifierInput(remoteAccess?.identifier ?? "") - setRemoteAccessUrlInput(remoteAccess?.url ?? "") - setRemoteAccessNotesInput(remoteAccess?.notes ?? "") - }, [remoteAccessDialog, remoteAccess]) + setRemoteAccessIdentifierInput(editingRemoteAccess?.identifier ?? "") + setRemoteAccessUrlInput(editingRemoteAccess?.url ?? "") + setRemoteAccessNotesInput(editingRemoteAccess?.notes ?? "") + }, [remoteAccessDialog, editingRemoteAccess]) useEffect(() => { if (remoteAccessDialog) return - if (!remoteAccess) { + if (!editingRemoteAccessClientId) { setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value) setRemoteAccessCustomProvider("") setRemoteAccessIdentifierInput("") setRemoteAccessUrlInput("") setRemoteAccessNotesInput("") } - }, [remoteAccess, remoteAccessDialog]) + }, [editingRemoteAccessClientId, remoteAccessDialog]) useEffect(() => { setShowAllWindowsSoftware(false) @@ -2060,6 +2134,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) { setRemoteAccessSaving(true) try { + if (editingRemoteAccess && !editingRemoteAccess.id) { + const cleanupPayload: Record = { + machineId: machine.id, + action: "delete", + } + if (editingRemoteAccess.provider) cleanupPayload.provider = editingRemoteAccess.provider + if (editingRemoteAccess.identifier) cleanupPayload.identifier = editingRemoteAccess.identifier + if (editingRemoteAccess.clientId) cleanupPayload.entryId = editingRemoteAccess.clientId + await fetch("/api/admin/machines/remote-access", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cleanupPayload), + }).catch(() => null) + } + const response = await fetch("/api/admin/machines/remote-access", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -2069,18 +2158,21 @@ export function MachineDetails({ machine }: MachineDetailsProps) { identifier, url: normalizedUrl, notes: notes.length ? notes : undefined, - action: "save", + action: "upsert", + entryId: editingRemoteAccess?.id ?? undefined, }), }) - const payload = await response.json().catch(() => null) + const responsePayload = await response.json().catch(() => null) if (!response.ok) { - const message = typeof payload?.error === "string" ? payload.error : "Falha ao atualizar acesso remoto." - throw new Error(message) + const message = typeof responsePayload?.error === "string" ? responsePayload.error : "Falha ao atualizar acesso remoto." + const detailMessage = typeof responsePayload?.detail === "string" ? responsePayload.detail : null + throw new Error(detailMessage ? `${message}. ${detailMessage}` : message) } toast.success("Acesso remoto atualizado.", { id: "remote-access" }) setRemoteAccessDialog(false) + setEditingRemoteAccessClientId(null) } catch (error) { const message = error instanceof Error ? error.message : "Falha ao atualizar acesso remoto." toast.error(message, { id: "remote-access" }) @@ -2095,9 +2187,10 @@ export function MachineDetails({ machine }: MachineDetailsProps) { remoteAccessIdentifierInput, remoteAccessUrlInput, remoteAccessNotesInput, + editingRemoteAccess, ]) - const handleRemoveRemoteAccess = useCallback(async () => { + const handleRemoveRemoteAccess = useCallback(async (entry: MachineRemoteAccessEntry) => { if (!machine) return if (!canManageRemoteAccess) { toast.error("Você não tem permissão para ajustar o acesso remoto desta máquina.") @@ -2107,21 +2200,35 @@ export function MachineDetails({ machine }: MachineDetailsProps) { toast.loading("Removendo acesso remoto...", { id: "remote-access" }) setRemoteAccessSaving(true) try { + const requestPayload: Record = { + machineId: machine.id, + action: "delete", + } + if (entry.id) { + requestPayload.entryId = entry.id + } else if (entry.clientId) { + requestPayload.entryId = entry.clientId + } + if (entry.provider) { + requestPayload.provider = entry.provider + } + if (entry.identifier) { + requestPayload.identifier = entry.identifier + } const response = await fetch("/api/admin/machines/remote-access", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - machineId: machine.id, - action: "clear", - }), + body: JSON.stringify(requestPayload), }) - const payload = await response.json().catch(() => null) + const responsePayload = await response.json().catch(() => null) if (!response.ok) { - const message = typeof payload?.error === "string" ? payload.error : "Falha ao remover acesso remoto." - throw new Error(message) + const message = typeof responsePayload?.error === "string" ? responsePayload.error : "Falha ao remover acesso remoto." + const detailMessage = typeof responsePayload?.detail === "string" ? responsePayload.detail : null + throw new Error(detailMessage ? `${message}. ${detailMessage}` : message) } toast.success("Acesso remoto removido.", { id: "remote-access" }) setRemoteAccessDialog(false) + setEditingRemoteAccessClientId(null) } catch (error) { const message = error instanceof Error ? error.message : "Falha ao remover acesso remoto." toast.error(message, { id: "remote-access" }) @@ -2132,37 +2239,40 @@ export function MachineDetails({ machine }: MachineDetailsProps) { const handleToggleActive = async () => { if (!machine) return + const nextActive = !isActiveLocal + setIsActiveLocal(nextActive) setTogglingActive(true) try { const response = await fetch("/api/admin/machines/toggle-active", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ machineId: machine.id, active: !isActive }), + body: JSON.stringify({ machineId: machine.id, active: nextActive }), credentials: "include", }) if (!response.ok) { - const payload = await response.json().catch(() => ({})) as { error?: string } + const payload = (await response.json().catch(() => ({}))) as { error?: string } throw new Error(payload?.error ?? "Falha ao atualizar status") } - toast.success(!isActive ? "Máquina reativada" : "Máquina desativada") + toast.success(nextActive ? "Máquina reativada" : "Máquina desativada") } catch (error) { console.error(error) + setIsActiveLocal(!nextActive) toast.error("Não foi possível atualizar o status da máquina.") } finally { setTogglingActive(false) } } - const handleCopyRemoteIdentifier = useCallback(async () => { - if (!remoteAccess?.identifier) return + const handleCopyRemoteIdentifier = useCallback(async (identifier: string | null | undefined) => { + if (!identifier) return try { - await navigator.clipboard.writeText(remoteAccess.identifier) + await navigator.clipboard.writeText(identifier) toast.success("Identificador de acesso remoto copiado.") } catch (error) { console.error(error) toast.error("Não foi possível copiar o identificador.") } - }, [remoteAccess?.identifier]) + }, []) return ( @@ -2178,7 +2288,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
) : null} {!isDeactivated ? : null} - {!isActive ? ( + {!isActiveLocal ? ( Máquina desativada @@ -2213,42 +2323,48 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ))} -
-
-

Tickets abertos por esta máquina

- - {machineTickets.length} - +
+
+
+

Tickets abertos por esta máquina

+ {machineTickets.length === 0 ? ( +

Nenhum chamado em aberto registrado diretamente por esta máquina.

+ ) : ( +
    + {machineTickets.map((ticket) => ( +
  • +
    +

    + #{ticket.reference} · {ticket.subject} +

    +

    + Atualizado {formatRelativeTime(new Date(ticket.updatedAt))} +

    +
    +
    + + {ticket.priority} + + + {TICKET_STATUS_LABELS[ticket.status] ?? ticket.status} + +
    +
  • + ))} +
+ )} +
+
+
+ + {machineTickets.length} + +
+
- {machineTickets.length === 0 ? ( -

Nenhum chamado em aberto registrado diretamente por esta máquina.

- ) : ( -
    - {machineTickets.map((ticket) => ( -
  • -
    -

    - #{ticket.reference} · {ticket.subject} -

    -

    - Atualizado {formatRelativeTime(new Date(ticket.updatedAt))} -

    -
    -
    - - {ticket.priority} - - - {TICKET_STATUS_LABELS[ticket.status] ?? ticket.status} - -
    -
  • - ))} -
- )}
{machine.authEmail ? ( @@ -2263,30 +2379,37 @@ export function MachineDetails({ machine }: MachineDetailsProps) { {machine.registeredBy ? ( - + Registrada via {machine.registeredBy} - + ) : null}

Acesso remoto

- {remoteAccess?.provider ? ( - - {remoteAccess.provider} + {hasRemoteAccess ? ( + + {remoteAccessEntries.length === 1 + ? remoteAccessEntries[0].provider ?? "Configuração única" + : `${remoteAccessEntries.length} acessos`} ) : null}
@@ -2295,59 +2418,114 @@ export function MachineDetails({ machine }: MachineDetailsProps) { size="sm" variant="outline" className="gap-2 border-dashed" - onClick={() => setRemoteAccessDialog(true)} + onClick={() => { + setEditingRemoteAccessClientId(null) + setRemoteAccessDialog(true) + }} > - {hasRemoteAccess ? "Editar acesso" : "Adicionar acesso"} + {hasRemoteAccess ? "Adicionar acesso" : "Cadastrar acesso"} ) : null}
{hasRemoteAccess ? ( -
-
-
- {remoteAccess?.identifier ? ( -
- {remoteAccess.identifier} - +
+ {remoteAccessEntries.map((entry) => { + const metadataEntries = extractRemoteAccessMetadataEntries(entry.metadata) + const lastVerifiedDate = + entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt) + ? new Date(entry.lastVerifiedAt) + : null + return ( +
+
+
+
+ {entry.provider ? ( + + {entry.provider} + + ) : null} + {entry.identifier ? ( + {entry.identifier} + ) : null} + {entry.identifier ? ( + + ) : null} +
+ {entry.url ? ( + + Abrir console remoto + + ) : null} + {entry.notes ? ( +

{entry.notes}

+ ) : null} + {lastVerifiedDate ? ( +

+ Atualizado {formatRelativeTime(lastVerifiedDate)}{" "} + ({formatAbsoluteDateTime(lastVerifiedDate)}) +

+ ) : null} +
+ {canManageRemoteAccess ? ( +
+ + +
+ ) : null}
- ) : null} - {remoteAccess?.url ? ( - - Abrir console remoto - - ) : null} - {remoteAccess?.notes ? ( -

{remoteAccess.notes}

- ) : null} - {remoteAccessLastVerifiedDate ? ( -

- Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}{" "} - ({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)}) -

- ) : null} -
-
- {remoteAccessMetadataEntries.length ? ( -
- {remoteAccessMetadataEntries.map(([key, value]) => ( -
- {formatRemoteAccessMetadataKey(key)} - {formatRemoteAccessMetadataValue(value)} -
- ))} -
- ) : null} + {metadataEntries.length ? ( +
+ + Metadados adicionais + +
+ {metadataEntries.map(([key, value]) => ( +
+ {formatRemoteAccessMetadataKey(key)} + {formatRemoteAccessMetadataValue(value)} +
+ ))} +
+
+ ) : null} +
+ ) + })}
) : ( -
+
Nenhum identificador de acesso remoto cadastrado. Registre o ID do TeamViewer, AnyDesk ou outra ferramenta para agilizar o suporte.
)} @@ -2357,11 +2535,11 @@ export function MachineDetails({ machine }: MachineDetailsProps) {

Usuários vinculados

- {collaborator?.email ? ( + {primaryLinkedUser?.email ? (
- {collaborator.name || collaborator.email} - {collaborator.name ? `· ${collaborator.email}` : ''} + {primaryLinkedUser.name || primaryLinkedUser.email} + {primaryLinkedUser.name ? `· ${primaryLinkedUser.email}` : ""} Principal
) : null} @@ -2394,7 +2572,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) { ))} ) : null} - {!collaborator?.email && (!machine.linkedUsers || machine.linkedUsers.length === 0) ? ( + {!primaryLinkedUser?.email && (!machine.linkedUsers || machine.linkedUsers.length === 0) ? (

Nenhum usuário vinculado.

) : null}
@@ -2431,7 +2609,7 @@ export function MachineDetails({ machine }: MachineDetailsProps) { Adicionar vínculo Somente colaboradores/gestores. - Gerenciar usuários + Gerenciar usuários
@@ -2518,14 +2696,15 @@ export function MachineDetails({ machine }: MachineDetailsProps) { setRemoteAccessDialog(open) if (!open) { setRemoteAccessSaving(false) + setEditingRemoteAccessClientId(null) } }} > - Detalhes de acesso remoto + {editingRemoteAccess ? "Editar acesso remoto" : "Adicionar acesso remoto"} - Registre o provedor e o identificador utilizado para acesso remoto à máquina. + Registre os detalhes do acesso remoto utilizado por esta máquina.
- {hasRemoteAccess ? ( + {editingRemoteAccess ? (
@@ -3894,7 +4073,7 @@ function MachineCard({ machine, companyName }: { machine: MachinesQueryItem; com Usuário vinculado: {collaborator.name ? `${collaborator.name} · ` : ""}{collaborator.email} - Gerenciar usuários + Gerenciar usuários
) : null} {!isActive ? (