sistema-de-chamados/src/components/admin/users/admin-users-workspace.tsx

1211 lines
48 KiB
TypeScript

"use client"
import { useCallback, useMemo, useState, useTransition } from "react"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import {
IconBuildingSkyscraper,
IconChevronRight,
IconFilter,
IconMapPin,
IconNotebook,
IconPencil,
IconPlus,
IconSearch,
IconTrash,
IconUsers,
} from "@tabler/icons-react"
import { toast } from "sonner"
import {
COMPANY_CONTACT_ROLES,
COMPANY_CONTRACT_SCOPES,
COMPANY_CONTRACT_TYPES,
COMPANY_LOCATION_TYPES,
companyFormSchema,
type CompanyContact,
type CompanyContract,
type CompanyFormValues,
type CompanyLocation,
} from "@/lib/schemas/company"
import type { NormalizedCompany } from "@/server/company-service"
import { cn } from "@/lib/utils"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
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 { MultiValueInput } from "@/components/ui/multi-value-input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
export type AdminAccount = {
id: string
email: string
name: string
role: "MANAGER" | "COLLABORATOR"
companyId: string | null
companyName: string | null
tenantId: string
createdAt: string
updatedAt: string
authUserId: string | null
lastSeenAt: string | null
}
type Props = {
initialAccounts: AdminAccount[]
companies: NormalizedCompany[]
}
type SectionEditorState =
| null
| {
section: "contacts" | "locations" | "contracts"
company: NormalizedCompany
}
const ROLE_LABEL: Record<AdminAccount["role"], string> = {
MANAGER: "Gestor",
COLLABORATOR: "Colaborador",
}
const NO_CONTACT_VALUE = "__none__"
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 formatDate(value: string | null) {
if (!value) return "Nunca"
const parsed = new Date(value)
return format(parsed, "dd/MM/yy HH:mm", { locale: ptBR })
}
function FieldError({ message }: { message?: string }) {
if (!message) return null
return <p className="text-xs font-medium text-destructive">{message}</p>
}
export function AdminUsersWorkspace({ initialAccounts, companies }: Props) {
const [tab, setTab] = useState<"accounts" | "structure">("accounts")
return (
<Tabs value={tab} onValueChange={(value) => setTab(value as typeof tab)}>
<TabsList className="mb-6">
<TabsTrigger value="accounts">Usuários com acesso</TabsTrigger>
<TabsTrigger value="structure">Estrutura das empresas</TabsTrigger>
</TabsList>
<TabsContent value="accounts">
<AccountsTable initialAccounts={initialAccounts} />
</TabsContent>
<TabsContent value="structure">
<CompanyStructurePanel initialCompanies={companies} />
</TabsContent>
</Tabs>
)
}
function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] }) {
const [accounts, setAccounts] = useState(initialAccounts)
const [search, setSearch] = useState("")
const [roleFilter, setRoleFilter] = useState<"all" | AdminAccount["role"]>("all")
const [companyFilter, setCompanyFilter] = useState<string>("all")
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const [isPending, startTransition] = useTransition()
const [deleteDialogIds, setDeleteDialogIds] = useState<string[]>([])
const filteredAccounts = useMemo(() => {
const term = search.trim().toLowerCase()
return accounts.filter((account) => {
if (roleFilter !== "all" && account.role !== roleFilter) return false
if (companyFilter !== "all" && account.companyId !== companyFilter) return false
if (!term) return true
return (
account.name.toLowerCase().includes(term) ||
account.email.toLowerCase().includes(term) ||
(account.companyName ?? "").toLowerCase().includes(term)
)
})
}, [accounts, roleFilter, companyFilter, search])
const selectedIds = useMemo(
() => Object.entries(rowSelection).filter(([, selected]) => selected).map(([id]) => id),
[rowSelection]
)
const companies = useMemo(() => {
const map = new Map<string, string>()
accounts.forEach((account) => {
if (account.companyId && account.companyName) {
map.set(account.companyId, account.companyName)
}
})
return Array.from(map.entries()).map(([id, name]) => ({ id, name }))
}, [accounts])
const openDeleteDialog = useCallback((ids: string[]) => {
setDeleteDialogIds(ids)
}, [])
const closeDeleteDialog = useCallback(() => setDeleteDialogIds([]), [])
const handleDelete = useCallback(
(ids: string[]) => {
if (ids.length === 0) return
startTransition(async () => {
try {
const response = await fetch("/api/admin/users", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
})
if (!response.ok) {
const payload = await response.json().catch(() => null)
throw new Error(payload?.error ?? "Não foi possível excluir os usuários selecionados.")
}
const json = (await response.json()) as { deletedIds: string[] }
setAccounts((prev) => prev.filter((account) => !json.deletedIds.includes(account.id)))
setRowSelection({})
setDeleteDialogIds([])
toast.success(
json.deletedIds.length === 1
? "Usuário removido com sucesso."
: `${json.deletedIds.length} usuários removidos com sucesso.`,
)
} catch (error) {
const message =
error instanceof Error ? error.message : "Não foi possível excluir os usuários selecionados."
toast.error(message)
}
})
},
[startTransition],
)
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Usuários do cliente</CardTitle>
<CardDescription>Gestores e colaboradores com acesso ao portal de chamados.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-1 flex-wrap 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
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Buscar por nome, e-mail ou empresa..."
className="pl-9"
/>
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2 text-muted-foreground">
<IconFilter className="size-4" />
<Select value={roleFilter} onValueChange={(value: typeof roleFilter) => setRoleFilter(value)}>
<SelectTrigger className="h-9 w-[12rem]">
<SelectValue placeholder="Papel" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os papéis</SelectItem>
<SelectItem value="MANAGER">Gestores</SelectItem>
<SelectItem value="COLLABORATOR">Colaboradores</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={companyFilter} onValueChange={setCompanyFilter}>
<SelectTrigger className="h-9 w-[16rem]">
<SelectValue placeholder="Empresa" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as empresas</SelectItem>
{companies.map((company) => (
<SelectItem key={company.id} value={company.id}>
{company.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="destructive"
disabled={selectedIds.length === 0 || isPending}
onClick={() => openDeleteDialog(selectedIds)}
>
<IconTrash className="mr-2 size-4" />
Remover
</Button>
</div>
</div>
<div className="overflow-x-auto">
<div className="min-w-[64rem] overflow-hidden rounded-lg border">
<Table className="w-full table-fixed text-sm">
<TableHeader className="bg-muted">
<TableRow>
<TableHead>Usuário</TableHead>
<TableHead>Empresa</TableHead>
<TableHead>Papel</TableHead>
<TableHead>Último acesso</TableHead>
<TableHead className="text-right">Selecionar</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAccounts.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground">
Nenhum usuário encontrado.
</TableCell>
</TableRow>
) : (
filteredAccounts.map((account) => {
const initials = account.name
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join("")
return (
<TableRow key={account.id} className="hover:bg-muted/40">
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="size-9 border border-border/60">
<AvatarFallback>{initials || account.email.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 space-y-1">
<p className="font-semibold text-foreground">{account.name}</p>
<p className="text-xs text-muted-foreground">{account.email}</p>
</div>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{account.companyName ?? <span className="italic text-muted-foreground/70">Sem empresa</span>}
</TableCell>
<TableCell className="text-sm">
<Badge variant="secondary">{ROLE_LABEL[account.role]}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatDate(account.lastSeenAt)}</TableCell>
<TableCell className="text-right">
<Checkbox
checked={rowSelection[account.id] ?? false}
onCheckedChange={(checked) =>
setRowSelection((prev) => ({ ...prev, [account.id]: Boolean(checked) }))
}
/>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
<Dialog open={deleteDialogIds.length > 0} onOpenChange={(open) => (!open ? closeDeleteDialog() : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remover usuários</DialogTitle>
<DialogDescription>
Os usuários selecionados perderão o acesso ao portal e seus convites ativos serão revogados.
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={closeDeleteDialog}>
Cancelar
</Button>
<Button variant="destructive" onClick={() => handleDelete(deleteDialogIds)}>
Remover
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
function CompanyStructurePanel({ initialCompanies }: { initialCompanies: NormalizedCompany[] }) {
const [companies, setCompanies] = useState(initialCompanies)
const [search, setSearch] = useState("")
const [selectedId, setSelectedId] = useState<string | null>(initialCompanies[0]?.id ?? null)
const [editor, setEditor] = useState<SectionEditorState>(null)
const filtered = useMemo(() => {
const term = search.trim().toLowerCase()
return companies.filter((company) => {
if (!term) return true
return (
company.name.toLowerCase().includes(term) ||
company.slug.toLowerCase().includes(term) ||
(company.domain?.toLowerCase().includes(term) ?? false)
)
})
}, [companies, search])
const selectedCompany = useMemo(
() => companies.find((company) => company.id === selectedId) ?? null,
[companies, selectedId]
)
const openEditor = useCallback(
(section: "contacts" | "locations" | "contracts") => {
if (!selectedCompany) return
setEditor({ section, company: selectedCompany })
},
[selectedCompany],
)
const closeEditor = useCallback(() => setEditor(null), [])
const applyUpdate = useCallback((updated: NormalizedCompany) => {
setCompanies((prev) => prev.map((company) => (company.id === updated.id ? updated : company)))
}, [])
return (
<div className="grid gap-6 lg:grid-cols-[minmax(18rem,24rem),1fr]">
<Card className="h-fit">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">Empresas</CardTitle>
<CardDescription>Selecione uma empresa para visualizar contatos, localizações e contratos.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="relative">
<IconSearch className="pointer-events-none absolute left-3 top-2.5 size-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Buscar empresa..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
<div className="max-h-[24rem] space-y-2 overflow-y-auto pr-1">
{filtered.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 p-6 text-center text-sm text-muted-foreground">
Nenhuma empresa encontrada.
</div>
) : (
filtered.map((company) => {
const isActive = company.id === selectedId
return (
<button
key={company.id}
type="button"
onClick={() => setSelectedId(company.id)}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2 text-left transition",
isActive
? "border-primary bg-primary/10 text-primary-foreground"
: "border-border/60 hover:border-primary hover:bg-primary/5",
)}
>
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{company.name}</p>
<p className="truncate text-xs text-muted-foreground">{company.slug}</p>
</div>
<IconChevronRight className="size-4 text-muted-foreground" />
</button>
)
})
)}
</div>
</CardContent>
</Card>
<Card className="min-h-[32rem]">
{selectedCompany ? (
<>
<CardHeader className="pb-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<IconBuildingSkyscraper className="size-5 text-muted-foreground" />
{selectedCompany.name}
</CardTitle>
<CardDescription className="text-xs">
{selectedCompany.domain ?? "Sem domínio"} ·{" "}
{selectedCompany.isAvulso ? "Cliente avulso" : "Cliente com contrato"}
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => openEditor("contacts")}>
<IconUsers className="mr-1 size-4" />
Editar contatos
</Button>
<Button variant="outline" size="sm" onClick={() => openEditor("locations")}>
<IconMapPin className="mr-1 size-4" />
Editar localizações
</Button>
<Button variant="outline" size="sm" onClick={() => openEditor("contracts")}>
<IconNotebook className="mr-1 size-4" />
Editar contratos
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-6">
<SectionSummary
title="Contatos estratégicos"
emptyMessage="Nenhum contato cadastrado."
items={selectedCompany.contacts.map((contact) => ({
id: contact.id,
title: contact.fullName,
subtitle: contact.email,
description: contact.role.replace("_", " "),
}))}
/>
<SectionSummary
title="Localizações"
emptyMessage="Nenhuma unidade cadastrada."
items={selectedCompany.locations.map((location) => ({
id: location.id,
title: location.name,
subtitle: location.type,
description: location.address?.city ? `${location.address.city}/${location.address.state}` : location.notes ?? "",
}))}
/>
<SectionSummary
title="Contratos"
emptyMessage="Nenhum contrato cadastrado."
items={selectedCompany.contracts.map((contract) => ({
id: contract.id,
title: `${contract.contractType.toUpperCase()}${contract.planSku ? ` · ${contract.planSku}` : ""}`,
subtitle: contract.scope.length ? contract.scope.join(", ") : "Escopo não informado",
description: contract.notes ?? "",
}))}
/>
</CardContent>
</>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-sm text-muted-foreground">
<IconBuildingSkyscraper className="size-6" />
<p>Selecione uma empresa para visualizar os detalhes.</p>
</div>
)}
</Card>
<CompanySectionSheet editor={editor} onClose={closeEditor} onUpdated={applyUpdate} />
</div>
)
}
function SectionSummary({
title,
items,
emptyMessage,
}: {
title: string
items: { id: string; title: string; subtitle?: string | null; description?: string | null }[]
emptyMessage: string
}) {
return (
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">{title}</p>
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-border/60 p-6 text-sm text-muted-foreground">
{emptyMessage}
</div>
) : (
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="rounded-md border border-border/60 px-3 py-2">
<p className="text-sm font-semibold text-foreground">{item.title}</p>
{item.subtitle ? <p className="text-xs text-muted-foreground">{item.subtitle}</p> : null}
{item.description ? (
<p className="text-xs text-muted-foreground/90">{item.description}</p>
) : null}
</div>
))}
</div>
)}
</div>
)
}
type CompanySectionSheetProps = {
editor: SectionEditorState
onClose(): void
onUpdated(company: NormalizedCompany): void
}
function CompanySectionSheet({ editor, onClose, onUpdated }: CompanySectionSheetProps) {
const [isSubmitting, startSubmit] = useTransition()
const open = Boolean(editor)
const handleSave = (payload: Partial<CompanyFormValues>, companyId: string) => {
startSubmit(async () => {
try {
const response = await fetch(`/api/admin/companies/${companyId}`, {
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("Dados atualizados com sucesso.")
onClose()
} catch (error) {
const message = error instanceof Error ? error.message : "Não foi possível salvar as alterações."
toast.error(message)
}
})
}
let content: React.ReactNode = null
if (editor) {
if (editor.section === "contacts") {
content = (
<ContactsEditor
company={editor.company}
onCancel={onClose}
onSubmit={(contacts) => handleSave({ contacts }, editor.company.id)}
/>
)
} else if (editor.section === "locations") {
content = (
<LocationsEditor
company={editor.company}
onCancel={onClose}
onSubmit={(locations) => handleSave({ locations }, editor.company.id)}
/>
)
} else if (editor.section === "contracts") {
content = (
<ContractsEditor
company={editor.company}
onCancel={onClose}
onSubmit={(contracts) => handleSave({ contracts }, editor.company.id)}
/>
)
}
}
return (
<Sheet open={open} onOpenChange={(value) => (!value ? onClose() : null)}>
<SheetContent className="flex w-full max-w-none flex-col gap-0 bg-background p-0 sm:max-w-[48rem] lg:max-w-[60rem] xl:max-w-[68rem]">
<SheetHeader className="border-b border-border/60 px-10 py-7">
<SheetTitle className="text-xl font-semibold">
{editor?.section === "contacts" ? "Contatos da empresa" : null}
{editor?.section === "locations" ? "Localizações e unidades" : null}
{editor?.section === "contracts" ? "Contratos ativos" : null}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-10 py-8">{content}</div>
<SheetFooter className="border-t border-border/60 px-10 py-5">
<div className="flex w-full items-center justify-end">
{isSubmitting ? (
<span className="text-sm text-muted-foreground">Salvando...</span>
) : null}
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
function ContactsEditor({
company,
onCancel,
onSubmit,
}: {
company: NormalizedCompany
onCancel(): void
onSubmit(contacts: CompanyContact[]): void
}) {
const form = useForm<{ contacts: CompanyContact[] }>({
resolver: zodResolver(companyFormSchema.pick({ contacts: true })),
defaultValues: {
contacts: company.contacts.length
? company.contacts
: [
{
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,
},
],
},
})
const fieldArray = useFieldArray({ control: form.control, name: "contacts" })
const submit = form.handleSubmit((values) => {
onSubmit(values.contacts)
})
return (
<FormProvider {...form}>
<form onSubmit={submit} className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<p className="text-base font-semibold text-foreground">Contatos estratégicos</p>
<p className="text-sm text-muted-foreground">
Cadastre responsáveis por aprovação, faturamento e comunicação.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="self-start sm:self-auto"
onClick={() =>
fieldArray.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-2 size-4" />
Novo contato
</Button>
</div>
<div className="space-y-6">
{fieldArray.fields.map((field, index) => {
const errors = form.formState.errors.contacts?.[index]
return (
<Card key={field.id} className="border border-border/60 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg font-semibold">Contato #{index + 1}</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fieldArray.remove(index)}
className="text-destructive"
>
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Nome completo
</Label>
<Input {...form.register(`contacts.${index}.fullName` as const)} />
<FieldError message={errors?.fullName?.message} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
E-mail
</Label>
<Input {...form.register(`contacts.${index}.email` as const)} />
<FieldError message={errors?.email?.message} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Telefone
</Label>
<Input {...form.register(`contacts.${index}.phone` as const)} placeholder="(11) 99999-0000" />
<FieldError message={errors?.phone?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
WhatsApp
</Label>
<Input {...form.register(`contacts.${index}.whatsapp` as const)} placeholder="(11) 99999-0000" />
<FieldError message={errors?.whatsapp?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Função
</Label>
<Controller
name={`contacts.${index}.role` as const}
control={form.control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
{COMPANY_CONTACT_ROLES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Cargo interno
</Label>
<Input {...form.register(`contacts.${index}.title` as const)} placeholder="ex.: Coordenador TI" />
</div>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Preferências de contato
</Label>
<Controller
name={`contacts.${index}.preference` as const}
control={form.control}
render={({ field }) => (
<MultiValueInput
values={field.value}
onChange={field.onChange}
placeholder="email, phone, whatsapp..."
/>
)}
/>
</div>
<div className="md:col-span-2 flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
<Checkbox
checked={form.watch(`contacts.${index}.canAuthorizeTickets` as const)}
onCheckedChange={(checked) =>
form.setValue(`contacts.${index}.canAuthorizeTickets` as const, Boolean(checked))
}
/>
<span className="text-sm font-medium text-neutral-700">Pode autorizar tickets</span>
</div>
<div className="md:col-span-2 flex items-center gap-3 rounded-lg border border-border/60 bg-muted/30 px-4 py-3">
<Checkbox
checked={form.watch(`contacts.${index}.canApproveCosts` as const)}
onCheckedChange={(checked) =>
form.setValue(`contacts.${index}.canApproveCosts` as const, Boolean(checked))
}
/>
<span className="text-sm font-medium text-neutral-700">Pode aprovar custos</span>
</div>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Anotações
</Label>
<Textarea {...form.register(`contacts.${index}.notes` as const)} />
</div>
</CardContent>
</Card>
)
})}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" className="whitespace-nowrap sm:w-auto">
<IconPencil className="mr-2 size-4" />
Salvar contatos
</Button>
</div>
</form>
</FormProvider>
)
}
function LocationsEditor({
company,
onCancel,
onSubmit,
}: {
company: NormalizedCompany
onCancel(): void
onSubmit(locations: CompanyLocation[]): void
}) {
const form = useForm<{ locations: CompanyLocation[] }>({
resolver: zodResolver(companyFormSchema.pick({ locations: true })),
defaultValues: {
locations: company.locations.length
? company.locations
: [
{
id: createId("location"),
name: "",
type: COMPANY_LOCATION_TYPES[0]?.value ?? "matrix",
address: null,
responsibleContactId: null,
serviceWindow: { mode: "inherit", periods: [] },
notes: null,
},
],
},
})
const fieldArray = useFieldArray({ control: form.control, name: "locations" })
const submit = form.handleSubmit((values) => onSubmit(values.locations))
const contacts = company.contacts
return (
<FormProvider {...form}>
<form onSubmit={submit} className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<p className="text-base font-semibold text-foreground">Localizações</p>
<p className="text-sm text-muted-foreground">
Defina unidades críticas, data centers e filiais para atendimento dedicado.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="self-start sm:self-auto"
onClick={() =>
fieldArray.append({
id: createId("location"),
name: "",
type: COMPANY_LOCATION_TYPES[0]?.value ?? "matrix",
address: null,
responsibleContactId: null,
serviceWindow: { mode: "inherit", periods: [] },
notes: null,
})
}
>
<IconPlus className="mr-2 size-4" />
Nova unidade
</Button>
</div>
<div className="space-y-6">
{fieldArray.fields.map((field, index) => {
const errors = form.formState.errors.locations?.[index]
return (
<Card key={field.id} className="border border-border/60 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg font-semibold">Unidade #{index + 1}</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fieldArray.remove(index)}
className="text-destructive"
>
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nome</Label>
<Input {...form.register(`locations.${index}.name` as const)} />
<FieldError message={errors?.name?.message} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Tipo</Label>
<Controller
name={`locations.${index}.type` as const}
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 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Contato responsável
</Label>
<Controller
name={`locations.${index}.responsibleContactId` as const}
control={form.control}
render={({ field }) => (
<Select
value={field.value ?? NO_CONTACT_VALUE}
onValueChange={(value) =>
field.onChange(value === NO_CONTACT_VALUE ? null : value)
}
>
<SelectTrigger>
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_CONTACT_VALUE}>Nenhum</SelectItem>
{contacts.map((contact) => (
<SelectItem key={contact.id} value={contact.id}>
{contact.fullName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Notas
</Label>
<Textarea {...form.register(`locations.${index}.notes` as const)} />
</div>
</CardContent>
</Card>
)
})}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" className="whitespace-nowrap sm:w-auto">
<IconPencil className="mr-2 size-4" />
Salvar localizações
</Button>
</div>
</form>
</FormProvider>
)
}
function ContractsEditor({
company,
onCancel,
onSubmit,
}: {
company: NormalizedCompany
onCancel(): void
onSubmit(contracts: CompanyContract[]): void
}) {
const form = useForm<{ contracts: CompanyContract[] }>({
resolver: zodResolver(companyFormSchema.pick({ contracts: true })),
defaultValues: {
contracts: company.contracts.length
? company.contracts
: [
{
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,
},
],
},
})
const fieldArray = useFieldArray({ control: form.control, name: "contracts" })
const submit = form.handleSubmit((values) => onSubmit(values.contracts))
return (
<FormProvider {...form}>
<form onSubmit={submit} className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1.5">
<p className="text-base font-semibold text-foreground">Contratos</p>
<p className="text-sm text-muted-foreground">
Registre vigência, escopo e condições para este cliente.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="self-start sm:self-auto"
onClick={() =>
fieldArray.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-2 size-4" />
Novo contrato
</Button>
</div>
<div className="space-y-6">
{fieldArray.fields.map((field, index) => {
const errors = form.formState.errors.contracts?.[index]
return (
<Card key={field.id} className="border border-border/60 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
<CardTitle className="text-lg font-semibold">Contrato #{index + 1}</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fieldArray.remove(index)}
className="text-destructive"
>
<IconTrash className="size-4" />
</Button>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Tipo
</Label>
<Controller
name={`contracts.${index}.contractType` as const}
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 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
SKU/plano
</Label>
<Input {...form.register(`contracts.${index}.planSku` as const)} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Início
</Label>
<Input type="date" {...form.register(`contracts.${index}.startDate` as const)} />
<FieldError message={errors?.startDate?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Fim
</Label>
<Input type="date" {...form.register(`contracts.${index}.endDate` as const)} />
<FieldError message={errors?.endDate?.message as string | undefined} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Renovação
</Label>
<Input type="date" {...form.register(`contracts.${index}.renewalDate` as const)} />
</div>
<div className="space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Valor (R$)
</Label>
<Input
type="number"
step="0.01"
{...form.register(`contracts.${index}.price` as const, { valueAsNumber: true })}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Escopo
</Label>
<Controller
name={`contracts.${index}.scope` as const}
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
: "Escopo fora do catálogo"
}
/>
)}
/>
</div>
<div className="md:col-span-2 space-y-2">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Observações
</Label>
<Textarea {...form.register(`contracts.${index}.notes` as const)} />
</div>
</CardContent>
</Card>
)
})}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" className="whitespace-nowrap sm:w-auto" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit" className="whitespace-nowrap sm:w-auto">
<IconPencil className="mr-2 size-4" />
Salvar contratos
</Button>
</div>
</form>
</FormProvider>
)
}