feat: expand admin companies and users modules
This commit is contained in:
parent
a043b1203c
commit
2e3b46a7b5
31 changed files with 5626 additions and 2003 deletions
|
|
@ -1,440 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import { useCallback, useMemo, useState, useTransition } from "react"
|
||||
import { format } from "date-fns"
|
||||
import { ptBR } from "date-fns/locale"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table"
|
||||
import { IconFilter, IconTrash, IconUser } from "@tabler/icons-react"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { TablePagination } from "@/components/ui/table-pagination"
|
||||
|
||||
export type AdminClient = {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
role: "MANAGER" | "COLLABORATOR"
|
||||
companyId: string | null
|
||||
companyName: string | null
|
||||
tenantId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
authUserId: string | null
|
||||
lastSeenAt: string | null
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<AdminClient["role"], string> = {
|
||||
MANAGER: "Gestor",
|
||||
COLLABORATOR: "Colaborador",
|
||||
}
|
||||
|
||||
function formatDate(dateString: string) {
|
||||
const date = new Date(dateString)
|
||||
return format(date, "dd/MM/yy HH:mm", { locale: ptBR })
|
||||
}
|
||||
|
||||
function formatLastSeen(lastSeen: string | null) {
|
||||
if (!lastSeen) return "Nunca conectado"
|
||||
return formatDate(lastSeen)
|
||||
}
|
||||
|
||||
export function AdminClientsManager({ initialClients }: { initialClients: AdminClient[] }) {
|
||||
const [clients, setClients] = useState(initialClients)
|
||||
const [search, setSearch] = useState("")
|
||||
const [roleFilter, setRoleFilter] = useState<"all" | AdminClient["role"]>("all")
|
||||
const [companyFilter, setCompanyFilter] = useState<string>("all")
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [deleteDialogIds, setDeleteDialogIds] = useState<string[]>([])
|
||||
|
||||
const companies = useMemo(() => {
|
||||
const entries = new Map<string, string>()
|
||||
clients.forEach((client) => {
|
||||
if (client.companyId && client.companyName) {
|
||||
entries.set(client.companyId, client.companyName)
|
||||
}
|
||||
})
|
||||
return Array.from(entries.entries()).map(([id, name]) => ({ id, name }))
|
||||
}, [clients])
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return clients.filter((client) => {
|
||||
if (roleFilter !== "all" && client.role !== roleFilter) return false
|
||||
if (companyFilter !== "all" && client.companyId !== companyFilter) return false
|
||||
if (!search.trim()) return true
|
||||
const term = search.trim().toLowerCase()
|
||||
return (
|
||||
client.name.toLowerCase().includes(term) ||
|
||||
client.email.toLowerCase().includes(term) ||
|
||||
(client.companyName ?? "").toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
}, [clients, roleFilter, companyFilter, search])
|
||||
|
||||
const deleteTargets = useMemo(
|
||||
() => clients.filter((client) => deleteDialogIds.includes(client.id)),
|
||||
[clients, deleteDialogIds],
|
||||
)
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const response = await fetch("/api/admin/clients", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null)
|
||||
throw new Error(payload?.error ?? "Não foi possível excluir os clientes selecionados.")
|
||||
}
|
||||
const { deletedIds } = (await response.json().catch(() => ({ deletedIds: [] }))) as {
|
||||
deletedIds: string[]
|
||||
}
|
||||
if (deletedIds.length > 0) {
|
||||
setClients((prev) => prev.filter((client) => !deletedIds.includes(client.id)))
|
||||
setRowSelection({})
|
||||
setDeleteDialogIds([])
|
||||
}
|
||||
toast.success(
|
||||
deletedIds.length === 1
|
||||
? "Cliente removido com sucesso."
|
||||
: `${deletedIds.length} clientes removidos com sucesso.`,
|
||||
)
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Não foi possível excluir os clientes selecionados."
|
||||
toast.error(message)
|
||||
}
|
||||
})
|
||||
},
|
||||
[startTransition],
|
||||
)
|
||||
|
||||
const columns = useMemo<ColumnDef<AdminClient>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Selecionar todos"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Selecionar linha"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Cliente",
|
||||
cell: ({ row }) => {
|
||||
const client = row.original
|
||||
const initials = client.name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((value) => value.charAt(0).toUpperCase())
|
||||
.join("")
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-9 border border-slate-200">
|
||||
<AvatarFallback>{initials || client.email.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-neutral-900">{client.name}</p>
|
||||
<p className="truncate text-xs text-neutral-500">{client.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Perfil",
|
||||
cell: ({ row }) => {
|
||||
const role = row.original.role
|
||||
const variant = role === "MANAGER" ? "default" : "secondary"
|
||||
return <Badge variant={variant}>{ROLE_LABEL[role]}</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "companyName",
|
||||
header: "Empresa",
|
||||
cell: ({ row }) =>
|
||||
row.original.companyName ? (
|
||||
<Badge variant="outline" className="bg-slate-50 text-xs font-medium">
|
||||
{row.original.companyName}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-neutral-500">Sem empresa</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Cadastrado em",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-neutral-600">{formatDate(row.original.createdAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "lastSeenAt",
|
||||
header: "Último acesso",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-neutral-600">{formatLastSeen(row.original.lastSeenAt)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700"
|
||||
disabled={isPending}
|
||||
onClick={() => setDeleteDialogIds([row.original.id])}
|
||||
>
|
||||
<IconTrash className="mr-2 size-4" /> Remover
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[isPending, setDeleteDialogIds]
|
||||
)
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: { rowSelection, sorting },
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getRowId: (row) => row.id,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 10,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original)
|
||||
const isBulkDelete = deleteTargets.length > 1
|
||||
const dialogTitle = isBulkDelete ? "Remover clientes selecionados" : "Remover cliente"
|
||||
const dialogDescription = isBulkDelete
|
||||
? "Essa ação remove os clientes selecionados e revoga o acesso ao portal."
|
||||
: "Essa ação remove o cliente escolhido e revoga o acesso ao portal."
|
||||
const previewTargets = deleteTargets.slice(0, 3)
|
||||
const remainingCount = deleteTargets.length - previewTargets.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
<IconUser className="size-4" />
|
||||
{clients.length} cliente{clients.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Buscar por nome, e-mail ou empresa"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="h-9 w-full md:w-72"
|
||||
/>
|
||||
<Button variant="outline" size="icon" className="md:hidden">
|
||||
<IconFilter className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={roleFilter} onValueChange={(value) => setRoleFilter(value as typeof roleFilter)}>
|
||||
<SelectTrigger className="h-9 w-40">
|
||||
<SelectValue placeholder="Perfil" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os perfis</SelectItem>
|
||||
<SelectItem value="MANAGER">Gestores</SelectItem>
|
||||
<SelectItem value="COLLABORATOR">Colaboradores</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={companyFilter} onValueChange={(value) => setCompanyFilter(value)}>
|
||||
<SelectTrigger className="h-9 w-48">
|
||||
<SelectValue placeholder="Empresa" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-64">
|
||||
<SelectItem value="all">Todas as empresas</SelectItem>
|
||||
{companies.map((company) => (
|
||||
<SelectItem key={company.id} value={company.id}>
|
||||
{company.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-rose-200 text-rose-600 hover:bg-rose-50 hover:text-rose-700 disabled:text-rose-300 disabled:border-rose-100"
|
||||
disabled={selectedRows.length === 0 || isPending}
|
||||
onClick={() => setDeleteDialogIds(selectedRows.map((row) => row.id))}
|
||||
>
|
||||
<IconTrash className="size-4" />
|
||||
Excluir selecionados
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-50">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-slate-200">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id} className="text-xs uppercase tracking-wide text-neutral-500">
|
||||
{header.isPlaceholder ? null : header.column.columnDef.header instanceof Function
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center text-sm text-neutral-500">
|
||||
Nenhum cliente encontrado para os filtros selecionados.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="align-middle text-sm text-neutral-700">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<TablePagination
|
||||
table={table}
|
||||
pageSizeOptions={[10, 20, 30, 40, 50]}
|
||||
rowsPerPageLabel="Itens por página"
|
||||
showSelectedRows
|
||||
selectionLabel={(selected, total) => `${selected} de ${total} selecionados`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={deleteDialogIds.length > 0}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isPending) {
|
||||
setDeleteDialogIds([])
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{deleteTargets.length > 0 ? (
|
||||
<div className="space-y-3 py-2 text-sm text-neutral-600">
|
||||
<p>
|
||||
Confirme a exclusão de {isBulkDelete ? `${deleteTargets.length} clientes selecionados` : "um cliente"}. O acesso ao portal será revogado imediatamente.
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{previewTargets.map((target) => (
|
||||
<li key={target.id} className="rounded-md bg-slate-100 px-3 py-2 text-sm text-neutral-800">
|
||||
<span className="font-medium">{target.name}</span>
|
||||
<span className="text-neutral-500"> — {target.email}</span>
|
||||
</li>
|
||||
))}
|
||||
{remainingCount > 0 ? (
|
||||
<li className="px-3 py-1 text-xs text-neutral-500">+ {remainingCount} outro{remainingCount === 1 ? "" : "s"}</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogIds([])} disabled={isPending}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(deleteDialogIds)}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? "Removendo..." : isBulkDelete ? "Excluir clientes" : "Excluir cliente"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,17 +3,25 @@
|
|||
import { useMemo } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { MachineDetails, type MachinesQueryItem } from "@/components/admin/machines/admin-machines-overview"
|
||||
import {
|
||||
MachineDetails,
|
||||
normalizeMachineItem,
|
||||
type MachinesQueryItem,
|
||||
} from "@/components/admin/machines/admin-machines-overview"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export function AdminMachineDetailsClient({ tenantId, machineId }: { tenantId: string; machineId: string }) {
|
||||
const queryResult = useQuery(api.machines.listByTenant, { tenantId, includeMetadata: true }) as MachinesQueryItem[] | undefined
|
||||
const isLoading = queryResult === undefined
|
||||
const rawResult = useQuery(api.machines.listByTenant, { tenantId, includeMetadata: true }) as Array<Record<string, unknown>> | undefined
|
||||
const machines: MachinesQueryItem[] | undefined = useMemo(() => {
|
||||
if (!rawResult) return undefined
|
||||
return rawResult.map((item) => normalizeMachineItem(item))
|
||||
}, [rawResult])
|
||||
const isLoading = rawResult === undefined
|
||||
const machine = useMemo(() => {
|
||||
if (!queryResult) return null
|
||||
return queryResult.find((m) => m.id === machineId) ?? null
|
||||
}, [queryResult, machineId])
|
||||
if (!machines) return null
|
||||
return machines.find((m) => m.id === machineId) ?? null
|
||||
}, [machines, machineId])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -224,6 +224,15 @@ type MachineInventory = {
|
|||
collaborator?: { email?: string; name?: string; role?: string }
|
||||
}
|
||||
|
||||
type MachineRemoteAccess = {
|
||||
provider: string | null
|
||||
identifier: string | null
|
||||
url: string | null
|
||||
notes: string | null
|
||||
lastVerifiedAt: number | null
|
||||
metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function collectInitials(name: string): string {
|
||||
const words = name.split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return "?"
|
||||
|
|
@ -267,6 +276,59 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
|||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess | null {
|
||||
if (!raw) return null
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return null
|
||||
const isUrl = /^https?:\/\//i.test(trimmed)
|
||||
return {
|
||||
provider: null,
|
||||
identifier: isUrl ? null : trimmed,
|
||||
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") ?? 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
|
||||
return {
|
||||
provider,
|
||||
identifier,
|
||||
url,
|
||||
notes,
|
||||
lastVerifiedAt,
|
||||
metadata: record,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemoteAccessMetadataKey(key: string) {
|
||||
return key
|
||||
.replace(/[_.-]+/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function formatRemoteAccessMetadataValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (value instanceof Date) return formatAbsoluteDateTime(value)
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function readText(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
const stringValue = readString(record, ...keys)
|
||||
if (stringValue) return stringValue
|
||||
|
|
@ -663,15 +725,23 @@ export type MachinesQueryItem = {
|
|||
postureAlerts?: Array<Record<string, unknown>> | null
|
||||
lastPostureAt?: number | null
|
||||
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
||||
remoteAccess: MachineRemoteAccess | null
|
||||
}
|
||||
|
||||
export function normalizeMachineItem(raw: Record<string, unknown>): MachinesQueryItem {
|
||||
const base = raw as MachinesQueryItem
|
||||
return {
|
||||
...base,
|
||||
remoteAccess: normalizeMachineRemoteAccess(raw["remoteAccess"]) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
|
||||
return (
|
||||
(useQuery(api.machines.listByTenant, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
}) ?? []) as MachinesQueryItem[]
|
||||
)
|
||||
const result = useQuery(api.machines.listByTenant, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
}) as Array<Record<string, unknown>> | undefined
|
||||
return useMemo(() => (result ?? []).map((item) => normalizeMachineItem(item)), [result])
|
||||
}
|
||||
|
||||
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
|
||||
|
|
@ -975,11 +1045,11 @@ function OsIcon({ osName }: { osName?: string | null }) {
|
|||
return <Monitor className="size-4 text-black" />
|
||||
}
|
||||
|
||||
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||
export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "all" }: { tenantId: string; initialCompanyFilterSlug?: string }) {
|
||||
const machines = useMachinesQuery(tenantId)
|
||||
const [q, setQ] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>("all")
|
||||
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
|
||||
const [companySearch, setCompanySearch] = useState<string>("")
|
||||
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
|
||||
const { convexUserId } = useAuth()
|
||||
|
|
@ -1599,6 +1669,53 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
|
||||
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
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 summaryChips = useMemo(() => {
|
||||
const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = []
|
||||
const osName = osNameDisplay || "Sistema desconhecido"
|
||||
|
|
@ -1652,8 +1769,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
icon: <ShieldCheck className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
if (remoteAccess && (remoteAccess.identifier || remoteAccess.url)) {
|
||||
const value = remoteAccess.identifier ?? remoteAccess.url ?? "—"
|
||||
const label = remoteAccess.provider ? `Acesso (${remoteAccess.provider})` : "Acesso remoto"
|
||||
chips.push({
|
||||
key: "remote-access",
|
||||
label,
|
||||
value,
|
||||
icon: <Key className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
return chips
|
||||
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName])
|
||||
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName, remoteAccess])
|
||||
|
||||
const companyName = (() => {
|
||||
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
||||
|
|
@ -1782,6 +1909,17 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleCopyRemoteIdentifier = useCallback(async () => {
|
||||
if (!remoteAccess?.identifier) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(remoteAccess.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 (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="gap-1">
|
||||
|
|
@ -1861,6 +1999,61 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{hasRemoteAccess ? (
|
||||
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-xs sm:text-sm text-slate-700">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-indigo-600">
|
||||
<Key className="size-4" />
|
||||
Acesso remoto
|
||||
{remoteAccess?.provider ? (
|
||||
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold text-indigo-700">
|
||||
{remoteAccess.provider}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{remoteAccess?.identifier ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-slate-900">{remoteAccess.identifier}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-2 px-2" onClick={handleCopyRemoteIdentifier}>
|
||||
<ClipboardCopy className="size-3.5" /> Copiar ID
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{remoteAccess?.url ? (
|
||||
<a
|
||||
href={remoteAccess.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 text-indigo-700 underline-offset-4 hover:underline"
|
||||
>
|
||||
Abrir console remoto
|
||||
</a>
|
||||
) : null}
|
||||
{remoteAccess?.notes ? (
|
||||
<p className="text-[11px] text-slate-600">{remoteAccess.notes}</p>
|
||||
) : null}
|
||||
{remoteAccessLastVerifiedDate ? (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}
|
||||
{" "}
|
||||
<span className="text-slate-400">({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)})</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{remoteAccessMetadataEntries.length ? (
|
||||
<div className="mt-3 grid gap-2 text-[11px] text-slate-600 sm:grid-cols-2">
|
||||
{remoteAccessMetadataEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-indigo-100 bg-white px-2 py-1">
|
||||
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
|
||||
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
|
|
|
|||
1163
src/components/admin/users/admin-users-workspace.tsx
Normal file
1163
src/components/admin/users/admin-users-workspace.tsx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue