From 43230e0310f70741e04c481f522ccaf2db58c10f Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Wed, 15 Oct 2025 23:19:24 -0300 Subject: [PATCH] Refine admin companies layout and relax provisioning schema --- convex/schema.ts | 2 +- src/app/admin/companies/page.tsx | 4 +- .../companies/admin-companies-manager.tsx | 686 ++++++++++++++---- src/lib/auth-client.tsx | 23 +- 4 files changed, 573 insertions(+), 142 deletions(-) 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 ? ( + + + + ) : 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."} - - - -
+ + + + {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."} + + + +
- setForm((p) => ({ ...p, name: e.target.value }))} /> + setForm((p) => ({ ...p, name: e.target.value }))} + placeholder="Nome da empresa ou apelido interno" + />
- setForm((p) => ({ ...p, slug: e.target.value }))} /> + setForm((p) => ({ ...p, slug: e.target.value }))} + placeholder="empresa-exemplo" + />
@@ -213,19 +280,35 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
- setForm((p) => ({ ...p, cnpj: e.target.value }))} /> + setForm((p) => ({ ...p, cnpj: e.target.value }))} + placeholder="00.000.000/0000-00" + />
- setForm((p) => ({ ...p, domain: e.target.value }))} /> + setForm((p) => ({ ...p, domain: e.target.value }))} + placeholder="empresa.com.br" + />
- setForm((p) => ({ ...p, phone: e.target.value }))} /> + setForm((p) => ({ ...p, phone: e.target.value }))} + placeholder="(+55) 11 99999-9999" + />
- setForm((p) => ({ ...p, address: e.target.value }))} /> + setForm((p) => ({ ...p, address: e.target.value }))} + placeholder="Rua, número, bairro, cidade/UF" + />
@@ -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" />
-
- setForm((p) => ({ ...p, isAvulso: Boolean(v) }))} - id="is-avulso" - /> - -
-
- - {editingId ? ( - - ) : null} -
- -
-
+
+ setForm((p) => ({ ...p, isAvulso: Boolean(v) }))} + id="is-avulso" + /> + +
+
+ + {editingId ? ( + + ) : null} +
+ +
+
- - - 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" + /> +
+ +
+
+ + {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)}… - - -
- - - - - {c.domain ?? "—"} - {c.phone ?? "—"} - {c.cnpj ?? "—"} - - {lastAlerts[c.slug] - ? `${new Date(lastAlerts[c.slug]!.createdAt).toLocaleString("pt-BR")}` - : "—"} - - -
- - -
-
- - ))} - -
-
-
+
+
+ + + {getInitials(company.name)} + + +
+

{company.name}

+

{company.slug}

+ {company.domain ? ( +

{company.domain}

+ ) : null} + {company.description ? ( +

{company.description}

+ ) : null} +
+
+ + + + + + 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"} + + +
+
+ {alertInfo ? ( + <> + + {lastAlertDistance} + · Consumo {Math.round(usagePct)}% / limite {threshold}% + + ) : ( + <> + + Sem alertas recentes + + )} +
+
+ ) + }) + ) : ( + emptyContent + )} +
+ ) : ( +
+ + + + Empresa + Provisionamento + Cliente avulso + Uso e alertas + Ações + + + + {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 ( + + +
+ + + {getInitials(company.name)} + + +
+
+ + {company.name} + + {company.cnpj ? ( + + CNPJ + + ) : null} + {typeof company.contractedHoursPerMonth === "number" ? ( + + {company.contractedHoursPerMonth}h/mês + + ) : null} +
+
+ + {company.slug} + + {company.domain ? ( + <> + + {company.domain} + + ) : null} +
+
+ {company.phone ? {company.phone} : null} + {company.phone && company.address ? ( + + ) : null} + {company.address ? {company.address} : null} + {!company.phone && !company.address && company.description ? ( + {company.description} + ) : null} +
+
+
+
+ + + + +
+ + {company.isAvulso ? "Cliente avulso" : "Recorrente"} + + + + + + + {company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"} + + +
+
+ + {alertInfo ? ( +
+
+ + {lastAlertDistance} +
+ +
+ {isThresholdExceeded ? ( + + ) : ( + + )} + + Consumo {Math.round(usagePct)}% · Limite {threshold}% + +
+
+ ) : ( +
+ + Sem alertas recentes +
+ )} +
+ + + + + + + handleEdit(company)}> + + Editar empresa + + void toggleAvulso(company)}> + + {company.isAvulso ? "Marcar como recorrente" : "Marcar como avulso"} + + + setDeleteId(company.id)} + > + + Remover empresa + + + + +
+ ) + }) + ) : ( + + + {emptyContent} + + + )} +
+
+
+ )} + + + ) } + +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 | 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 ( +
+ Nenhum código provisionado no momento. +
+ ) + } + + return ( +
+
+
+ + {code} + + +
+
+ {isCopied ? ( +
+ + Código copiado para a área de transferência +
+ ) : null} +
+ ) +} diff --git a/src/lib/auth-client.tsx b/src/lib/auth-client.tsx index c827221..8b4920e 100644 --- a/src/lib/auth-client.tsx +++ b/src/lib/auth-client.tsx @@ -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({