From 409cbea7b9728596d5fe5c27cf7a49245168059c Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Mon, 6 Oct 2025 20:35:40 -0300 Subject: [PATCH] feat: seed real agents and enable comment templates --- web/agents.md | 68 +++ web/convex/_generated/api.d.ts | 4 + web/convex/commentTemplates.ts | 173 +++++++ web/convex/migrations.ts | 488 ++++++++++++++++++ web/convex/schema.ts | 12 + web/convex/seed.ts | 69 ++- web/scripts/import-convex-to-prisma.mjs | 292 +++++++++++ web/scripts/seed-auth.mjs | 63 ++- web/scripts/sync-prisma-to-convex.mjs | 136 +++++ web/src/app/settings/templates/page.tsx | 24 + .../settings/comment-templates-manager.tsx | 322 ++++++++++++ .../components/settings/settings-content.tsx | 10 +- .../tickets/ticket-comments.rich.tsx | 90 +++- 13 files changed, 1722 insertions(+), 29 deletions(-) create mode 100644 web/agents.md create mode 100644 web/convex/commentTemplates.ts create mode 100644 web/convex/migrations.ts create mode 100644 web/scripts/import-convex-to-prisma.mjs create mode 100644 web/scripts/sync-prisma-to-convex.mjs create mode 100644 web/src/app/settings/templates/page.tsx create mode 100644 web/src/components/settings/comment-templates-manager.tsx diff --git a/web/agents.md b/web/agents.md new file mode 100644 index 0000000..f13814a --- /dev/null +++ b/web/agents.md @@ -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) diff --git a/web/convex/_generated/api.d.ts b/web/convex/_generated/api.d.ts index 7f415c7..78a14b4 100644 --- a/web/convex/_generated/api.d.ts +++ b/web/convex/_generated/api.d.ts @@ -10,9 +10,11 @@ import type * as bootstrap from "../bootstrap.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 files from "../files.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 rbac from "../rbac.js"; import type * as reports from "../reports.js"; @@ -39,9 +41,11 @@ import type { declare const fullApi: ApiFromModules<{ bootstrap: typeof bootstrap; categories: typeof categories; + commentTemplates: typeof commentTemplates; fields: typeof fields; files: typeof files; invites: typeof invites; + migrations: typeof migrations; queues: typeof queues; rbac: typeof rbac; reports: typeof reports; diff --git a/web/convex/commentTemplates.ts b/web/convex/commentTemplates.ts new file mode 100644 index 0000000..59baa5e --- /dev/null +++ b/web/convex/commentTemplates.ts @@ -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) + }, +}) diff --git a/web/convex/migrations.ts b/web/convex/migrations.ts new file mode 100644 index 0000000..5af4719 --- /dev/null +++ b/web/convex/migrations.ts @@ -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>(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> +) { + 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> +) { + 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>() + const queueCache = new Map>() + + 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, + } + }, +}) diff --git a/web/convex/schema.ts b/web/convex/schema.ts index 5a3a77a..862369b 100644 --- a/web/convex/schema.ts +++ b/web/convex/schema.ts @@ -107,6 +107,18 @@ export default defineSchema({ createdAt: v.number(), }).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({ ticketId: v.id("tickets"), agentId: v.id("users"), diff --git a/web/convex/seed.ts b/web/convex/seed.ts index 6842a31..65e08c0 100644 --- a/web/convex/seed.ts +++ b/web/convex/seed.ts @@ -61,14 +61,71 @@ export const seedDemo = mutation({ .query("users") .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", email)) .first(); - if (found) return found._id; - return await ctx.db.insert("users", { tenantId, name, email, role, avatarUrl: `https://avatar.vercel.sh/${name.split(" ")[0]}` }); + if (found) { + const updates: Record = {}; + 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 agenteDemoId = await ensureUser("Agente Demo", "agente.demo@sistema.dev"); + const adminId = await ensureUser("Administrador", "admin@sistema.dev", "ADMIN"); + 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 clienteDemoId = await ensureUser("Cliente Demo", "cliente.demo@sistema.dev", "CUSTOMER"); + const templateDefinitions = [ + { + title: "A Rever agradece seu contato", + body: "

A Rever agradece seu contato. Recebemos sua solicitação e nossa equipe já está analisando os detalhes. Retornaremos com atualizações em breve.

", + }, + { + title: "Atualização do chamado", + body: "

Seu chamado foi atualizado. Caso tenha novas informações ou dúvidas, basta responder a esta mensagem.

", + }, + { + title: "Chamado resolvido", + body: "

Concluímos o atendimento deste chamado. A Rever agradece a parceria e permanecemos à disposição para novos suportes.

", + }, + ]; + + 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 const now = Date.now(); const newestRef = await ctx.db @@ -90,7 +147,7 @@ export const seedDemo = mutation({ channel: "EMAIL", queueId: queue1, requesterId: eduardaId, - assigneeId: reverId, + assigneeId: defaultAssigneeId, createdAt: now - 1000 * 60 * 60 * 5, updatedAt: now - 1000 * 60 * 10, tags: ["portal", "cliente"], @@ -107,7 +164,7 @@ export const seedDemo = mutation({ channel: "WHATSAPP", queueId: queue2, requesterId: clienteDemoId, - assigneeId: agenteDemoId, + assigneeId: defaultAssigneeId, createdAt: now - 1000 * 60 * 60 * 8, updatedAt: now - 1000 * 60 * 30, tags: ["Integração", "erp"], diff --git a/web/scripts/import-convex-to-prisma.mjs b/web/scripts/import-convex-to-prisma.mjs new file mode 100644 index 0000000..a4ee1e3 --- /dev/null +++ b/web/scripts/import-convex-to-prisma.mjs @@ -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() + }) diff --git a/web/scripts/seed-auth.mjs b/web/scripts/seed-auth.mjs index eaa43e9..5002869 100644 --- a/web/scripts/seed-auth.mjs +++ b/web/scripts/seed-auth.mjs @@ -24,13 +24,6 @@ const defaultUsers = singleUserFromEnv ?? [ role: "admin", tenantId, }, - { - email: "agente.demo@sistema.dev", - password: "agent123", - name: "Agente Demo", - role: "agent", - tenantId, - }, { email: "cliente.demo@sistema.dev", password: "cliente123", @@ -38,6 +31,62 @@ const defaultUsers = singleUserFromEnv ?? [ role: "customer", 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]) { diff --git a/web/scripts/sync-prisma-to-convex.mjs b/web/scripts/sync-prisma-to-convex.mjs new file mode 100644 index 0000000..4b3fd05 --- /dev/null +++ b/web/scripts/sync-prisma-to-convex.mjs @@ -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() + }) diff --git a/web/src/app/settings/templates/page.tsx b/web/src/app/settings/templates/page.tsx new file mode 100644 index 0000000..5d76883 --- /dev/null +++ b/web/src/app/settings/templates/page.tsx @@ -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 ( + + } + > + + + ) +} diff --git a/web/src/components/settings/comment-templates-manager.tsx b/web/src/components/settings/comment-templates-manager.tsx new file mode 100644 index 0000000..ec823ab --- /dev/null +++ b/web/src/components/settings/comment-templates-manager.tsx @@ -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) { + 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 ( + + + Templates de comentário + Faça login para gerenciar os templates de resposta rápida. + + + ) + } + + return ( +
+ + + Templates de comentário + + Mantenha respostas rápidas prontas para uso. Administradores e agentes podem criar, editar e remover templates. + + + +
+
+ + setTitle(event.target.value)} + required + /> +
+
+ + +
+
+ {body ? ( + + ) : null} + +
+
+
+
+ + + + Templates cadastrados + + + Gerencie as mensagens prontas utilizadas nos comentários de tickets. + + + + {isLoading ? ( +
+ Carregando templates... +
+ ) : orderedTemplates.length === 0 ? ( + + + + + + Nenhum template cadastrado + Crie seu primeiro template usando o formulário acima. + + + ) : ( +
+ {orderedTemplates.map((template) => ( + + ))} +
+ )} +
+
+
+ ) +} + +type TemplateItemProps = { + template: { + id: Id<"commentTemplates"> + title: string + body: string + updatedAt: number + } + onSave: (templateId: Id<"commentTemplates">, title: string, body: string) => Promise + onDelete: (templateId: Id<"commentTemplates">) => Promise +} + +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 ( +
+
+ {isEditing ? ( +
+ setTitle(event.target.value)} placeholder="Título" /> + +
+ ) : ( +
+

{template.title}

+ +
+ )} +
+ Atualizado em {lastUpdated.toLocaleString("pt-BR")} +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+
+
+ ) +} diff --git a/web/src/components/settings/settings-content.tsx b/web/src/components/settings/settings-content.tsx index 636f596..af4cd64 100644 --- a/web/src/components/settings/settings-content.tsx +++ b/web/src/components/settings/settings-content.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" 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 { Button } from "@/components/ui/button" @@ -67,6 +67,14 @@ const SETTINGS_ACTIONS: SettingsAction[] = [ requiredRole: "admin", 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", description: "Defina padrões de notificação e comportamento do modo play para toda a equipe.", diff --git a/web/src/components/tickets/ticket-comments.rich.tsx b/web/src/components/tickets/ticket-comments.rich.tsx index 5524568..4cd9512 100644 --- a/web/src/components/tickets/ticket-comments.rich.tsx +++ b/web/src/components/tickets/ticket-comments.rich.tsx @@ -3,9 +3,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { formatDistanceToNow } from "date-fns" 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 { useAction, useMutation } from "convex/react" +import { useAction, useMutation, useQuery } from "convex/react" // @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" @@ -19,6 +19,7 @@ import { toast } from "sonner" import { Dropzone } from "@/components/ui/dropzone" import { RichTextEditor, RichTextContent, sanitizeEditorHtml } from "@/components/ui/rich-text-editor" 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 { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty" 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" export function TicketComments({ ticket }: TicketCommentsProps) { - const { convexUserId } = useAuth() + const { convexUserId, isStaff } = useAuth() const addComment = useMutation(api.tickets.addComment) const removeAttachment = useMutation(api.tickets.removeCommentAttachment) const updateComment = useMutation(api.tickets.updateComment) @@ -48,6 +49,25 @@ export function TicketComments({ ticket }: TicketCommentsProps) { const [savingCommentId, setSavingCommentId] = useState(null) const [localBodies, setLocalBodies] = useState>({}) + 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}


${sanitized}` + return sanitizeEditorHtml(merged) + }) + } + const startEditingComment = useCallback((commentId: string, currentBody: string) => { setEditingComment({ id: commentId, value: currentBody || "" }) }, []) @@ -352,18 +372,58 @@ export function TicketComments({ ticket }: TicketCommentsProps) { })} ) : null} -
-
- Visibilidade: - +
+
+ {canUseTemplates ? ( + + + + + + {templatesLoading ? ( +
+ + Carregando templates... +
+ ) : templates.length === 0 ? ( +
+ Nenhum template disponível. Cadastre novos em configurações. +
+ ) : ( + templates.map((template) => ( + insertTemplateIntoBody(template.body)} + > + {template.title} + + )) + )} +
+
+ ) : null} +
+ Visibilidade: + +