358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useMemo, useState } from "react"
|
|
import { useMutation, useQuery } from "convex/react"
|
|
import { toast } from "sonner"
|
|
import { IconFileText, IconPlus, IconTrash, IconX } 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 { sanitizeEditorHtml, RichTextEditor, RichTextContent } from "@/components/ui/rich-text-editor"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
|
import { Spinner } from "@/components/ui/spinner"
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
|
|
export function CommentTemplatesManager() {
|
|
const { convexUserId, session } = useAuth()
|
|
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
|
|
const viewerId = convexUserId as Id<"users"> | undefined
|
|
const [activeKind, setActiveKind] = useState<"comment" | "closing">("comment")
|
|
|
|
const templates = useQuery(
|
|
viewerId ? api.commentTemplates.list : "skip",
|
|
viewerId ? { tenantId, viewerId, kind: activeKind } : "skip"
|
|
) as
|
|
| {
|
|
id: Id<"commentTemplates">
|
|
title: string
|
|
body: string
|
|
createdAt: number
|
|
updatedAt: number
|
|
createdBy: Id<"users">
|
|
updatedBy: Id<"users"> | null
|
|
kind: "comment" | "closing" | string
|
|
}[]
|
|
| undefined
|
|
|
|
const createTemplate = useMutation(api.commentTemplates.create)
|
|
const updateTemplate = useMutation(api.commentTemplates.update)
|
|
const deleteTemplate = useMutation(api.commentTemplates.remove)
|
|
|
|
const [title, setTitle] = useState("")
|
|
const [body, setBody] = useState("")
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
|
|
const isLoading = viewerId && templates === undefined
|
|
|
|
const orderedTemplates = useMemo(() => templates ?? [], [templates])
|
|
|
|
const kindLabels: Record<typeof activeKind, { title: string; description: string; placeholder: string; empty: { title: string; description: string } }> = {
|
|
comment: {
|
|
title: "Templates de comentário",
|
|
description: "Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.",
|
|
placeholder: "Escreva a mensagem padrão...",
|
|
empty: {
|
|
title: "Nenhum template cadastrado",
|
|
description: "Crie seu primeiro template de comentário usando o formulário acima.",
|
|
},
|
|
},
|
|
closing: {
|
|
title: "Templates de encerramento",
|
|
description: "Padronize as mensagens de fechamento de tickets. Os nomes dos clientes podem ser inseridos automaticamente com {{cliente}}.",
|
|
placeholder: "Conteúdo da mensagem de encerramento...",
|
|
empty: {
|
|
title: "Nenhum template de encerramento",
|
|
description: "Cadastre mensagens padrão para encerrar tickets rapidamente.",
|
|
},
|
|
},
|
|
}
|
|
|
|
async function handleCreate(event: React.FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
if (!viewerId) return
|
|
const trimmedTitle = title.trim()
|
|
const sanitizedBody = sanitizeEditorHtml(body)
|
|
if (trimmedTitle.length < 3) {
|
|
toast.error("Informe um título com pelo menos 3 caracteres.")
|
|
return
|
|
}
|
|
if (!sanitizedBody) {
|
|
toast.error("Escreva o conteúdo do template antes de salvar.")
|
|
return
|
|
}
|
|
setIsSubmitting(true)
|
|
toast.loading("Criando template...", { id: "create-template" })
|
|
try {
|
|
await createTemplate({ tenantId, actorId: viewerId, title: trimmedTitle, body: sanitizedBody, kind: activeKind })
|
|
toast.success("Template criado!", { id: "create-template" })
|
|
setTitle("")
|
|
setBody("")
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível criar o template.", { id: "create-template" })
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
async function handleUpdate(templateId: Id<"commentTemplates">, nextTitle: string, nextBody: string, kind: "comment" | "closing" | string) {
|
|
if (!viewerId) return
|
|
const trimmedTitle = nextTitle.trim()
|
|
const sanitizedBody = sanitizeEditorHtml(nextBody)
|
|
if (trimmedTitle.length < 3) {
|
|
toast.error("Informe um título com pelo menos 3 caracteres.")
|
|
return false
|
|
}
|
|
if (!sanitizedBody) {
|
|
toast.error("Escreva o conteúdo do template antes de salvar.")
|
|
return false
|
|
}
|
|
const toastId = `update-template-${templateId}`
|
|
toast.loading("Atualizando template...", { id: toastId })
|
|
try {
|
|
await updateTemplate({
|
|
templateId,
|
|
tenantId,
|
|
actorId: viewerId,
|
|
title: trimmedTitle,
|
|
body: sanitizedBody,
|
|
kind,
|
|
})
|
|
toast.success("Template atualizado!", { id: toastId })
|
|
return true
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível atualizar o template.", { id: toastId })
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function handleDelete(templateId: Id<"commentTemplates">) {
|
|
if (!viewerId) return
|
|
const toastId = `delete-template-${templateId}`
|
|
toast.loading("Removendo template...", { id: toastId })
|
|
try {
|
|
await deleteTemplate({ templateId, tenantId, actorId: viewerId })
|
|
toast.success("Template removido!", { id: toastId })
|
|
} catch (error) {
|
|
console.error(error)
|
|
toast.error("Não foi possível remover o template.", { id: toastId })
|
|
}
|
|
}
|
|
|
|
if (!viewerId) {
|
|
return (
|
|
<Card className="border border-slate-200">
|
|
<CardHeader>
|
|
<CardTitle>Templates de comentário</CardTitle>
|
|
<CardDescription>Faça login para gerenciar os templates de resposta rápida.</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card className="border border-slate-200">
|
|
<CardHeader className="flex flex-col gap-3">
|
|
<div className="flex flex-col gap-1">
|
|
<CardTitle className="text-xl font-semibold text-neutral-900">Templates rápidos</CardTitle>
|
|
<CardDescription className="text-sm text-neutral-600">
|
|
Gerencie mensagens padrão para comentários e encerramentos de tickets.
|
|
</CardDescription>
|
|
</div>
|
|
<Tabs value={activeKind} onValueChange={(value) => setActiveKind(value as "comment" | "closing")} className="w-full">
|
|
<TabsList className="h-10 w-full justify-start rounded-lg bg-slate-100 p-1">
|
|
<TabsTrigger value="comment" className="rounded-md px-4 py-1.5 text-sm font-medium">Comentários</TabsTrigger>
|
|
<TabsTrigger value="closing" className="rounded-md px-4 py-1.5 text-sm font-medium">Encerramentos</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<CardDescription className="mb-4 text-sm text-neutral-600">
|
|
{kindLabels[activeKind].description}
|
|
</CardDescription>
|
|
<form className="space-y-4" onSubmit={handleCreate}>
|
|
<div className="space-y-2">
|
|
<label htmlFor="template-title" className="text-sm font-medium text-neutral-800">
|
|
Título do template
|
|
</label>
|
|
<Input
|
|
id="template-title"
|
|
placeholder="Ex.: A Rever agradece seu contato"
|
|
value={title}
|
|
onChange={(event) => setTitle(event.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label htmlFor="template-body" className="text-sm font-medium text-neutral-800">
|
|
Conteúdo padrão
|
|
</label>
|
|
<RichTextEditor value={body} onChange={setBody} minHeight={180} placeholder={kindLabels[activeKind].placeholder} />
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
{body ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="inline-flex items-center gap-2"
|
|
onClick={() => {
|
|
setBody("")
|
|
setTitle("")
|
|
}}
|
|
disabled={isSubmitting}
|
|
>
|
|
<IconX className="size-4" />
|
|
Limpar
|
|
</Button>
|
|
) : null}
|
|
<Button type="submit" className="inline-flex items-center gap-2" disabled={isSubmitting}>
|
|
{isSubmitting ? <Spinner className="size-4 text-white" /> : <IconPlus className="size-4" />}Salvar template
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="border border-slate-200">
|
|
<CardHeader className="flex flex-col gap-1">
|
|
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-neutral-900">
|
|
<IconFileText className="size-5 text-neutral-500" /> Templates cadastrados
|
|
</CardTitle>
|
|
<CardDescription className="text-sm text-neutral-600">
|
|
Gerencie as mensagens prontas utilizadas nos {activeKind === "comment" ? "comentários" : "encerramentos"} de tickets.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
|
<Spinner className="size-4" /> Carregando templates...
|
|
</div>
|
|
) : orderedTemplates.length === 0 ? (
|
|
<Empty>
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<IconFileText className="size-5 text-neutral-500" />
|
|
</EmptyMedia>
|
|
<EmptyTitle>{kindLabels[activeKind].empty.title}</EmptyTitle>
|
|
<EmptyDescription>{kindLabels[activeKind].empty.description}</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
{orderedTemplates.map((template) => (
|
|
<TemplateItem
|
|
key={template.id}
|
|
template={template}
|
|
onSave={handleUpdate}
|
|
onDelete={handleDelete}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type TemplateItemProps = {
|
|
template: {
|
|
id: Id<"commentTemplates">
|
|
title: string
|
|
body: string
|
|
updatedAt: number
|
|
kind: "comment" | "closing" | string
|
|
}
|
|
onSave: (templateId: Id<"commentTemplates">, title: string, body: string, kind: "comment" | "closing" | string) => Promise<boolean | void>
|
|
onDelete: (templateId: Id<"commentTemplates">) => Promise<void>
|
|
}
|
|
|
|
function TemplateItem({ template, onSave, onDelete }: TemplateItemProps) {
|
|
const [isEditing, setIsEditing] = useState(false)
|
|
const [title, setTitle] = useState(template.title)
|
|
const [body, setBody] = useState(template.body)
|
|
const [isSaving, setIsSaving] = useState(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
|
|
const lastUpdated = useMemo(() => new Date(template.updatedAt), [template.updatedAt])
|
|
|
|
async function handleSave() {
|
|
setIsSaving(true)
|
|
const ok = await onSave(template.id, title, body, template.kind ?? "comment")
|
|
setIsSaving(false)
|
|
if (ok !== false) {
|
|
setIsEditing(false)
|
|
}
|
|
}
|
|
|
|
async function handleDelete() {
|
|
setIsDeleting(true)
|
|
await onDelete(template.id)
|
|
setIsDeleting(false)
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
|
<div className="flex flex-col gap-3">
|
|
{isEditing ? (
|
|
<div className="space-y-3">
|
|
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="Título" />
|
|
<RichTextEditor value={body} onChange={setBody} minHeight={160} />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<h3 className="text-base font-semibold text-neutral-900">{template.title}</h3>
|
|
<RichTextContent html={template.body} className="text-sm text-neutral-700" />
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap items-center justify-between gap-3 text-xs text-neutral-500">
|
|
<span>Atualizado em {lastUpdated.toLocaleString("pt-BR")}</span>
|
|
<div className="flex items-center gap-2">
|
|
{isEditing ? (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setIsEditing(false)
|
|
setTitle(template.title)
|
|
setBody(template.body)
|
|
}}
|
|
disabled={isSaving}
|
|
>
|
|
Cancelar
|
|
</Button>
|
|
<Button type="button" size="sm" onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? <Spinner className="size-4 text-white" /> : "Salvar"}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
|
Editar
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="sm"
|
|
className="inline-flex items-center gap-1"
|
|
onClick={handleDelete}
|
|
disabled={isDeleting}
|
|
>
|
|
{isDeleting ? <Spinner className="size-4 text-white" /> : <IconTrash className="size-4" />}Excluir
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|