1084 lines
52 KiB
TypeScript
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>
|
|
)
|
|
}
|