550 lines
22 KiB
TypeScript
550 lines
22 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 { 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"
|
|
|
|
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
|
|
}
|
|
|
|
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 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 [saving, setSaving] = useState(false)
|
|
const [editingField, setEditingField] = useState<Field | null>(null)
|
|
|
|
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([])
|
|
}
|
|
|
|
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
|
|
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,
|
|
})
|
|
toast.success("Campo criado", { id: "field" })
|
|
resetForm()
|
|
} 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)
|
|
}
|
|
|
|
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
|
|
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,
|
|
})
|
|
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>
|
|
<form onSubmit={handleCreate} className="grid gap-4 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>
|
|
<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">
|
|
<Button type="submit" disabled={saving}>
|
|
Criar campo
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</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) => (
|
|
<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>
|
|
{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-2 text-xs text-neutral-500">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="px-2"
|
|
disabled={index === 0}
|
|
onClick={() => moveField(field, "up")}
|
|
>
|
|
Subir
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="px-2"
|
|
disabled={index === fields.length - 1}
|
|
onClick={() => moveField(field, "down")}
|
|
>
|
|
Descer
|
|
</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={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>
|
|
<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>
|
|
)
|
|
}
|