feat: seed real agents and enable comment templates
This commit is contained in:
parent
df8c4e29bb
commit
409cbea7b9
13 changed files with 1722 additions and 29 deletions
68
web/agents.md
Normal file
68
web/agents.md
Normal 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)
|
||||
4
web/convex/_generated/api.d.ts
vendored
4
web/convex/_generated/api.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
173
web/convex/commentTemplates.ts
Normal file
173
web/convex/commentTemplates.ts
Normal 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
488
web/convex/migrations.ts
Normal 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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
const reverId = await ensureUser("Rever", "renan.pac@paulicon.com.br");
|
||||
const agenteDemoId = await ensureUser("Agente Demo", "agente.demo@sistema.dev");
|
||||
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 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: "<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
|
||||
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"],
|
||||
|
|
|
|||
292
web/scripts/import-convex-to-prisma.mjs
Normal file
292
web/scripts/import-convex-to-prisma.mjs
Normal 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()
|
||||
})
|
||||
|
|
@ -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]) {
|
||||
|
|
|
|||
136
web/scripts/sync-prisma-to-convex.mjs
Normal file
136
web/scripts/sync-prisma-to-convex.mjs
Normal 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()
|
||||
})
|
||||
24
web/src/app/settings/templates/page.tsx
Normal file
24
web/src/app/settings/templates/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
322
web/src/components/settings/comment-templates-manager.tsx
Normal file
322
web/src/components/settings/comment-templates-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
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) => {
|
||||
setEditingComment({ id: commentId, value: currentBody || "" })
|
||||
}, [])
|
||||
|
|
@ -352,8 +372,47 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-neutral-600">
|
||||
{canUseTemplates ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2 border-slate-200 text-sm text-neutral-700 hover:bg-slate-50"
|
||||
disabled={templatesLoading}
|
||||
>
|
||||
<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}>
|
||||
|
|
@ -365,6 +424,7 @@ export function TicketComments({ ticket }: TicketCommentsProps) {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" size="sm" className={submitButtonClass}>
|
||||
Enviar
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue