feat: seed real agents and enable comment templates

This commit is contained in:
esdrasrenan 2025-10-06 20:35:40 -03:00
parent df8c4e29bb
commit 409cbea7b9
13 changed files with 1722 additions and 29 deletions

68
web/agents.md Normal file
View file

@ -0,0 +1,68 @@
# 🧩 Permissões e acessos
- [ ] Criar perfil **Gestor da Empresa (cliente)** com permissões específicas
- [ ] Ver todos os chamados da sua empresa
- [ ] Acessar relatórios e dashboards resumidos
- [ ] Exportar relatórios em PDF ou CSV
- [ ] Manter perfis: Administrador, Técnico, Gestor da Empresa, Usuário Final
---
# 🧾 Tickets e atendimentos
- [ ] Adicionar opção **Exportar histórico completo em PDF** (conversa, logs, movimentações)
- [ ] Implementar **justificativa obrigatória ao pausar** o chamado
- [ ] Categorias: Falta de contato / Aguardando terceiro / Em procedimento
- [ ] Ajustar **status padrão dos tickets**
- [ ] Pendentes
- [ ] Aguardando atendimento
- [ ] Pausados
- [ ] (Remover “Aguardando resposta” e “Violados”)
- [ ] Remover automaticamente da listagem ao finalizar o chamado
---
# 📊 Dashboards e relatórios
- [ ] Criar **dashboard inicial com fila de atendimento**
- [ ] Exibir chamados em: atendimento, laboratório, visitas
- [ ] Indicadores: abertos, resolvidos, tempo médio, SLA
- [ ] Criar **relatório de horas por cliente (CSV/Dashboard)**
- [ ] Separar por atendimento interno e externo
- [ ] Filtrar por período (dia, semana, mês)
- [ ] Permitir exportar relatórios completos (CSV ou PDF)
---
# ⏱️ Controle de tempo e contratos
- [ ] Adicionar botão **Play interno** (atendimento remoto)
- [ ] Adicionar botão **Play externo** (atendimento presencial)
- [ ] Separar contagem de horas por tipo (interno/externo)
- [ ] Exibir e somar **horas gastas por cliente** (com base no tipo)
- [ ] Incluir no cadastro:
- [ ] Horas contratadas por mês
- [ ] Tipo de cliente: mensalista ou avulso
- [ ] Enviar alerta automático por e-mail quando atingir limite de horas
---
# 💬 Comunicação e notificações
- [ ] Diferenciar **comentários públicos** e **privados**
- [ ] Público → envia e-mail ao cliente
- [ ] Privado → visível apenas internamente
- [ ] Enviar e-mail automático quando houver comentário público
- [ ] Incluir trecho da mensagem e link direto para o chamado
- [x] Criar **biblioteca de templates de comentário prontos**
- [x] Exemplo: “Agradecemos seu contato”, “Seu chamado foi atualizado”, etc.
---
# ⚙️ Extras e automações (futuro)
- [ ] Enviar alertas automáticos para gestores quando:
- [ ] Chamado estiver pausado há mais de 48h
- [ ] Horas contratadas atingirem 90% do limite
- [ ] Implementar **trilha de auditoria** (quem pausou, finalizou, comentou)
- [ ] Permitir exportar logs de auditoria (CSV/PDF)

View file

@ -10,9 +10,11 @@
import type * as bootstrap from "../bootstrap.js"; import type * as bootstrap from "../bootstrap.js";
import type * as categories from "../categories.js"; import type * as categories from "../categories.js";
import type * as commentTemplates from "../commentTemplates.js";
import type * as fields from "../fields.js"; import type * as fields from "../fields.js";
import type * as files from "../files.js"; import type * as files from "../files.js";
import type * as invites from "../invites.js"; import type * as invites from "../invites.js";
import type * as migrations from "../migrations.js";
import type * as queues from "../queues.js"; import type * as queues from "../queues.js";
import type * as rbac from "../rbac.js"; import type * as rbac from "../rbac.js";
import type * as reports from "../reports.js"; import type * as reports from "../reports.js";
@ -39,9 +41,11 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
bootstrap: typeof bootstrap; bootstrap: typeof bootstrap;
categories: typeof categories; categories: typeof categories;
commentTemplates: typeof commentTemplates;
fields: typeof fields; fields: typeof fields;
files: typeof files; files: typeof files;
invites: typeof invites; invites: typeof invites;
migrations: typeof migrations;
queues: typeof queues; queues: typeof queues;
rbac: typeof rbac; rbac: typeof rbac;
reports: typeof reports; reports: typeof reports;

View file

@ -0,0 +1,173 @@
import sanitizeHtml from "sanitize-html"
import { ConvexError, v } from "convex/values"
import { mutation, query } from "./_generated/server"
import { requireStaff } from "./rbac"
const SANITIZE_OPTIONS: sanitizeHtml.IOptions = {
allowedTags: [
"p",
"br",
"a",
"strong",
"em",
"u",
"s",
"blockquote",
"ul",
"ol",
"li",
"code",
"pre",
"span",
"h1",
"h2",
"h3",
],
allowedAttributes: {
a: ["href", "name", "target", "rel"],
span: ["style"],
code: ["class"],
pre: ["class"],
},
allowedSchemes: ["http", "https", "mailto"],
transformTags: {
a: sanitizeHtml.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
},
allowVulnerableTags: false,
}
function sanitizeTemplateBody(body: string) {
const sanitized = sanitizeHtml(body || "", SANITIZE_OPTIONS).trim()
return sanitized
}
function normalizeTitle(title: string) {
return title?.trim()
}
export const list = query({
args: {
tenantId: v.string(),
viewerId: v.id("users"),
},
handler: async (ctx, { tenantId, viewerId }) => {
await requireStaff(ctx, viewerId, tenantId)
const templates = await ctx.db
.query("commentTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
return templates
.sort((a, b) => a.title.localeCompare(b.title, "pt-BR", { sensitivity: "base" }))
.map((template) => ({
id: template._id,
title: template.title,
body: template.body,
createdAt: template.createdAt,
updatedAt: template.updatedAt,
createdBy: template.createdBy,
updatedBy: template.updatedBy ?? null,
}))
},
})
export const create = mutation({
args: {
tenantId: v.string(),
actorId: v.id("users"),
title: v.string(),
body: v.string(),
},
handler: async (ctx, { tenantId, actorId, title, body }) => {
await requireStaff(ctx, actorId, tenantId)
const normalizedTitle = normalizeTitle(title)
if (!normalizedTitle || normalizedTitle.length < 3) {
throw new ConvexError("Informe um título válido para o template")
}
const sanitizedBody = sanitizeTemplateBody(body)
if (!sanitizedBody) {
throw new ConvexError("Informe o conteúdo do template")
}
const existing = await ctx.db
.query("commentTemplates")
.withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle))
.first()
if (existing) {
throw new ConvexError("Já existe um template com este título")
}
const now = Date.now()
const id = await ctx.db.insert("commentTemplates", {
tenantId,
title: normalizedTitle,
body: sanitizedBody,
createdBy: actorId,
updatedBy: actorId,
createdAt: now,
updatedAt: now,
})
return id
},
})
export const update = mutation({
args: {
templateId: v.id("commentTemplates"),
tenantId: v.string(),
actorId: v.id("users"),
title: v.string(),
body: v.string(),
},
handler: async (ctx, { templateId, tenantId, actorId, title, body }) => {
await requireStaff(ctx, actorId, tenantId)
const template = await ctx.db.get(templateId)
if (!template || template.tenantId !== tenantId) {
throw new ConvexError("Template não encontrado")
}
const normalizedTitle = normalizeTitle(title)
if (!normalizedTitle || normalizedTitle.length < 3) {
throw new ConvexError("Informe um título válido para o template")
}
const sanitizedBody = sanitizeTemplateBody(body)
if (!sanitizedBody) {
throw new ConvexError("Informe o conteúdo do template")
}
const duplicate = await ctx.db
.query("commentTemplates")
.withIndex("by_tenant_title", (q) => q.eq("tenantId", tenantId).eq("title", normalizedTitle))
.first()
if (duplicate && duplicate._id !== templateId) {
throw new ConvexError("Já existe um template com este título")
}
const now = Date.now()
await ctx.db.patch(templateId, {
title: normalizedTitle,
body: sanitizedBody,
updatedBy: actorId,
updatedAt: now,
})
},
})
export const remove = mutation({
args: {
templateId: v.id("commentTemplates"),
tenantId: v.string(),
actorId: v.id("users"),
},
handler: async (ctx, { templateId, tenantId, actorId }) => {
await requireStaff(ctx, actorId, tenantId)
const template = await ctx.db.get(templateId)
if (!template || template.tenantId !== tenantId) {
throw new ConvexError("Template não encontrado")
}
await ctx.db.delete(templateId)
},
})

488
web/convex/migrations.ts Normal file
View file

@ -0,0 +1,488 @@
import { ConvexError, v } from "convex/values"
import { mutation, query } from "./_generated/server"
import type { Id } from "./_generated/dataModel"
import type { MutationCtx, QueryCtx } from "./_generated/server"
const SECRET = process.env.CONVEX_SYNC_SECRET ?? "dev-sync-secret"
const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"])
function normalizeEmail(value: string) {
return value.trim().toLowerCase()
}
type ImportedUser = {
email: string
name: string
role?: string | null
avatarUrl?: string | null
teams?: string[] | null
}
type ImportedQueue = {
slug?: string | null
name: string
}
function normalizeRole(role: string | null | undefined) {
if (!role) return "AGENT"
const normalized = role.toUpperCase()
if (STAFF_ROLES.has(normalized)) return normalized
if (normalized === "CUSTOMER") return "CUSTOMER"
return "AGENT"
}
function slugify(value: string) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\w\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.toLowerCase()
}
function pruneUndefined<T extends Record<string, unknown>>(input: T): T {
for (const key of Object.keys(input)) {
if (input[key] === undefined) {
delete input[key]
}
}
return input
}
async function ensureUser(
ctx: MutationCtx,
tenantId: string,
data: ImportedUser,
cache: Map<string, Id<"users">>
) {
if (cache.has(data.email)) {
return cache.get(data.email)!
}
const existing = await ctx.db
.query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", data.email))
.first()
const role = normalizeRole(data.role)
const record = existing
? (() => {
const needsPatch =
existing.name !== data.name ||
existing.role !== role ||
existing.avatarUrl !== (data.avatarUrl ?? undefined) ||
JSON.stringify(existing.teams ?? []) !== JSON.stringify(data.teams ?? [])
if (needsPatch) {
return ctx.db.patch(existing._id, {
name: data.name,
role,
avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined,
tenantId,
})
}
return Promise.resolve()
})()
: ctx.db.insert("users", {
tenantId,
email: data.email,
name: data.name,
role,
avatarUrl: data.avatarUrl ?? undefined,
teams: data.teams ?? undefined,
})
const id = existing ? existing._id : ((await record) as Id<"users">)
cache.set(data.email, id)
return id
}
async function ensureQueue(
ctx: MutationCtx,
tenantId: string,
data: ImportedQueue,
cache: Map<string, Id<"queues">>
) {
const slug = data.slug && data.slug.trim().length > 0 ? data.slug : slugify(data.name)
if (cache.has(slug)) return cache.get(slug)!
const bySlug = await ctx.db
.query("queues")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
if (bySlug) {
if (bySlug.name !== data.name) {
await ctx.db.patch(bySlug._id, { name: data.name })
}
cache.set(slug, bySlug._id)
return bySlug._id
}
const byName = await ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.filter((q) => q.eq(q.field("name"), data.name))
.first()
if (byName) {
if (byName.slug !== slug) {
await ctx.db.patch(byName._id, { slug })
}
cache.set(slug, byName._id)
return byName._id
}
const id = await ctx.db.insert("queues", {
tenantId,
name: data.name,
slug,
teamId: undefined,
})
cache.set(slug, id)
return id
}
async function getTenantUsers(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
async function getTenantQueues(ctx: QueryCtx, tenantId: string) {
return ctx.db
.query("queues")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
}
export const exportTenantSnapshot = query({
args: {
secret: v.string(),
tenantId: v.string(),
},
handler: async (ctx, { secret, tenantId }) => {
if (secret !== SECRET) {
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
}
const [users, queues] = await Promise.all([getTenantUsers(ctx, tenantId), getTenantQueues(ctx, tenantId)])
const userMap = new Map(users.map((user) => [user._id, user]))
const queueMap = new Map(queues.map((queue) => [queue._id, queue]))
const tickets = await ctx.db
.query("tickets")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
const ticketsWithRelations = []
for (const ticket of tickets) {
const comments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
.collect()
const events = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id))
.collect()
const requester = userMap.get(ticket.requesterId)
const assignee = ticket.assigneeId ? userMap.get(ticket.assigneeId) : undefined
const queue = ticket.queueId ? queueMap.get(ticket.queueId) : undefined
if (!requester) {
continue
}
ticketsWithRelations.push({
reference: ticket.reference,
subject: ticket.subject,
summary: ticket.summary ?? null,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
queueSlug: queue?.slug ?? undefined,
requesterEmail: requester.email,
assigneeEmail: assignee?.email ?? undefined,
dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined,
closedAt: ticket.closedAt ?? undefined,
createdAt: ticket.createdAt,
updatedAt: ticket.updatedAt,
tags: ticket.tags ?? [],
comments: comments
.map((comment) => {
const author = userMap.get(comment.authorId)
if (!author) {
return null
}
return {
authorEmail: author.email,
visibility: comment.visibility,
body: comment.body,
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
}
})
.filter((value): value is {
authorEmail: string
visibility: string
body: string
createdAt: number
updatedAt: number
} => value !== null),
events: events.map((event) => ({
type: event.type,
payload: event.payload ?? {},
createdAt: event.createdAt,
})),
})
}
return {
tenantId,
users: users.map((user) => ({
email: user.email,
name: user.name,
role: user.role ?? null,
avatarUrl: user.avatarUrl ?? null,
teams: user.teams ?? [],
})),
queues: queues.map((queue) => ({
name: queue.name,
slug: queue.slug,
})),
tickets: ticketsWithRelations,
}
},
})
export const importPrismaSnapshot = mutation({
args: {
secret: v.string(),
snapshot: v.object({
tenantId: v.string(),
users: v.array(
v.object({
email: v.string(),
name: v.string(),
role: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
teams: v.optional(v.array(v.string())),
})
),
queues: v.array(
v.object({
name: v.string(),
slug: v.optional(v.string()),
})
),
tickets: v.array(
v.object({
reference: v.number(),
subject: v.string(),
summary: v.optional(v.string()),
status: v.string(),
priority: v.string(),
channel: v.string(),
queueSlug: v.optional(v.string()),
requesterEmail: v.string(),
assigneeEmail: v.optional(v.string()),
dueAt: v.optional(v.number()),
firstResponseAt: v.optional(v.number()),
resolvedAt: v.optional(v.number()),
closedAt: v.optional(v.number()),
createdAt: v.number(),
updatedAt: v.number(),
tags: v.optional(v.array(v.string())),
comments: v.array(
v.object({
authorEmail: v.string(),
visibility: v.string(),
body: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
})
),
events: v.array(
v.object({
type: v.string(),
payload: v.optional(v.any()),
createdAt: v.number(),
})
),
})
),
}),
},
handler: async (ctx, { secret, snapshot }) => {
if (!SECRET) {
throw new ConvexError("CONVEX_SYNC_SECRET não configurada no backend")
}
if (secret !== SECRET) {
throw new ConvexError("Segredo inválido para sincronização")
}
const userCache = new Map<string, Id<"users">>()
const queueCache = new Map<string, Id<"queues">>()
for (const user of snapshot.users) {
await ensureUser(ctx, snapshot.tenantId, user, userCache)
}
for (const queue of snapshot.queues) {
await ensureQueue(ctx, snapshot.tenantId, queue, queueCache)
}
const snapshotStaffEmails = new Set(
snapshot.users
.filter((user) => normalizeRole(user.role ?? null) !== "CUSTOMER")
.map((user) => normalizeEmail(user.email))
)
const existingTenantUsers = await ctx.db
.query("users")
.withIndex("by_tenant", (q) => q.eq("tenantId", snapshot.tenantId))
.collect()
for (const user of existingTenantUsers) {
const role = normalizeRole(user.role ?? null)
if (STAFF_ROLES.has(role) && !snapshotStaffEmails.has(normalizeEmail(user.email))) {
await ctx.db.delete(user._id)
}
}
let ticketsUpserted = 0
let commentsInserted = 0
let eventsInserted = 0
for (const ticket of snapshot.tickets) {
const requesterId = await ensureUser(
ctx,
snapshot.tenantId,
{
email: ticket.requesterEmail,
name: ticket.requesterEmail,
},
userCache
)
const assigneeId = ticket.assigneeEmail
? await ensureUser(
ctx,
snapshot.tenantId,
{
email: ticket.assigneeEmail,
name: ticket.assigneeEmail,
},
userCache
)
: undefined
const queueId = ticket.queueSlug ? queueCache.get(ticket.queueSlug) ?? (await ensureQueue(ctx, snapshot.tenantId, { name: ticket.queueSlug, slug: ticket.queueSlug }, queueCache)) : undefined
const existing = await ctx.db
.query("tickets")
.withIndex("by_tenant_reference", (q) => q.eq("tenantId", snapshot.tenantId).eq("reference", ticket.reference))
.first()
const payload = pruneUndefined({
tenantId: snapshot.tenantId,
reference: ticket.reference,
subject: ticket.subject,
summary: ticket.summary ?? undefined,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
queueId: queueId as Id<"queues"> | undefined,
categoryId: undefined,
subcategoryId: undefined,
requesterId,
assigneeId: assigneeId as Id<"users"> | undefined,
working: false,
slaPolicyId: undefined,
dueAt: ticket.dueAt ?? undefined,
firstResponseAt: ticket.firstResponseAt ?? undefined,
resolvedAt: ticket.resolvedAt ?? undefined,
closedAt: ticket.closedAt ?? undefined,
updatedAt: ticket.updatedAt,
createdAt: ticket.createdAt,
tags: ticket.tags && ticket.tags.length > 0 ? ticket.tags : undefined,
customFields: undefined,
totalWorkedMs: undefined,
activeSessionId: undefined,
})
let ticketId: Id<"tickets">
if (existing) {
await ctx.db.patch(existing._id, payload)
ticketId = existing._id
} else {
ticketId = await ctx.db.insert("tickets", payload)
}
ticketsUpserted += 1
const existingComments = await ctx.db
.query("ticketComments")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect()
for (const comment of existingComments) {
await ctx.db.delete(comment._id)
}
const existingEvents = await ctx.db
.query("ticketEvents")
.withIndex("by_ticket", (q) => q.eq("ticketId", ticketId))
.collect()
for (const event of existingEvents) {
await ctx.db.delete(event._id)
}
for (const comment of ticket.comments) {
const authorId = await ensureUser(
ctx,
snapshot.tenantId,
{
email: comment.authorEmail,
name: comment.authorEmail,
},
userCache
)
await ctx.db.insert("ticketComments", {
ticketId,
authorId,
visibility: comment.visibility,
body: comment.body,
attachments: [],
createdAt: comment.createdAt,
updatedAt: comment.updatedAt,
})
commentsInserted += 1
}
for (const event of ticket.events) {
await ctx.db.insert("ticketEvents", {
ticketId,
type: event.type,
payload: event.payload ?? {},
createdAt: event.createdAt,
})
eventsInserted += 1
}
}
return {
usersProcessed: userCache.size,
queuesProcessed: queueCache.size,
ticketsUpserted,
commentsInserted,
eventsInserted,
}
},
})

View file

@ -107,6 +107,18 @@ export default defineSchema({
createdAt: v.number(), createdAt: v.number(),
}).index("by_ticket", ["ticketId"]), }).index("by_ticket", ["ticketId"]),
commentTemplates: defineTable({
tenantId: v.string(),
title: v.string(),
body: v.string(),
createdBy: v.id("users"),
updatedBy: v.optional(v.id("users")),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_tenant", ["tenantId"])
.index("by_tenant_title", ["tenantId", "title"]),
ticketWorkSessions: defineTable({ ticketWorkSessions: defineTable({
ticketId: v.id("tickets"), ticketId: v.id("tickets"),
agentId: v.id("users"), agentId: v.id("users"),

View file

@ -61,14 +61,71 @@ export const seedDemo = mutation({
.query("users") .query("users")
.withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email)) .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email))
.first(); .first();
if (found) return found._id; if (found) {
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: `https://avatar.vercel.sh/${name.split(" ")[0]}` }); const updates: Record<string, unknown> = {};
if (found.name !== name) updates.name = name;
if ((found.role ?? "AGENT") !== role) updates.role = role;
const desiredAvatar = role === "CUSTOMER" ? found.avatarUrl ?? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}`;
if (found.avatarUrl !== desiredAvatar) updates.avatarUrl = desiredAvatar;
if (Object.keys(updates).length > 0) {
await ctx.db.patch(found._id, updates);
}
return found._id;
}
return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: role === "CUSTOMER" ? undefined : `https://avatar.vercel.sh/${name.split(" ")[0]}` });
} }
const reverId = await ensureUser("Rever", "renan.pac@paulicon.com.br"); const adminId = await ensureUser("Administrador", "admin@sistema.dev", "ADMIN");
const agenteDemoId = await ensureUser("Agente Demo", "agente.demo@sistema.dev"); const staffRoster = [
{ name: "Gabriel Oliveira", email: "gabriel.oliveira@rever.com.br" },
{ name: "George Araujo", email: "george.araujo@rever.com.br" },
{ name: "Hugo Soares", email: "hugo.soares@rever.com.br" },
{ name: "Julio Cesar", email: "julio@rever.com.br" },
{ name: "Lorena Magalhães", email: "lorena@rever.com.br" },
{ name: "Rever", email: "renan.pac@paulicon.com.br" },
{ name: "Thiago Medeiros", email: "thiago.medeiros@rever.com.br" },
{ name: "Weslei Magalhães", email: "weslei@rever.com.br" },
];
const staffIds = await Promise.all(staffRoster.map((staff) => ensureUser(staff.name, staff.email)));
const defaultAssigneeId = staffIds[0] ?? adminId;
const eduardaId = await ensureUser("Eduarda Rocha", "eduarda.rocha@example.com", "CUSTOMER"); const eduardaId = await ensureUser("Eduarda Rocha", "eduarda.rocha@example.com", "CUSTOMER");
const clienteDemoId = await ensureUser("Cliente Demo", "cliente.demo@sistema.dev", "CUSTOMER"); const clienteDemoId = await ensureUser("Cliente Demo", "cliente.demo@sistema.dev", "CUSTOMER");
const templateDefinitions = [
{
title: "A Rever agradece seu contato",
body: "<p>A Rever agradece seu contato. Recebemos sua solicitação e nossa equipe já está analisando os detalhes. Retornaremos com atualizações em breve.</p>",
},
{
title: "Atualização do chamado",
body: "<p>Seu chamado foi atualizado. Caso tenha novas informações ou dúvidas, basta responder a esta mensagem.</p>",
},
{
title: "Chamado resolvido",
body: "<p>Concluímos o atendimento deste chamado. A Rever agradece a parceria e permanecemos à disposição para novos suportes.</p>",
},
];
const existingTemplates = await ctx.db
.query("commentTemplates")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect();
for (const definition of templateDefinitions) {
const already = existingTemplates.find((template) => template?.title === definition.title);
if (already) continue;
const timestamp = Date.now();
await ctx.db.insert("commentTemplates", {
tenantId,
title: definition.title,
body: definition.body,
createdBy: adminId,
updatedBy: adminId,
createdAt: timestamp,
updatedAt: timestamp,
});
}
// Seed a couple of tickets // Seed a couple of tickets
const now = Date.now(); const now = Date.now();
const newestRef = await ctx.db const newestRef = await ctx.db
@ -90,7 +147,7 @@ export const seedDemo = mutation({
channel: "EMAIL", channel: "EMAIL",
queueId: queue1, queueId: queue1,
requesterId: eduardaId, requesterId: eduardaId,
assigneeId: reverId, assigneeId: defaultAssigneeId,
createdAt: now - 1000 * 60 * 60 * 5, createdAt: now - 1000 * 60 * 60 * 5,
updatedAt: now - 1000 * 60 * 10, updatedAt: now - 1000 * 60 * 10,
tags: ["portal", "cliente"], tags: ["portal", "cliente"],
@ -107,7 +164,7 @@ export const seedDemo = mutation({
channel: "WHATSAPP", channel: "WHATSAPP",
queueId: queue2, queueId: queue2,
requesterId: clienteDemoId, requesterId: clienteDemoId,
assigneeId: agenteDemoId, assigneeId: defaultAssigneeId,
createdAt: now - 1000 * 60 * 60 * 8, createdAt: now - 1000 * 60 * 60 * 8,
updatedAt: now - 1000 * 60 * 30, updatedAt: now - 1000 * 60 * 30,
tags: ["Integração", "erp"], tags: ["Integração", "erp"],

View file

@ -0,0 +1,292 @@
import { PrismaClient } from "@prisma/client"
import { ConvexHttpClient } from "convex/browser"
const prisma = new PrismaClient()
const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas"
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210"
const secret = process.env.CONVEX_SYNC_SECRET
const STAFF_ROSTER = [
{ email: "admin@sistema.dev", name: "Administrador", role: "ADMIN" },
{ email: "gabriel.oliveira@rever.com.br", name: "Gabriel Oliveira", role: "AGENT" },
{ email: "george.araujo@rever.com.br", name: "George Araujo", role: "AGENT" },
{ email: "hugo.soares@rever.com.br", name: "Hugo Soares", role: "AGENT" },
{ email: "julio@rever.com.br", name: "Julio Cesar", role: "AGENT" },
{ email: "lorena@rever.com.br", name: "Lorena Magalhães", role: "AGENT" },
{ email: "renan.pac@paulicon.com.br", name: "Rever", role: "AGENT" },
{ email: "thiago.medeiros@rever.com.br", name: "Thiago Medeiros", role: "AGENT" },
{ email: "weslei@rever.com.br", name: "Weslei Magalhães", role: "AGENT" },
]
const rawDefaultAssigneeEmail = process.env.SYNC_DEFAULT_ASSIGNEE || "gabriel.oliveira@rever.com.br"
if (!secret) {
console.error("CONVEX_SYNC_SECRET não configurado. Configure no .env.")
process.exit(1)
}
const allowedRoles = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR", "CUSTOMER"])
const client = new ConvexHttpClient(convexUrl)
function normalizeEmail(email) {
if (!email) return null
return email.trim().toLowerCase()
}
const defaultAssigneeEmail = normalizeEmail(rawDefaultAssigneeEmail)
function toDate(value) {
if (!value && value !== 0) return null
return new Date(value)
}
async function upsertUsers(snapshotUsers) {
const map = new Map()
for (const user of snapshotUsers) {
const normalizedEmail = normalizeEmail(user.email)
if (!normalizedEmail) continue
const normalizedRole = (user.role ?? "CUSTOMER").toUpperCase()
const role = allowedRoles.has(normalizedRole) ? normalizedRole : "CUSTOMER"
const record = await prisma.user.upsert({
where: { email: normalizedEmail },
update: {
name: user.name ?? normalizedEmail,
role,
tenantId,
avatarUrl: user.avatarUrl ?? null,
},
create: {
email: normalizedEmail,
name: user.name ?? normalizedEmail,
role,
tenantId,
avatarUrl: user.avatarUrl ?? null,
},
})
map.set(normalizedEmail, record.id)
}
for (const staff of STAFF_ROSTER) {
const normalizedEmail = normalizeEmail(staff.email)
if (!normalizedEmail) continue
const record = await prisma.user.upsert({
where: { email: normalizedEmail },
update: {
name: staff.name,
role: staff.role,
tenantId,
},
create: {
email: normalizedEmail,
name: staff.name,
role: staff.role,
tenantId,
avatarUrl: null,
},
})
map.set(normalizedEmail, record.id)
}
const allowedStaffEmails = new Set(STAFF_ROSTER.map((staff) => normalizeEmail(staff.email)).filter(Boolean))
const removableStaff = await prisma.user.findMany({
where: {
tenantId,
role: { in: ["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"] },
email: {
notIn: Array.from(allowedStaffEmails),
},
},
})
const fallbackAssigneeId = defaultAssigneeEmail ? map.get(defaultAssigneeEmail) ?? null : null
for (const staff of removableStaff) {
if (fallbackAssigneeId) {
await prisma.ticket.updateMany({
where: { tenantId, assigneeId: staff.id },
data: { assigneeId: fallbackAssigneeId },
})
await prisma.ticketComment.updateMany({
where: { authorId: staff.id },
data: { authorId: fallbackAssigneeId },
})
}
await prisma.user.update({
where: { id: staff.id },
data: {
role: "CUSTOMER",
},
})
}
return map
}
async function upsertQueues(snapshotQueues) {
const map = new Map()
for (const queue of snapshotQueues) {
if (!queue.slug) continue
const record = await prisma.queue.upsert({
where: {
tenantId_slug: {
tenantId,
slug: queue.slug,
},
},
update: {
name: queue.name,
},
create: {
tenantId,
name: queue.name,
slug: queue.slug,
},
})
map.set(queue.slug, record.id)
}
return map
}
async function upsertTickets(snapshotTickets, userMap, queueMap) {
let created = 0
let updated = 0
const fallbackAssigneeId = defaultAssigneeEmail ? userMap.get(defaultAssigneeEmail) ?? null : null
for (const ticket of snapshotTickets) {
if (!ticket.requesterEmail) continue
const requesterId = userMap.get(normalizeEmail(ticket.requesterEmail))
if (!requesterId) continue
const queueId = ticket.queueSlug ? queueMap.get(ticket.queueSlug) ?? null : null
const desiredAssigneeEmail = defaultAssigneeEmail || normalizeEmail(ticket.assigneeEmail)
const assigneeId = desiredAssigneeEmail ? userMap.get(desiredAssigneeEmail) || fallbackAssigneeId || null : fallbackAssigneeId || null
const existing = await prisma.ticket.findFirst({
where: {
tenantId,
reference: ticket.reference,
},
})
const data = {
subject: ticket.subject,
summary: ticket.summary ?? null,
status: (ticket.status ?? "NEW").toUpperCase(),
priority: (ticket.priority ?? "MEDIUM").toUpperCase(),
channel: (ticket.channel ?? "MANUAL").toUpperCase(),
queueId,
requesterId,
assigneeId,
dueAt: toDate(ticket.dueAt),
firstResponseAt: toDate(ticket.firstResponseAt),
resolvedAt: toDate(ticket.resolvedAt),
closedAt: toDate(ticket.closedAt),
createdAt: toDate(ticket.createdAt) ?? new Date(),
updatedAt: toDate(ticket.updatedAt) ?? new Date(),
}
let ticketRecord
if (existing) {
ticketRecord = await prisma.ticket.update({
where: { id: existing.id },
data,
})
updated += 1
} else {
ticketRecord = await prisma.ticket.create({
data: {
tenantId,
reference: ticket.reference,
...data,
},
})
created += 1
}
await prisma.ticketComment.deleteMany({ where: { ticketId: ticketRecord.id } })
await prisma.ticketEvent.deleteMany({ where: { ticketId: ticketRecord.id } })
const commentsData = ticket.comments
.map((comment) => {
const authorId = comment.authorEmail ? userMap.get(normalizeEmail(comment.authorEmail)) : null
if (!authorId) {
return null
}
return {
ticketId: ticketRecord.id,
authorId,
visibility: (comment.visibility ?? "INTERNAL").toUpperCase(),
body: comment.body ?? "",
attachments: null,
createdAt: toDate(comment.createdAt) ?? new Date(),
updatedAt: toDate(comment.updatedAt) ?? new Date(),
}
})
.filter(Boolean)
if (commentsData.length > 0) {
await prisma.ticketComment.createMany({ data: commentsData })
}
const eventsData = ticket.events.map((event) => ({
ticketId: ticketRecord.id,
type: event.type ?? "UNKNOWN",
payload: event.payload ?? {},
createdAt: toDate(event.createdAt) ?? new Date(),
}))
if (eventsData.length > 0) {
await prisma.ticketEvent.createMany({ data: eventsData })
}
}
return { created, updated }
}
async function run() {
console.log("Baixando snapshot do Convex...")
const snapshot = await client.query("migrations:exportTenantSnapshot", {
secret,
tenantId,
})
console.log(`Usuários recebidos: ${snapshot.users.length}`)
console.log(`Filas recebidas: ${snapshot.queues.length}`)
console.log(`Tickets recebidos: ${snapshot.tickets.length}`)
console.log("Sincronizando usuários no Prisma...")
const userMap = await upsertUsers(snapshot.users)
console.log(`Usuários ativos no mapa: ${userMap.size}`)
console.log("Sincronizando filas no Prisma...")
const queueMap = await upsertQueues(snapshot.queues)
console.log(`Filas ativas no mapa: ${queueMap.size}`)
console.log("Sincronizando tickets no Prisma...")
const ticketStats = await upsertTickets(snapshot.tickets, userMap, queueMap)
console.log(`Tickets criados: ${ticketStats.created}`)
console.log(`Tickets atualizados: ${ticketStats.updated}`)
}
run()
.catch((error) => {
console.error("Falha ao importar dados do Convex para Prisma", error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View file

@ -24,13 +24,6 @@ const defaultUsers = singleUserFromEnv ?? [
role: "admin", role: "admin",
tenantId, tenantId,
}, },
{
email: "agente.demo@sistema.dev",
password: "agent123",
name: "Agente Demo",
role: "agent",
tenantId,
},
{ {
email: "cliente.demo@sistema.dev", email: "cliente.demo@sistema.dev",
password: "cliente123", password: "cliente123",
@ -38,6 +31,62 @@ const defaultUsers = singleUserFromEnv ?? [
role: "customer", role: "customer",
tenantId, tenantId,
}, },
{
email: "gabriel.oliveira@rever.com.br",
password: "agent123",
name: "Gabriel Oliveira",
role: "agent",
tenantId,
},
{
email: "george.araujo@rever.com.br",
password: "agent123",
name: "George Araujo",
role: "agent",
tenantId,
},
{
email: "hugo.soares@rever.com.br",
password: "agent123",
name: "Hugo Soares",
role: "agent",
tenantId,
},
{
email: "julio@rever.com.br",
password: "agent123",
name: "Julio Cesar",
role: "agent",
tenantId,
},
{
email: "lorena@rever.com.br",
password: "agent123",
name: "Lorena Magalhães",
role: "agent",
tenantId,
},
{
email: "renan.pac@paulicon.com.br",
password: "agent123",
name: "Rever",
role: "agent",
tenantId,
},
{
email: "thiago.medeiros@rever.com.br",
password: "agent123",
name: "Thiago Medeiros",
role: "agent",
tenantId,
},
{
email: "weslei@rever.com.br",
password: "agent123",
name: "Weslei Magalhães",
role: "agent",
tenantId,
},
] ]
async function upsertAuthUser({ email, password, name, role, tenantId: userTenant }: (typeof defaultUsers)[number]) { async function upsertAuthUser({ email, password, name, role, tenantId: userTenant }: (typeof defaultUsers)[number]) {

View file

@ -0,0 +1,136 @@
import { PrismaClient } from "@prisma/client"
import { ConvexHttpClient } from "convex/browser"
const prisma = new PrismaClient()
function toMillis(date) {
return date instanceof Date ? date.getTime() : date ? new Date(date).getTime() : undefined
}
function normalizeString(value, fallback = "") {
if (!value) return fallback
return value.trim()
}
function slugify(value) {
return normalizeString(value)
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-") || undefined
}
async function main() {
const tenantId = process.env.SYNC_TENANT_ID || "tenant-atlas"
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || "http://127.0.0.1:3210"
const secret = process.env.CONVEX_SYNC_SECRET
if (!secret) {
console.error("CONVEX_SYNC_SECRET não configurado. Configure no .env.")
process.exit(1)
}
const [users, queues, tickets] = await Promise.all([
prisma.user.findMany({
include: {
teams: {
include: { team: true },
},
},
}),
prisma.queue.findMany(),
prisma.ticket.findMany({
include: {
requester: true,
assignee: true,
queue: true,
comments: {
include: {
author: true,
},
},
events: true,
},
orderBy: { createdAt: "asc" },
}),
])
const userSnapshot = users.map((user) => ({
email: user.email,
name: normalizeString(user.name, user.email),
role: user.role,
avatarUrl: user.avatarUrl ?? undefined,
teams: user.teams
.map((membership) => membership.team?.name)
.filter((name) => Boolean(name) && typeof name === "string"),
}))
const queueSnapshot = queues.map((queue) => ({
name: normalizeString(queue.name, queue.slug ?? queue.id),
slug: queue.slug ? queue.slug : normalizeString(queue.name, queue.id).toLowerCase().replace(/\s+/g, "-"),
}))
const referenceFallbackStart = 41000
let referenceCounter = referenceFallbackStart
const ticketSnapshot = tickets.map((ticket) => {
const reference = ticket.reference && ticket.reference > 0 ? ticket.reference : ++referenceCounter
const requesterEmail = ticket.requester?.email ?? userSnapshot[0]?.email ?? "unknown@example.com"
const assigneeEmail = ticket.assignee?.email ?? undefined
const queueSlug = ticket.queue?.slug ?? slugify(ticket.queue?.name)
return {
reference,
subject: normalizeString(ticket.subject, `Ticket ${reference}`),
summary: ticket.summary ?? undefined,
status: ticket.status,
priority: ticket.priority,
channel: ticket.channel,
queueSlug: queueSlug ?? undefined,
requesterEmail,
assigneeEmail,
dueAt: toMillis(ticket.dueAt) ?? undefined,
firstResponseAt: toMillis(ticket.firstResponseAt) ?? undefined,
resolvedAt: toMillis(ticket.resolvedAt) ?? undefined,
closedAt: toMillis(ticket.closedAt) ?? undefined,
createdAt: toMillis(ticket.createdAt) ?? Date.now(),
updatedAt: toMillis(ticket.updatedAt) ?? Date.now(),
tags: Array.isArray(ticket.tags) ? ticket.tags : undefined,
comments: ticket.comments.map((comment) => ({
authorEmail: comment.author?.email ?? requesterEmail,
visibility: comment.visibility,
body: comment.body,
createdAt: toMillis(comment.createdAt) ?? Date.now(),
updatedAt: toMillis(comment.updatedAt) ?? Date.now(),
})),
events: ticket.events.map((event) => ({
type: event.type,
payload: event.payload ?? {},
createdAt: toMillis(event.createdAt) ?? Date.now(),
})),
}
})
const client = new ConvexHttpClient(convexUrl)
const result = await client.mutation("migrations:importPrismaSnapshot", {
secret,
snapshot: {
tenantId,
users: userSnapshot,
queues: queueSnapshot,
tickets: ticketSnapshot,
},
})
console.log("Sincronização concluída:", result)
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

View file

@ -0,0 +1,24 @@
import { AppShell } from "@/components/app-shell"
import { CommentTemplatesManager } from "@/components/settings/comment-templates-manager"
import { SiteHeader } from "@/components/site-header"
import { requireStaffSession } from "@/lib/auth-server"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export default async function CommentTemplatesPage() {
await requireStaffSession()
return (
<AppShell
header={
<SiteHeader
title="Templates de comentário"
lead="Mantenha respostas prontas e alinhadas com a voz da Rever."
/>
}
>
<CommentTemplatesManager />
</AppShell>
)
}

View file

@ -0,0 +1,322 @@
"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"
// @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 { 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"
export function CommentTemplatesManager() {
const { convexUserId, session } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const viewerId = convexUserId as Id<"users"> | undefined
const templates = useQuery(
viewerId ? api.commentTemplates.list : "skip",
viewerId ? { tenantId, viewerId } : "skip"
) as
| {
id: Id<"commentTemplates">
title: string
body: string
createdAt: number
updatedAt: number
createdBy: Id<"users">
updatedBy: Id<"users"> | null
}[]
| 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])
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 })
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) {
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,
})
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-1">
<CardTitle className="text-xl font-semibold text-neutral-900">Templates de comentário</CardTitle>
<CardDescription className="text-sm text-neutral-600">
Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates.
</CardDescription>
</CardHeader>
<CardContent>
<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="Escreva a mensagem padrão..." />
</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 comentários 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>Nenhum template cadastrado</EmptyTitle>
<EmptyDescription>Crie seu primeiro template usando o formulário acima.</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
}
onSave: (templateId: Id<"commentTemplates">, title: string, body: 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)
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>
)
}

View file

@ -4,7 +4,7 @@ import { useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { toast } from "sonner"
import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3 } from "lucide-react" import { Settings2, Share2, ShieldCheck, UserCog, UserPlus, Users2, Layers3, MessageSquareText } from "lucide-react"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@ -67,6 +67,14 @@ const SETTINGS_ACTIONS: SettingsAction[] = [
requiredRole: "admin", requiredRole: "admin",
icon: UserPlus, icon: UserPlus,
}, },
{
title: "Templates de comentários",
description: "Gerencie mensagens rápidas utilizadas nos atendimentos.",
href: "/settings/templates",
cta: "Abrir templates",
requiredRole: "staff",
icon: MessageSquareText,
},
{ {
title: "Preferências da equipe", title: "Preferências da equipe",
description: "Defina padrões de notificação e comportamento do modo play para toda a equipe.", description: "Defina padrões de notificação e comportamento do modo play para toda a equipe.",

View file

@ -3,9 +3,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale" import { ptBR } from "date-fns/locale"
import { IconLock, IconMessage } from "@tabler/icons-react" import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react"
import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react" import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-react"
import { useAction, useMutation } from "convex/react" import { useAction, useMutation, useQuery } from "convex/react"
// @ts-expect-error Convex runtime API lacks TypeScript declarations // @ts-expect-error Convex runtime API lacks TypeScript declarations
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api"
import { useAuth } from "@/lib/auth-client" import { useAuth } from "@/lib/auth-client"
@ -19,6 +19,7 @@ import { toast } from "sonner"
import { Dropzone } from "@/components/ui/dropzone" import { Dropzone } from "@/components/ui/dropzone"
import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Spinner } from "@/components/ui/spinner" import { Spinner } from "@/components/ui/spinner"
@ -33,7 +34,7 @@ const submitButtonClass =
"inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30" "inline-flex items-center gap-2 rounded-lg border border-black bg-black px-3 py-2 text-sm font-semibold text-white transition hover:bg-[#18181b]/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#18181b]/30"
export function TicketComments({ ticket }: TicketCommentsProps) { export function TicketComments({ ticket }: TicketCommentsProps) {
const { convexUserId } = useAuth() const { convexUserId, isStaff } = useAuth()
const addComment = useMutation(api.tickets.addComment) const addComment = useMutation(api.tickets.addComment)
const removeAttachment = useMutation(api.tickets.removeCommentAttachment) const removeAttachment = useMutation(api.tickets.removeCommentAttachment)
const updateComment = useMutation(api.tickets.updateComment) const updateComment = useMutation(api.tickets.updateComment)
@ -48,6 +49,25 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
const [savingCommentId, setSavingCommentId] = useState<string | null>(null) const [savingCommentId, setSavingCommentId] = useState<string | null>(null)
const [localBodies, setLocalBodies] = useState<Record<string, string>>({}) const [localBodies, setLocalBodies] = useState<Record<string, string>>({})
const templateArgs = convexUserId && isStaff
? { tenantId: ticket.tenantId, viewerId: convexUserId as Id<"users"> }
: "skip"
const templatesResult = useQuery(convexUserId && isStaff ? api.commentTemplates.list : "skip", templateArgs) as
| { id: string; title: string; body: string }[]
| undefined
const templates = templatesResult ?? []
const templatesLoading = Boolean(convexUserId && isStaff) && templatesResult === undefined
const canUseTemplates = Boolean(convexUserId && isStaff)
const insertTemplateIntoBody = (html: string) => {
const sanitized = sanitizeEditorHtml(html)
setBody((current) => {
if (!current) return sanitized
const merged = `${current}<p><br /></p>${sanitized}`
return sanitizeEditorHtml(merged)
})
}
const startEditingComment = useCallback((commentId: string, currentBody: string) => { const startEditingComment = useCallback((commentId: string, currentBody: string) => {
setEditingComment({ id: commentId, value: currentBody || "" }) setEditingComment({ id: commentId, value: currentBody || "" })
}, []) }, [])
@ -352,18 +372,58 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
})} })}
</div> </div>
) : null} ) : null}
<div className="flex items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs text-neutral-600"> <div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
Visibilidade: {canUseTemplates ? (
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}> <DropdownMenu>
<SelectTrigger className={selectTriggerClass}> <DropdownMenuTrigger asChild>
<SelectValue placeholder="Visibilidade" /> <Button
</SelectTrigger> type="button"
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm"> variant="outline"
<SelectItem value="PUBLIC">Pública</SelectItem> size="sm"
<SelectItem value="INTERNAL">Interna</SelectItem> className="inline-flex items-center gap-2 border-slate-200 text-sm text-neutral-700 hover:bg-slate-50"
</SelectContent> disabled={templatesLoading}
</Select> >
<IconFileText className="size-4" />
Inserir template
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{templatesLoading ? (
<div className="flex items-center gap-2 px-3 py-2 text-sm text-neutral-500">
<Spinner className="size-4" />
Carregando templates...
</div>
) : templates.length === 0 ? (
<div className="px-3 py-2 text-sm text-neutral-500">
Nenhum template disponível. Cadastre novos em configurações.
</div>
) : (
templates.map((template) => (
<DropdownMenuItem
key={template.id}
className="flex flex-col items-start whitespace-normal py-2"
onSelect={() => insertTemplateIntoBody(template.body)}
>
<span className="text-sm font-medium text-neutral-800">{template.title}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<div className="flex items-center gap-2">
Visibilidade:
<Select value={visibility} onValueChange={(value) => setVisibility(value as "PUBLIC" | "INTERNAL")}>
<SelectTrigger className={selectTriggerClass}>
<SelectValue placeholder="Visibilidade" />
</SelectTrigger>
<SelectContent className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<SelectItem value="PUBLIC">Pública</SelectItem>
<SelectItem value="INTERNAL">Interna</SelectItem>
</SelectContent>
</Select>
</div>
</div> </div>
<Button type="submit" size="sm" className={submitButtonClass}> <Button type="submit" size="sm" className={submitButtonClass}>
Enviar Enviar