1211 lines
48 KiB
TypeScript
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>
|
|
)
|
|
}
|