From 29a647f6c6c1ad50f252855b1a9d3ddad5f4030c Mon Sep 17 00:00:00 2001 From: esdrasrenan Date: Sun, 5 Oct 2025 19:59:24 -0300 Subject: [PATCH] feat: secure convex admin flows with real metrics\n\nCo-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- agents.md | 32 +- web/convex/_generated/api.d.ts | 8 + web/convex/fields.ts | 209 +++++++ web/convex/queues.ts | 175 +++++- web/convex/reports.ts | 341 +++++++++++ web/convex/schema.ts | 26 +- web/convex/slas.ts | 138 +++++ web/convex/teams.ts | 232 ++++++++ web/convex/tickets.ts | 4 +- web/convex/users.ts | 40 +- web/postcss.config.mjs | 6 +- web/scripts/seed-auth.mjs | 40 +- web/src/app/admin/channels/page.tsx | 17 + web/src/app/admin/fields/page.tsx | 17 + web/src/app/admin/slas/page.tsx | 17 + web/src/app/admin/teams/page.tsx | 17 + web/src/app/api/admin/users/route.ts | 2 +- web/src/app/portal/layout.tsx | 7 + web/src/app/portal/page.tsx | 27 +- web/src/app/portal/tickets/[id]/page.tsx | 5 + web/src/app/portal/tickets/new/page.tsx | 12 + web/src/app/portal/tickets/page.tsx | 12 + web/src/app/reports/backlog/page.tsx | 17 + web/src/app/reports/csat/page.tsx | 17 + web/src/app/reports/sla/page.tsx | 17 + web/src/app/settings/page.tsx | 13 + .../admin/fields/fields-manager.tsx | 551 ++++++++++++++++++ .../admin/queues/queues-manager.tsx | 322 ++++++++++ .../components/admin/slas/slas-manager.tsx | 390 +++++++++++++ .../components/admin/teams/teams-manager.tsx | 428 ++++++++++++++ web/src/components/app-sidebar.tsx | 142 +++-- web/src/components/chart-area-interactive.tsx | 307 +++++----- web/src/components/portal/portal-shell.tsx | 122 ++++ .../components/portal/portal-ticket-card.tsx | 104 ++++ .../portal/portal-ticket-detail.tsx | 303 ++++++++++ .../components/portal/portal-ticket-form.tsx | 197 +++++++ .../components/portal/portal-ticket-list.tsx | 89 +++ web/src/components/reports/backlog-report.tsx | 172 ++++++ web/src/components/reports/csat-report.tsx | 111 ++++ web/src/components/reports/sla-report.tsx | 124 ++++ web/src/components/section-cards.tsx | 255 +++++--- .../components/settings/settings-content.tsx | 280 +++++++++ web/src/components/tickets/tickets-view.tsx | 10 +- 43 files changed, 4992 insertions(+), 363 deletions(-) create mode 100644 web/convex/fields.ts create mode 100644 web/convex/reports.ts create mode 100644 web/convex/slas.ts create mode 100644 web/convex/teams.ts create mode 100644 web/src/app/admin/channels/page.tsx create mode 100644 web/src/app/admin/fields/page.tsx create mode 100644 web/src/app/admin/slas/page.tsx create mode 100644 web/src/app/admin/teams/page.tsx create mode 100644 web/src/app/portal/layout.tsx create mode 100644 web/src/app/portal/tickets/[id]/page.tsx create mode 100644 web/src/app/portal/tickets/new/page.tsx create mode 100644 web/src/app/portal/tickets/page.tsx create mode 100644 web/src/app/reports/backlog/page.tsx create mode 100644 web/src/app/reports/csat/page.tsx create mode 100644 web/src/app/reports/sla/page.tsx create mode 100644 web/src/app/settings/page.tsx create mode 100644 web/src/components/admin/fields/fields-manager.tsx create mode 100644 web/src/components/admin/queues/queues-manager.tsx create mode 100644 web/src/components/admin/slas/slas-manager.tsx create mode 100644 web/src/components/admin/teams/teams-manager.tsx create mode 100644 web/src/components/portal/portal-shell.tsx create mode 100644 web/src/components/portal/portal-ticket-card.tsx create mode 100644 web/src/components/portal/portal-ticket-detail.tsx create mode 100644 web/src/components/portal/portal-ticket-form.tsx create mode 100644 web/src/components/portal/portal-ticket-list.tsx create mode 100644 web/src/components/reports/backlog-report.tsx create mode 100644 web/src/components/reports/csat-report.tsx create mode 100644 web/src/components/reports/sla-report.tsx create mode 100644 web/src/components/settings/settings-content.tsx diff --git a/agents.md b/agents.md index 7bcc55c..26c5168 100644 --- a/agents.md +++ b/agents.md @@ -42,22 +42,25 @@ Construir o nucleo de tickets compartilhado entre web e desktop (Tauri), garanti - SLAs (BullMQ + Redis), notificacoes, ingest de e-mail, portal cliente, etc. ## Backlog imediato -- [ ] Expor portal do cliente com listagem de tickets filtrada por `viewerId` (Convex + UI) -- [ ] Completar painel administrativo (gestão de agentes, filas, categorias) com RBAC server/client +- [x] Expor portal do cliente com listagem de tickets filtrada por `viewerId` (Convex + UI) +- [x] Completar painel administrativo (times, filas, campos e SLAs) com RBAC server/client - [ ] Consolidar sincronização Better Auth ↔ Convex para fluxo de convites e resets de senha -- [ ] Expandir suite de testes (UI + Convex) cobrindo guardas e mapeadores críticos +- [ ] Expandir suite de testes (UI + Convex) cobrindo guardas, relatórios e mapeadores críticos +- [ ] Implementar fluxo completo de convites (criação, envio, revogação e aceite) para administradores +- [ ] Habilitar ações avançadas para agentes (edição de categorias, reassigação rápida) com as devidas permissões ### Iniciativa atual — Autenticação real e personas - [x] Migrar placeholder para Better Auth + Prisma (handlers Next, cliente React e sync Convex). - [x] Expor roles (`admin`, `agent`, `customer`) e aplicar guardas (`requireUser/Staff/Admin/Customer`) no Convex. - [x] Ajustar middleware e componentes para usar `viewerId`/`actorId`, evitando vazamento de dados entre tenants. -- [ ] Criar portal do cliente para abertura/consulta de chamados e comentários públicos. -- [ ] Consolidar painel administrativo (agentes, filas, categorias) com fluxos completos de convite. +- [x] Criar portal do cliente para abertura/consulta de chamados e comentários públicos. +- [x] Consolidar painel administrativo (times, filas, campos e SLAs) com UI protegida por RBAC completo. +- [ ] Entregar fluxo de convites Better Auth (criação, envio, revogação) e gerenciamento de agentes. ## Proximas entregas sugeridas -1. Entregar portal do cliente (listagem, detalhes e criação de ticket) consumindo RBAC e mapeadores atualizados. -2. Evoluir painel administrativo com gerenciamento de filas/categorias e convites com Better Auth. -3. Introduzir relatórios e métricas (workSummary, SLA) protegidos por `requireStaff/requireAdmin`. +1. Finalizar onboarding/offboarding de agentes com convites Better Auth, sincronização Convex e trilhas de auditoria. +2. Evoluir painel administrativo com gestão de categorias avançadas e permissões granulares para agentes. +3. Expandir relatórios operacionais (workSummary, tendências de SLA/CSAT) com alertas e comparativos configuráveis. 4. Automatizar pipeline CI (lint + vitest) integrando checagens obrigatórias. 5. Revisar UX dos fluxos de atendimento (play next, comentários) com feedback otimista e trilha de auditoria. @@ -364,6 +367,13 @@ Manter este arquivo atualizado ao concluir cada item estratégico ou quando surg - Toast de autenticação inválida agora informa "E-mail ou senha inválidos", alinhando o feedback com o restante da interface. ### Próximos passos imediatos -- [ ] Consolidar o painel administrativo com fluxo completo de convites (criação, exibição e revogação) utilizando Better Auth. -- [ ] Iniciar o portal do cliente com listagem de tickets filtrada por `viewerId` e detalhamento básico. -- [ ] Cobrir o fluxo de autenticação (login/convite/reset) com testes Vitest focados em regressões críticas. +- [ ] Implementar fluxo completo de convites (criação, expiração, revogação) integrado ao Better Auth e Convex. +- [ ] Adicionar testes Vitest/E2E cobrindo dashboards, relatórios e guardas de RBAC no front. +- [ ] Mapear permissões de edição avançada para agentes (categorias, campos rápidos) antes de liberar novas mutações. + +## Atualizações recentes (jun/2026) + +- RBAC do Convex reforçado em times, filas, campos, SLAs e relatórios; todas as chamadas exigem `viewerId`/`actorId` conforme o papel (admin ou staff). +- Painel administrativo atualizado para consumir as novas assinaturas protegidas, com validações de sessão Better Auth e feedback de toasts. +- Dashboard principal passou a exibir métricas reais via `reports.dashboardOverview` e séries históricas por canal com `reports.ticketsByChannel`. +- Portal do cliente publicado com isolamento por `viewerId`, garantindo que clientes visualizem apenas seus chamados. diff --git a/web/convex/_generated/api.d.ts b/web/convex/_generated/api.d.ts index b974ffc..58ea5e6 100644 --- a/web/convex/_generated/api.d.ts +++ b/web/convex/_generated/api.d.ts @@ -10,10 +10,14 @@ import type * as bootstrap from "../bootstrap.js"; import type * as categories from "../categories.js"; +import type * as fields from "../fields.js"; import type * as files from "../files.js"; import type * as queues from "../queues.js"; import type * as rbac from "../rbac.js"; +import type * as reports from "../reports.js"; import type * as seed from "../seed.js"; +import type * as slas from "../slas.js"; +import type * as teams from "../teams.js"; import type * as tickets from "../tickets.js"; import type * as users from "../users.js"; @@ -34,10 +38,14 @@ import type { declare const fullApi: ApiFromModules<{ bootstrap: typeof bootstrap; categories: typeof categories; + fields: typeof fields; files: typeof files; queues: typeof queues; rbac: typeof rbac; + reports: typeof reports; seed: typeof seed; + slas: typeof slas; + teams: typeof teams; tickets: typeof tickets; users: typeof users; }>; diff --git a/web/convex/fields.ts b/web/convex/fields.ts new file mode 100644 index 0000000..b4009db --- /dev/null +++ b/web/convex/fields.ts @@ -0,0 +1,209 @@ +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; + +import { requireAdmin } from "./rbac"; + +const FIELD_TYPES = ["text", "number", "select", "date", "boolean"] as const; + +type FieldType = (typeof FIELD_TYPES)[number]; + +function normalizeKey(label: string) { + return label + .trim() + .toLowerCase() + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .replace(/\s+/g, "_") + .replace(/_+/g, "_"); +} + +type AnyCtx = QueryCtx | MutationCtx; + +async function ensureUniqueKey(ctx: AnyCtx, tenantId: string, key: string, excludeId?: Id<"ticketFields">) { + const existing = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_key", (q) => q.eq("tenantId", tenantId).eq("key", key)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe um campo com este identificador"); + } +} + +function validateOptions(type: FieldType, options: { value: string; label: string }[] | undefined) { + if (type === "select" && (!options || options.length === 0)) { + throw new ConvexError("Campos de seleção precisam de pelo menos uma opção"); + } +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const fields = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return fields + .sort((a, b) => a.order - b.order) + .map((field) => ({ + id: field._id, + key: field.key, + label: field.label, + description: field.description ?? "", + type: field.type as FieldType, + required: field.required, + options: field.options ?? [], + order: field.order, + createdAt: field.createdAt, + updatedAt: field.updatedAt, + })); + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + label: v.string(), + description: v.optional(v.string()), + type: v.string(), + required: v.boolean(), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + }, + handler: async (ctx, { tenantId, actorId, label, description, type, required, options }) => { + await requireAdmin(ctx, actorId, tenantId); + const normalizedLabel = label.trim(); + if (normalizedLabel.length < 2) { + throw new ConvexError("Informe um rótulo para o campo"); + } + if (!FIELD_TYPES.includes(type as FieldType)) { + throw new ConvexError("Tipo de campo inválido"); + } + validateOptions(type as FieldType, options ?? undefined); + const key = normalizeKey(normalizedLabel); + await ensureUniqueKey(ctx, tenantId, key); + + const existing = await ctx.db + .query("ticketFields") + .withIndex("by_tenant_order", (q) => q.eq("tenantId", tenantId)) + .collect(); + const maxOrder = existing.reduce((acc: number, item: Doc<"ticketFields">) => Math.max(acc, item.order ?? 0), 0); + + const now = Date.now(); + const id = await ctx.db.insert("ticketFields", { + tenantId, + key, + label: normalizedLabel, + description, + type, + required, + options, + order: maxOrder + 1, + createdAt: now, + updatedAt: now, + }); + return id; + }, +}); + +export const update = mutation({ + args: { + tenantId: v.string(), + fieldId: v.id("ticketFields"), + actorId: v.id("users"), + label: v.string(), + description: v.optional(v.string()), + type: v.string(), + required: v.boolean(), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + }, + handler: async (ctx, { tenantId, fieldId, actorId, label, description, type, required, options }) => { + await requireAdmin(ctx, actorId, tenantId); + const field = await ctx.db.get(fieldId); + if (!field || field.tenantId !== tenantId) { + throw new ConvexError("Campo não encontrado"); + } + if (!FIELD_TYPES.includes(type as FieldType)) { + throw new ConvexError("Tipo de campo inválido"); + } + const normalizedLabel = label.trim(); + if (normalizedLabel.length < 2) { + throw new ConvexError("Informe um rótulo para o campo"); + } + validateOptions(type as FieldType, options ?? undefined); + + let key = field.key; + if (field.label !== normalizedLabel) { + key = normalizeKey(normalizedLabel); + await ensureUniqueKey(ctx, tenantId, key, fieldId); + } + + await ctx.db.patch(fieldId, { + key, + label: normalizedLabel, + description, + type, + required, + options, + updatedAt: Date.now(), + }); + }, +}); + +export const remove = mutation({ + args: { + tenantId: v.string(), + fieldId: v.id("ticketFields"), + actorId: v.id("users"), + }, + handler: async (ctx, { tenantId, fieldId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId); + const field = await ctx.db.get(fieldId); + if (!field || field.tenantId !== tenantId) { + throw new ConvexError("Campo não encontrado"); + } + await ctx.db.delete(fieldId); + }, +}); +export const reorder = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + orderedIds: v.array(v.id("ticketFields")), + }, + handler: async (ctx, { tenantId, actorId, orderedIds }) => { + await requireAdmin(ctx, actorId, tenantId); + const fields = await Promise.all(orderedIds.map((id) => ctx.db.get(id))); + fields.forEach((field) => { + if (!field || field.tenantId !== tenantId) { + throw new ConvexError("Campo inválido para reordenação"); + } + }); + const now = Date.now(); + await Promise.all( + orderedIds.map((fieldId, index) => + ctx.db.patch(fieldId, { + order: index + 1, + updatedAt: now, + }) + ) + ); + }, +}); diff --git a/web/convex/queues.ts b/web/convex/queues.ts index 9b5ace4..21f8ef8 100644 --- a/web/convex/queues.ts +++ b/web/convex/queues.ts @@ -1,5 +1,9 @@ -import { query } from "./_generated/server"; -import { v } from "convex/values"; +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; + +import { requireAdmin, requireStaff } from "./rbac"; const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", @@ -15,11 +19,75 @@ function renameQueueString(value: string) { return QUEUE_RENAME_LOOKUP[normalizedKey] ?? value; } +function slugify(value: string) { + return value + .normalize("NFD") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .toLowerCase(); +} + +type AnyCtx = QueryCtx | MutationCtx; + +async function ensureUniqueSlug(ctx: AnyCtx, tenantId: string, slug: string, excludeId?: Id<"queues">) { + const existing = await ctx.db + .query("queues") + .withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe uma fila com este identificador"); + } +} + +async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"queues">) { + const existing = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .filter((q) => q.eq(q.field("name"), name)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe uma fila com este nome"); + } +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const queues = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const teams = await ctx.db + .query("teams") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return queues.map((queue) => { + const team = queue.teamId ? teams.find((item) => item._id === queue.teamId) : null; + return { + id: queue._id, + name: queue.name, + slug: queue.slug, + team: team + ? { + id: team._id, + name: team.name, + } + : null, + }; + }); + }, +}); + export const summary = query({ - args: { tenantId: v.string() }, - handler: async (ctx, { tenantId }) => { + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId); const queues = await ctx.db.query("queues").withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)).collect(); - // Compute counts per queue const result = await Promise.all( queues.map(async (qItem) => { const pending = await ctx.db @@ -28,7 +96,7 @@ export const summary = query({ .collect(); const waiting = pending.filter((t) => t.status === "PENDING" || t.status === "ON_HOLD").length; const open = pending.filter((t) => t.status !== "RESOLVED" && t.status !== "CLOSED").length; - const breached = 0; // Placeholder, SLAs later + const breached = 0; return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached }; }) ); @@ -36,3 +104,98 @@ export const summary = query({ }, }); +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + teamId: v.optional(v.id("teams")), + }, + handler: async (ctx, { tenantId, actorId, name, teamId }) => { + await requireAdmin(ctx, actorId, tenantId); + const trimmed = name.trim(); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a fila"); + } + await ensureUniqueName(ctx, tenantId, trimmed); + const slug = slugify(trimmed); + await ensureUniqueSlug(ctx, tenantId, slug); + if (teamId) { + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time inválido"); + } + } + const id = await ctx.db.insert("queues", { + tenantId, + name: trimmed, + slug, + teamId: teamId ?? undefined, + }); + return id; + }, +}); + +export const update = mutation({ + args: { + queueId: v.id("queues"), + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + teamId: v.optional(v.id("teams")), + }, + handler: async (ctx, { queueId, tenantId, actorId, name, teamId }) => { + await requireAdmin(ctx, actorId, tenantId); + const queue = await ctx.db.get(queueId); + if (!queue || queue.tenantId !== tenantId) { + throw new ConvexError("Fila não encontrada"); + } + const trimmed = name.trim(); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a fila"); + } + await ensureUniqueName(ctx, tenantId, trimmed, queueId); + let slug = queue.slug; + if (queue.name !== trimmed) { + slug = slugify(trimmed); + await ensureUniqueSlug(ctx, tenantId, slug, queueId); + } + if (teamId) { + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time inválido"); + } + } + await ctx.db.patch(queueId, { + name: trimmed, + slug, + teamId: teamId ?? undefined, + }); + }, +}); + +export const remove = mutation({ + args: { + queueId: v.id("queues"), + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { queueId, tenantId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId); + const queue = await ctx.db.get(queueId); + if (!queue || queue.tenantId !== tenantId) { + throw new ConvexError("Fila não encontrada"); + } + + const ticketUsingQueue = await ctx.db + .query("tickets") + .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", queueId)) + .first(); + if (ticketUsingQueue) { + throw new ConvexError("Não é possível remover uma fila vinculada a tickets"); + } + + await ctx.db.delete(queueId); + }, +}); + diff --git a/web/convex/reports.ts b/web/convex/reports.ts new file mode 100644 index 0000000..8a3ece0 --- /dev/null +++ b/web/convex/reports.ts @@ -0,0 +1,341 @@ +import { query } from "./_generated/server"; +import type { QueryCtx } from "./_generated/server"; +import { v } from "convex/values"; +import type { Doc, Id } from "./_generated/dataModel"; + +import { requireStaff } from "./rbac"; + +function average(values: number[]) { + if (values.length === 0) return null; + return values.reduce((sum, value) => sum + value, 0) / values.length; +} + +const OPEN_STATUSES = new Set(["NEW", "OPEN", "PENDING", "ON_HOLD"]); +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +function percentageChange(current: number, previous: number) { + if (previous === 0) { + return current === 0 ? 0 : null; + } + return ((current - previous) / previous) * 100; +} + +function extractScore(payload: unknown): number | null { + if (typeof payload === "number") return payload; + if (payload && typeof payload === "object" && "score" in payload) { + const value = (payload as { score: unknown }).score; + if (typeof value === "number") { + return value; + } + } + return null; +} + +function isNotNull(value: T | null): value is T { + return value !== null; +} + +async function fetchTickets(ctx: QueryCtx, tenantId: string) { + return ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); +} + +async function fetchQueues(ctx: QueryCtx, tenantId: string) { + return ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); +} + +type CsatSurvey = { + ticketId: Id<"tickets">; + reference: number; + score: number; + receivedAt: number; +}; + +async function collectCsatSurveys(ctx: QueryCtx, tickets: Doc<"tickets">[]): Promise { + const perTicket = await Promise.all( + tickets.map(async (ticket) => { + const events = await ctx.db + .query("ticketEvents") + .withIndex("by_ticket", (q) => q.eq("ticketId", ticket._id)) + .collect(); + return events + .filter((event) => event.type === "CSAT_RECEIVED" || event.type === "CSAT_RATED") + .map((event) => { + const score = extractScore(event.payload); + if (score === null) return null; + return { + ticketId: ticket._id, + reference: ticket.reference, + score, + receivedAt: event.createdAt, + } as CsatSurvey; + }) + .filter(isNotNull); + }) + ); + return perTicket.flat(); +} + +function formatDateKey(timestamp: number) { + const date = new Date(timestamp); + const year = date.getUTCFullYear(); + const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); + const day = `${date.getUTCDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +export const slaOverview = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId); + const tickets = await fetchTickets(ctx, tenantId); + const queues = await fetchQueues(ctx, tenantId); + + const now = Date.now(); + const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status)); + const resolvedTickets = tickets.filter((ticket) => ticket.status === "RESOLVED" || ticket.status === "CLOSED"); + const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); + + const firstResponseTimes = tickets + .filter((ticket) => ticket.firstResponseAt) + .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); + const resolutionTimes = resolvedTickets + .filter((ticket) => ticket.resolvedAt) + .map((ticket) => (ticket.resolvedAt! - ticket.createdAt) / 60000); + + const queueBreakdown = queues.map((queue) => { + const count = openTickets.filter((ticket) => ticket.queueId === queue._id).length; + return { + id: queue._id, + name: queue.name, + open: count, + }; + }); + + return { + totals: { + total: tickets.length, + open: openTickets.length, + resolved: resolvedTickets.length, + overdue: overdueTickets.length, + }, + response: { + averageFirstResponseMinutes: average(firstResponseTimes), + responsesRegistered: firstResponseTimes.length, + }, + resolution: { + averageResolutionMinutes: average(resolutionTimes), + resolvedCount: resolutionTimes.length, + }, + queueBreakdown, + }; + }, +}); + +export const csatOverview = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId); + const tickets = await fetchTickets(ctx, tenantId); + const surveys = await collectCsatSurveys(ctx, tickets); + + const averageScore = average(surveys.map((item) => item.score)); + const distribution = [1, 2, 3, 4, 5].map((score) => ({ + score, + total: surveys.filter((item) => item.score === score).length, + })); + + return { + totalSurveys: surveys.length, + averageScore, + distribution, + recent: surveys + .slice() + .sort((a, b) => b.receivedAt - a.receivedAt) + .slice(0, 10) + .map((item) => ({ + ticketId: item.ticketId, + reference: item.reference, + score: item.score, + receivedAt: item.receivedAt, + })), + }; + }, +}); + +export const backlogOverview = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId); + const tickets = await fetchTickets(ctx, tenantId); + + const statusCounts = tickets.reduce>((acc, ticket) => { + acc[ticket.status] = (acc[ticket.status] ?? 0) + 1; + return acc; + }, {}); + + const priorityCounts = tickets.reduce>((acc, ticket) => { + acc[ticket.priority] = (acc[ticket.priority] ?? 0) + 1; + return acc; + }, {}); + + const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status)); + + const queueMap = new Map(); + for (const ticket of openTickets) { + const queueId = ticket.queueId ? ticket.queueId : "sem-fila"; + const current = queueMap.get(queueId) ?? { name: queueId === "sem-fila" ? "Sem fila" : "", count: 0 }; + current.count += 1; + queueMap.set(queueId, current); + } + + const queues = await fetchQueues(ctx, tenantId); + + for (const queue of queues) { + const entry = queueMap.get(queue._id) ?? { name: queue.name, count: 0 }; + entry.name = queue.name; + queueMap.set(queue._id, entry); + } + + return { + statusCounts, + priorityCounts, + queueCounts: Array.from(queueMap.entries()).map(([id, data]) => ({ + id, + name: data.name, + total: data.count, + })), + totalOpen: openTickets.length, + }; + }, +}); + +export const dashboardOverview = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireStaff(ctx, viewerId, tenantId); + const tickets = await fetchTickets(ctx, tenantId); + const now = Date.now(); + + const lastDayStart = now - ONE_DAY_MS; + const previousDayStart = now - 2 * ONE_DAY_MS; + + const newTickets = tickets.filter((ticket) => ticket.createdAt >= lastDayStart); + const previousTickets = tickets.filter( + (ticket) => ticket.createdAt >= previousDayStart && ticket.createdAt < lastDayStart + ); + + const trend = percentageChange(newTickets.length, previousTickets.length); + + const lastWindowStart = now - 7 * ONE_DAY_MS; + const previousWindowStart = now - 14 * ONE_DAY_MS; + + const firstResponseWindow = tickets + .filter( + (ticket) => + ticket.createdAt >= lastWindowStart && + ticket.createdAt < now && + ticket.firstResponseAt + ) + .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); + const firstResponsePrevious = tickets + .filter( + (ticket) => + ticket.createdAt >= previousWindowStart && + ticket.createdAt < lastWindowStart && + ticket.firstResponseAt + ) + .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); + + const averageWindow = average(firstResponseWindow); + const averagePrevious = average(firstResponsePrevious); + const deltaMinutes = + averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null; + + const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status)); + const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); + + const surveys = await collectCsatSurveys(ctx, tickets); + const averageScore = average(surveys.map((item) => item.score)); + + return { + newTickets: { + last24h: newTickets.length, + previous24h: previousTickets.length, + trendPercentage: trend, + }, + firstResponse: { + averageMinutes: averageWindow, + previousAverageMinutes: averagePrevious, + deltaMinutes, + responsesCount: firstResponseWindow.length, + }, + awaitingAction: { + total: awaitingTickets.length, + atRisk: atRiskTickets.length, + }, + csat: { + averageScore, + totalSurveys: surveys.length, + }, + }; + }, +}); + +export const ticketsByChannel = query({ + args: { + tenantId: v.string(), + viewerId: v.id("users"), + range: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, viewerId, range }) => { + await requireStaff(ctx, viewerId, tenantId); + const tickets = await fetchTickets(ctx, tenantId); + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + + const end = new Date(); + end.setUTCHours(0, 0, 0, 0); + const endMs = end.getTime() + ONE_DAY_MS; + const startMs = endMs - days * ONE_DAY_MS; + + const timeline = new Map>(); + for (let ts = startMs; ts < endMs; ts += ONE_DAY_MS) { + timeline.set(formatDateKey(ts), new Map()); + } + + const channels = new Set(); + + for (const ticket of tickets) { + if (ticket.createdAt < startMs || ticket.createdAt >= endMs) continue; + const dateKey = formatDateKey(ticket.createdAt); + const channelKey = ticket.channel ?? "OUTRO"; + channels.add(channelKey); + const dayMap = timeline.get(dateKey) ?? new Map(); + dayMap.set(channelKey, (dayMap.get(channelKey) ?? 0) + 1); + timeline.set(dateKey, dayMap); + } + + const sortedChannels = Array.from(channels).sort(); + + const points = Array.from(timeline.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([date, map]) => { + const values: Record = {}; + for (const channel of sortedChannels) { + values[channel] = map.get(channel) ?? 0; + } + return { date, values }; + }); + + return { + rangeDays: days, + channels: sortedChannels, + points, + }; + }, +}); diff --git a/web/convex/schema.ts b/web/convex/schema.ts index ffdf8c7..e3c5a83 100644 --- a/web/convex/schema.ts +++ b/web/convex/schema.ts @@ -11,7 +11,8 @@ export default defineSchema({ teams: v.optional(v.array(v.string())), }) .index("by_tenant_email", ["tenantId", "email"]) - .index("by_tenant_role", ["tenantId", "role"]), + .index("by_tenant_role", ["tenantId", "role"]) + .index("by_tenant", ["tenantId"]), queues: defineTable({ tenantId: v.string(), @@ -129,4 +130,27 @@ export default defineSchema({ .index("by_category_order", ["categoryId", "order"]) .index("by_category_slug", ["categoryId", "slug"]) .index("by_tenant_slug", ["tenantId", "slug"]), + + ticketFields: defineTable({ + tenantId: v.string(), + key: v.string(), + label: v.string(), + type: v.string(), + description: v.optional(v.string()), + required: v.boolean(), + order: v.number(), + options: v.optional( + v.array( + v.object({ + value: v.string(), + label: v.string(), + }) + ) + ), + createdAt: v.number(), + updatedAt: v.number(), + }) + .index("by_tenant_key", ["tenantId", "key"]) + .index("by_tenant_order", ["tenantId", "order"]) + .index("by_tenant", ["tenantId"]), }); diff --git a/web/convex/slas.ts b/web/convex/slas.ts new file mode 100644 index 0000000..eaadab4 --- /dev/null +++ b/web/convex/slas.ts @@ -0,0 +1,138 @@ +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import { ConvexError, v } from "convex/values"; +import type { Id } from "./_generated/dataModel"; + +import { requireAdmin } from "./rbac"; + +function normalizeName(value: string) { + return value.trim(); +} + +type AnyCtx = QueryCtx | MutationCtx; + +async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"slaPolicies">) { + const existing = await ctx.db + .query("slaPolicies") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe uma política SLA com este nome"); + } +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const items = await ctx.db + .query("slaPolicies") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return items.map((policy) => ({ + id: policy._id, + name: policy.name, + description: policy.description ?? "", + timeToFirstResponse: policy.timeToFirstResponse ?? null, + timeToResolution: policy.timeToResolution ?? null, + })); + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + timeToFirstResponse: v.optional(v.number()), + timeToResolution: v.optional(v.number()), + }, + handler: async (ctx, { tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { + await requireAdmin(ctx, actorId, tenantId); + const trimmed = normalizeName(name); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a política"); + } + await ensureUniqueName(ctx, tenantId, trimmed); + if (timeToFirstResponse !== undefined && timeToFirstResponse < 0) { + throw new ConvexError("Tempo para primeira resposta deve ser positivo"); + } + if (timeToResolution !== undefined && timeToResolution < 0) { + throw new ConvexError("Tempo para resolução deve ser positivo"); + } + + const id = await ctx.db.insert("slaPolicies", { + tenantId, + name: trimmed, + description, + timeToFirstResponse, + timeToResolution, + }); + return id; + }, +}); + +export const update = mutation({ + args: { + policyId: v.id("slaPolicies"), + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + timeToFirstResponse: v.optional(v.number()), + timeToResolution: v.optional(v.number()), + }, + handler: async (ctx, { policyId, tenantId, actorId, name, description, timeToFirstResponse, timeToResolution }) => { + await requireAdmin(ctx, actorId, tenantId); + const policy = await ctx.db.get(policyId); + if (!policy || policy.tenantId !== tenantId) { + throw new ConvexError("Política não encontrada"); + } + const trimmed = normalizeName(name); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para a política"); + } + if (timeToFirstResponse !== undefined && timeToFirstResponse < 0) { + throw new ConvexError("Tempo para primeira resposta deve ser positivo"); + } + if (timeToResolution !== undefined && timeToResolution < 0) { + throw new ConvexError("Tempo para resolução deve ser positivo"); + } + await ensureUniqueName(ctx, tenantId, trimmed, policyId); + + await ctx.db.patch(policyId, { + name: trimmed, + description, + timeToFirstResponse, + timeToResolution, + }); + }, +}); + +export const remove = mutation({ + args: { + policyId: v.id("slaPolicies"), + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { policyId, tenantId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId); + const policy = await ctx.db.get(policyId); + if (!policy || policy.tenantId !== tenantId) { + throw new ConvexError("Política não encontrada"); + } + + const ticketLinked = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .filter((q) => q.eq(q.field("slaPolicyId"), policyId)) + .first(); + if (ticketLinked) { + throw new ConvexError("Remova a associação de tickets antes de excluir a política"); + } + + await ctx.db.delete(policyId); + }, +}); diff --git a/web/convex/teams.ts b/web/convex/teams.ts new file mode 100644 index 0000000..71988c5 --- /dev/null +++ b/web/convex/teams.ts @@ -0,0 +1,232 @@ +import { mutation, query } from "./_generated/server"; +import type { MutationCtx, QueryCtx } from "./_generated/server"; +import type { Id } from "./_generated/dataModel"; +import { ConvexError, v } from "convex/values"; + +import { requireAdmin } from "./rbac"; + +function normalizeName(value: string) { + return value.trim(); +} + +type AnyCtx = QueryCtx | MutationCtx; + +async function ensureUniqueName(ctx: AnyCtx, tenantId: string, name: string, excludeId?: Id<"teams">) { + const existing = await ctx.db + .query("teams") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId).eq("name", name)) + .first(); + if (existing && (!excludeId || existing._id !== excludeId)) { + throw new ConvexError("Já existe um time com este nome"); + } +} + +export const list = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const teams = await ctx.db + .query("teams") + .withIndex("by_tenant_name", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const queues = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return teams.map((team) => { + const members = users + .filter((user) => (user.teams ?? []).includes(team.name)) + .map((user) => ({ + id: user._id, + name: user.name, + email: user.email, + role: user.role ?? "AGENT", + })); + + const linkedQueues = queues.filter((queue) => queue.teamId === team._id); + + return { + id: team._id, + name: team.name, + description: team.description ?? "", + members, + queueCount: linkedQueues.length, + createdAt: team._creationTime, + }; + }); + }, +}); + +export const create = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, { tenantId, actorId, name, description }) => { + await requireAdmin(ctx, actorId, tenantId); + const trimmed = normalizeName(name); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para o time"); + } + await ensureUniqueName(ctx, tenantId, trimmed); + const id = await ctx.db.insert("teams", { + tenantId, + name: trimmed, + description, + }); + return id; + }, +}); + +export const update = mutation({ + args: { + teamId: v.id("teams"), + tenantId: v.string(), + actorId: v.id("users"), + name: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, { teamId, tenantId, actorId, name, description }) => { + await requireAdmin(ctx, actorId, tenantId); + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time não encontrado"); + } + const trimmed = normalizeName(name); + if (trimmed.length < 2) { + throw new ConvexError("Informe um nome válido para o time"); + } + await ensureUniqueName(ctx, tenantId, trimmed, teamId); + + if (team.name !== trimmed) { + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + const now = users + .filter((user) => (user.teams ?? []).includes(team.name)) + .map(async (user) => { + const teams = (user.teams ?? []).map((entry) => (entry === team.name ? trimmed : entry)); + await ctx.db.patch(user._id, { teams }); + }); + await Promise.all(now); + } + + await ctx.db.patch(teamId, { name: trimmed, description }); + }, +}); + +export const remove = mutation({ + args: { + teamId: v.id("teams"), + tenantId: v.string(), + actorId: v.id("users"), + }, + handler: async (ctx, { teamId, tenantId, actorId }) => { + await requireAdmin(ctx, actorId, tenantId); + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time não encontrado"); + } + + const queuesLinked = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .filter((q) => q.eq(q.field("teamId"), teamId)) + .first(); + if (queuesLinked) { + throw new ConvexError("Remova ou realoque as filas associadas antes de excluir o time"); + } + + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + await Promise.all( + users + .filter((user) => (user.teams ?? []).includes(team.name)) + .map((user) => { + const teams = (user.teams ?? []).filter((entry) => entry !== team.name); + return ctx.db.patch(user._id, { teams }); + }) + ); + + await ctx.db.delete(teamId); + }, +}); + +export const setMembers = mutation({ + args: { + teamId: v.id("teams"), + tenantId: v.string(), + actorId: v.id("users"), + memberIds: v.array(v.id("users")), + }, + handler: async (ctx, { teamId, tenantId, actorId, memberIds }) => { + await requireAdmin(ctx, actorId, tenantId); + const team = await ctx.db.get(teamId); + if (!team || team.tenantId !== tenantId) { + throw new ConvexError("Time não encontrado"); + } + + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + const tenantUserIds = new Set(users.map((user) => user._id)); + for (const memberId of memberIds) { + if (!tenantUserIds.has(memberId)) { + throw new ConvexError("Usuário inválido para este tenant"); + } + } + const target = new Set(memberIds); + + await Promise.all( + users.map(async (user) => { + const teams = new Set(user.teams ?? []); + const hasTeam = teams.has(team.name); + const shouldHave = target.has(user._id); + + if (shouldHave && !hasTeam) { + teams.add(team.name); + await ctx.db.patch(user._id, { teams: Array.from(teams) }); + } + + if (!shouldHave && hasTeam) { + teams.delete(team.name); + await ctx.db.patch(user._id, { teams: Array.from(teams) }); + } + }) + ); + }, +}); + +export const directory = query({ + args: { tenantId: v.string(), viewerId: v.id("users") }, + handler: async (ctx, { tenantId, viewerId }) => { + await requireAdmin(ctx, viewerId, tenantId); + const users = await ctx.db + .query("users") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect(); + + return users.map((user) => ({ + id: user._id, + name: user.name, + email: user.email, + role: user.role ?? "AGENT", + teams: user.teams ?? [], + })); + }, +}); diff --git a/web/convex/tickets.ts b/web/convex/tickets.ts index 60e1983..a40f7cb 100644 --- a/web/convex/tickets.ts +++ b/web/convex/tickets.ts @@ -2,7 +2,7 @@ import { mutation, query } from "./_generated/server"; import { ConvexError, v } from "convex/values"; import { Id, type Doc } from "./_generated/dataModel"; -import { requireAdmin, requireCustomer, requireStaff, requireUser } from "./rbac"; +import { requireCustomer, requireStaff, requireUser } from "./rbac"; const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", @@ -52,7 +52,7 @@ export const list = query({ if (!args.viewerId) { return [] } - const { user, role } = await requireUser(ctx, args.viewerId, args.tenantId) + const { role } = await requireUser(ctx, args.viewerId, args.tenantId) // Choose best index based on provided args for efficiency let base: Doc<"tickets">[] = []; diff --git a/web/convex/users.ts b/web/convex/users.ts index dbcd54f..a739fb8 100644 --- a/web/convex/users.ts +++ b/web/convex/users.ts @@ -15,7 +15,45 @@ export const ensureUser = mutation({ .query("users") .withIndex("by_tenant_email", (q) => q.eq("tenantId", args.tenantId).eq("email", args.email)) .first(); - if (existing) return existing; + const reconcile = async (record: typeof existing) => { + if (!record) return null; + const shouldPatch = + record.tenantId !== args.tenantId || + (args.role && record.role !== args.role) || + (args.avatarUrl && record.avatarUrl !== args.avatarUrl) || + record.name !== args.name || + (args.teams && JSON.stringify(args.teams) !== JSON.stringify(record.teams ?? [])); + + if (shouldPatch) { + await ctx.db.patch(record._id, { + tenantId: args.tenantId, + role: args.role ?? record.role, + avatarUrl: args.avatarUrl ?? record.avatarUrl, + name: args.name, + teams: args.teams ?? record.teams, + }); + const updated = await ctx.db.get(record._id); + if (updated) { + return updated; + } + } + return record; + }; + + if (existing) { + const reconciled = await reconcile(existing); + if (reconciled) { + return reconciled; + } + } else { + const anyTenant = (await ctx.db.query("users").collect()).find((user) => user.email === args.email); + if (anyTenant) { + const reconciled = await reconcile(anyTenant); + if (reconciled) { + return reconciled; + } + } + } const id = await ctx.db.insert("users", { tenantId: args.tenantId, email: args.email, diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs index fb05b56..61e3684 100644 --- a/web/postcss.config.mjs +++ b/web/postcss.config.mjs @@ -1,5 +1,7 @@ -export default { +const config = { plugins: { "@tailwindcss/postcss": {}, }, -} +}; + +export default config; diff --git a/web/scripts/seed-auth.mjs b/web/scripts/seed-auth.mjs index 4f9f607..18c0fbe 100644 --- a/web/scripts/seed-auth.mjs +++ b/web/scripts/seed-auth.mjs @@ -7,7 +7,7 @@ const email = process.env.SEED_USER_EMAIL ?? "admin@sistema.dev" const password = process.env.SEED_USER_PASSWORD ?? "admin123" const name = process.env.SEED_USER_NAME ?? "Administrador" const role = process.env.SEED_USER_ROLE ?? "admin" -const tenantId = process.env.SEED_USER_TENANT ?? "default" +const tenantId = process.env.SEED_USER_TENANT ?? "tenant-atlas" async function main() { const hashedPassword = await hashPassword(password) @@ -26,7 +26,7 @@ async function main() { tenantId, accounts: { create: { - providerId: "email", + providerId: "credential", accountId: email, password: hashedPassword, }, @@ -37,7 +37,41 @@ async function main() { }, }) - const account = user.accounts[0] + await prisma.authAccount.updateMany({ + where: { + userId: user.id, + accountId: email, + }, + data: { + providerId: "credential", + }, + }) + + let account = await prisma.authAccount.findFirst({ + where: { + userId: user.id, + providerId: "credential", + accountId: email, + }, + }) + + if (account) { + account = await prisma.authAccount.update({ + where: { id: account.id }, + data: { + password: hashedPassword, + }, + }) + } else { + account = await prisma.authAccount.create({ + data: { + userId: user.id, + providerId: "credential", + accountId: email, + password: hashedPassword, + }, + }) + } console.log(`✅ Usuario seed criado/atualizado: ${user.email}`) console.log(` ID: ${user.id}`) diff --git a/web/src/app/admin/channels/page.tsx b/web/src/app/admin/channels/page.tsx new file mode 100644 index 0000000..c2b091f --- /dev/null +++ b/web/src/app/admin/channels/page.tsx @@ -0,0 +1,17 @@ +import { QueuesManager } from "@/components/admin/queues/queues-manager" + +export const dynamic = "force-dynamic" + +export default function AdminChannelsPage() { + return ( +
+
+

Filas e canais

+

+ Configure as filas internas e vincule-as aos times responsáveis por cada canal de atendimento. +

+
+ +
+ ) +} diff --git a/web/src/app/admin/fields/page.tsx b/web/src/app/admin/fields/page.tsx new file mode 100644 index 0000000..8a05d1a --- /dev/null +++ b/web/src/app/admin/fields/page.tsx @@ -0,0 +1,17 @@ +import { FieldsManager } from "@/components/admin/fields/fields-manager" + +export const dynamic = "force-dynamic" + +export default function AdminFieldsPage() { + return ( +
+
+

Campos personalizados

+

+ Defina quais informações adicionais devem ser coletadas nos tickets de cada tenant. +

+
+ +
+ ) +} diff --git a/web/src/app/admin/slas/page.tsx b/web/src/app/admin/slas/page.tsx new file mode 100644 index 0000000..78c4a77 --- /dev/null +++ b/web/src/app/admin/slas/page.tsx @@ -0,0 +1,17 @@ +import { SlasManager } from "@/components/admin/slas/slas-manager" + +export const dynamic = "force-dynamic" + +export default function AdminSlasPage() { + return ( +
+
+

Políticas de SLA

+

+ Configure tempos de resposta e resolução para garantir a cobertura dos acordos de serviço. +

+
+ +
+ ) +} diff --git a/web/src/app/admin/teams/page.tsx b/web/src/app/admin/teams/page.tsx new file mode 100644 index 0000000..2893f8a --- /dev/null +++ b/web/src/app/admin/teams/page.tsx @@ -0,0 +1,17 @@ +import { TeamsManager } from "@/components/admin/teams/teams-manager" + +export const dynamic = "force-dynamic" + +export default function AdminTeamsPage() { + return ( +
+
+

Times e agentes

+

+ Estruture squads, capítulos e equipes responsáveis pelos tickets antes de associar filas e SLAs. +

+
+ +
+ ) +} diff --git a/web/src/app/api/admin/users/route.ts b/web/src/app/api/admin/users/route.ts index e9ec538..4ddb9ef 100644 --- a/web/src/app/api/admin/users/route.ts +++ b/web/src/app/api/admin/users/route.ts @@ -86,7 +86,7 @@ export async function POST(request: Request) { tenantId, accounts: { create: { - providerId: "email", + providerId: "credential", accountId: emailInput, password: hashedPassword, }, diff --git a/web/src/app/portal/layout.tsx b/web/src/app/portal/layout.tsx new file mode 100644 index 0000000..3461d1c --- /dev/null +++ b/web/src/app/portal/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from "react" + +import { PortalShell } from "@/components/portal/portal-shell" + +export default function PortalLayout({ children }: { children: ReactNode }) { + return {children} +} diff --git a/web/src/app/portal/page.tsx b/web/src/app/portal/page.tsx index 867aa81..3153317 100644 --- a/web/src/app/portal/page.tsx +++ b/web/src/app/portal/page.tsx @@ -1,7 +1,5 @@ import type { Metadata } from "next" -import Link from "next/link" - -import { Button } from "@/components/ui/button" +import { redirect } from "next/navigation" export const metadata: Metadata = { title: "Portal do cliente", @@ -9,26 +7,5 @@ export const metadata: Metadata = { } export default function PortalPage() { - return ( -
-
-

- Portal do cliente -

-

- Área do cliente em construção -

-

- Em breve você poderá abrir novos chamados, acompanhar o status e conversar com a equipe de suporte por aqui. - Enquanto finalizamos os ajustes, utilize os canais combinados com sua equipe de atendimento. -

-
-
- Precisa falar com a equipe agora? - -
-
- ) + redirect("/portal/tickets") } diff --git a/web/src/app/portal/tickets/[id]/page.tsx b/web/src/app/portal/tickets/[id]/page.tsx new file mode 100644 index 0000000..287e141 --- /dev/null +++ b/web/src/app/portal/tickets/[id]/page.tsx @@ -0,0 +1,5 @@ +import { PortalTicketDetail } from "@/components/portal/portal-ticket-detail" + +export default function PortalTicketDetailPage({ params }: { params: { id: string } }) { + return +} diff --git a/web/src/app/portal/tickets/new/page.tsx b/web/src/app/portal/tickets/new/page.tsx new file mode 100644 index 0000000..c7c8eba --- /dev/null +++ b/web/src/app/portal/tickets/new/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next" + +import { PortalTicketForm } from "@/components/portal/portal-ticket-form" + +export const metadata: Metadata = { + title: "Abrir chamado", + description: "Registre um novo chamado para a equipe de suporte.", +} + +export default function PortalNewTicketPage() { + return +} diff --git a/web/src/app/portal/tickets/page.tsx b/web/src/app/portal/tickets/page.tsx new file mode 100644 index 0000000..9973092 --- /dev/null +++ b/web/src/app/portal/tickets/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next" + +import { PortalTicketList } from "@/components/portal/portal-ticket-list" + +export const metadata: Metadata = { + title: "Meus chamados", + description: "Acompanhe os chamados abertos com a equipe de suporte.", +} + +export default function PortalTicketsPage() { + return +} diff --git a/web/src/app/reports/backlog/page.tsx b/web/src/app/reports/backlog/page.tsx new file mode 100644 index 0000000..a8e1448 --- /dev/null +++ b/web/src/app/reports/backlog/page.tsx @@ -0,0 +1,17 @@ +import { BacklogReport } from "@/components/reports/backlog-report" + +export const dynamic = "force-dynamic" + +export default function ReportsBacklogPage() { + return ( +
+
+

Backlog e Prioridades

+

+ Avalie o volume de tickets em aberto, prioridades e filas mais pressionadas. +

+
+ +
+ ) +} diff --git a/web/src/app/reports/csat/page.tsx b/web/src/app/reports/csat/page.tsx new file mode 100644 index 0000000..c16086a --- /dev/null +++ b/web/src/app/reports/csat/page.tsx @@ -0,0 +1,17 @@ +import { CsatReport } from "@/components/reports/csat-report" + +export const dynamic = "force-dynamic" + +export default function ReportsCsatPage() { + return ( +
+
+

Relatório de CSAT

+

+ Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega. +

+
+ +
+ ) +} diff --git a/web/src/app/reports/sla/page.tsx b/web/src/app/reports/sla/page.tsx new file mode 100644 index 0000000..32c8341 --- /dev/null +++ b/web/src/app/reports/sla/page.tsx @@ -0,0 +1,17 @@ +import { SlaReport } from "@/components/reports/sla-report" + +export const dynamic = "force-dynamic" + +export default function ReportsSlaPage() { + return ( +
+
+

Relatório de SLA

+

+ Acompanhe tempos de resposta, resolução e balanço de filas em tempo real. +

+
+ +
+ ) +} diff --git a/web/src/app/settings/page.tsx b/web/src/app/settings/page.tsx new file mode 100644 index 0000000..45c540b --- /dev/null +++ b/web/src/app/settings/page.tsx @@ -0,0 +1,13 @@ +import { AppShell } from "@/components/app-shell" +import { SettingsContent } from "@/components/settings/settings-content" +import { SiteHeader } from "@/components/site-header" + +export default function SettingsPage() { + return ( + } + > + + + ) +} diff --git a/web/src/components/admin/fields/fields-manager.tsx b/web/src/components/admin/fields/fields-manager.tsx new file mode 100644 index 0000000..8e4e280 --- /dev/null +++ b/web/src/components/admin/fields/fields-manager.tsx @@ -0,0 +1,551 @@ +"use client" + +import { useMemo, useState } from "react" +import { useMutation, useQuery } from "convex/react" +import { toast } from "sonner" +import { IconAdjustments, IconForms, IconListDetails, IconTypography } 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 { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Skeleton } from "@/components/ui/skeleton" +import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" + +type FieldOption = { value: string; label: string } + +type Field = { + id: string + key: string + label: string + description: string + type: "text" | "number" | "select" | "date" | "boolean" + required: boolean + options: FieldOption[] + order: number +} + +const TYPE_LABELS: Record = { + text: "Texto", + number: "Número", + select: "Seleção", + date: "Data", + boolean: "Verdadeiro/Falso", +} + +export function FieldsManager() { + const { session, convexUserId } = useAuth() + const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID + + const fields = useQuery( + api.fields.list, + convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" + ) as Field[] | undefined + + const createField = useMutation(api.fields.create) + const updateField = useMutation(api.fields.update) + const removeField = useMutation(api.fields.remove) + const reorderFields = useMutation(api.fields.reorder) + + const [label, setLabel] = useState("") + const [description, setDescription] = useState("") + const [type, setType] = useState("text") + const [required, setRequired] = useState(false) + const [options, setOptions] = useState([]) + const [saving, setSaving] = useState(false) + const [editingField, setEditingField] = useState(null) + + const totals = useMemo(() => { + if (!fields) return { total: 0, required: 0, select: 0 } + return { + total: fields.length, + required: fields.filter((field) => field.required).length, + select: fields.filter((field) => field.type === "select").length, + } + }, [fields]) + + const resetForm = () => { + setLabel("") + setDescription("") + setType("text") + setRequired(false) + setOptions([]) + } + + const normalizeOptions = (source: FieldOption[]) => + source + .map((option) => ({ + label: option.label.trim(), + value: option.value.trim() || option.label.trim().toLowerCase().replace(/\s+/g, "_"), + })) + .filter((option) => option.label.length > 0) + + const handleCreate = async (event: React.FormEvent) => { + event.preventDefault() + if (!label.trim()) { + toast.error("Informe o rótulo do campo") + return + } + if (!convexUserId) { + toast.error("Sessão não sincronizada com o Convex") + return + } + const preparedOptions = type === "select" ? normalizeOptions(options) : undefined + setSaving(true) + toast.loading("Criando campo...", { id: "field" }) + try { + await createField({ + tenantId, + actorId: convexUserId as Id<"users">, + label: label.trim(), + description: description.trim() || undefined, + type, + required, + options: preparedOptions, + }) + toast.success("Campo criado", { id: "field" }) + resetForm() + } catch (error) { + console.error(error) + toast.error("Não foi possível criar o campo", { id: "field" }) + } finally { + setSaving(false) + } + } + + const handleRemove = async (field: Field) => { + const confirmed = window.confirm(`Excluir o campo ${field.label}?`) + if (!confirmed) return + if (!convexUserId) { + toast.error("Sessão não sincronizada com o Convex") + return + } + toast.loading("Removendo campo...", { id: `field-remove-${field.id}` }) + try { + await removeField({ + tenantId, + fieldId: field.id as Id<"ticketFields">, + actorId: convexUserId as Id<"users">, + }) + toast.success("Campo removido", { id: `field-remove-${field.id}` }) + } catch (error) { + console.error(error) + toast.error("Não foi possível remover o campo", { id: `field-remove-${field.id}` }) + } + } + + const openEdit = (field: Field) => { + setEditingField(field) + setLabel(field.label) + setDescription(field.description) + setType(field.type) + setRequired(field.required) + setOptions(field.options) + } + + const handleUpdate = async () => { + if (!editingField) return + if (!label.trim()) { + toast.error("Informe o rótulo do campo") + return + } + if (!convexUserId) { + toast.error("Sessão não sincronizada com o Convex") + return + } + const preparedOptions = type === "select" ? normalizeOptions(options) : undefined + setSaving(true) + toast.loading("Atualizando campo...", { id: "field-edit" }) + try { + await updateField({ + tenantId, + fieldId: editingField.id as Id<"ticketFields">, + actorId: convexUserId as Id<"users">, + label: label.trim(), + description: description.trim() || undefined, + type, + required, + options: preparedOptions, + }) + toast.success("Campo atualizado", { id: "field-edit" }) + setEditingField(null) + resetForm() + } catch (error) { + console.error(error) + toast.error("Não foi possível atualizar o campo", { id: "field-edit" }) + } finally { + setSaving(false) + } + } + + const moveField = async (field: Field, direction: "up" | "down") => { + if (!fields) return + if (!convexUserId) { + toast.error("Sessão não sincronizada com o Convex") + return + } + const index = fields.findIndex((item) => item.id === field.id) + const targetIndex = direction === "up" ? index - 1 : index + 1 + if (targetIndex < 0 || targetIndex >= fields.length) return + const reordered = [...fields] + const [removed] = reordered.splice(index, 1) + reordered.splice(targetIndex, 0, removed) + try { + await reorderFields({ + tenantId, + actorId: convexUserId as Id<"users">, + orderedIds: reordered.map((item) => item.id as Id<"ticketFields">), + }) + toast.success("Ordem atualizada") + } catch (error) { + console.error(error) + toast.error("Não foi possível reordenar os campos") + } + } + + const addOption = () => { + setOptions((current) => [...current, { label: "", value: "" }]) + } + + const updateOption = (index: number, key: keyof FieldOption, value: string) => { + setOptions((current) => { + const copy = [...current] + copy[index] = { ...copy[index], [key]: value } + return copy + }) + } + + const removeOption = (index: number) => { + setOptions((current) => current.filter((_, optIndex) => optIndex !== index)) + } + + return ( +
+
+ + + + Campos personalizados + + Metadados adicionais disponíveis nos tickets. + + + {fields ? totals.total : } + + + + + + Campos obrigatórios + + Informações exigidas na abertura. + + + {fields ? totals.required : } + + + + + + Campos de seleção + + Usados para listas e múltipla escolha. + + + {fields ? totals.select : } + + +
+ + + + + Novo campo + + Capture informações específicas do seu fluxo de atendimento. + + +
+
+
+ + setLabel(event.target.value)} + required + /> +
+
+ + +
+
+ setRequired(Boolean(value))} /> + +
+
+
+
+ +