diff --git a/convex/schema.ts b/convex/schema.ts
index 4acf1b4..ac41bb6 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -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()),
diff --git a/src/app/admin/companies/page.tsx b/src/app/admin/companies/page.tsx
index 45c8509..b7442d2 100644
--- a/src/app/admin/companies/page.tsx
+++ b/src/app/admin/companies/page.tsx
@@ -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() {
/>
}
>
-
+
diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx
index 5095e61..004b570 100644
--- a/src/components/admin/companies/admin-companies-manager.tsx
+++ b/src/components/admin/companies/admin-companies-manager.tsx
@@ -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
(initialCompanies)
+ const [companies, setCompanies] = useState(() => initialCompanies ?? [])
const [isPending, startTransition] = useTransition()
const [form, setForm] = useState>({})
const [editingId, setEditingId] = useState(null)
const [lastAlerts, setLastAlerts] = useState>({})
const [deleteId, setDeleteId] = useState(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 = (
+
+
+
+
+
+ Nenhuma empresa encontrada
+
+ {searchTerm
+ ? "Nenhum cadastro corresponde à busca realizada. Ajuste os termos e tente novamente."
+ : "Cadastre uma empresa para começar a gerenciar clientes por aqui."}
+
+
+ {searchTerm ? (
+
+ setSearchTerm("")}>
+ Limpar busca
+
+
+ ) : null}
+
+ )
return (
-
-
- {editingId ? `Editar empresa${editingCompanyName ? ` · ${editingCompanyName}` : ""}` : "Nova empresa"}
-
- {editingId
- ? "Atualize os dados cadastrais e as informações de faturamento do cliente selecionado."
- : "Cadastre um cliente/empresa e defina se é avulso."}
-
-
-
-
+
+
-
-
- Empresas cadastradas
- Gerencie empresas e o status de cliente avulso.
-
-
-
-
-
- Nome
- Slug
- Código
- Avulso
- Domínio
- Telefone
- CNPJ
- Último alerta
- Ações
-
-
-
- {companies.map((c) => (
-
- {c.name}
- {c.slug}
-
-
-
+
+
+
+ Empresas cadastradas
+ Gerencie empresas e o status de cliente avulso.
+
+
+ {filteredCompanies.length} {filteredCompanies.length === 1 ? "empresa" : "empresas"}
+
+
+
+
+
+ setSearchTerm(event.target.value)}
+ placeholder="Buscar por nome, slug ou domínio..."
+ className="pl-9"
+ />
+
+
startTransition(() => { void refresh() })}
+ >
+
+ Atualizar lista
+
+
+
+
+ {isMobile ? (
+
+ {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 (
+
- {c.provisioningCode.slice(0, 10)}…
-
- handleCopyProvisioningCode(c.provisioningCode)}
- >
- Copiar
-
-
-
-
- void toggleAvulso(c)}>
- {c.isAvulso ? "Sim" : "Não"}
-
-
-
{c.domain ?? "—"}
-
{c.phone ?? "—"}
-
{c.cnpj ?? "—"}
-
- {lastAlerts[c.slug]
- ? `${new Date(lastAlerts[c.slug]!.createdAt).toLocaleString("pt-BR")}`
- : "—"}
-
-
-
- handleEdit(c)}>
- Editar
-
- setDeleteId(c.id)}
- >
- Remover
-
-
-
-
- ))}
-
-
-
-
+
+
+
+
+ {getInitials(company.name)}
+
+
+
+
{company.name}
+
{company.slug}
+ {company.domain ? (
+
{company.domain}
+ ) : null}
+ {company.description ? (
+
{company.description}
+ ) : null}
+
+
+
+
+
+
+ Abrir menu de ações
+
+
+
+ handleEdit(company)}>
+
+ Editar empresa
+
+ void toggleAvulso(company)}>
+
+ {company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"}
+
+
+ setDeleteId(company.id)}
+ >
+
+ Remover empresa
+
+
+
+
+
+ {company.phone ? {company.phone} : null}
+ {company.address ? {company.address} : null}
+
+
+
+
+ {company.isAvulso ? "Cliente avulso" : "Recorrente"}
+
+ void toggleAvulso(company)}
+ disabled={isPending}
+ >
+
+ {company.isAvulso ? "Marcar recorrente" : "Marcar avulso"}
+
+
+
+ {alertInfo ? (
+ <>
+
+ {lastAlertDistance}
+ · Consumo {Math.round(usagePct)}% / limite {threshold}%
+ >
+ ) : (
+ <>
+
+ Sem alertas recentes
+ >
+ )}
+
+
+ )
+ })
+ ) : (
+ emptyContent
+ )}
+
+ ) : (
+