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

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