sistema-de-chamados/src/components/admin/companies/admin-companies-manager.tsx
rever-tecnologia 498b9789b5
Some checks failed
CI/CD Web + Desktop / Detect changes (push) Successful in 7s
CI/CD Web + Desktop / Deploy (VPS Linux) (push) Successful in 3m33s
Quality Checks / Lint, Test and Build (push) Successful in 3m41s
CI/CD Web + Desktop / Deploy Convex functions (push) Has been cancelled
feat(email): adiciona templates React Email e melhora UI admin
- Cria 10 novos templates React Email (invite, password-reset, new-login,
  sla-warning, sla-breached, ticket-created, ticket-resolved,
  ticket-assigned, ticket-status, ticket-comment)
- Adiciona envio de email ao criar convite de usuario
- Adiciona security_invite em COLLABORATOR_VISIBLE_TYPES
- Melhora tabela de equipe com badges de papel e colunas fixas
- Atualiza TicketCard com nova interface de props
- Remove botao de limpeza de dados antigos do admin

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 11:46:02 -03:00

2418 lines
111 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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: () => (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" />
<span>Carregando dispositivos...</span>
</div>
),
}
)
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 <p className="text-xs font-medium text-destructive">{error}</p>
}
export function AdminCompaniesManager({ initialCompanies, tenantId, autoOpenCreate = false }: Props) {
const [companies, setCompanies] = useState<NormalizedCompany[]>(() => initialCompanies)
const [view, setView] = useState<ViewMode>("table")
const [search, setSearch] = useState("")
const [contractFilter, setContractFilter] = useState<string>("all")
const [regulatedFilter, setRegulatedFilter] = useState<string>("all")
const [isRefreshing, startRefresh] = useTransition()
const [editor, setEditor] = useState<CompanyEditorState | null>(null)
const [isDeleting, setIsDeleting] = useState<NormalizedCompany | null>(null)
const [alertsBySlug, setAlertsBySlug] = useState<Record<string, LastAlertInfo>>({})
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<string, number> = {}
;(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<string>()
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<string>()
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<string, LastAlertInfo> }
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<string, NormalizedCompany[]>()
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 (
<Card key={company.id} className="border-border/60 shadow-sm transition hover:border-primary">
<CardHeader className="space-y-2 pb-3">
<div className="flex items-start justify-between gap-2">
<div>
<CardTitle className="text-base font-semibold">{company.name}</CardTitle>
<CardDescription className="text-xs text-muted-foreground">
{company.slug}
</CardDescription>
</div>
<Button size="icon" variant="ghost" onClick={() => openEdit(company)}>
<IconPencil className="size-4" />
<span className="sr-only">Editar empresa</span>
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
{company.isAvulso ? <Badge variant="outline">Avulso</Badge> : null}
{company.regulatedEnvironments.map((env) => (
<Badge key={env} variant="secondary">
{env.toUpperCase()}
</Badge>
))}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Contratos
</p>
{firstContract ? (
<div className="mt-1 text-sm">
<p className="font-medium capitalize">{firstContract.contractType}</p>
<p className="text-xs text-muted-foreground">
{firstContract.scope.length
? firstContract.scope.join(", ")
: "Escopo indefinido"}
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">Nenhum contrato cadastrado.</p>
)}
</div>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-muted/30 px-3 py-2 text-xs">
<IconClock className="size-3.5 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium text-muted-foreground">Último alerta</p>
{alert ? (
<p>
{alert.usagePct}% usado · limiar {alert.threshold}% ·{" "}
{new Date(alert.createdAt).toLocaleDateString("pt-BR")}
</p>
) : (
<p>Nenhum alerta registrado</p>
)}
</div>
</div>
<div className="space-y-1 text-xs">
<p className="font-medium text-muted-foreground uppercase">Canais principais</p>
<div className="space-y-1">
{company.supportEmail ? <p>Suporte: {company.supportEmail}</p> : null}
{company.billingEmail ? <p>Financeiro: {company.billingEmail}</p> : null}
{company.phone ? <p>Telefone: {company.phone}</p> : null}
</div>
</div>
<div className="flex items-center justify-between border-t border-border/60 pt-3 text-xs">
<button
type="button"
className="inline-flex items-center gap-1 text-muted-foreground transition hover:text-foreground"
onClick={() => {
window.location.href = `/admin/devices?company=${company.slug}`
}}
>
<IconDeviceDesktop className="size-3.5" /> Dispositivos
</button>
<button
type="button"
className="inline-flex items-center gap-1 text-muted-foreground transition hover:text-foreground"
onClick={() => {
if (!company.provisioningCode) {
toast.info("A empresa ainda não possui código de provisionamento.")
return
}
navigator.clipboard
.writeText(company.provisioningCode)
.then(() => toast.success("Código copiado para a área de transferência."))
.catch(() => toast.error("Não foi possível copiar o código."))
}}
>
<IconClipboard className="size-3.5" /> Provisionamento
</button>
<button
type="button"
className="inline-flex items-center gap-1 text-destructive transition hover:text-destructive/80"
onClick={() => confirmDelete(company)}
>
<IconTrash className="size-3.5" /> Remover
</button>
</div>
</CardContent>
</Card>
)
}
return (
<>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 rounded-lg border border-dashed border-border/60 bg-muted/30 p-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold text-foreground">Empresas atendidas</p>
<p className="text-xs text-muted-foreground">
Cadastre, edite e visualize contratos, contatos e SLAs das empresas.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={refresh} disabled={isRefreshing}>
<IconRefresh className={cn("mr-1 size-3.5", isRefreshing && "animate-spin")} />
Atualizar
</Button>
<Button size="sm" onClick={openCreate}>
<IconPlus className="mr-1 size-3.5" />
Nova empresa
</Button>
</div>
</div>
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-1 flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[16rem]">
<IconSearch className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
placeholder="Buscar por nome, domínio ou contato..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="pl-9"
/>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<IconFilter className="size-4 text-muted-foreground" />
<Select value={contractFilter} onValueChange={setContractFilter}>
<SelectTrigger className="h-9 w-[12rem]">
<SelectValue placeholder="Contrato" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os contratos</SelectItem>
{contractOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<IconAlertTriangle className="size-4" />
<Select value={regulatedFilter} onValueChange={setRegulatedFilter}>
<SelectTrigger className="h-9 w-[14rem]">
<SelectValue placeholder="Regulação" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os ambientes</SelectItem>
{regulatedOptions.map((option) => (
<SelectItem key={option} value={option}>
{option.toUpperCase()}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<ToggleGroup
type="single"
value={view}
onValueChange={(next: ViewMode | string) => {
if (!next) return
setView(next as ViewMode)
}}
variant="outline"
className="rounded-md border border-border/60 bg-muted/30"
>
<ToggleGroupItem value="table" aria-label="Listagem em tabela" className="min-w-[84px] justify-center gap-2">
<IconList className="size-4" />
<span>Lista</span>
</ToggleGroupItem>
<ToggleGroupItem value="board" aria-label="Visão em quadro" className="min-w-[84px] justify-center gap-2">
<IconBuildingSkyscraper className="size-4" />
<span>Quadro</span>
</ToggleGroupItem>
</ToggleGroup>
</div>
</CardHeader>
<CardContent>
{view === "table" ? (
<TableView
companies={filtered}
machineCountsBySlug={machineCountsBySlug}
onEdit={openEdit}
onDelete={confirmDelete}
/>
) : (
<div className="grid gap-3 lg:grid-cols-2 xl:grid-cols-3">
{BOARD_COLUMNS.map((column) => {
const list = boardGroups.get(column.id) ?? []
return (
<div key={column.id} className="rounded-lg border border-border/60 bg-muted/20">
<div className="flex items-center justify-between border-b border-border/60 px-4 py-3">
<div>
<p className="text-sm font-semibold text-foreground">{column.title}</p>
<p className="text-xs text-muted-foreground">{column.description}</p>
</div>
<Badge variant="outline">{list.length}</Badge>
</div>
<div className="space-y-3 p-3">
{list.length === 0 ? (
<EmptyColumn />
) : (
list.map((company) => renderBoardCard(company))
)}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
<CompanySheet
tenantId={effectiveTenantId}
editor={editor}
onClose={closeEditor}
onCreated={(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)),
)
}}
/>
<Dialog open={Boolean(isDeleting)} onOpenChange={(open) => (!open ? cancelDelete() : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover empresa</DialogTitle>
<DialogDescription>
Esta ação desvincula usuários e tickets da empresa selecionada. Confirme para continuar.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={cancelDelete}>
Cancelar
</Button>
<Button variant="destructive" onClick={handleDelete}>
Remover
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
function EmptyColumn() {
return (
<div className="flex flex-col items-center gap-2 rounded-md border border-dashed border-border/60 bg-background/70 py-6 text-center text-xs text-muted-foreground">
<IconBuildingSkyscraper className="size-6 text-border" />
<p>Sem empresas nesta categoria.</p>
</div>
)
}
type TableViewProps = {
companies: NormalizedCompany[]
machineCountsBySlug: Record<string, number>
onEdit(company: NormalizedCompany): void
onDelete(company: NormalizedCompany): void
}
function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableViewProps) {
const [pageSize, setPageSize] = useState<number>(10)
const [pageIndex, setPageIndex] = useState<number>(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 (
<div className="space-y-3">
<div className="overflow-hidden rounded-lg border">
<Table className="w-full table-auto">
<TableHeader className="bg-slate-100/80 border-b border-slate-200">
<TableRow>
<TableHead className="text-center">Empresa</TableHead>
<TableHead className="text-center">Contratos ativos</TableHead>
<TableHead className="text-center">Contatos</TableHead>
<TableHead className="text-center">Dispositivos</TableHead>
<TableHead className="text-center">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.length === 0 ? (
<TableRow>
<TableCell colSpan={5}>
<div className="flex flex-col items-center gap-2 py-6 text-sm text-muted-foreground">
<IconBuildingSkyscraper className="size-6 text-border" />
<p>Nenhuma empresa encontrada com os filtros atuais.</p>
</div>
</TableCell>
</TableRow>
) : (
current.map((company) => {
const contracts = company.contracts
const contacts = company.contacts.slice(0, 3)
const machineCount = machineCountsBySlug[company.slug] ?? 0
return (
<TableRow key={company.id} className="hover:bg-muted/40">
<TableCell className="align-middle">
<div className="flex min-h-[3.5rem] flex-col justify-center gap-1">
<div className="flex items-center gap-2">
<p className="font-semibold text-foreground">{company.name}</p>
{company.isAvulso ? <Badge variant="outline">Avulso</Badge> : null}
</div>
<div className="flex flex-wrap gap-1 text-xs text-muted-foreground">
{company.domain ? <span>{company.domain}</span> : null}
{company.phone ? (
<span className="inline-flex items-center gap-1">
<IconUsers className="size-3" />
{company.phone}
</span>
) : null}
</div>
<div className="flex flex-wrap gap-1">
{company.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px] uppercase">
{tag}
</Badge>
))}
</div>
</div>
</TableCell>
<TableCell className="align-middle text-sm">
{contracts.length === 0 ? (
<p className="text-muted-foreground">Nenhum contrato registrado.</p>
) : (
<ul className="space-y-1 text-xs">
{contracts.map((contract) => (
<li key={contract.id} className="rounded-md border border-border/60 px-2 py-1">
<p className="font-semibold capitalize">{contract.contractType}</p>
<p className="text-muted-foreground">
{contract.scope.length ? contract.scope.join(", ") : "Escopo base"}
</p>
<p className="text-muted-foreground">
Vigência: {formatDate(contract.startDate)} {formatDate(contract.endDate)}
</p>
<p className="text-muted-foreground">
Valor: {formatCurrency(contract.price ?? null)}
</p>
</li>
))}
</ul>
)}
</TableCell>
<TableCell className="align-middle text-sm">
{contacts.length === 0 ? (
<p className="text-muted-foreground">Nenhum contato cadastrado.</p>
) : (
<ul className="space-y-1 text-xs">
{contacts.map((contact) => (
<li key={contact.id} className="rounded-md border border-border/60 px-2 py-1">
<p className="font-semibold">{contact.fullName}</p>
<p className="text-muted-foreground">{contact.email}</p>
<p className="text-muted-foreground capitalize">{contact.role.replace("_", " ")}</p>
</li>
))}
{company.contacts.length > contacts.length ? (
<li className="text-xs text-muted-foreground">
+ {company.contacts.length - contacts.length} outros contatos
</li>
) : null}
</ul>
)}
</TableCell>
<TableCell className="align-middle text-sm">
<Badge variant="outline">{machineCount}</Badge>
</TableCell>
<TableCell className="align-middle text-right text-sm">
<div className="flex flex-wrap items-center justify-end gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => {
if (!company.provisioningCode) {
toast.info("Esta empresa não possui código de provisionamento.")
return
}
navigator.clipboard
.writeText(company.provisioningCode)
.then(() => toast.success("Código copiado."))
.catch(() => toast.error("Não foi possível copiar o código."))
}}
>
<IconCopy className="mr-2 size-3.5" />
Código
</Button>
<Button size="sm" variant="outline" className="whitespace-nowrap" onClick={() => { window.location.href = `/admin/devices?company=${company.slug}` }}>
<IconDeviceDesktop className="mr-2 size-3.5" />
Dispositivos
</Button>
<Button
size="icon"
aria-label="Editar empresa"
title="Editar empresa"
className="h-9 w-9 rounded-lg border border-slate-200 bg-white text-neutral-800 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-200"
onClick={() => onEdit(company)}
>
<IconPencil className="size-4" />
</Button>
<Button
size="icon"
aria-label="Remover empresa"
onClick={() => onDelete(company)}
className="h-9 w-9 rounded-lg border border-rose-300 bg-white text-rose-600 transition hover:bg-rose-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200"
>
<IconTrash className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center justify-between gap-3 border-t border-slate-200 px-2 pt-2 text-sm text-neutral-600 md:flex-row">
<div className="text-xs text-neutral-500 md:text-sm">
{total === 0 ? "Nenhum registro" : `Mostrando ${start}-${end} de ${total}`}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 text-xs tracking-wide text-neutral-500 md:text-sm">
<span>Itens por página</span>
<Select value={`${pageSize}`} onValueChange={(v) => { setPageSize(Number(v)); setPageIndex(0) }}>
<SelectTrigger className="h-8 w-20">
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent align="end">
{[10, 20, 30, 50].map((n) => (
<SelectItem key={n} value={`${n}`}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious disabled={pageIndex === 0} onClick={() => setPageIndex((p) => Math.max(0, p - 1))} />
</PaginationItem>
<PaginationItem>
<PaginationLink
href="#"
isActive
onClick={(event) => {
event.preventDefault()
}}
>
{pageIndex + 1}
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext disabled={pageIndex >= pageCount - 1} onClick={() => setPageIndex((p) => Math.min(pageCount - 1, p + 1))} />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</div>
)
}
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<CompanyFormValues>({
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 (
<Dialog open={open} onOpenChange={(value) => (!value ? close() : null)}>
<DialogContent className="max-w-5xl gap-0 overflow-hidden rounded-3xl border border-border/60 bg-background p-0 shadow-2xl">
<FormProvider {...form}>
<div className="flex max-h-[85vh] flex-col">
<div className="flex flex-col gap-4 border-b border-border/60 px-6 py-5 md:flex-row md:items-center md:justify-between">
<DialogHeader className="gap-1.5 p-0 text-left">
<DialogTitle className="flex items-center gap-2 text-xl font-semibold text-foreground">
{editor?.mode === "edit" ? (
<>
<IconPencil className="size-4 text-muted-foreground" />
Editar empresa
</>
) : (
<>
<IconPlus className="size-4 text-muted-foreground" />
Nova empresa
</>
)}
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
Gerencie dados cadastrais, contatos, contratos e SLAs da organização.
</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-center gap-2 md:justify-end">
<Button type="button" variant="outline" onClick={close} disabled={isSubmitting}>
Cancelar
</Button>
<Button
type="submit"
form="company-form"
disabled={isSubmitting}
onClick={async (e) => {
const valid = await form.trigger()
if (!valid) {
e.preventDefault()
toast.error("Corrija os campos destacados antes de salvar.")
}
}}
>
{isSubmitting ? (
<>
<IconRefresh className="mr-2 size-4 animate-spin" />
Salvando...
</>
) : (
<>
<IconCheck className="mr-2 size-4" />
Salvar
</>
)}
</Button>
</div>
</div>
<ScrollArea className="flex-1">
<form
id="company-form"
className="space-y-6 px-6 py-6"
onSubmit={form.handleSubmit(handleSubmit)}
>
<section className="space-y-4">
<div>
<p className="text-sm font-semibold text-foreground">Identificação</p>
<p className="text-xs text-muted-foreground">
Dados básicos exibidos nas listagens e relatórios.
</p>
</div>
<div className="grid gap-x-4 gap-y-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name" className="flex items-center gap-1">Nome fantasia <span className="text-destructive">*</span></Label>
<Input id="name" {...form.register("name")} autoFocus />
<FieldError error={form.formState.errors.name?.message} />
</div>
<div className="space-y-2">
<Label htmlFor="slug" className="flex items-center gap-1">Apelido <span className="text-destructive">*</span></Label>
<Input id="slug" {...form.register("slug")} placeholder="ex: acme" />
<FieldError error={form.formState.errors.slug?.message} />
</div>
<div className="space-y-2">
<Label htmlFor="legalName">Razão social</Label>
<Input id="legalName" {...form.register("legalName")} />
<FieldError error={form.formState.errors.legalName?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label htmlFor="tradeName">Nome fantasia alternativo</Label>
<Input id="tradeName" {...form.register("tradeName")} />
<FieldError error={form.formState.errors.tradeName?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label htmlFor="cnpj">CNPJ</Label>
<Input id="cnpj" {...form.register("cnpj")} placeholder="00.000.000/0000-00" />
<FieldError error={form.formState.errors.cnpj?.message as string | undefined} />
</div>
<div className="grid gap-x-4 gap-y-3 md:grid-cols-[2fr,1fr]">
<div className="space-y-2">
<Label htmlFor="stateRegistration">Inscrição estadual</Label>
<Input id="stateRegistration" {...form.register("stateRegistration")} />
<FieldError error={form.formState.errors.stateRegistration?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label htmlFor="stateRegistrationType">Tipo</Label>
<Controller
control={form.control}
name="stateRegistrationType"
render={({ field }) => (
<Select
value={field.value ?? EMPTY_SELECT_VALUE}
onValueChange={(value) =>
field.onChange(value === EMPTY_SELECT_VALUE ? undefined : value)
}
>
<SelectTrigger id="stateRegistrationType">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_SELECT_VALUE}>Nenhum</SelectItem>
{COMPANY_STATE_REGISTRATION_TYPES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError error={form.formState.errors.stateRegistrationType?.message as string | undefined} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="primaryCnae">CNAE principal</Label>
<Input id="primaryCnae" {...form.register("primaryCnae")} />
<FieldError error={form.formState.errors.primaryCnae?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label htmlFor="description">Descrição</Label>
<Textarea id="description" {...form.register("description")} />
</div>
<div className="space-y-2">
<Label htmlFor="domain">Domínio principal</Label>
<Input id="domain" {...form.register("domain")} placeholder="cliente.com.br" />
<FieldError error={form.formState.errors.domain?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefone</Label>
<Input id="phone" {...form.register("phone")} placeholder="(11) 99999-0000" />
<FieldError error={form.formState.errors.phone?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label htmlFor="address">Endereço padrão</Label>
<Textarea
id="address"
{...form.register("address")}
placeholder="Rua, número, bairro, cidade/UF"
/>
<FieldError error={form.formState.errors.address?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label htmlFor="contractedHoursPerMonth">Horas contratadas / mês</Label>
<Input
id="contractedHoursPerMonth"
type="number"
min={0}
step="0.5"
{...form.register("contractedHoursPerMonth", { valueAsNumber: true })}
/>
<FieldError
error={form.formState.errors.contractedHoursPerMonth?.message as string | undefined}
/>
</div>
<div className="flex items-center gap-3">
<Checkbox id="isAvulso" checked={form.watch("isAvulso")} onCheckedChange={(checked) => form.setValue("isAvulso", Boolean(checked))} />
<Label htmlFor="isAvulso" className="text-sm text-muted-foreground">
Empresa avulsa (sem contrato recorrente)
</Label>
</div>
</div>
</section>
<Accordion type="multiple" defaultValue={["communication", "contacts", "contracts"]} className="space-y-2">
<AccordionItem value="communication" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Canais e comunicação</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="grid gap-x-4 gap-y-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="supportEmail">E-mail de suporte</Label>
<Input id="supportEmail" {...form.register("supportEmail")} placeholder="suporte@cliente.com.br" />
<FieldError error={form.formState.errors.supportEmail?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label htmlFor="billingEmail">E-mail financeiro</Label>
<Input id="billingEmail" {...form.register("billingEmail")} placeholder="financeiro@cliente.com.br" />
<FieldError error={form.formState.errors.billingEmail?.message as string | undefined} />
</div>
</div>
<div className="grid gap-x-4 gap-y-3 md:grid-cols-2">
<Controller
name="communicationChannels.supportEmails"
control={form.control}
render={({ field }) => (
<div className="space-y-2">
<Label>Outros e-mails de suporte</Label>
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="adicionar e-mail"
format={(value) => value.toLowerCase()}
/>
<FieldError
error={
form.formState.errors.communicationChannels?.supportEmails?.message as string | undefined
}
/>
</div>
)}
/>
<Controller
name="communicationChannels.billingEmails"
control={form.control}
render={({ field }) => (
<div className="space-y-2">
<Label>Outros e-mails financeiros</Label>
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="adicionar e-mail"
format={(value) => value.toLowerCase()}
/>
<FieldError
error={
form.formState.errors.communicationChannels?.billingEmails?.message as string | undefined
}
/>
</div>
)}
/>
<Controller
name="communicationChannels.phones"
control={form.control}
render={({ field }) => (
<div className="space-y-2">
<Label>Telefones</Label>
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="+55 (11) 99999-0000"
/>
<FieldError
error={
form.formState.errors.communicationChannels?.phones?.message as string | undefined
}
/>
</div>
)}
/>
<Controller
name="communicationChannels.whatsappNumbers"
control={form.control}
render={({ field }) => (
<div className="space-y-2">
<Label>WhatsApp</Label>
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="+55 (11) 99999-0000"
/>
<FieldError
error={
form.formState.errors.communicationChannels?.whatsappNumbers?.message as
| string
| undefined
}
/>
</div>
)}
/>
</div>
<div className="grid gap-x-4 gap-y-3 md:grid-cols-2">
<Controller
name="clientDomains"
control={form.control}
render={({ field }) => (
<div className="space-y-2">
<Label>Domínios permitidos para usuários</Label>
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="cliente.com.br"
format={(value) => value.toLowerCase()}
/>
<FieldError error={form.formState.errors.clientDomains?.message as string | undefined} />
</div>
)}
/>
<div className="space-y-2">
<Label htmlFor="contactPreferences.defaultChannel">Canal padrão</Label>
<Controller
name="contactPreferences.defaultChannel"
control={form.control}
render={({ field }) => (
<Select
value={field.value ?? EMPTY_SELECT_VALUE}
onValueChange={(value) =>
field.onChange(value === EMPTY_SELECT_VALUE ? null : value)
}
>
<SelectTrigger id="contactPreferences.defaultChannel">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_SELECT_VALUE}>Não informado</SelectItem>
{COMPANY_CONTACT_PREFERENCES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FieldError
error={
form.formState.errors.contactPreferences?.defaultChannel?.message as string | undefined
}
/>
<Label htmlFor="contactPreferences.escalationNotes" className="block text-sm pt-2">
Observações para escalonamento
</Label>
<Textarea
id="contactPreferences.escalationNotes"
{...form.register("contactPreferences.escalationNotes")}
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="contacts" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Contatos estratégicos</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Cadastre responsáveis por aprovação, comunicação e suporte.
</p>
<Button
variant="outline"
size="sm"
onClick={() =>
contactsArray.append({
id: createId("contact"),
fullName: "",
email: "",
role: COMPANY_CONTACT_ROLES[0]?.value ?? "usuario_chave",
phone: null,
whatsapp: null,
preference: [],
title: null,
canAuthorizeTickets: false,
canApproveCosts: false,
lgpdConsent: true,
notes: null,
})
}
>
<IconPlus className="mr-1 size-3.5" />
Novo contato
</Button>
</div>
{contactsArray.fields.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center text-sm text-muted-foreground">
Nenhum contato cadastrado.
</div>
) : (
<div className="space-y-4">
{contactsArray.fields.map((field, index) => {
const fieldErrors = form.formState.errors.contacts?.[index]
return (
<Card key={field.id} className="border-border/60">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle className="text-base font-semibold">Contato #{index + 1}</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => contactsArray.remove(index)}
className="text-destructive"
>
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Nome completo</Label>
<Input {...form.register(`contacts.${index}.fullName`)} />
<FieldError error={fieldErrors?.fullName?.message} />
</div>
<div className="space-y-2">
<Label>E-mail</Label>
<Input {...form.register(`contacts.${index}.email`)} />
<FieldError error={fieldErrors?.email?.message} />
</div>
<div className="space-y-2">
<Label>Telefone</Label>
<Input {...form.register(`contacts.${index}.phone`)} placeholder="(11) 99999-0000" />
<FieldError error={fieldErrors?.phone?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label>WhatsApp</Label>
<Input {...form.register(`contacts.${index}.whatsapp`)} placeholder="(11) 99999-0000" />
<FieldError error={fieldErrors?.whatsapp?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label>Cargo/Função</Label>
<Controller
name={`contacts.${index}.role`}
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{COMPANY_CONTACT_ROLES.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Cargo interno</Label>
<Input {...form.register(`contacts.${index}.title`)} placeholder="Coordenador de TI" />
</div>
<div className="space-y-2 md:col-span-2">
<Label>Preferências</Label>
<Controller
name={`contacts.${index}.preference`}
control={form.control}
render={({ field }) => (
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="Adicionar preferência"
emptyState={
<span className="text-muted-foreground">
Use email, phone, whatsapp...
</span>
}
/>
)}
/>
</div>
<div className="flex items-center gap-3">
<Checkbox
checked={form.watch(`contacts.${index}.canAuthorizeTickets`)}
onCheckedChange={(checked) =>
form.setValue(`contacts.${index}.canAuthorizeTickets`, Boolean(checked))
}
/>
<span className="text-sm text-muted-foreground">
Pode autorizar abertura/alteração de tickets
</span>
</div>
<div className="flex items-center gap-3">
<Checkbox
checked={form.watch(`contacts.${index}.canApproveCosts`)}
onCheckedChange={(checked) =>
form.setValue(`contacts.${index}.canApproveCosts`, Boolean(checked))
}
/>
<span className="text-sm text-muted-foreground">
Pode aprovar custos adicionais
</span>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Anotações</Label>
<Textarea {...form.register(`contacts.${index}.notes`)} />
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="locations" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Localizações e unidades</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Registre filiais, data centers ou unidades críticas.</p>
<Button
variant="outline"
size="sm"
onClick={() =>
locationsArray.append({
id: createId("location"),
name: "",
type: COMPANY_LOCATION_TYPES[0]?.value ?? "matrix",
address: null,
responsibleContactId: null,
serviceWindow: { mode: "inherit", periods: [] },
notes: null,
})
}
>
<IconMapPin className="mr-1 size-3.5" />
Nova unidade
</Button>
</div>
{locationsArray.fields.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center text-sm text-muted-foreground">
Nenhuma unidade cadastrada.
</div>
) : (
<div className="space-y-4">
{locationsArray.fields.map((field, index) => {
const fieldErrors = form.formState.errors.locations?.[index]
return (
<Card key={field.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div>
<CardTitle className="text-base font-semibold">Unidade #{index + 1}</CardTitle>
<CardDescription>
{form.watch(`locations.${index}.type`) === "matrix" ? "Matriz" : "Filial"}
</CardDescription>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => locationsArray.remove(index)}
className="text-destructive"
>
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Nome</Label>
<Input {...form.register(`locations.${index}.name`)} />
<FieldError error={fieldErrors?.name?.message} />
</div>
<div className="space-y-2">
<Label>Tipo</Label>
<Controller
name={`locations.${index}.type`}
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{COMPANY_LOCATION_TYPES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Contato responsável</Label>
<Controller
name={`locations.${index}.responsibleContactId`}
control={form.control}
render={({ field }) => {
const contacts = form.watch("contacts")
return (
<Select
value={field.value ?? EMPTY_SELECT_VALUE}
onValueChange={(value) =>
field.onChange(value === EMPTY_SELECT_VALUE ? null : value)
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value={EMPTY_SELECT_VALUE}>Nenhum</SelectItem>
{contacts.map((contact) => (
<SelectItem key={contact.id} value={contact.id}>
{contact.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
)
}}
/>
</div>
<div className="space-y-2">
<Label>Observações</Label>
<Textarea {...form.register(`locations.${index}.notes`)} />
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="businessHours" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Horários de atendimento</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="grid gap-x-4 gap-y-3 md:grid-cols-2">
<div className="space-y-2">
<Label>Modo</Label>
<Controller
name="businessHours.mode"
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="business">Horário comercial</SelectItem>
<SelectItem value="twentyfour">24x7</SelectItem>
<SelectItem value="custom">Personalizado</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="businessHours.timezone">Fuso horário</Label>
<Input id="businessHours.timezone" {...form.register("businessHours.timezone")} />
</div>
</div>
<BusinessHoursEditor form={form} />
</AccordionContent>
</AccordionItem>
<AccordionItem value="requestTypes" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Tipos de solicitação</AccordionTrigger>
<AccordionContent className="pb-5">
<CompanyRequestTypesControls tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
<CompanyExportTemplateSelector tenantId={tenantId} companyId={editor?.mode === "edit" ? editor.company.id : null} />
</AccordionContent>
</AccordionItem>
{editor?.mode === "edit" ? (
<AccordionItem value="machines" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Dispositivos vinculadas</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="rounded-lg border border-border/60 bg-background p-3">
<AdminDevicesOverview tenantId={tenantId} initialCompanyFilterSlug={editor.company.slug} />
</div>
</AccordionContent>
</AccordionItem>
) : null}
<AccordionItem value="contracts" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Contratos e escopo</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Cadastre contratos ativos, escopo e vigência.
</p>
<Button
variant="outline"
size="sm"
onClick={() =>
contractsArray.append({
id: createId("contract"),
contractType: COMPANY_CONTRACT_TYPES[0]?.value ?? "monthly",
planSku: null,
startDate: null,
endDate: null,
renewalDate: null,
scope: [],
price: null,
costCenter: null,
criticality: "medium",
notes: null,
})
}
>
<IconPlus className="mr-1 size-3.5" />
Adicionar contrato
</Button>
</div>
{contractsArray.fields.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/60 p-6 text-center text-sm text-muted-foreground">
Nenhum contrato cadastrado.
</div>
) : (
<div className="space-y-4">
{contractsArray.fields.map((field, index) => {
const fieldErrors = form.formState.errors.contracts?.[index]
return (
<Card key={field.id}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div>
<CardTitle className="text-base font-semibold">Contrato #{index + 1}</CardTitle>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => contractsArray.remove(index)}
className="text-destructive"
>
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Tipo</Label>
<Controller
name={`contracts.${index}.contractType`}
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{COMPANY_CONTRACT_TYPES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>SKU/plano</Label>
<Input {...form.register(`contracts.${index}.planSku`)} placeholder="SKU interno" />
</div>
<div className="space-y-2">
<Label>Data de início</Label>
<Controller
name={`contracts.${index}.startDate`}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecionar data"
/>
)}
/>
<FieldError error={fieldErrors?.startDate?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label>Data de fim</Label>
<Controller
name={`contracts.${index}.endDate`}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecionar data"
/>
)}
/>
<FieldError error={fieldErrors?.endDate?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label>Renovação</Label>
<Controller
name={`contracts.${index}.renewalDate`}
control={form.control}
render={({ field }) => (
<DatePicker
value={field.value}
onChange={(next) => field.onChange(next ?? null)}
placeholder="Selecionar data"
allowClear
/>
)}
/>
</div>
<div className="space-y-2">
<Label>Valor mensal/projeto</Label>
<Input
type="number"
step="0.01"
{...form.register(`contracts.${index}.price`, { valueAsNumber: true })}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Escopo</Label>
<Controller
name={`contracts.${index}.scope`}
control={form.control}
render={({ field }) => (
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="Adicionar item de escopo"
validate={(value) =>
COMPANY_CONTRACT_SCOPES.includes(value as (typeof COMPANY_CONTRACT_SCOPES)[number])
? null
: "Use escopos padronizados"
}
emptyState={
<span className="text-muted-foreground">
Sugestões: {COMPANY_CONTRACT_SCOPES.slice(0, 4).join(", ")}...
</span>
}
/>
)}
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Observações</Label>
<Textarea {...form.register(`contracts.${index}.notes`)} />
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="sla" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">SLA e horários de atendimento</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="grid gap-x-4 gap-y-3 md:grid-cols-2">
<div className="space-y-2">
<Label>Calendário</Label>
<Controller
name="sla.calendar"
control={form.control}
render={({ field }) => (
<Select value={field.value ?? "business"} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="24x7">24x7</SelectItem>
<SelectItem value="business">Horário comercial</SelectItem>
<SelectItem value="custom">Personalizado</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sla.serviceWindow.timezone">Fuso horário</Label>
<Input id="sla.serviceWindow.timezone" {...form.register("sla.serviceWindow.timezone")} />
</div>
<Controller
name="sla.validChannels"
control={form.control}
render={({ field }) => (
<div className="space-y-2 md:col-span-2">
<Label>Canais válidos</Label>
<MultiValueInput
values={field.value ?? []}
onChange={field.onChange}
placeholder="email, telefone, portal..."
/>
</div>
)}
/>
<Controller
name="sla.holidays"
control={form.control}
render={({ field }) => (
<div className="space-y-2 md:col-span-2">
<Label>Feriados (AAAA-MM-DD)</Label>
<MultiValueInput
values={field.value ?? []}
onChange={field.onChange}
placeholder="2025-12-25"
/>
</div>
)}
/>
</div>
<div className="mt-4">
<p className="text-sm font-semibold text-foreground">Severidades</p>
<div className="mt-2 overflow-x-auto">
<Table className="min-w-[30rem]">
<TableHeader>
<TableRow>
<TableHead>Nível</TableHead>
<TableHead>Resposta (min)</TableHead>
<TableHead>Resolução (min)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{form.watch("sla.severities")?.map((severity, index) => (
<TableRow key={severity.level}>
<TableCell>{severity.level}</TableCell>
<TableCell>
<Input
type="number"
min={0}
{...form.register(`sla.severities.${index}.responseMinutes`, { valueAsNumber: true })}
/>
</TableCell>
<TableCell>
<Input
type="number"
min={0}
{...form.register(`sla.severities.${index}.resolutionMinutes`, { valueAsNumber: true })}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="metadata" className="rounded-lg border border-border/60 bg-muted/20 px-4">
<AccordionTrigger className="py-3 font-semibold">Metadados e observações</AccordionTrigger>
<AccordionContent className="pb-5">
<div className="grid gap-4 md:grid-cols-2">
<Controller
name="tags"
control={form.control}
render={({ field }) => (
<div className="space-y-2">
<Label>Tags</Label>
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="Adicionar tag"
/>
</div>
)}
/>
<div className="flex items-center gap-3">
<Checkbox
id="privacyPolicy.accepted"
checked={form.watch("privacyPolicy.accepted")}
onCheckedChange={(checked) =>
form.setValue("privacyPolicy.accepted", Boolean(checked))
}
/>
<Label htmlFor="privacyPolicy.accepted" className="text-sm text-muted-foreground">
Política de privacidade aceita
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="privacyPolicy.reference">Referência da política</Label>
<Input id="privacyPolicy.reference" {...form.register("privacyPolicy.reference")} placeholder="https://..." />
</div>
<div className="space-y-2 md:col-span-2">
<Label>Campos personalizados</Label>
<div className="space-y-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
customFieldsArray.append({
id: createId("field"),
key: "",
label: "",
type: "text",
value: "",
})
}
>
<IconPlus className="mr-1 size-3.5" />
Adicionar campo
</Button>
{customFieldsArray.fields.length === 0 ? (
<p className="text-xs text-muted-foreground">Nenhum campo personalizado.</p>
) : (
<div className="space-y-3">
{customFieldsArray.fields.map((field, index) => (
<div key={field.id} className="grid gap-3 rounded-lg border border-border/60 p-3 md:grid-cols-4">
<div className="space-y-2 md:col-span-1">
<Label>Chave</Label>
<Input {...form.register(`customFields.${index}.key`)} />
</div>
<div className="space-y-2 md:col-span-1">
<Label>Rótulo</Label>
<Input {...form.register(`customFields.${index}.label`)} />
</div>
<div className="space-y-2 md:col-span-1">
<Label>Tipo</Label>
<Controller
name={`customFields.${index}.type`}
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="boolean">Booleano</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="url">URL</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2 md:col-span-1">
<Label>Valor</Label>
<Input {...form.register(`customFields.${index}.value`)} />
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="col-span-full justify-self-end text-destructive"
onClick={() => customFieldsArray.remove(index)}
>
<IconTrash className="size-4" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="notes">Notas internas</Label>
<Textarea id="notes" {...form.register("notes")} placeholder="Observações adicionais" />
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</form>
</ScrollArea>
<div className="border-t border-border/60 bg-muted/30 px-6 py-3 text-xs text-muted-foreground">
Campos marcados com * são obrigatórios.
</div>
</div>
</FormProvider>
</DialogContent>
</Dialog>
)
}
type BusinessHoursEditorProps = {
form: UseFormReturn<CompanyFormValues>
}
function BusinessHoursEditor({ form }: BusinessHoursEditorProps) {
const mode = form.watch("businessHours.mode")
const periods = form.watch("businessHours.periods") ?? []
if (mode === "twentyfour") {
return (
<div className="mt-4 rounded-md border border-dashed border-border/60 bg-background/70 p-4 text-sm text-muted-foreground">
Atendimento contínuo 24x7. Nenhum período customizado é necessário.
</div>
)
}
const toggleDay = (index: number, day: (typeof DAY_OPTIONS)[number]["value"]) => {
const current = periods ?? []
const target = current[index]
if (!target) return
const nextDays = new Set(target.days)
if (nextDays.has(day)) {
nextDays.delete(day)
} else {
nextDays.add(day)
}
const next = [...current]
next[index] = { ...target, days: Array.from(nextDays.values()) as typeof target.days }
form.setValue("businessHours.periods", next)
}
const updateTime = (index: number, field: "start" | "end", value: string) => {
const next = [...periods]
if (!next[index]) return
next[index] = { ...next[index], [field]: value }
form.setValue("businessHours.periods", next)
}
const removePeriod = (index: number) => {
const next = periods.filter((_, idx) => idx !== index)
form.setValue("businessHours.periods", next)
}
const addPeriod = () => {
const next = [
...periods,
{ days: ["mon", "tue", "wed", "thu", "fri"], start: "09:00", end: "18:00" } as CompanyBusinessHours["periods"][number],
]
form.setValue("businessHours.periods", next)
}
return (
<div className="mt-4 space-y-3">
<Button type="button" variant="outline" size="sm" onClick={addPeriod}>
<IconPlus className="mr-1 size-3.5" />
Adicionar período
</Button>
{periods.length === 0 ? (
<p className="text-xs text-muted-foreground">
Nenhum período personalizado. Defina ao menos um intervalo quando o modo for personalizado.
</p>
) : (
<div className="grid gap-3">
{periods.map((period, index) => (
<div key={index} className="rounded-lg border border-border/60 bg-muted/20 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase text-muted-foreground">Período #{index + 1}</p>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => removePeriod(index)}
className="text-destructive"
>
<IconTrash className="size-4" />
</Button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{DAY_OPTIONS.map((day) => {
const active = period.days.includes(day.value)
return (
<button
type="button"
key={day.value}
className={cn(
"rounded-md border px-3 py-1 text-xs font-semibold uppercase transition",
active
? "border-foreground/30 bg-muted text-foreground"
: "border-border/60 bg-background text-muted-foreground"
)}
onClick={() => toggleDay(index, day.value)}
>
{day.label}
</button>
)
})}
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>Início</Label>
<TimePicker value={period.start} onChange={(v) => updateTime(index, "start", v)} />
</div>
<div className="space-y-2">
<Label>Término</Label>
<TimePicker value={period.end} onChange={(v) => updateTime(index, "end", v)} />
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
type CompanyRequestTypesControlsProps = { tenantId?: string | null; companyId: string | null }
function CompanyRequestTypesControls({ tenantId, companyId }: CompanyRequestTypesControlsProps) {
const { convexUserId } = useAuth()
const canLoad = Boolean(tenantId && convexUserId)
const ensureDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
const hasEnsuredRef = useRef(false)
const settings = useQuery(
api.ticketFormSettings.list,
canLoad ? { tenantId: tenantId as string, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ template: string; scope: string; companyId?: string | null; enabled: boolean; updatedAt: number }> | undefined
const templates = useQuery(
api.ticketFormTemplates.listActive,
canLoad ? { tenantId: tenantId as string, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ key: string; label: string }> | undefined
const upsert = useMutation(api.ticketFormSettings.upsert)
useEffect(() => {
if (!tenantId || !convexUserId || hasEnsuredRef.current) return
hasEnsuredRef.current = true
ensureDefaults({ tenantId, actorId: convexUserId as Id<"users"> }).catch((error) => {
console.error("Falha ao garantir formulários padrão", error)
hasEnsuredRef.current = false
})
}, [ensureDefaults, tenantId, convexUserId])
const resolveEnabled = (template: string) => {
const scoped = (settings ?? []).filter((s) => s.template === template)
const base = true
if (!companyId) return base
const latest = scoped
.filter((s) => s.scope === "company" && String(s.companyId ?? "") === String(companyId))
.sort((a, b) => b.updatedAt - a.updatedAt)[0]
return typeof latest?.enabled === "boolean" ? latest.enabled : base
}
const handleToggle = async (template: string, enabled: boolean) => {
if (!tenantId || !convexUserId || !companyId) return
try {
await upsert({
tenantId,
actorId: convexUserId as Id<"users">,
template,
scope: "company",
companyId: companyId as unknown as Id<"companies">,
enabled,
})
toast.success("Configuração salva.")
} catch (error) {
console.error("Falha ao salvar configuração de formulário", error)
toast.error("Não foi possível salvar a configuração.")
}
}
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Defina quais tipos de solicitação estão disponíveis para colaboradores/gestores desta empresa. Administradores e agentes sempre veem todas as opções.</p>
{!templates ? (
<div className="space-y-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : templates.length === 0 ? (
<p className="text-sm text-neutral-500">Nenhum formulário disponível.</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{templates.map((template) => {
const enabled = resolveEnabled(template.key)
return (
<label key={template.key} className="flex items-center gap-2 text-sm text-foreground">
<Checkbox
checked={enabled}
onCheckedChange={(v) => handleToggle(template.key, Boolean(v))}
disabled={!companyId}
/>
<span>{template.label}</span>
</label>
)
})}
</div>
)}
</div>
)
}
type CompanyExportTemplateSelectorProps = { tenantId?: string | null; companyId: string | null }
function CompanyExportTemplateSelector({ tenantId, companyId }: CompanyExportTemplateSelectorProps) {
const { convexUserId } = useAuth()
const canLoad = Boolean(tenantId && convexUserId)
const templates = useQuery(
api.deviceExportTemplates.list,
canLoad
? {
tenantId: tenantId as string,
viewerId: convexUserId as Id<"users">,
companyId: companyId ? (companyId as unknown as Id<"companies">) : undefined,
includeInactive: true,
}
: "skip"
) as Array<{ id: string; name: string; companyId: string | null; isDefault: boolean; description?: string }> | undefined
const setDefaultTemplate = useMutation(api.deviceExportTemplates.setDefault)
const clearDefaultTemplate = useMutation(api.deviceExportTemplates.clearCompanyDefault)
const companyTemplates = useMemo(() => {
if (!templates || !companyId) return []
return templates.filter((tpl) => String(tpl.companyId ?? "") === String(companyId))
}, [templates, companyId])
const companyDefault = useMemo(() => companyTemplates.find((tpl) => tpl.isDefault) ?? null, [companyTemplates])
const handleChange = async (value: string) => {
if (!tenantId || !convexUserId || !companyId) return
try {
if (value === "inherit") {
await clearDefaultTemplate({
tenantId,
actorId: convexUserId as Id<"users">,
companyId: companyId as unknown as Id<"companies">,
})
toast.success("Template desta empresa voltou a herdar o padrão global.")
} else {
await setDefaultTemplate({
tenantId,
actorId: convexUserId as Id<"users">,
templateId: value as Id<"deviceExportTemplates">,
})
toast.success("Template aplicado para esta empresa.")
}
} catch (error) {
console.error("Falha ao definir template de exportação", error)
toast.error("Não foi possível atualizar o template.")
}
}
const selectValue = companyDefault ? companyDefault.id : "inherit"
return (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Defina o template padrão das exportações de inventário para esta empresa. Ao herdar, o template global será utilizado.
</p>
{!companyId ? (
<p className="text-xs text-neutral-500">Salve a empresa antes de configurar o template.</p>
) : !templates ? (
<Skeleton className="h-10 w-full rounded-md" />
) : companyTemplates.length === 0 ? (
<p className="text-xs text-neutral-500">
Nenhum template específico para esta empresa. Crie um template em <span className="font-semibold">Dispositivos &gt; Exportações</span> e associe a esta empresa para habilitar aqui.
</p>
) : (
<Select value={selectValue} onValueChange={handleChange} disabled={!companyId}>
<SelectTrigger>
<SelectValue placeholder="Herdar template global" />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Herdar template global</SelectItem>
{companyTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}