feat: secure convex admin flows with real metrics\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

This commit is contained in:
esdrasrenan 2025-10-05 19:59:24 -03:00
parent 0ec5b49e8a
commit 29a647f6c6
43 changed files with 4992 additions and 363 deletions

View file

@ -0,0 +1,551 @@
"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"
// @ts-expect-error Convex runtime API lacks TypeScript declarations
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>
)
}