feat: expand admin companies and users modules

This commit is contained in:
Esdras Renan 2025-10-22 01:27:43 -03:00
parent a043b1203c
commit 2e3b46a7b5
31 changed files with 5626 additions and 2003 deletions

View file

@ -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

View file

@ -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 (

View file

@ -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">

File diff suppressed because it is too large Load diff

View file

@ -23,16 +23,16 @@ export function AppShell({ header, children }: AppShellProps) {
<AuthGuard />
</Suspense>
{isLoading ? (
<div className="px-4 pt-4 lg:px-6">
<div className="flex items-center justify-between gap-4">
<Skeleton className="h-7 w-48" />
<div className="hidden items-center gap-2 sm:flex">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
<header className="flex h-auto shrink-0 flex-wrap items-start gap-3 border-b bg-background/80 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-background/60 transition-[width,height] ease-linear sm:h-(--header-height) sm:flex-nowrap sm:items-center sm:px-6 lg:px-8 sm:group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-4 w-52" />
<Skeleton className="h-7 w-40" />
</div>
<Skeleton className="mt-2 h-4 w-72" />
</div>
<div className="hidden items-center gap-2 sm:flex">
<Skeleton className="h-9 w-28" />
<Skeleton className="h-9 w-28" />
</div>
</header>
) : (
header
)}

View file

@ -106,7 +106,7 @@ const navigation: NavigationGroup[] = [
url: "/admin/companies",
icon: Building2,
requiredRole: "admin",
children: [{ title: "Clientes", url: "/admin/clients", icon: Users, requiredRole: "admin" }],
children: [{ title: "Usuários", url: "/admin/users", icon: Users, requiredRole: "admin" }],
},
{ title: "Máquinas", url: "/admin/machines", icon: MonitorCog, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },

View file

@ -0,0 +1,61 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b border-border/60 last:border-b-0", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-left text-sm font-medium transition-all hover:text-foreground/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<svg
viewBox="0 0 24 24"
stroke="currentColor"
fill="none"
className="h-4 w-4 shrink-0 transition-transform duration-200"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="m6 9 6 6 6-6" />
</svg>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View file

@ -0,0 +1,144 @@
import * as React from "react"
import { IconX } from "@tabler/icons-react"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
type MultiValueInputProps = {
values: string[]
onChange: (values: string[]) => void
placeholder?: string
disabled?: boolean
maxItems?: number
addOnBlur?: boolean
className?: string
inputClassName?: string
validate?: (value: string) => string | null
format?: (value: string) => string
emptyState?: React.ReactNode
}
export function MultiValueInput({
values,
onChange,
placeholder,
disabled,
maxItems,
addOnBlur,
className,
inputClassName,
validate,
format,
emptyState,
}: MultiValueInputProps) {
const [pending, setPending] = React.useState("")
const [error, setError] = React.useState<string | null>(null)
const inputRef = React.useRef<HTMLInputElement | null>(null)
const remainingSlots = typeof maxItems === "number" ? Math.max(maxItems - values.length, 0) : undefined
const canAdd = remainingSlots === undefined || remainingSlots > 0
const addValue = React.useCallback(
(raw: string) => {
const trimmed = raw.trim()
if (!trimmed) return
const formatted = format ? format(trimmed) : trimmed
if (values.includes(formatted)) {
setPending("")
setError(null)
return
}
if (validate) {
const validation = validate(formatted)
if (validation) {
setError(validation)
return
}
}
setError(null)
onChange([...values, formatted])
setPending("")
},
[format, onChange, validate, values]
)
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter" || event.key === "," || event.key === "Tab") {
if (!canAdd) return
event.preventDefault()
addValue(pending)
}
if (event.key === "Backspace" && pending.length === 0 && values.length > 0) {
onChange(values.slice(0, -1))
setError(null)
}
}
const handleBlur = () => {
if (addOnBlur && pending.trim()) {
addValue(pending)
}
}
const removeValue = (value: string) => {
onChange(values.filter((item) => item !== value))
setError(null)
inputRef.current?.focus()
}
return (
<div className={cn("space-y-2", className)}>
<div
className={cn(
"flex flex-wrap gap-2 rounded-md border border-input bg-background px-3 py-2 focus-within:ring-2 focus-within:ring-ring",
disabled && "opacity-60"
)}
>
{values.map((value) => (
<Badge
key={value}
variant="secondary"
className="flex items-center gap-1 rounded-full px-2 py-1 text-xs font-medium"
>
<span>{value}</span>
<button
type="button"
className="flex h-4 w-4 items-center justify-center rounded-full bg-muted text-muted-foreground hover:bg-muted/80"
onClick={() => removeValue(value)}
aria-label={`Remover ${value}`}
disabled={disabled}
>
<IconX className="h-3 w-3" strokeWidth={2} />
</button>
</Badge>
))}
{canAdd ? (
<Input
ref={inputRef}
value={pending}
onChange={(event) => {
setPending(event.target.value)
setError(null)
}}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
placeholder={values.length === 0 ? placeholder : undefined}
disabled={disabled}
className={cn(
"m-0 h-auto min-w-[8rem] flex-1 border-0 bg-transparent px-0 py-1 text-sm shadow-none focus-visible:ring-0",
inputClassName
)}
/>
) : null}
</div>
{error ? <p className="text-xs font-medium text-destructive">{error}</p> : null}
{!values.length && emptyState ? <div className="text-xs text-muted-foreground">{emptyState}</div> : null}
{typeof remainingSlots === "number" ? (
<div className="text-right text-[11px] text-muted-foreground">
{remainingSlots} item{remainingSlots === 1 ? "" : "s"} restantes
</div>
) : null}
</div>
)
}

View file

@ -0,0 +1,33 @@
"use client"
import * as React from "react"
type ScrollAreaProps = React.ComponentPropsWithoutRef<"div"> & {
orientation?: "vertical" | "horizontal" | "both"
}
const orientationClasses: Record<NonNullable<ScrollAreaProps["orientation"]>, string> = {
vertical: "overflow-y-auto",
horizontal: "overflow-x-auto",
both: "overflow-auto",
}
const baseClasses =
"relative [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-muted [&::-webkit-scrollbar-track]:bg-transparent"
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, orientation = "vertical", children, ...props }, ref) => {
return (
<div
ref={ref}
className={[baseClasses, orientationClasses[orientation], className].filter(Boolean).join(" ")}
{...props}
>
{children}
</div>
)
}
)
ScrollArea.displayName = "ScrollArea"
export { ScrollArea }

View file

@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { cn } from "@/lib/utils"
type TimePickerProps = {
value?: string | null
onChange?: (value: string) => void
className?: string
placeholder?: string
stepMinutes?: number
}
function pad2(n: number) {
return String(n).padStart(2, "0")
}
export function TimePicker({ value, onChange, className, placeholder = "Selecionar horário", stepMinutes = 15 }: TimePickerProps) {
const [open, setOpen] = React.useState(false)
const [hours, minutes] = React.useMemo(() => {
if (!value || !/^\d{2}:\d{2}$/.test(value)) return ["", ""]
const [h, m] = value.split(":")
return [h, m]
}, [value])
const minuteOptions = React.useMemo(() => {
const list: string[] = []
for (let i = 0; i < 60; i += stepMinutes) list.push(pad2(i))
if (!list.includes("00")) list.unshift("00")
return list
}, [stepMinutes])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" className={cn("w-full justify-between font-normal", className)}>
{value ? value : placeholder}
<ChevronDownIcon className="ml-2 size-4 opacity-60" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex gap-2 p-2">
<div className="max-h-56 w-16 overflow-auto rounded-md border">
{Array.from({ length: 24 }, (_, h) => pad2(h)).map((h) => (
<button
key={h}
type="button"
className={cn(
"block w-full px-3 py-1.5 text-left text-sm hover:bg-muted",
h === hours && "bg-muted/70 font-semibold"
)}
onClick={() => onChange?.(`${h}:${minutes || "00"}`)}
>
{h}
</button>
))}
</div>
<div className="max-h-56 w-16 overflow-auto rounded-md border">
{minuteOptions.map((m) => (
<button
key={m}
type="button"
className={cn(
"block w-full px-3 py-1.5 text-left text-sm hover:bg-muted",
m === minutes && "bg-muted/70 font-semibold"
)}
onClick={() => onChange?.(`${hours || "00"}:${m}`)}
>
{m}
</button>
))}
</div>
</div>
</PopoverContent>
</Popover>
)
}