sistema-de-chamados/src/components/admin/fields/ticket-form-templates-manager.tsx
esdrasrenan 88a9ef454e feat: checklists em tickets + automações
- Adiciona checklist no ticket (itens obrigatórios/opcionais) e bloqueia encerramento com pendências\n- Cria templates de checklist (globais/por empresa) + tela em /settings/checklists\n- Nova ação de automação: aplicar template de checklist\n- Corrige crash do Select (value vazio), warnings de Dialog e dimensionamento de charts\n- Ajusta SMTP (STARTTLS) e melhora teste de integração
2025-12-13 20:51:47 -03:00

350 lines
14 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { Plus, MoreHorizontal, Archive, RefreshCcw } from "lucide-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useAuth } from "@/lib/auth-client"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Checkbox } from "@/components/ui/checkbox"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
type Template = {
id: string
key: string
label: string
description: string
defaultEnabled: boolean
baseTemplateKey: string | null
isSystem: boolean
isArchived: boolean
order: number
}
const CLEAR_SELECT_VALUE = "__clear__"
export function TicketFormTemplatesManager() {
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = convexUserId as Id<"users"> | null
const ensureDefaults = useMutation(api.tickets.ensureTicketFormDefaults)
const createTemplate = useMutation(api.ticketFormTemplates.create)
const updateTemplate = useMutation(api.ticketFormTemplates.update)
const archiveTemplate = useMutation(api.ticketFormTemplates.archive)
const hasEnsuredRef = useRef(false)
useEffect(() => {
if (!viewerId || hasEnsuredRef.current) return
hasEnsuredRef.current = true
ensureDefaults({ tenantId, actorId: viewerId }).catch((error) => {
console.error("[ticket-templates] ensure defaults failed", error)
hasEnsuredRef.current = false
})
}, [ensureDefaults, tenantId, viewerId])
const templates = useQuery(
api.ticketFormTemplates.list,
viewerId ? { tenantId, viewerId } : "skip"
) as Template[] | undefined
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [newLabel, setNewLabel] = useState("")
const [newDescription, setNewDescription] = useState("")
const [baseTemplate, setBaseTemplate] = useState<string>("")
const [cloneFields, setCloneFields] = useState(true)
const [creating, setCreating] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null)
const [editLabel, setEditLabel] = useState("")
const [editDescription, setEditDescription] = useState("")
const [savingEdit, setSavingEdit] = useState(false)
const activeTemplates = useMemo(() => {
if (!templates) return []
return templates.filter((tpl) => !tpl.isArchived).sort((a, b) => a.order - b.order)
}, [templates])
const archivedTemplates = useMemo(() => {
if (!templates) return []
return templates.filter((tpl) => tpl.isArchived).sort((a, b) => a.label.localeCompare(b.label, "pt-BR"))
}, [templates])
const baseOptions = useMemo(() => {
return (templates ?? []).filter((tpl) => !tpl.isArchived)
}, [templates])
const handleCreate = async () => {
if (!viewerId) return
const label = newLabel.trim()
if (label.length < 3) {
toast.error("Informe um nome com pelo menos 3 caracteres")
return
}
setCreating(true)
try {
await createTemplate({
tenantId,
actorId: viewerId,
label,
description: newDescription.trim() || undefined,
baseTemplateKey: baseTemplate || undefined,
cloneFields,
})
toast.success("Formulário criado com sucesso.")
setCreateDialogOpen(false)
setNewLabel("")
setNewDescription("")
setBaseTemplate("")
setCloneFields(true)
} catch (error) {
console.error("[ticket-templates] create failed", error)
toast.error("Não foi possível criar o formulário.")
} finally {
setCreating(false)
}
}
const handleSaveEdit = async () => {
if (!viewerId || !editingTemplate) return
const label = editLabel.trim()
if (label.length < 3) {
toast.error("Informe um nome com pelo menos 3 caracteres")
return
}
setSavingEdit(true)
try {
await updateTemplate({
tenantId,
actorId: viewerId,
templateId: editingTemplate.id as Id<"ticketFormTemplates">,
label,
description: editDescription.trim() || undefined,
})
toast.success("Formulário atualizado.")
setEditingTemplate(null)
} catch (error) {
console.error("[ticket-templates] update failed", error)
toast.error("Não foi possível atualizar o formulário.")
} finally {
setSavingEdit(false)
}
}
const handleToggleArchive = async (template: Template, archived: boolean) => {
if (!viewerId) return
try {
await archiveTemplate({
tenantId,
actorId: viewerId,
templateId: template.id as Id<"ticketFormTemplates">,
archived,
})
toast.success(archived ? "Formulário arquivado." : "Formulário reativado.")
} catch (error) {
console.error("[ticket-templates] toggle archive failed", error)
toast.error("Não foi possível atualizar o formulário.")
}
}
const renderTemplateCard = (template: Template) => (
<Card key={template.id} className="border-slate-200">
<CardHeader className="flex flex-row items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle className="text-base font-semibold text-neutral-900">{template.label}</CardTitle>
<CardDescription>{template.description || "Sem descrição"}</CardDescription>
<div className="flex flex-wrap gap-2 pt-1">
<Badge variant="outline" className="rounded-full px-2 py-0.5 text-xs font-semibold">
{template.isSystem ? "Padrão do sistema" : "Personalizado"}
</Badge>
{!template.defaultEnabled ? (
<Badge variant="secondary" className="rounded-full px-2 py-0.5 text-xs font-semibold">
Desabilitado por padrão
</Badge>
) : null}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => {
setEditingTemplate(template)
setEditLabel(template.label)
setEditDescription(template.description)
}}
>
Renomear
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleToggleArchive(template, !template.isArchived)}>
{template.isArchived ? (
<span className="flex items-center gap-2 text-emerald-600">
<RefreshCcw className="size-3.5" />
Reativar
</span>
) : (
<span className="flex items-center gap-2 text-rose-600">
<Archive className="size-3.5" />
Arquivar
</span>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardHeader>
</Card>
)
return (
<Card className="border-slate-200">
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle className="text-lg font-semibold text-neutral-900">Modelos de formulário</CardTitle>
<CardDescription>Controle quais formulários especiais ficam disponíveis na abertura de tickets.</CardDescription>
</div>
<Button size="sm" className="gap-2" onClick={() => setCreateDialogOpen(true)}>
<Plus className="size-4" />
Novo formulário
</Button>
</CardHeader>
<CardContent className="space-y-4">
{!templates ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton key={index} className="h-28 w-full rounded-2xl" />
))}
</div>
) : (
<>
{activeTemplates.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-300 bg-slate-50/70 p-6 text-center text-sm text-muted-foreground">
Nenhum formulário personalizado. Clique em &quot;Novo formulário&quot; para começar.
</div>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeTemplates.map(renderTemplateCard)}
</div>
)}
{archivedTemplates.length > 0 ? (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500">Arquivados</p>
<div className="grid gap-3 md:grid-cols-2">
{archivedTemplates.map(renderTemplateCard)}
</div>
</div>
) : null}
</>
)}
</CardContent>
<Dialog open={createDialogOpen} onOpenChange={(open) => !creating && setCreateDialogOpen(open)}>
<DialogContent className="max-w-lg space-y-4">
<DialogHeader>
<DialogTitle>Novo formulário</DialogTitle>
<DialogDescription>Crie formulários específicos para fluxos como admissões, desligamentos ou demandas especiais.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Nome</label>
<Input
value={newLabel}
onChange={(event) => setNewLabel(event.target.value)}
placeholder="Ex.: Troca de equipamento"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Descrição</label>
<Textarea
value={newDescription}
onChange={(event) => setNewDescription(event.target.value)}
placeholder="Explique quando este formulário deve ser usado"
rows={3}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Basear em</label>
<Select
value={baseTemplate}
onValueChange={(value) => setBaseTemplate(value === CLEAR_SELECT_VALUE ? "" : value)}
>
<SelectTrigger>
<SelectValue placeholder="Em branco" />
</SelectTrigger>
<SelectContent>
<SelectItem value={CLEAR_SELECT_VALUE}>Começar do zero</SelectItem>
{baseOptions.map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.label}
</SelectItem>
))}
</SelectContent>
</Select>
{baseTemplate ? (
<label className="flex items-center gap-2 text-sm text-neutral-700">
<Checkbox
checked={cloneFields}
onCheckedChange={(value) => setCloneFields(Boolean(value))}
/>
<span>Copiar campos do formulário base</span>
</label>
) : null}
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => !creating && setCreateDialogOpen(false)}>
Cancelar
</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? "Criando..." : "Criar formulário"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editingTemplate)} onOpenChange={(open) => !savingEdit && !open && setEditingTemplate(null)}>
<DialogContent className="max-w-lg space-y-4">
<DialogHeader>
<DialogTitle>Editar formulário</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Nome</label>
<Input value={editLabel} onChange={(event) => setEditLabel(event.target.value)} />
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-neutral-700">Descrição</label>
<Textarea
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
rows={3}
/>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => !savingEdit && setEditingTemplate(null)}>
Cancelar
</Button>
<Button onClick={handleSaveEdit} disabled={savingEdit}>
{savingEdit ? "Salvando..." : "Salvar alterações"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}