sistema-de-chamados/src/components/admin/fields/fields-manager.tsx

733 lines
30 KiB
TypeScript

"use client"
import { useMemo, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { IconAdjustments, IconForms, IconListDetails, IconTypography } from "@tabler/icons-react"
import { ArrowDown, ArrowUp } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { SearchableCombobox, type SearchableComboboxOption } from "@/components/ui/searchable-combobox"
type FieldOption = { value: string; label: string }
type Field = {
id: string
key: string
label: string
description: string
type: "text" | "number" | "select" | "date" | "boolean"
required: boolean
options: FieldOption[]
order: number
scope: string
companyId: string | null
}
const TYPE_LABELS: Record<Field["type"], string> = {
text: "Texto",
number: "Número",
select: "Seleção",
date: "Data",
boolean: "Verdadeiro/Falso",
}
export function FieldsManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const fields = useQuery(
api.fields.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Field[] | undefined
const templates = useQuery(
api.ticketFormTemplates.listActive,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: string; key: string; label: string }> | undefined
const companies = useQuery(
api.companies.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Array<{ id: string; name: string; slug?: string }> | undefined
const scopeOptions = useMemo(
() => [
{ value: "all", label: "Todos os formulários" },
...((templates ?? []).map((tpl) => ({ value: tpl.key, label: tpl.label })) ?? []),
],
[templates]
)
const companyOptions = useMemo<SearchableComboboxOption[]>(() => {
if (!companies) return []
return companies
.map((company) => ({
value: company.id,
label: company.name,
description: company.slug ?? undefined,
keywords: company.slug ? [company.slug] : [],
}))
.sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
}, [companies])
const companyLabelById = useMemo(() => {
const map = new Map<string, string>()
companyOptions.forEach((option) => map.set(option.value, option.label))
return map
}, [companyOptions])
const companyComboboxOptions = useMemo<SearchableComboboxOption[]>(() => {
return [{ value: "all", label: "Todas as empresas" }, ...companyOptions]
}, [companyOptions])
const templateLabelByKey = useMemo(() => {
const map = new Map<string, string>()
templates?.forEach((tpl) => map.set(tpl.key, tpl.label))
return map
}, [templates])
const createField = useMutation(api.fields.create)
const updateField = useMutation(api.fields.update)
const removeField = useMutation(api.fields.remove)
const reorderFields = useMutation(api.fields.reorder)
const [label, setLabel] = useState("")
const [description, setDescription] = useState("")
const [type, setType] = useState<Field["type"]>("text")
const [required, setRequired] = useState(false)
const [options, setOptions] = useState<FieldOption[]>([])
const [scopeSelection, setScopeSelection] = useState<string>("all")
const [companySelection, setCompanySelection] = useState<string>("all")
const [saving, setSaving] = useState(false)
const [editingField, setEditingField] = useState<Field | null>(null)
const [editingScope, setEditingScope] = useState<string>("all")
const [editingCompanySelection, setEditingCompanySelection] = useState<string>("all")
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const totals = useMemo(() => {
if (!fields) return { total: 0, required: 0, select: 0 }
return {
total: fields.length,
required: fields.filter((field) => field.required).length,
select: fields.filter((field) => field.type === "select").length,
}
}, [fields])
const resetForm = () => {
setLabel("")
setDescription("")
setType("text")
setRequired(false)
setOptions([])
setScopeSelection("all")
setCompanySelection("all")
setEditingCompanySelection("all")
}
const closeCreateDialog = () => {
setIsCreateDialogOpen(false)
resetForm()
}
const normalizeOptions = (source: FieldOption[]) =>
source
.map((option) => ({
label: option.label.trim(),
value: option.value.trim() || option.label.trim().toLowerCase().replace(/\s+/g, "_"),
}))
.filter((option) => option.label.length > 0)
const handleCreate = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!label.trim()) {
toast.error("Informe o rótulo do campo")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
const scopeValue = scopeSelection === "all" ? undefined : scopeSelection
const companyIdValue = companySelection === "all" ? undefined : (companySelection as Id<"companies">)
setSaving(true)
toast.loading("Criando campo...", { id: "field" })
try {
await createField({
tenantId,
actorId: convexUserId as Id<"users">,
label: label.trim(),
description: description.trim() || undefined,
type,
required,
options: preparedOptions,
scope: scopeValue,
companyId: companyIdValue,
})
toast.success("Campo criado", { id: "field" })
closeCreateDialog()
} catch (error) {
console.error(error)
toast.error("Não foi possível criar o campo", { id: "field" })
} finally {
setSaving(false)
}
}
const handleRemove = async (field: Field) => {
const confirmed = window.confirm(`Excluir o campo ${field.label}?`)
if (!confirmed) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
toast.loading("Removendo campo...", { id: `field-remove-${field.id}` })
try {
await removeField({
tenantId,
fieldId: field.id as Id<"ticketFields">,
actorId: convexUserId as Id<"users">,
})
toast.success("Campo removido", { id: `field-remove-${field.id}` })
} catch (error) {
console.error(error)
toast.error("Não foi possível remover o campo", { id: `field-remove-${field.id}` })
}
}
const openEdit = (field: Field) => {
setEditingField(field)
setLabel(field.label)
setDescription(field.description)
setType(field.type)
setRequired(field.required)
setOptions(field.options)
setEditingScope(field.scope ?? "all")
setEditingCompanySelection(field.companyId ?? "all")
}
const handleUpdate = async () => {
if (!editingField) return
if (!label.trim()) {
toast.error("Informe o rótulo do campo")
return
}
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const preparedOptions = type === "select" ? normalizeOptions(options) : undefined
const scopeValue = editingScope === "all" ? undefined : editingScope
const companyIdValue = editingCompanySelection === "all" ? undefined : (editingCompanySelection as Id<"companies">)
setSaving(true)
toast.loading("Atualizando campo...", { id: "field-edit" })
try {
await updateField({
tenantId,
fieldId: editingField.id as Id<"ticketFields">,
actorId: convexUserId as Id<"users">,
label: label.trim(),
description: description.trim() || undefined,
type,
required,
options: preparedOptions,
scope: scopeValue,
companyId: companyIdValue,
})
toast.success("Campo atualizado", { id: "field-edit" })
setEditingField(null)
resetForm()
} catch (error) {
console.error(error)
toast.error("Não foi possível atualizar o campo", { id: "field-edit" })
} finally {
setSaving(false)
}
}
const moveField = async (field: Field, direction: "up" | "down") => {
if (!fields) return
if (!convexUserId) {
toast.error("Sessão não sincronizada com o Convex")
return
}
const index = fields.findIndex((item) => item.id === field.id)
const targetIndex = direction === "up" ? index - 1 : index + 1
if (targetIndex < 0 || targetIndex >= fields.length) return
const reordered = [...fields]
const [removed] = reordered.splice(index, 1)
reordered.splice(targetIndex, 0, removed)
try {
await reorderFields({
tenantId,
actorId: convexUserId as Id<"users">,
orderedIds: reordered.map((item) => item.id as Id<"ticketFields">),
})
toast.success("Ordem atualizada")
} catch (error) {
console.error(error)
toast.error("Não foi possível reordenar os campos")
}
}
const addOption = () => {
setOptions((current) => [...current, { label: "", value: "" }])
}
const updateOption = (index: number, key: keyof FieldOption, value: string) => {
setOptions((current) => {
const copy = [...current]
copy[index] = { ...copy[index], [key]: value }
return copy
})
}
const removeOption = (index: number) => {
setOptions((current) => current.filter((_, optIndex) => optIndex !== index))
}
return (
<div className="space-y-8">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconForms className="size-4" /> Campos personalizados
</CardTitle>
<CardDescription>Metadados adicionais disponíveis nos tickets.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.total : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconTypography className="size-4" /> Campos obrigatórios
</CardTitle>
<CardDescription>Informações exigidas na abertura.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.required : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-neutral-600">
<IconListDetails className="size-4" /> Campos de seleção
</CardTitle>
<CardDescription>Usados para listas e múltipla escolha.</CardDescription>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{fields ? totals.select : <Skeleton className="h-8 w-16" />}
</CardContent>
</Card>
</div>
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
<IconAdjustments className="size-5 text-neutral-500" /> Novo campo
</CardTitle>
<CardDescription>Capture informações específicas do seu fluxo de atendimento.</CardDescription>
</CardHeader>
<CardContent className="flex justify-between gap-4">
<p className="text-sm text-neutral-600">Abra o formulário avançado para definir escopo, empresa e opções.</p>
<Button
onClick={() => {
resetForm()
setIsCreateDialogOpen(true)
}}
>
Configurar campo
</Button>
</CardContent>
</Card>
<div className="space-y-4">
{fields === undefined ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className="h-28 rounded-2xl" />
))}
</div>
) : fields.length === 0 ? (
<Card className="border-dashed border-slate-300 bg-slate-50/80">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Nenhum campo cadastrado</CardTitle>
<CardDescription className="text-neutral-600">
Crie campos personalizados para enriquecer os tickets com informações importantes.
</CardDescription>
</CardHeader>
</Card>
) : (
fields.map((field, index) => {
const scopeLabel =
field.scope === "all"
? "Todos os formulários"
: templateLabelByKey.get(field.scope) ?? `Formulário: ${field.scope}`
return (
<Card key={field.id} className="border-slate-200">
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-3">
<CardTitle className="text-xl font-semibold text-neutral-900">{field.label}</CardTitle>
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{TYPE_LABELS[field.type]}
</Badge>
{field.required ? (
<Badge variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
obrigatório
</Badge>
) : null}
</div>
<CardDescription className="text-neutral-600">Identificador: {field.key}</CardDescription>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
{scopeLabel}
</Badge>
<Badge variant="secondary" className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-neutral-700">
{field.companyId ? `Empresa: ${companyLabelById.get(field.companyId) ?? "Específica"}` : "Todas as empresas"}
</Badge>
</div>
{field.description ? (
<p className="text-sm text-neutral-600">{field.description}</p>
) : null}
</div>
<div className="flex flex-col items-end gap-2">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(field)}>
Editar
</Button>
<Button variant="destructive" size="sm" onClick={() => handleRemove(field)}>
Excluir
</Button>
</div>
<div className="flex gap-1 pt-4">
<Button
type="button"
size="icon"
variant="ghost"
className="size-8"
disabled={index === 0}
onClick={() => moveField(field, "up")}
>
<ArrowUp className="size-4" />
<span className="sr-only">Subir</span>
</Button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-8"
disabled={index === fields.length - 1}
onClick={() => moveField(field, "down")}
>
<ArrowDown className="size-4" />
<span className="sr-only">Descer</span>
</Button>
</div>
</div>
</div>
</CardHeader>
{field.type === "select" && field.options.length > 0 ? (
<CardContent>
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-neutral-500">Opções cadastradas</p>
<div className="flex flex-wrap gap-2">
{field.options.map((option) => (
<Badge key={option.value} variant="outline" className="rounded-full border-neutral-300 text-neutral-600">
{option.label} ({option.value})
</Badge>
))}
</div>
</CardContent>
) : null}
</Card>
)
})
)}
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={(open) => {
if (!open) {
closeCreateDialog()
} else {
setIsCreateDialogOpen(true)
}
}}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Novo campo personalizado</DialogTitle>
</DialogHeader>
<form onSubmit={handleCreate} className="grid gap-4 py-2 lg:grid-cols-[minmax(0,280px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="field-label">Rótulo</Label>
<Input
id="field-label"
placeholder="Ex.: Número do contrato"
value={label}
onChange={(event) => setLabel(event.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label>Tipo de dado</Label>
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto curto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="select">Seleção</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
<Label htmlFor="field-required" className="text-sm font-normal text-neutral-600">
Campo obrigatório na abertura
</Label>
</div>
<div className="space-y-2">
<Label>Aplicar em</Label>
<Select value={scopeSelection} onValueChange={setScopeSelection}>
<SelectTrigger>
<SelectValue placeholder="Todos os formulários" />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Empresa (opcional)</Label>
<SearchableCombobox
value={companySelection}
onValueChange={(value) => setCompanySelection(value ?? "all")}
options={companyComboboxOptions}
placeholder="Todas as empresas"
renderValue={(option) =>
option ? (
<span className="truncate">{option.label}</span>
) : (
<span className="text-muted-foreground">Todas as empresas</span>
)
}
/>
<p className="text-xs text-neutral-500">
Sem seleção o campo aparecerá para todos os tickets.
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="field-description">Descrição</Label>
<textarea
id="field-description"
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
placeholder="Como este campo será utilizado"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{type === "select" ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Opções</Label>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
Adicionar opção
</Button>
</div>
{options.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Adicione pelo menos uma opção para este campo de seleção.
</p>
) : (
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
<Input
placeholder="Rótulo"
value={option.label}
onChange={(event) => updateOption(index, "label", event.target.value)}
/>
<Input
placeholder="Valor"
value={option.value}
onChange={(event) => updateOption(index, "value", event.target.value)}
/>
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
Remover
</Button>
</div>
))}
</div>
)}
</div>
) : null}
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={closeCreateDialog} disabled={saving}>
Cancelar
</Button>
<Button type="submit" disabled={saving}>
{saving ? "Salvando..." : "Criar campo"}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingField)} onOpenChange={(value) => (!value ? setEditingField(null) : null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Editar campo</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-2 lg:grid-cols-[minmax(0,260px)_minmax(0,1fr)]">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="edit-field-label">Rótulo</Label>
<Input
id="edit-field-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Tipo de dado</Label>
<Select value={type} onValueChange={(value) => setType(value as Field["type"])}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text">Texto curto</SelectItem>
<SelectItem value="number">Número</SelectItem>
<SelectItem value="select">Seleção</SelectItem>
<SelectItem value="date">Data</SelectItem>
<SelectItem value="boolean">Verdadeiro/Falso</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox id="edit-field-required" checked={required} onCheckedChange={(value) => setRequired(Boolean(value))} />
<Label htmlFor="edit-field-required" className="text-sm font-normal text-neutral-600">
Campo obrigatório na abertura
</Label>
</div>
<div className="space-y-2">
<Label>Aplicar em</Label>
<Select value={editingScope} onValueChange={setEditingScope}>
<SelectTrigger>
<SelectValue placeholder="Todos os formulários" />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Empresa (opcional)</Label>
<SearchableCombobox
value={editingCompanySelection}
onValueChange={(value) => setEditingCompanySelection(value ?? "all")}
options={companyComboboxOptions}
placeholder="Todas as empresas"
renderValue={(option) =>
option ? (
<span className="truncate">{option.label}</span>
) : (
<span className="text-muted-foreground">Todas as empresas</span>
)
}
/>
<p className="text-xs text-neutral-500">
Defina uma empresa para restringir este campo apenas aos tickets dela.
</p>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-field-description">Descrição</Label>
<textarea
id="edit-field-description"
className="min-h-[96px] w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-neutral-700 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-900/10"
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</div>
{type === "select" ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>Opções</Label>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
Adicionar opção
</Button>
</div>
{options.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-4 text-sm text-neutral-500">
Inclua ao menos uma opção para salvar este campo.
</p>
) : (
<div className="space-y-3">
{options.map((option, index) => (
<div key={index} className="grid gap-3 rounded-lg border border-slate-200 p-3 md:grid-cols-[minmax(0,1fr)_minmax(0,200px)_auto]">
<Input
placeholder="Rótulo"
value={option.label}
onChange={(event) => updateOption(index, "label", event.target.value)}
/>
<Input
placeholder="Valor"
value={option.value}
onChange={(event) => updateOption(index, "value", event.target.value)}
/>
<Button variant="ghost" type="button" onClick={() => removeOption(index)}>
Remover
</Button>
</div>
))}
</div>
)}
</div>
) : null}
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setEditingField(null)}>
Cancelar
</Button>
<Button onClick={handleUpdate} disabled={saving}>
Salvar alterações
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}