Refine admin companies layout and relax provisioning schema

This commit is contained in:
Esdras Renan 2025-10-15 23:19:24 -03:00
parent 2cba553efa
commit 43230e0310
4 changed files with 573 additions and 142 deletions

View file

@ -20,7 +20,7 @@ export default defineSchema({
tenantId: v.string(),
name: v.string(),
slug: v.string(),
provisioningCode: v.string(),
provisioningCode: v.optional(v.string()),
isAvulso: v.optional(v.boolean()),
contractedHoursPerMonth: v.optional(v.number()),
cnpj: v.optional(v.string()),

View file

@ -15,7 +15,7 @@ export default async function AdminCompaniesPage() {
tenantId: c.tenantId,
name: c.name,
slug: c.slug,
provisioningCode: c.provisioningCode,
provisioningCode: c.provisioningCode ?? null,
isAvulso: Boolean(extra.isAvulso ?? false),
contractedHoursPerMonth: extra.contractedHoursPerMonth ?? null,
cnpj: c.cnpj ?? null,
@ -34,7 +34,7 @@ export default async function AdminCompaniesPage() {
/>
}
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<div className="mx-auto w-full max-w-7xl px-4 md:px-8 lg:px-10">
<AdminCompaniesManager initialCompanies={companies} />
</div>
</AppShell>

View file

@ -1,13 +1,40 @@
"use client"
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"
import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import {
IconAlertTriangle,
IconBuildingSkyscraper,
IconClock,
IconCopy,
IconDotsVertical,
IconCheck,
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 { 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,
@ -17,13 +44,16 @@ import {
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"
type Company = {
id: string
tenantId: string
name: string
slug: string
provisioningCode: string
provisioningCode: string | null
isAvulso: boolean
contractedHoursPerMonth?: number | null
cnpj: string | null
@ -34,21 +64,24 @@ type Company = {
}
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
const [companies, setCompanies] = useState<Company[]>(initialCompanies)
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 isMobile = useIsMobile()
const resetForm = () => setForm({})
async function refresh() {
const r = await fetch("/api/admin/companies", { credentials: "include" })
const json = (await r.json()) as { companies: Company[] }
setCompanies(json.companies)
void loadLastAlerts(json.companies)
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) {
@ -59,19 +92,6 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
})
}
const handleCopyProvisioningCode = useCallback(async (code: string) => {
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(code)
toast.success("Código copiado para a área de transferência")
} else {
throw new Error("Clipboard indisponível")
}
} catch {
toast.error("Não foi possível copiar o código. Copie manualmente.")
}
}, [])
const loadLastAlerts = useCallback(async (list: Company[] = companies) => {
if (!list || list.length === 0) return
const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") })
@ -185,27 +205,74 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
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">
<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">
<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>Nome</Label>
<Input value={form.name ?? ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
<Input
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>Slug</Label>
<Input value={form.slug ?? ""} onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))} />
<Input
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>Descrição</Label>
@ -213,19 +280,35 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</div>
<div className="grid gap-2">
<Label>CNPJ</Label>
<Input value={form.cnpj ?? ""} onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))} />
<Input
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>Domínio</Label>
<Input value={form.domain ?? ""} onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))} />
<Input
value={form.domain ?? ""}
onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))}
placeholder="empresa.com.br"
/>
</div>
<div className="grid gap-2">
<Label>Telefone</Label>
<Input value={form.phone ?? ""} onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} />
<Input
value={form.phone ?? ""}
onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))}
placeholder="(+55) 11 99999-9999"
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Endereço</Label>
<Input value={form.address ?? ""} onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))} />
<Input
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>Horas contratadas/mês</Label>
@ -235,104 +318,361 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
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>
<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>
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Empresas cadastradas</CardTitle>
<CardDescription>Gerencie empresas e o status de cliente avulso.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Código</TableHead>
<TableHead>Avulso</TableHead>
<TableHead>Domínio</TableHead>
<TableHead>Telefone</TableHead>
<TableHead>CNPJ</TableHead>
<TableHead>Último alerta</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>{c.slug}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code
className="rounded bg-slate-100 px-2 py-1 text-[11px] font-mono text-neutral-700"
title={c.provisioningCode}
<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 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"
>
{c.provisioningCode.slice(0, 10)}
</code>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyProvisioningCode(c.provisioningCode)}
>
Copiar
</Button>
</div>
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
{c.isAvulso ? "Sim" : "Não"}
</Button>
</TableCell>
<TableCell>{c.domain ?? "—"}</TableCell>
<TableCell>{c.phone ?? "—"}</TableCell>
<TableCell>{c.cnpj ?? "—"}</TableCell>
<TableCell>
{lastAlerts[c.slug]
? `${new Date(lastAlerts[c.slug]!.createdAt).toLocaleString("pt-BR")}`
: "—"}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => handleEdit(c)}>
Editar
</Button>
<Button
size="sm"
variant="ghost"
className="text-red-600 transition-colors hover:bg-red-500/10"
onClick={() => setDeleteId(c.id)}
>
Remover
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<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">
{company.phone ? <span>{company.phone}</span> : null}
{company.address ? <span>{company.address}</span> : null}
</div>
<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="size-3" />
<span>{lastAlertDistance}</span>
<span>· 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-x-auto">
<Table className="min-w-full table-fixed text-sm">
<TableHeader>
<TableRow className="border-slate-100/80 dark:border-slate-800/60">
<TableHead className="w-[30%] min-w-[220px] pl-6 text-slate-500 dark:text-slate-300">Empresa</TableHead>
<TableHead className="w-[22%] min-w-[180px] text-slate-500 dark:text-slate-300">Provisionamento</TableHead>
<TableHead className="w-[18%] min-w-[160px] text-slate-500 dark:text-slate-300">Cliente avulso</TableHead>
<TableHead className="w-[20%] min-w-[170px] text-slate-500 dark:text-slate-300">Uso e alertas</TableHead>
<TableHead className="w-[10%] min-w-[90px] pr-6 text-right text-slate-500 dark:text-slate-300">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
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}
</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">
{company.phone ? <span>{company.phone}</span> : null}
{company.phone && 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}
{!company.phone && !company.address && company.description ? (
<span className="truncate text-muted-foreground/70">{company.description}</span>
) : null}
</div>
</div>
</div>
</TableCell>
<TableCell className="align-middle pr-10">
<ProvisioningCodeCard code={company.provisioningCode} />
</TableCell>
<TableCell className="pl-6 pr-4 align-middle">
<div className="flex items-center gap-4">
<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="pr-4 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">
<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>
</TableCell>
</TableRow>
)
})
) : (
<TableRow>
<TableCell colSpan={5} className="py-12">
{emptyContent}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</TooltipProvider>
<Dialog
open={deleteId !== null}
@ -371,3 +711,89 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
</div>
)
}
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>
)
}

View file

@ -136,15 +136,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const message =
(payload && typeof payload.error === "string" && payload.error.trim()) || fallbackMessage
if (!cancelled) {
const debugPayload = {
status: response.status,
message,
payload,
timestamp: new Date().toISOString(),
}
console.error("[auth] machine context request failed", debugPayload)
if (typeof window !== "undefined") {
window.__machineContextDebug = debugPayload
// For 401/403/404 we silently ignore: most browsers hitting the
// web app are not machines and won't have a machine session.
if (response.status !== 401 && response.status !== 403 && response.status !== 404) {
const debugPayload = {
status: response.status,
message,
payload,
timestamp: new Date().toISOString(),
}
// Use warn to reduce noise in production consoles
console.warn("[auth] machine context request failed", debugPayload)
if (typeof window !== "undefined") {
window.__machineContextDebug = debugPayload
}
}
setMachineContext(null)
setMachineContextError({