"use client" import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react" import dynamic from "next/dynamic" import { Controller, FormProvider, useFieldArray, useForm, type UseFormReturn } from "react-hook-form" import { zodResolver } from "@/lib/zod-resolver" import { IconAlertTriangle, IconBuildingSkyscraper, IconCheck, IconClipboard, IconClock, IconCopy, IconFilter, IconList, IconMapPin, IconDeviceDesktop, IconPencil, IconPlus, IconRefresh, IconSearch, IconTrash, IconUsers, } from "@tabler/icons-react" import { toast } from "sonner" import { COMPANY_CONTACT_PREFERENCES, COMPANY_CONTACT_ROLES, COMPANY_CONTRACT_SCOPES, COMPANY_CONTRACT_TYPES, COMPANY_LOCATION_TYPES, COMPANY_STATE_REGISTRATION_TYPES, companyFormSchema, type CompanyBusinessHours, type CompanyContract, type CompanyFormValues, } from "@/lib/schemas/company" import { DEFAULT_TENANT_ID } from "@/lib/constants" import type { NormalizedCompany } from "@/server/company-service" import { cn } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Checkbox } from "@/components/ui/checkbox" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { ScrollArea } from "@/components/ui/scroll-area" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination" import { Textarea } from "@/components/ui/textarea" import { TimePicker } from "@/components/ui/time-picker" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Skeleton } from "@/components/ui/skeleton" import { useQuery, useMutation } from "convex/react" import { useAuth } from "@/lib/auth-client" import type { Id } from "@/convex/_generated/dataModel" import { api } from "@/convex/_generated/api" import { MultiValueInput } from "@/components/ui/multi-value-input" import { Spinner } from "@/components/ui/spinner" import { DatePicker } from "@/components/ui/date-picker" type LastAlertInfo = { createdAt: number; usagePct: number; threshold: number } | null type Props = { initialCompanies: NormalizedCompany[] tenantId?: string | null autoOpenCreate?: boolean } type ViewMode = "table" | "board" export type CompanyEditorState = | { mode: "create" } | { mode: "edit"; company: NormalizedCompany } const AdminDevicesOverview = dynamic( () => import("@/components/admin/devices/admin-devices-overview").then( (mod) => mod.AdminDevicesOverview ), { ssr: false, loading: () => (
Carregando dispositivos...
), } ) const BOARD_COLUMNS = [ { id: "monthly", title: "Mensalistas", description: "Contratos recorrentes ou planos mensais." }, { id: "time_bank", title: "Banco de horas", description: "Clientes com consumo controlado por horas." }, { id: "project", title: "Projetos", description: "Clientes com projetos fechados." }, { id: "per_ticket", title: "Por chamado", description: "Pagamento por ticket/chamado." }, { id: "avulso", title: "Avulsos", description: "Sem contrato ou marcação como avulso." }, { id: "other", title: "Outros", description: "Contratos customizados ou sem categoria definida." }, ] as const const DAY_OPTIONS = [ { value: "mon", label: "Seg" }, { value: "tue", label: "Ter" }, { value: "wed", label: "Qua" }, { value: "thu", label: "Qui" }, { value: "fri", label: "Sex" }, { value: "sat", label: "Sáb" }, { value: "sun", label: "Dom" }, ] as const const EMPTY_SELECT_VALUE = "__empty__" function createId(prefix: string) { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `${prefix}-${crypto.randomUUID()}` } return `${prefix}-${Math.random().toString(36).slice(2, 10)}` } function toFormValues(company: NormalizedCompany): CompanyFormValues { const { id: _id, provisioningCode: _code, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = company void _id void _code void _createdAt void _updatedAt return rest } function emptyCompany(tenantId: string): CompanyFormValues { return { tenantId, name: "", slug: "", legalName: null, tradeName: null, cnpj: null, stateRegistration: null, stateRegistrationType: undefined, primaryCnae: null, description: null, domain: null, phone: null, address: null, contractedHoursPerMonth: null, businessHours: { mode: "business", timezone: "America/Sao_Paulo", periods: [ { days: ["mon", "tue", "wed", "thu", "fri"], start: "09:00", end: "18:00", }, ], }, communicationChannels: { supportEmails: [], billingEmails: [], whatsappNumbers: [], phones: [], portals: [], }, supportEmail: null, billingEmail: null, contactPreferences: { defaultChannel: null, escalationNotes: null, }, clientDomains: [], fiscalAddress: null, hasBranches: false, regulatedEnvironments: [], privacyPolicy: { accepted: false, reference: null, }, contacts: [], locations: [], contracts: [], sla: { calendar: "business", validChannels: [], holidays: [], severities: [ { level: "P1", responseMinutes: 30, resolutionMinutes: 240 }, { level: "P2", responseMinutes: 60, resolutionMinutes: 480 }, { level: "P3", responseMinutes: 120, resolutionMinutes: 1440 }, { level: "P4", responseMinutes: 240, resolutionMinutes: 2880 }, ], serviceWindow: { timezone: "America/Sao_Paulo", periods: [ { days: ["mon", "tue", "wed", "thu", "fri"], start: "09:00", end: "18:00", }, ], }, }, tags: [], customFields: [], notes: null, isAvulso: false, } } function sanitisePayload(values: CompanyFormValues) { // Remove helper-only fields that should not be persisted when blank const normalizedPrivacy = values.privacyPolicy ? { ...values.privacyPolicy, reference: typeof values.privacyPolicy.reference === "string" && values.privacyPolicy.reference.trim().length === 0 ? null : values.privacyPolicy.reference ?? null, } : undefined return { ...values, privacyPolicy: normalizedPrivacy, contactPreferences: values.contactPreferences && values.contactPreferences.defaultChannel ? values.contactPreferences : values.contactPreferences?.escalationNotes ? values.contactPreferences : undefined, } } function inferBoardBucket(company: NormalizedCompany) { if (company.isAvulso) return "avulso" const contractTypes = new Set(company.contracts.map((contract) => contract.contractType)) for (const column of BOARD_COLUMNS) { if (column.id === "avulso" || column.id === "other") continue if (contractTypes.has(column.id as CompanyContract["contractType"])) return column.id } if (contractTypes.size === 0) return "other" return contractTypes.values().next().value ?? "other" } function formatCurrency(value: number | null | undefined) { if (value === null || value === undefined) return "—" return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(value) } function formatDate(value: string | null | undefined) { if (!value) return "—" const date = new Date(value) if (Number.isNaN(date.getTime())) return value return date.toLocaleDateString("pt-BR") } function FieldError({ error }: { error?: string }) { if (!error) return null return

{error}

} export function AdminCompaniesManager({ initialCompanies, tenantId, autoOpenCreate = false }: Props) { const [companies, setCompanies] = useState(() => initialCompanies) const [view, setView] = useState("table") const [search, setSearch] = useState("") const [contractFilter, setContractFilter] = useState("all") const [regulatedFilter, setRegulatedFilter] = useState("all") const [isRefreshing, startRefresh] = useTransition() const [editor, setEditor] = useState(null) const [isDeleting, setIsDeleting] = useState(null) const [alertsBySlug, setAlertsBySlug] = useState>({}) const effectiveTenantId = tenantId ?? companies[0]?.tenantId ?? DEFAULT_TENANT_ID // Dispositivos por empresa para contagem rápida const machines = useQuery(api.devices.listByTenant, { tenantId: effectiveTenantId, includeMetadata: false, }) as unknown[] | undefined function extractCompanySlug(entry: unknown): string | undefined { if (!entry || typeof entry !== "object") { return undefined } if ("companySlug" in entry) { const value = (entry as { companySlug?: unknown }).companySlug return typeof value === "string" && value.length > 0 ? value : undefined } return undefined } const machineCountsBySlug = useMemo(() => { const map: Record = {} ;(machines ?? []).forEach((entry) => { const slug = extractCompanySlug(entry) if (!slug) return map[slug] = (map[slug] ?? 0) + 1 }) return map }, [machines]) const filtered = useMemo(() => { const term = search.trim().toLowerCase() return companies.filter((company) => { if (term) { const matchesTerm = company.name.toLowerCase().includes(term) || company.slug.toLowerCase().includes(term) || (company.domain?.toLowerCase().includes(term) ?? false) || company.contacts.some((contact) => contact.fullName.toLowerCase().includes(term)) if (!matchesTerm) return false } if (contractFilter !== "all") { const types = new Set(company.contracts.map((contract) => contract.contractType)) if (!types.has(contractFilter as CompanyContract["contractType"])) return false } if (regulatedFilter !== "all") { if (!company.regulatedEnvironments.includes(regulatedFilter)) return false } return true }) }, [companies, contractFilter, regulatedFilter, search]) const contractOptions = useMemo(() => { const entries = new Set() companies.forEach((company) => { company.contracts.forEach((contract) => entries.add(contract.contractType)) }) return Array.from(entries.values()).sort() }, [companies]) const regulatedOptions = useMemo(() => { const entries = new Set() companies.forEach((company) => company.regulatedEnvironments.forEach((item) => entries.add(item)), ) return Array.from(entries.values()).sort() }, [companies]) useEffect(() => { const slugs = companies.map((company) => company.slug).filter(Boolean) if (slugs.length === 0) { setAlertsBySlug({}) return } let active = true void fetch(`/api/admin/companies/last-alerts?${new URLSearchParams({ slugs: slugs.join(",") })}`, { credentials: "include", }) .then(async (response) => { if (!response.ok) return const json = (await response.json()) as { items?: Record } if (active && json?.items) { setAlertsBySlug(json.items) } }) .catch((error) => { console.warn("Failed to load last alerts", error) }) return () => { active = false } }, [companies]) const refresh = useCallback(() => { startRefresh(async () => { try { const response = await fetch("/api/admin/companies", { credentials: "include" }) if (!response.ok) throw new Error("Falha ao atualizar a listagem de empresas.") const json = (await response.json()) as { companies?: NormalizedCompany[] } const nextCompanies = json.companies ?? [] setCompanies(nextCompanies) toast.success("Empresas atualizadas.") } catch (error) { const message = error instanceof Error ? error.message : "Não foi possível atualizar as empresas." toast.error(message) } }) }, []) const openCreate = useCallback(() => { setEditor({ mode: "create" }) }, []) const openEdit = useCallback((company: NormalizedCompany) => { setEditor({ mode: "edit", company }) }, []) const closeEditor = useCallback(() => { setEditor(null) }, []) const confirmDelete = useCallback((company: NormalizedCompany) => { setIsDeleting(company) }, []) const cancelDelete = useCallback(() => setIsDeleting(null), []) useEffect(() => { if (autoOpenCreate) { openCreate() } }, [autoOpenCreate, openCreate]) useEffect(() => { if (typeof window === "undefined") return const handler = () => { openCreate() } window.addEventListener("quick-open-company", handler) return () => window.removeEventListener("quick-open-company", handler) }, [openCreate]) const handleDelete = useCallback(async () => { if (!isDeleting) return try { const response = await fetch(`/api/admin/companies/${isDeleting.id}`, { method: "DELETE", credentials: "include", }) if (!response.ok) { const payload = await response.json().catch(() => null) throw new Error(payload?.error ?? "Não foi possível remover a empresa.") } setCompanies((prev) => prev.filter((company) => company.id !== isDeleting.id)) toast.success(`Empresa “${isDeleting.name}” removida.`) setIsDeleting(null) } catch (error) { const message = error instanceof Error ? error.message : "Não foi possível remover a empresa." toast.error(message) } }, [isDeleting]) const boardGroups = useMemo(() => { const map = new Map() filtered.forEach((company) => { const bucket = inferBoardBucket(company) const bucketId = BOARD_COLUMNS.some((column) => column.id === bucket) ? bucket : "other" const list = map.get(bucketId) ?? [] list.push(company) map.set(bucketId, list) }) return map }, [filtered]) const renderBoardCard = (company: NormalizedCompany) => { const alert = alertsBySlug[company.slug] const firstContract = company.contracts[0] return (
{company.name} {company.slug}
{company.isAvulso ? Avulso : null} {company.regulatedEnvironments.map((env) => ( {env.toUpperCase()} ))}

Contratos

{firstContract ? (

{firstContract.contractType}

{firstContract.scope.length ? firstContract.scope.join(", ") : "Escopo indefinido"}

) : (

Nenhum contrato cadastrado.

)}

Último alerta

{alert ? (

{alert.usagePct}% usado · limiar {alert.threshold}% ·{" "} {new Date(alert.createdAt).toLocaleDateString("pt-BR")}

) : (

Nenhum alerta registrado

)}

Canais principais

{company.supportEmail ?

Suporte: {company.supportEmail}

: null} {company.billingEmail ?

Financeiro: {company.billingEmail}

: null} {company.phone ?

Telefone: {company.phone}

: null}
) } return ( <>

Empresas atendidas

Cadastre, edite e visualize contratos, contatos e SLAs das empresas.

setSearch(event.target.value)} className="pl-9" />
{ if (!next) return setView(next as ViewMode) }} variant="outline" className="rounded-md border border-border/60 bg-muted/30" > Lista Quadro
{view === "table" ? ( ) : (
{BOARD_COLUMNS.map((column) => { const list = boardGroups.get(column.id) ?? [] return (

{column.title}

{column.description}

{list.length}
{list.length === 0 ? ( ) : ( list.map((company) => renderBoardCard(company)) )}
) })}
)}
{ setCompanies((prev) => [...prev, company].sort((a, b) => a.name.localeCompare(b.name))) }} onUpdated={(company) => { setCompanies((prev) => prev.map((item) => (item.id === company.id ? company : item)).sort((a, b) => a.name.localeCompare(b.name)), ) }} /> (!open ? cancelDelete() : null)}> Remover empresa Esta ação desvincula usuários e tickets da empresa selecionada. Confirme para continuar. ) } function EmptyColumn() { return (

Sem empresas nesta categoria.

) } type TableViewProps = { companies: NormalizedCompany[] machineCountsBySlug: Record onEdit(company: NormalizedCompany): void onDelete(company: NormalizedCompany): void } function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableViewProps) { const [pageSize, setPageSize] = useState(10) const [pageIndex, setPageIndex] = useState(0) const total = companies.length const pageCount = Math.max(1, Math.ceil(total / pageSize)) const start = total === 0 ? 0 : pageIndex * pageSize + 1 const end = total === 0 ? 0 : Math.min(total, pageIndex * pageSize + pageSize) const current = useMemo( () => companies.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize), [companies, pageIndex, pageSize] ) useEffect(() => { if (pageIndex > pageCount - 1) setPageIndex(Math.max(0, pageCount - 1)) }, [pageCount, pageIndex]) return (
Empresa Contratos ativos Contatos Dispositivos Ações {companies.length === 0 ? (

Nenhuma empresa encontrada com os filtros atuais.

) : ( current.map((company) => { const contracts = company.contracts const contacts = company.contacts.slice(0, 3) const machineCount = machineCountsBySlug[company.slug] ?? 0 return (

{company.name}

{company.isAvulso ? Avulso : null}
{company.domain ? {company.domain} : null} {company.phone ? ( {company.phone} ) : null}
{company.tags.map((tag) => ( {tag} ))}
{contracts.length === 0 ? (

Nenhum contrato registrado.

) : (
    {contracts.map((contract) => (
  • {contract.contractType}

    {contract.scope.length ? contract.scope.join(", ") : "Escopo base"}

    Vigência: {formatDate(contract.startDate)} – {formatDate(contract.endDate)}

    Valor: {formatCurrency(contract.price ?? null)}

  • ))}
)}
{contacts.length === 0 ? (

Nenhum contato cadastrado.

) : (
    {contacts.map((contact) => (
  • {contact.fullName}

    {contact.email}

    {contact.role.replace("_", " ")}

  • ))} {company.contacts.length > contacts.length ? (
  • + {company.contacts.length - contacts.length} outros contatos
  • ) : null}
)}
{machineCount}
) }) )}
{total === 0 ? "Nenhum registro" : `Mostrando ${start}-${end} de ${total}`}
Itens por página
setPageIndex((p) => Math.max(0, p - 1))} /> { event.preventDefault() }} > {pageIndex + 1} = pageCount - 1} onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))} />
) } type CompanySheetProps = { tenantId: string editor: CompanyEditorState | null onClose(): void onCreated(company: NormalizedCompany): void onUpdated(company: NormalizedCompany): void } export function CompanySheet({ tenantId, editor, onClose, onCreated, onUpdated }: CompanySheetProps) { const [isSubmitting, startSubmit] = useTransition() const open = Boolean(editor) const form = useForm({ resolver: zodResolver(companyFormSchema), defaultValues: emptyCompany(tenantId), mode: "onBlur", }) const contactsArray = useFieldArray({ control: form.control, name: "contacts", }) const locationsArray = useFieldArray({ control: form.control, name: "locations", }) const contractsArray = useFieldArray({ control: form.control, name: "contracts", }) const customFieldsArray = useFieldArray({ control: form.control, name: "customFields", }) useEffect(() => { if (!editor) return if (editor.mode === "create") { form.reset(emptyCompany(tenantId)) return } const values = toFormValues(editor.company) if (!values.businessHours) { values.businessHours = emptyCompany(tenantId).businessHours } if (!values.sla) { values.sla = emptyCompany(tenantId).sla } form.reset(values) }, [editor, form, tenantId]) const close = () => { form.reset(emptyCompany(tenantId)) onClose() } const handleSubmit = (values: CompanyFormValues) => { startSubmit(async () => { const payload = sanitisePayload(values) try { if (editor?.mode === "edit") { const response = await fetch(`/api/admin/companies/${editor.company.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) if (!response.ok) { const json = await response.json().catch(() => null) throw new Error(json?.error ?? "Falha ao atualizar a empresa.") } const json = (await response.json()) as { company: NormalizedCompany } onUpdated(json.company) toast.success(`Empresa “${json.company.name}” atualizada com sucesso.`) } else { const response = await fetch("/api/admin/companies", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) if (!response.ok) { const json = await response.json().catch(() => null) throw new Error(json?.error ?? "Falha ao criar a empresa.") } const json = (await response.json()) as { company: NormalizedCompany } onCreated(json.company) toast.success(`Empresa “${json.company.name}” criada com sucesso.`) } close() } catch (error) { const message = error instanceof Error ? error.message : "Não foi possível salvar a empresa." toast.error(message) } }) } return ( (!value ? close() : null)}>
{editor?.mode === "edit" ? ( <> Editar empresa ) : ( <> Nova empresa )} Gerencie dados cadastrais, contatos, contratos e SLAs da organização.

Identificação

Dados básicos exibidos nas listagens e relatórios.

( )} />