sistema-de-chamados/src/components/admin/companies/admin-companies-manager.tsx

1084 lines
52 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition, useId } from "react"
import Link from "next/link"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { useQuery } from "convex/react"
import {
IconAlertTriangle,
IconBuildingSkyscraper,
IconClock,
IconCopy,
IconDotsVertical,
IconCheck,
IconDeviceDesktop,
IconPencil,
IconRefresh,
IconSearch,
IconShieldCheck,
IconSwitchHorizontal,
IconTrash,
} from "@tabler/icons-react"
import { toast } from "sonner"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { PhoneInput, formatPhoneDisplay } from "@/components/ui/phone-input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Progress } from "@/components/ui/progress"
import { useIsMobile } from "@/hooks/use-mobile"
import { api } from "@/convex/_generated/api"
type Company = {
id: string
tenantId: string
name: string
slug: string
provisioningCode: string | null
isAvulso: boolean
contractedHoursPerMonth?: number | null
cnpj: string | null
domain: string | null
phone: string | null
description: string | null
address: string | null
}
type MachineSummary = {
id: string
tenantId: string
companyId: string | null
hostname: string
status: string | null
lastHeartbeatAt: number | null
isActive?: boolean | null
authEmail?: string | null
osName?: string | null
osVersion?: string | null
architecture?: string | null
}
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
const [companies, setCompanies] = useState<Company[]>(() => initialCompanies ?? [])
const [isPending, startTransition] = useTransition()
const [form, setForm] = useState<Partial<Company>>({})
const [editingId, setEditingId] = useState<string | null>(null)
const [lastAlerts, setLastAlerts] = useState<Record<string, { createdAt: number; usagePct: number; threshold: number } | null>>({})
const [deleteId, setDeleteId] = useState<string | null>(null)
const [isDeleting, setIsDeleting] = useState(false)
const [searchTerm, setSearchTerm] = useState("")
const [machinesDialog, setMachinesDialog] = useState<{ companyId: string; name: string } | null>(null)
const isMobile = useIsMobile()
const nameId = useId()
const slugId = useId()
const descriptionId = useId()
const cnpjId = useId()
const domainId = useId()
const phoneId = useId()
const addressId = useId()
const hoursId = useId()
const machinesQuery = useQuery(api.machines.listByTenant, { includeMetadata: false }) as MachineSummary[] | undefined
const machinesByCompanyId = useMemo(() => {
const map = new Map<string, MachineSummary[]>()
;(machinesQuery ?? []).forEach((machine) => {
if (!machine.companyId) return
const list = map.get(machine.companyId) ?? []
list.push(machine)
map.set(machine.companyId, list)
})
return map
}, [machinesQuery])
const editingCompanyMachines = useMemo(() => {
if (!editingId) return []
return machinesByCompanyId.get(editingId) ?? []
}, [machinesByCompanyId, editingId])
const machinesDialogList = useMemo(() => {
if (!machinesDialog) return []
return machinesByCompanyId.get(machinesDialog.companyId) ?? []
}, [machinesByCompanyId, machinesDialog])
const resetForm = () => setForm({})
async function refresh() {
const r = await fetch("/api/admin/companies", { credentials: "include" })
const json = (await r.json()) as { companies?: Company[] }
const nextCompanies = Array.isArray(json.companies) ? json.companies : []
setCompanies(nextCompanies)
void loadLastAlerts(nextCompanies)
}
function handleEdit(c: Company) {
setEditingId(c.id)
setForm({
...c,
contractedHoursPerMonth: c.contractedHoursPerMonth ?? undefined,
})
}
const loadLastAlerts = useCallback(async (list: Company[] = companies) => {
if (!list || list.length === 0) return
const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") })
try {
const r = await fetch(`/api/admin/companies/last-alerts?${params.toString()}`, { credentials: "include" })
const json = (await r.json()) as { items: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> }
setLastAlerts(json.items ?? {})
} catch {
// ignore
}
}, [companies])
useEffect(() => { void loadLastAlerts(companies) }, [loadLastAlerts, companies])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const contractedHours =
typeof form.contractedHoursPerMonth === "number" && Number.isFinite(form.contractedHoursPerMonth)
? form.contractedHoursPerMonth
: null
const payload = {
name: form.name?.trim(),
slug: form.slug?.trim(),
isAvulso: Boolean(form.isAvulso ?? false),
cnpj: form.cnpj?.trim() || null,
domain: form.domain?.trim() || null,
phone: form.phone?.trim() || null,
description: form.description?.trim() || null,
address: form.address?.trim() || null,
contractedHoursPerMonth: contractedHours,
}
if (!payload.name || !payload.slug) {
toast.error("Informe nome e slug válidos")
return
}
startTransition(async () => {
toast.loading(editingId ? "Atualizando empresa..." : "Criando empresa...", { id: "companies" })
try {
if (editingId) {
const r = await fetch(`/api/admin/companies/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
})
const data = (await r.json().catch(() => ({}))) as { error?: string }
if (!r.ok) throw new Error(data?.error ?? "Falha ao atualizar empresa")
} else {
const r = await fetch(`/api/admin/companies`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
})
const data = (await r.json().catch(() => ({}))) as { error?: string }
if (!r.ok) throw new Error(data?.error ?? "Falha ao criar empresa")
}
await refresh()
resetForm()
setEditingId(null)
toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" })
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível salvar"
toast.error(message, { id: "companies" })
}
})
}
async function toggleAvulso(c: Company) {
startTransition(async () => {
try {
const r = await fetch(`/api/admin/companies/${c.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isAvulso: !c.isAvulso }),
credentials: "include",
})
if (!r.ok) throw new Error("toggle_failed")
await refresh()
toast.success(`Cliente ${!c.isAvulso ? "marcado como avulso" : "marcado como recorrente"}`)
} catch {
toast.error("Não foi possível atualizar o cliente avulso")
}
})
}
async function handleDeleteConfirmed() {
if (!deleteId) return
setIsDeleting(true)
try {
const response = await fetch(`/api/admin/companies/${deleteId}`, {
method: "DELETE",
credentials: "include",
})
const data = (await response.json().catch(() => ({}))) as {
error?: string
detachedUsers?: number
detachedTickets?: number
}
if (!response.ok) {
throw new Error(data?.error ?? "Falha ao excluir empresa")
}
const detachedUsers = data?.detachedUsers ?? 0
const detachedTickets = data?.detachedTickets ?? 0
const details: string[] = []
if (detachedUsers > 0) {
details.push(`${detachedUsers} usuário${detachedUsers > 1 ? "s" : ""} desvinculado${detachedUsers > 1 ? "s" : ""}`)
}
if (detachedTickets > 0) {
details.push(`${detachedTickets} ticket${detachedTickets > 1 ? "s" : ""} atualizado${detachedTickets > 1 ? "s" : ""}`)
}
const successMessage = details.length > 0 ? `Empresa removida (${details.join(", ")})` : "Empresa removida"
toast.success(successMessage)
if (editingId === deleteId) {
resetForm()
setEditingId(null)
}
await refresh()
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível remover a empresa"
toast.error(message)
} finally {
setIsDeleting(false)
setDeleteId(null)
}
}
const editingCompanyName = useMemo(() => companies.find((company) => company.id === editingId)?.name ?? null, [companies, editingId])
const deleteTarget = useMemo(() => companies.find((company) => company.id === deleteId) ?? null, [companies, deleteId])
const filteredCompanies = useMemo(() => {
const query = searchTerm.trim().toLowerCase()
if (!query) return companies
return companies.filter((company) => {
return [
company.name,
company.slug,
company.domain,
company.cnpj,
company.phone,
company.description,
].some((value) => value?.toLowerCase().includes(query))
})
}, [companies, searchTerm])
const hasCompanies = filteredCompanies.length > 0
const emptyContent = (
<Empty className="mx-auto max-w-md border-none bg-background/60 shadow-none">
<EmptyHeader>
<EmptyMedia variant="icon">
<IconBuildingSkyscraper className="size-5" />
</EmptyMedia>
<EmptyTitle>Nenhuma empresa encontrada</EmptyTitle>
<EmptyDescription>
{searchTerm
? "Nenhum cadastro corresponde à busca realizada. Ajuste os termos e tente novamente."
: "Cadastre uma empresa para começar a gerenciar clientes por aqui."}
</EmptyDescription>
</EmptyHeader>
{searchTerm ? (
<EmptyContent>
<Button type="button" variant="ghost" size="sm" onClick={() => setSearchTerm("")}>
Limpar busca
</Button>
</EmptyContent>
) : null}
</Empty>
)
return (
<div className="space-y-6">
<TooltipProvider delayDuration={120}>
<Card className="border-slate-200">
<CardHeader>
<CardTitle>{editingId ? `Editar empresa${editingCompanyName ? ` · ${editingCompanyName}` : ""}` : "Nova empresa"}</CardTitle>
<CardDescription>
{editingId
? "Atualize os dados cadastrais e as informações de faturamento do cliente selecionado."
: "Cadastre um cliente/empresa e defina se é avulso."}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={nameId}>Nome</Label>
<Input
id={nameId}
name="companyName"
value={form.name ?? ""}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))}
placeholder="Nome da empresa ou apelido interno"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={slugId}>Slug</Label>
<Input
id={slugId}
name="companySlug"
value={form.slug ?? ""}
onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))}
placeholder="empresa-exemplo"
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor={descriptionId}>Descrição</Label>
<Input
id={descriptionId}
name="companyDescription"
value={form.description ?? ""}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))}
placeholder="Resumo, segmento ou observações internas"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={cnpjId}>CNPJ</Label>
<Input
id={cnpjId}
name="companyCnpj"
value={form.cnpj ?? ""}
onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))}
placeholder="00.000.000/0000-00"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={domainId}>Domínio</Label>
<Input
id={domainId}
name="companyDomain"
value={form.domain ?? ""}
onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))}
placeholder="empresa.com.br"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={phoneId}>Telefone</Label>
<PhoneInput
id={phoneId}
name="companyPhone"
value={form.phone ?? ""}
onChange={(value) => setForm((p) => ({ ...p, phone: value || null }))}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label htmlFor={addressId}>Endereço</Label>
<Input
id={addressId}
name="companyAddress"
value={form.address ?? ""}
onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))}
placeholder="Rua, número, bairro, cidade/UF"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={hoursId}>Horas contratadas/mês</Label>
<Input
type="number"
id={hoursId}
name="companyHours"
min={0}
step="0.25"
value={form.contractedHoursPerMonth ?? ""}
onChange={(e) => setForm((p) => ({ ...p, contractedHoursPerMonth: e.target.value === "" ? undefined : Number(e.target.value) }))}
placeholder="Ex.: 40"
/>
</div>
<div className="flex items-center gap-2 md:col-span-2">
<Checkbox
checked={Boolean(form.isAvulso ?? false)}
onCheckedChange={(v) => setForm((p) => ({ ...p, isAvulso: Boolean(v) }))}
id="is-avulso"
/>
<Label htmlFor="is-avulso">Cliente avulso?</Label>
</div>
<div className="md:col-span-2">
<Button type="submit" disabled={isPending}>{editingId ? "Salvar alterações" : "Cadastrar empresa"}</Button>
{editingId ? (
<Button type="button" variant="ghost" className="ml-2" onClick={() => { resetForm(); setEditingId(null) }}>
Cancelar
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
{editingId ? (
<Card className="border-slate-200/80 shadow-none">
<CardHeader>
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base font-semibold">Máquinas desta empresa</CardTitle>
<CardDescription className="text-sm">
Status e último sinal das máquinas vinculadas.
</CardDescription>
</div>
<Badge variant="outline" className="border-slate-200 bg-white text-xs font-medium dark:border-slate-800 dark:bg-slate-900">
{editingCompanyMachines.length} máquina{editingCompanyMachines.length === 1 ? "" : "s"}
</Badge>
</div>
</CardHeader>
<CardContent>
{editingCompanyMachines.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2">
{editingCompanyMachines.map((machine) => {
const variant = getMachineStatusVariant(machine.status)
return (
<div
key={machine.id}
className="flex flex-col gap-1 rounded-lg border border-slate-200/80 bg-slate-50/60 p-3 dark:border-slate-800/60 dark:bg-slate-900/40"
>
<div className="flex items-center justify-between gap-2">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-slate-50">{machine.hostname}</p>
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium", variant.className)}>
{variant.label}
</span>
</div>
<p className="text-xs text-muted-foreground">
Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"}
</p>
</div>
)
})}
</div>
) : (
<p className="text-sm text-muted-foreground">Nenhuma máquina vinculada a esta empresa.</p>
)}
</CardContent>
</Card>
) : null}
<Card className="border-slate-200/80 shadow-none">
<CardHeader className="gap-4 border-b border-slate-100 py-6 dark:border-slate-800/60">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-semibold tracking-tight">Empresas cadastradas</CardTitle>
<CardDescription>Gerencie empresas e o status de cliente avulso.</CardDescription>
</div>
<Badge variant="outline" className="self-start text-xs font-medium">
{filteredCompanies.length} {filteredCompanies.length === 1 ? "empresa" : "empresas"}
</Badge>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-xs">
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
placeholder="Buscar por nome, slug ou domínio..."
className="pl-9"
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="sm:self-start"
disabled={isPending}
onClick={() => startTransition(() => { void refresh() })}
>
<IconRefresh className={cn("size-4", isPending && "animate-spin")} />
Atualizar lista
</Button>
</div>
</CardHeader>
<CardContent className="p-0">
{isMobile ? (
<div className="space-y-4 p-4">
{hasCompanies ? (
filteredCompanies.map((company) => {
const companyMachines = machinesByCompanyId.get(company.id) ?? []
const formattedPhone = formatPhoneDisplay(company.phone)
const alertInfo = lastAlerts[company.slug] ?? null
const usagePct = alertInfo?.usagePct ?? 0
const threshold = alertInfo?.threshold ?? 0
const isThresholdExceeded = Boolean(alertInfo) && usagePct >= threshold
const lastAlertDistance = alertInfo
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
: null
return (
<div
key={company.id}
className="rounded-2xl border border-slate-200 bg-white p-4 shadow-sm"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<Avatar className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-100">
<AvatarFallback className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-100">
{getInitials(company.name)}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-50">{company.name}</p>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{company.slug}</p>
{company.domain ? (
<p className="text-[11px] text-muted-foreground">{company.domain}</p>
) : null}
{company.description ? (
<p className="text-[11px] text-muted-foreground">{company.description}</p>
) : null}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<IconDotsVertical className="size-4" />
<span className="sr-only">Abrir menu de ações</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => handleEdit(company)}>
<IconPencil className="size-4" />
Editar empresa
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void toggleAvulso(company)}>
<IconSwitchHorizontal className="size-4" />
{company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeleteId(company.id)}
>
<IconTrash className="size-4" />
Remover empresa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="mt-3 grid gap-1 text-xs text-muted-foreground">
{formattedPhone ? <span>{formattedPhone}</span> : null}
{company.address ? <span>{company.address}</span> : null}
</div>
{companyMachines.length > 0 ? (
<div className="mt-3 space-y-2">
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<Badge variant="outline" className="border-slate-200 bg-white px-2 text-[11px] font-medium dark:border-slate-800 dark:bg-slate-900">
{companyMachines.length} máquina{companyMachines.length > 1 ? "s" : ""}
</Badge>
{Object.entries(summarizeStatus(companyMachines)).map(([status, count]) => (
<Badge key={`${company.id}-${status}`} variant="outline" className={cn("px-2 text-[11px] font-medium", getMachineStatusVariant(status).className)}>
{getMachineStatusVariant(status).label}: {count}
</Badge>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
{companyMachines.slice(0, 3).map((machine) => {
const variant = getMachineStatusVariant(machine.isActive === false ? "deactivated" : machine.status)
return (
<Tooltip key={machine.id}>
<TooltipTrigger asChild>
<span className={cn("inline-flex items-center gap-1 rounded-full border px-2 py-1 text-[11px] font-medium", variant.className)}>
{machine.hostname}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs font-medium text-foreground">{machine.hostname}</p>
<p className="text-[11px] text-muted-foreground">
Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"}
</p>
</TooltipContent>
</Tooltip>
)
})}
{companyMachines.length > 3 ? (
<span className="text-[11px] text-muted-foreground">
+{companyMachines.length - 3} {companyMachines.length - 3 === 1 ? "outra" : "outras"}
</span>
) : null}
</div>
</div>
) : (
<p className="mt-3 text-[11px] text-muted-foreground">Nenhuma máquina vinculada.</p>
)}
<div className="mt-4">
<ProvisioningCodeCard code={company.provisioningCode} />
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Badge
variant={company.isAvulso ? "default" : "outline"}
className={cn(
"inline-flex h-8 items-center rounded-full px-3 text-xs font-medium",
company.isAvulso ? "bg-sky-500/10 text-sky-600 dark:bg-sky-500/20 dark:text-sky-200" : "border-slate-200 text-slate-600 dark:border-slate-700 dark:text-slate-200"
)}
>
{company.isAvulso ? "Cliente avulso" : "Recorrente"}
</Badge>
<Button
type="button"
size="sm"
variant="outline"
className="flex h-8 items-center gap-1 rounded-full border-slate-200 px-3"
onClick={() => void toggleAvulso(company)}
disabled={isPending}
>
<IconSwitchHorizontal className="size-4" />
{company.isAvulso ? "Marcar recorrente" : "Marcar avulso"}
</Button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{alertInfo ? (
<>
<IconClock
className={cn(
"size-3",
isThresholdExceeded ? "text-amber-500" : "text-emerald-500"
)}
/>
<span>{lastAlertDistance}</span>
<span
className={cn(
"font-medium",
isThresholdExceeded ? "text-amber-600" : "text-emerald-600"
)}
>
· Consumo {Math.round(usagePct)}% / limite {threshold}%
</span>
</>
) : (
<>
<IconShieldCheck className="text-emerald-500 size-3" />
<span>Sem alertas recentes</span>
</>
)}
</div>
</div>
)
})
) : (
emptyContent
)}
</div>
) : (
<div className="overflow-hidden rounded-lg border border-slate-200">
<Table className="min-w-full table-fixed text-sm">
<TableHeader className="sticky top-0 z-10 bg-muted/60 backdrop-blur supports-[backdrop-filter]:bg-muted/40">
<TableRow className="border-b border-slate-200 dark:border-slate-800/60 [&_th]:h-10 [&_th]:text-xs [&_th]:font-medium [&_th]:uppercase [&_th]:tracking-wide [&_th]:text-muted-foreground [&_th:first-child]:rounded-tl-lg [&_th:last-child]:rounded-tr-lg">
<TableHead className="w-[30%] min-w-[220px] pl-6">Empresa</TableHead>
<TableHead className="w-[22%] min-w-[180px] pl-4">Provisionamento</TableHead>
<TableHead className="w-[18%] min-w-[160px] pl-12">Cliente avulso</TableHead>
<TableHead className="w-[20%] min-w-[170px] pl-12">Uso e alertas</TableHead>
<TableHead className="w-[10%] min-w-[90px] pr-6 text-right">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{hasCompanies ? (
filteredCompanies.map((company) => {
const alertInfo = lastAlerts[company.slug] ?? null
const usagePct = alertInfo?.usagePct ?? 0
const threshold = alertInfo?.threshold ?? 0
const isThresholdExceeded = Boolean(alertInfo) && usagePct >= threshold
const lastAlertDistance = alertInfo
? formatDistanceToNow(alertInfo.createdAt, { addSuffix: true, locale: ptBR })
: null
const formattedPhone = formatPhoneDisplay(company.phone)
const companyMachines = machinesByCompanyId.get(company.id) ?? []
const machineCount = companyMachines.length
return (
<TableRow
key={company.id}
className="border-slate-100/60 transition-colors hover:bg-slate-50 dark:border-slate-800/60 dark:hover:bg-slate-900/40"
>
<TableCell className="min-w-[240px] pl-6">
<div className="flex items-start gap-4">
<Avatar className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-100">
<AvatarFallback className="bg-slate-200 text-slate-600 dark:bg-slate-700 dark:text-slate-100">
{getInitials(company.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold leading-none text-slate-900 dark:text-slate-50">
{company.name}
</span>
{company.cnpj ? (
<Badge variant="outline" className="border-slate-200 bg-white text-[11px] font-medium uppercase tracking-wide dark:border-slate-800 dark:bg-slate-900">
CNPJ
</Badge>
) : null}
{typeof company.contractedHoursPerMonth === "number" ? (
<Badge variant="outline" className="border-slate-200 bg-white text-[11px] font-medium dark:border-slate-800 dark:bg-slate-900">
{company.contractedHoursPerMonth}h/mês
</Badge>
) : null}
<Badge
variant="outline"
className="border-slate-200 bg-white text-[11px] font-medium dark:border-slate-800 dark:bg-slate-900"
>
{machineCount} máquina{machineCount === 1 ? "" : "s"}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="text-[12px] font-medium text-slate-500 dark:text-slate-400">
{company.slug}
</span>
{company.domain ? (
<>
<span className="bg-slate-300/70 dark:bg-slate-700/70 block size-1 rounded-full" />
<span className="truncate">{company.domain}</span>
</>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{formattedPhone ? <span>{formattedPhone}</span> : null}
{formattedPhone && company.address ? (
<span className="bg-slate-300/70 dark:bg-slate-700/70 block size-1 rounded-full" />
) : null}
{company.address ? <span className="truncate">{company.address}</span> : null}
{!formattedPhone && !company.address && company.description ? (
<span className="truncate text-muted-foreground/70">{company.description}</span>
) : null}
</div>
</div>
</div>
</TableCell>
<TableCell className="align-middle pr-8 pl-2">
<ProvisioningCodeCard code={company.provisioningCode} />
</TableCell>
<TableCell className="pl-12 pr-6 align-middle">
<div className="flex items-center gap-6">
<Badge
variant={company.isAvulso ? "default" : "outline"}
className={cn(
"inline-flex h-8 items-center rounded-full px-3 text-xs font-medium",
company.isAvulso ? "bg-sky-500/10 text-sky-600 dark:bg-sky-500/20 dark:text-sky-200" : "border-slate-200 text-slate-600 dark:border-slate-700 dark:text-slate-200"
)}
>
{company.isAvulso ? "Cliente avulso" : "Recorrente"}
</Badge>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
className="flex h-8 w-8 items-center justify-center rounded-full border border-transparent hover:border-slate-200 dark:hover:border-slate-700"
onClick={() => void toggleAvulso(company)}
disabled={isPending}
>
<IconSwitchHorizontal className="size-4" />
<span className="sr-only">Alternar cliente avulso</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
</TooltipContent>
</Tooltip>
</div>
</TableCell>
<TableCell className="pl-12 pr-6 align-middle">
{alertInfo ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IconClock className="size-3" />
<span>{lastAlertDistance}</span>
</div>
<Progress
value={usagePct}
className="bg-slate-100 dark:bg-slate-800"
indicatorClassName={cn(
"bg-primary",
isThresholdExceeded && "bg-amber-500"
)}
/>
<div className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300">
{isThresholdExceeded ? (
<IconAlertTriangle className="text-amber-500 size-3" />
) : (
<IconShieldCheck className="text-emerald-500 size-3" />
)}
<span>
Consumo {Math.round(usagePct)}% · Limite {threshold}%
</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IconShieldCheck className="text-emerald-500 size-3" />
<span>Sem alertas recentes</span>
</div>
)}
</TableCell>
<TableCell className="pr-6 text-right align-top">
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
onClick={() => setMachinesDialog({ companyId: company.id, name: company.name })}
>
<IconDeviceDesktop className="size-4" /> Ver máquinas
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="ml-auto">
<IconDotsVertical className="size-4" />
<span className="sr-only">Abrir menu de ações</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem onSelect={() => handleEdit(company)}>
<IconPencil className="size-4" />
Editar empresa
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void toggleAvulso(company)}>
<IconSwitchHorizontal className="size-4" />
{company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => setDeleteId(company.id)}
>
<IconTrash className="size-4" />
Remover empresa
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
)
})
) : (
<TableRow>
<TableCell colSpan={5} className="py-12">
{emptyContent}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
<Dialog open={!!machinesDialog} onOpenChange={(open) => { if (!open) setMachinesDialog(null) }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Máquinas {machinesDialog?.name ?? ""}</DialogTitle>
</DialogHeader>
{machinesDialogList.length === 0 ? (
<p className="text-sm text-muted-foreground">Nenhuma máquina vinculada a esta empresa.</p>
) : (
<ul className="space-y-3">
{machinesDialogList.map((machine) => {
const statusKey = machine.isActive === false ? "deactivated" : machine.status
const statusVariant = getMachineStatusVariant(statusKey)
return (
<li key={machine.id} className="flex flex-col gap-2 rounded-xl border border-slate-200 bg-slate-50/60 px-4 py-3 text-sm text-neutral-700">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-neutral-900">{machine.hostname}</p>
<p className="text-xs text-neutral-500">{machine.authEmail ?? "Sem e-mail definido"}</p>
</div>
<Badge variant="outline" className={cn("h-7 px-3 text-xs font-medium", statusVariant.className)}>
{statusVariant.label}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-neutral-500">
<span>{machine.osName ?? "SO desconhecido"}</span>
{machine.osVersion ? <span className="text-neutral-400"></span> : null}
{machine.osVersion ? <span>{machine.osVersion}</span> : null}
{machine.architecture ? (
<span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-medium text-neutral-600 shadow-sm">
{machine.architecture.toUpperCase()}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<Button asChild variant="outline" size="sm" className="text-xs">
<Link href={`/admin/machines/${machine.id}`}>Ver detalhes</Link>
</Button>
<span className="text-xs text-neutral-500">
Último sinal: {formatRelativeTimestamp(machine.lastHeartbeatAt) ?? "nunca"}
</span>
</div>
</li>
)
})}
</ul>
)}
</DialogContent>
</Dialog>
</Card>
</TooltipProvider>
<Dialog
open={deleteId !== null}
onOpenChange={(open) => {
if (!open) {
setDeleteId(null)
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Excluir empresa</DialogTitle>
<DialogDescription>
Esta operação remove o cadastro do cliente e impede novos vínculos automáticos.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm text-neutral-600">
<p>
Confirme a exclusão de{" "}
<span className="font-semibold text-neutral-900">{deleteTarget?.name ?? "empresa selecionada"}</span>.
</p>
<p className="text-xs text-neutral-500">
Registros históricos que apontem para a empresa poderão impedir a exclusão.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)} disabled={isDeleting}>
Cancelar
</Button>
<Button variant="destructive" onClick={() => void handleDeleteConfirmed()} disabled={isDeleting}>
{isDeleting ? "Removendo..." : "Remover empresa"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
const MACHINE_STATUS_VARIANTS: Record<string, { label: string; className: string }> = {
online: { label: "Online", className: "border-emerald-200 bg-emerald-500/10 text-emerald-600" },
offline: { label: "Offline", className: "border-rose-200 bg-rose-500/10 text-rose-600" },
stale: { label: "Sem sinal", className: "border-slate-300 bg-slate-200/60 text-slate-700" },
maintenance: { label: "Manutenção", className: "border-amber-200 bg-amber-500/10 text-amber-600" },
blocked: { label: "Bloqueada", className: "border-orange-200 bg-orange-500/10 text-orange-600" },
deactivated: { label: "Desativada", className: "border-slate-300 bg-slate-100 text-slate-600" },
unknown: { label: "Desconhecida", className: "border-slate-200 bg-slate-100 text-slate-600" },
}
function getMachineStatusVariant(status?: string | null) {
const normalized = (status ?? "unknown").toLowerCase()
return MACHINE_STATUS_VARIANTS[normalized] ?? MACHINE_STATUS_VARIANTS.unknown
}
function summarizeStatus(machines: MachineSummary[]): Record<string, number> {
return machines.reduce<Record<string, number>>((acc, machine) => {
const normalized = (machine.isActive === false ? "deactivated" : machine.status ?? "unknown").toLowerCase()
acc[normalized] = (acc[normalized] ?? 0) + 1
return acc
}, {})
}
function formatRelativeTimestamp(timestamp?: number | null): string | null {
if (!timestamp || !Number.isFinite(timestamp)) return null
try {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true, locale: ptBR })
} catch {
return null
}
}
function getInitials(value: string) {
const cleaned = value.trim()
if (!cleaned) return "?"
const pieces = cleaned
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((piece) => piece.charAt(0).toUpperCase())
if (pieces.length === 0) {
return cleaned.slice(0, 2).toUpperCase()
}
return pieces.join("") || cleaned.slice(0, 2).toUpperCase()
}
function ProvisioningCodeCard({ code }: { code: string | null }) {
const [isCopied, setIsCopied] = useState(false)
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const handleCopied = useCallback(() => {
if (resetTimerRef.current) {
clearTimeout(resetTimerRef.current)
}
setIsCopied(true)
resetTimerRef.current = setTimeout(() => setIsCopied(false), 2600)
}, [])
const handleCopy = useCallback(async () => {
if (!code) return
try {
await navigator.clipboard.writeText(code)
handleCopied()
} catch (error) {
console.error("Falha ao copiar código de provisionamento", error)
}
}, [code, handleCopied])
useEffect(() => {
return () => {
if (resetTimerRef.current) {
clearTimeout(resetTimerRef.current)
}
}
}, [])
if (!code) {
return (
<div className="w-full rounded-md border border-dashed border-slate-200 bg-slate-50 px-3 py-2 text-xs text-muted-foreground">
Nenhum código provisionado no momento.
</div>
)
}
return (
<div className="inline-block space-y-1.5 mr-4 md:mr-6">
<div
className={cn(
"group relative w-full max-w-full rounded-md border border-sidebar-border bg-sidebar-accent px-3 py-1 transition-transform duration-200 hover:-translate-y-0.5 hover:border-sidebar-ring dark:bg-sidebar-accent md:max-w-[16.5rem]",
isCopied && "ring-1 ring-sidebar-ring"
)}
>
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-mono text-sm font-semibold text-slate-800 dark:text-slate-100">
{code}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => void handleCopy()}
className="ml-auto border border-transparent text-sidebar-accent-foreground hover:border-sidebar-ring/40 hover:text-sidebar-accent-foreground dark:text-sidebar-accent-foreground"
>
{isCopied ? <IconCheck className="size-4" /> : <IconCopy className="size-4" />}
<span className="sr-only">{isCopied ? "Código copiado" : "Copiar código"}</span>
</Button>
</div>
</div>
{isCopied ? (
<div className="flex w-full items-center justify-center gap-2 text-[11px] font-medium text-emerald-600 dark:text-emerald-400">
<IconCheck className="size-3.5" />
<span>Código copiado para a área de transferência</span>
</div>
) : null}
</div>
)
}