Some checks failed
- 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>
2418 lines
111 KiB
TypeScript
2418 lines
111 KiB
TypeScript
"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 > 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>
|
||
)
|
||
}
|