From c15f0a5b09435e4816729e0dbabdd382d3ced9f7 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 7 Oct 2025 02:26:09 -0300 Subject: [PATCH] feat: enhance tickets portal and admin flows --- PROXIMOS_PASSOS.md | 18 +- convex/queues.ts | 29 +- convex/reports.ts | 39 ++- convex/schema.ts | 2 + convex/seed.ts | 5 +- convex/tickets.ts | 95 +++++-- package.json | 5 +- pnpm-lock.yaml | 163 +++++++++++ prisma/schema.prisma | 15 +- scripts/import-convex-to-prisma.mjs | 19 +- src/app/admin/page.tsx | 4 +- src/app/api/admin/invites/[id]/route.ts | 9 +- src/app/api/admin/invites/route.ts | 11 +- src/app/api/admin/users/route.ts | 1 - src/app/api/invites/[token]/route.ts | 16 +- src/app/api/tickets/[id]/export/pdf/route.ts | 261 ++++++++++++++++++ src/app/dashboard/page.tsx | 2 +- src/app/dev/seed/page.tsx | 1 - src/app/login/login-page-client.tsx | 73 +++++ src/app/login/page.tsx | 73 +---- src/app/tickets/new/page.tsx | 1 - .../admin/categories/categories-manager.tsx | 1 - .../admin/fields/fields-manager.tsx | 1 - .../admin/queues/queues-manager.tsx | 1 - src/components/admin/slas/slas-manager.tsx | 1 - src/components/admin/teams/teams-manager.tsx | 1 - src/components/app-shell.tsx | 6 +- .../background-paper-shaders-wrapper.tsx | 10 +- src/components/background-paper-shaders.tsx | 11 +- src/components/chart-area-interactive.tsx | 48 +++- src/components/portal/portal-ticket-card.tsx | 12 +- .../portal/portal-ticket-detail.tsx | 8 +- src/components/portal/portal-ticket-form.tsx | 1 - src/components/portal/portal-ticket-list.tsx | 1 - src/components/reports/backlog-report.tsx | 20 +- src/components/reports/csat-report.tsx | 5 +- src/components/reports/sla-report.tsx | 8 +- src/components/section-cards.tsx | 1 - .../settings/comment-templates-manager.tsx | 1 - .../tickets/delete-ticket-dialog.tsx | 1 - src/components/tickets/new-ticket-dialog.tsx | 1 - .../tickets/play-next-ticket-card.tsx | 1 - src/components/tickets/priority-select.tsx | 1 - .../tickets/recent-tickets-panel.tsx | 1 - src/components/tickets/status-badge.tsx | 7 +- src/components/tickets/status-select.tsx | 25 +- .../tickets/ticket-comments.rich.tsx | 1 - src/components/tickets/ticket-detail-view.tsx | 1 - .../tickets/ticket-queue-summary.tsx | 1 - .../tickets/ticket-summary-header.tsx | 177 ++++++++++-- src/components/tickets/ticket-timeline.tsx | 29 +- src/components/tickets/tickets-filters.tsx | 48 +++- src/components/tickets/tickets-table.tsx | 12 +- src/components/tickets/tickets-view.tsx | 21 +- .../ui/background-paper-shaders.tsx | 17 +- src/components/ui/dropzone.tsx | 1 - src/components/ui/sonner.tsx | 13 +- src/hooks/use-default-queues.ts | 1 - src/hooks/use-ticket-categories.ts | 1 - src/lib/auth-client.tsx | 4 +- src/lib/auth.ts | 2 + src/lib/env.ts | 1 + src/lib/mappers/__tests__/ticket.test.ts | 3 +- src/lib/mappers/ticket.ts | 23 +- src/lib/mocks/tickets.ts | 38 +-- src/lib/schemas/ticket.ts | 15 +- types/convex-react.d.ts | 14 + 67 files changed, 1101 insertions(+), 338 deletions(-) create mode 100644 src/app/api/tickets/[id]/export/pdf/route.ts create mode 100644 src/app/login/login-page-client.tsx create mode 100644 types/convex-react.d.ts diff --git a/PROXIMOS_PASSOS.md b/PROXIMOS_PASSOS.md index a5f7ca6..54bc7c5 100644 --- a/PROXIMOS_PASSOS.md +++ b/PROXIMOS_PASSOS.md @@ -14,15 +14,15 @@ Lista priorizada de evoluções propostas para o Sistema de Chamados. Confira `a # 🧾 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 +- [x] Adicionar opção **Exportar histórico completo em PDF** (conversa, logs, movimentações) +- [x] Implementar **justificativa obrigatória ao pausar** o chamado + - [x] Categorias: Falta de contato / Aguardando terceiro / Em procedimento +- [x] Ajustar **status padrão dos tickets** + - [x] Pendentes + - [x] Aguardando atendimento + - [x] Pausados + - [x] (Remover “Aguardando resposta” e “Violados”) +- [x] Remover automaticamente da listagem ao finalizar o chamado --- diff --git a/convex/queues.ts b/convex/queues.ts index c978b21..9b6c2f9 100644 --- a/convex/queues.ts +++ b/convex/queues.ts @@ -5,6 +5,25 @@ import type { Id } from "./_generated/dataModel"; import { requireAdmin, requireStaff } from "./rbac"; +type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED"; + +const STATUS_NORMALIZE_MAP: Record = { + NEW: "PENDING", + PENDING: "PENDING", + OPEN: "AWAITING_ATTENDANCE", + AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", + ON_HOLD: "PAUSED", + PAUSED: "PAUSED", + RESOLVED: "RESOLVED", + CLOSED: "CLOSED", +}; + +function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { + if (!status) return "PENDING"; + const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()]; + return normalized ?? "PENDING"; +} + const QUEUE_RENAME_LOOKUP: Record = { "Suporte N1": "Chamados", "suporte-n1": "Chamados", @@ -98,8 +117,14 @@ export const summary = query({ .query("tickets") .withIndex("by_tenant_queue", (q) => q.eq("tenantId", tenantId).eq("queueId", qItem._id)) .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 waiting = pending.filter((t) => { + const status = normalizeStatus(t.status); + return status === "PENDING" || status === "PAUSED"; + }).length; + const open = pending.filter((t) => { + const status = normalizeStatus(t.status); + return status !== "RESOLVED" && status !== "CLOSED"; + }).length; const breached = 0; return { id: qItem._id, name: renameQueueString(qItem.name), pending: open, waiting, breached }; }) diff --git a/convex/reports.ts b/convex/reports.ts index 596fdc4..4d25bd9 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -5,12 +5,31 @@ import type { Doc, Id } from "./_generated/dataModel"; import { requireStaff } from "./rbac"; +type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED"; + +const STATUS_NORMALIZE_MAP: Record = { + NEW: "PENDING", + PENDING: "PENDING", + OPEN: "AWAITING_ATTENDANCE", + AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", + ON_HOLD: "PAUSED", + PAUSED: "PAUSED", + RESOLVED: "RESOLVED", + CLOSED: "CLOSED", +}; + +function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { + if (!status) return "PENDING"; + const normalized = STATUS_NORMALIZE_MAP[status.toUpperCase()]; + return normalized ?? "PENDING"; +} + 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 OPEN_STATUSES = new Set(["PENDING", "AWAITING_ATTENDANCE", "PAUSED"]); const ONE_DAY_MS = 24 * 60 * 60 * 1000; function percentageChange(current: number, previous: number) { @@ -116,8 +135,11 @@ export const slaOverview = query({ 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 openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); + const resolvedTickets = tickets.filter((ticket) => { + const status = normalizeStatus(ticket.status); + return status === "RESOLVED" || status === "CLOSED"; + }); const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); const firstResponseTimes = tickets @@ -193,17 +215,18 @@ export const backlogOverview = query({ const viewer = await requireStaff(ctx, viewerId, tenantId); const tickets = await fetchScopedTickets(ctx, tenantId, viewer); - const statusCounts = tickets.reduce>((acc, ticket) => { - acc[ticket.status] = (acc[ticket.status] ?? 0) + 1; + const statusCounts = tickets.reduce>((acc, ticket) => { + const status = normalizeStatus(ticket.status); + acc[status] = (acc[status] ?? 0) + 1; return acc; - }, {}); + }, {} as Record); 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 openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const queueMap = new Map(); for (const ticket of openTickets) { @@ -276,7 +299,7 @@ export const dashboardOverview = query({ const deltaMinutes = averageWindow !== null && averagePrevious !== null ? averageWindow - averagePrevious : null; - const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(ticket.status)); + const awaitingTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const atRiskTickets = awaitingTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); const surveys = await collectCsatSurveys(ctx, tickets); diff --git a/convex/schema.ts b/convex/schema.ts index a4f54c9..f7b9b43 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -144,6 +144,8 @@ export default defineSchema({ startedAt: v.number(), stoppedAt: v.optional(v.number()), durationMs: v.optional(v.number()), + pauseReason: v.optional(v.string()), + pauseNote: v.optional(v.string()), }) .index("by_ticket", ["ticketId"]) .index("by_ticket_agent", ["ticketId", "agentId"]), diff --git a/convex/seed.ts b/convex/seed.ts index d27d6e9..f29b202 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -304,7 +304,7 @@ export const seedDemo = mutation({ reference: ++ref, subject: "Erro 500 ao acessar portal do cliente", summary: "Clientes relatam erro intermitente no portal web", - status: "OPEN", + status: "AWAITING_ATTENDANCE", priority: "URGENT", channel: "EMAIL", queueId: queue1, @@ -362,7 +362,7 @@ export const seedDemo = mutation({ reference: ++ref, subject: "Visita técnica para instalação de roteadores", summary: "Equipe Omni solicita agenda para instalação de novos pontos de rede", - status: "OPEN", + status: "AWAITING_ATTENDANCE", priority: "MEDIUM", channel: "PHONE", queueId: queueVisitas._id, @@ -387,4 +387,3 @@ export const seedDemo = mutation({ }); }, }); - diff --git a/convex/tickets.ts b/convex/tickets.ts index 2b30b81..12e64b1 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -7,6 +7,38 @@ import { requireCustomer, requireStaff, requireUser } from "./rbac"; const STAFF_ROLES = new Set(["ADMIN", "MANAGER", "AGENT", "COLLABORATOR"]); const INTERNAL_STAFF_ROLES = new Set(["ADMIN", "AGENT", "COLLABORATOR"]); +const PAUSE_REASON_LABELS: Record = { + NO_CONTACT: "Falta de contato", + WAITING_THIRD_PARTY: "Aguardando terceiro", + IN_PROCEDURE: "Em procedimento", +}; + +type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RESOLVED" | "CLOSED"; + +const STATUS_LABELS: Record = { + PENDING: "Pendente", + AWAITING_ATTENDANCE: "Aguardando atendimento", + PAUSED: "Pausado", + RESOLVED: "Resolvido", + CLOSED: "Fechado", +}; + +const LEGACY_STATUS_MAP: Record = { + NEW: "PENDING", + PENDING: "PENDING", + OPEN: "AWAITING_ATTENDANCE", + AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", + ON_HOLD: "PAUSED", + PAUSED: "PAUSED", + RESOLVED: "RESOLVED", + CLOSED: "CLOSED", +}; + +function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { + if (!status) return "PENDING"; + const normalized = LEGACY_STATUS_MAP[status.toUpperCase()]; + return normalized ?? "PENDING"; +} async function ensureManagerTicketAccess( ctx: MutationCtx | QueryCtx, @@ -232,11 +264,6 @@ export const list = query({ .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)) .collect(); - } else if (args.status) { - base = await ctx.db - .query("tickets") - .withIndex("by_tenant_status", (q) => q.eq("tenantId", args.tenantId).eq("status", args.status!)) - .collect(); } else if (args.queueId) { base = await ctx.db .query("tickets") @@ -258,9 +285,13 @@ export const list = query({ } filtered = filtered.filter((t) => t.companyId === user.companyId) } - + const normalizedStatusFilter = args.status ? normalizeStatus(args.status) : null; + if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority); if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel); + if (normalizedStatusFilter) { + filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter); + } if (args.search) { const term = args.search.toLowerCase(); filtered = filtered.filter( @@ -307,7 +338,7 @@ export const list = query({ tenantId: t.tenantId, subject: t.subject, summary: t.summary, - status: t.status, + status: normalizeStatus(t.status), priority: t.priority, channel: t.channel, queue: queueName, @@ -428,7 +459,7 @@ export const getById = query({ tenantId: t.tenantId, subject: t.subject, summary: t.summary, - status: t.status, + status: normalizeStatus(t.status), priority: t.priority, channel: t.channel, queue: queueName, @@ -588,12 +619,13 @@ export const create = mutation({ .take(1); const nextRef = existing[0]?.reference ? existing[0].reference + 1 : 41000; const now = Date.now(); + const initialStatus: TicketStatusNormalized = initialAssigneeId ? "AWAITING_ATTENDANCE" : "PENDING"; const id = await ctx.db.insert("tickets", { tenantId: args.tenantId, reference: nextRef, subject, summary: args.summary?.trim() || undefined, - status: "NEW", + status: initialStatus, priority: args.priority, channel: args.channel, queueId: args.queueId, @@ -847,20 +879,13 @@ export const updateStatus = mutation({ } const ticketDoc = ticket as Doc<"tickets"> await requireTicketStaff(ctx, actorId, ticketDoc) + const normalizedStatus = normalizeStatus(status) const now = Date.now(); - await ctx.db.patch(ticketId, { status, updatedAt: now }); - const statusPt: Record = { - NEW: "Novo", - OPEN: "Aberto", - PENDING: "Pendente", - ON_HOLD: "Em espera", - RESOLVED: "Resolvido", - CLOSED: "Fechado", - } as const; + await ctx.db.patch(ticketId, { status: normalizedStatus, updatedAt: now }); await ctx.db.insert("ticketEvents", { ticketId, type: "STATUS_CHANGED", - payload: { to: status, toLabel: statusPt[status] ?? status, actorId }, + payload: { to: normalizedStatus, toLabel: STATUS_LABELS[normalizedStatus], actorId }, createdAt: now, }); }, @@ -1095,8 +1120,13 @@ export const startWork = mutation({ }) export const pauseWork = mutation({ - args: { ticketId: v.id("tickets"), actorId: v.id("users") }, - handler: async (ctx, { ticketId, actorId }) => { + args: { + ticketId: v.id("tickets"), + actorId: v.id("users"), + reason: v.string(), + note: v.optional(v.string()), + }, + handler: async (ctx, { ticketId, actorId, reason, note }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") @@ -1106,6 +1136,10 @@ export const pauseWork = mutation({ return { status: "already_paused" } } + if (!PAUSE_REASON_LABELS[reason]) { + throw new ConvexError("Motivo de pausa inválido") + } + const session = await ctx.db.get(ticket.activeSessionId) if (!session) { await ctx.db.patch(ticketId, { activeSessionId: undefined, working: false }) @@ -1118,6 +1152,8 @@ export const pauseWork = mutation({ await ctx.db.patch(ticket.activeSessionId, { stoppedAt: now, durationMs, + pauseReason: reason, + pauseNote: note ?? "", }) await ctx.db.patch(ticketId, { @@ -1137,11 +1173,19 @@ export const pauseWork = mutation({ actorAvatar: actor?.avatarUrl, sessionId: session._id, sessionDurationMs: durationMs, + pauseReason: reason, + pauseReasonLabel: PAUSE_REASON_LABELS[reason], + pauseNote: note ?? "", }, createdAt: now, }) - return { status: "paused", durationMs } + return { + status: "paused", + durationMs, + pauseReason: reason, + pauseNote: note ?? "", + } }, }) @@ -1228,7 +1272,10 @@ export const playNext = mutation({ const chosen = candidates[0]; const now = Date.now(); - await ctx.db.patch(chosen._id, { assigneeId: agentId, status: chosen.status === "NEW" ? "OPEN" : chosen.status, updatedAt: now }); + const currentStatus = normalizeStatus(chosen.status); + const nextStatus: TicketStatusNormalized = + currentStatus === "PENDING" ? "AWAITING_ATTENDANCE" : currentStatus; + await ctx.db.patch(chosen._id, { assigneeId: agentId, status: nextStatus, updatedAt: now }); await ctx.db.insert("ticketEvents", { ticketId: chosen._id, type: "ASSIGNEE_CHANGED", @@ -1247,7 +1294,7 @@ export const playNext = mutation({ tenantId: chosen.tenantId, subject: chosen.subject, summary: chosen.summary, - status: chosen.status, + status: nextStatus, priority: chosen.priority, channel: chosen.channel, queue: queueName, diff --git a/package.json b/package.json index f627801..3c3471a 100644 --- a/package.json +++ b/package.json @@ -44,12 +44,13 @@ "better-auth": "^1.3.26", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "dotenv": "^16.4.5", "convex": "^1.27.3", "date-fns": "^4.1.0", + "dotenv": "^16.4.5", "lucide-react": "^0.544.0", "next": "15.5.4", "next-themes": "^0.4.6", + "pdfkit": "^0.17.2", "postcss": "^8.5.6", "react": "19.2.0", "react-dom": "19.2.0", @@ -67,9 +68,11 @@ "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/pdfkit": "^0.17.3", "@types/react": "^19", "@types/react-dom": "^19", "@types/sanitize-html": "^2.16.0", + "@types/three": "^0.180.0", "eslint": "^9", "eslint-config-next": "15.5.4", "prisma": "^6.16.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7b9535..fb6600d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + pdfkit: + specifier: ^0.17.2 + version: 0.17.2 postcss: specifier: ^8.5.6 version: 8.5.6 @@ -162,6 +165,9 @@ importers: '@types/node': specifier: ^20 version: 20.19.19 + '@types/pdfkit': + specifier: ^0.17.3 + version: 0.17.3 '@types/react': specifier: ^19 version: 19.2.0 @@ -171,6 +177,9 @@ importers: '@types/sanitize-html': specifier: ^2.16.0 version: 2.16.0 + '@types/three': + specifier: ^0.180.0 + version: 0.180.0 eslint: specifier: ^9 version: 9.37.0(jiti@2.6.1) @@ -212,6 +221,9 @@ packages: '@better-fetch/fetch@1.1.18': resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1783,6 +1795,9 @@ packages: '@tiptap/starter-kit@3.6.5': resolution: {integrity: sha512-LNAJQstB/VazmMlRbUyu3rCNVQ9af25Ywkn3Uyuwt3Ks9ZlliIm/x/zertdXTY2adoig+b36zT5Xcx1O4IdJ3A==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1834,6 +1849,9 @@ packages: '@types/node@20.19.19': resolution: {integrity: sha512-pb1Uqj5WJP7wrcbLU7Ru4QtA0+3kAXrkutGiD26wUKzSMgNNaPARTUDQmElUXp64kh3cWdou3Q0C7qwwxqSFmg==} + '@types/pdfkit@0.17.3': + resolution: {integrity: sha512-E4tp2qFaghqfS4K5TR4Gn1uTIkg0UAkhUgvVIszr5cS6ZmbioPWEkvhNDy3GtR9qdKC8DLQAnaaMlTcf346VsA==} + '@types/react-dom@19.2.0': resolution: {integrity: sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==} peerDependencies: @@ -1855,6 +1873,12 @@ packages: '@types/sanitize-html@2.16.0': resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.180.0': + resolution: {integrity: sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==} + '@types/use-sync-external-store@0.0.6': resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} @@ -2044,6 +2068,9 @@ packages: '@vitest/utils@2.1.9': resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + '@webgpu/types@0.1.65': + resolution: {integrity: sha512-cYrHab4d6wuVvDW5tdsfI6/o6vcLMDe6w2Citd1oS51Xxu2ycLCnVo4fqwujfKWijrZMInTJIKcXxteoy21nVA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2134,6 +2161,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@0.0.8: + resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==} + engines: {node: '>= 0.4'} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2179,6 +2210,9 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + brotli@1.3.3: + resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2242,6 +2276,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clone@2.1.2: + resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} + engines: {node: '>=0.8'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -2286,6 +2324,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -2407,6 +2448,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dfa@1.2.0: + resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -2668,6 +2712,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2687,6 +2734,9 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fontkit@2.0.4: + resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==} + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} @@ -2945,6 +2995,9 @@ packages: jose@6.1.0: resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + jpeg-exif@1.1.4: + resolution: {integrity: sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3055,6 +3108,9 @@ packages: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + linebreak@1.1.0: + resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==} + linkify-it@5.0.0: resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} @@ -3101,6 +3157,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@0.22.0: + resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3232,6 +3291,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3260,6 +3322,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdfkit@0.17.2: + resolution: {integrity: sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -3277,6 +3342,9 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + png-js@1.0.0: + resolution: {integrity: sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3512,6 +3580,9 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true + restructure@3.0.2: + resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3703,6 +3774,9 @@ packages: three@0.180.0: resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==} + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -3795,6 +3869,12 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicode-properties@1.4.1: + resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} + + unicode-trie@2.0.0: + resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==} + unicornstudio-react@1.4.31: resolution: {integrity: sha512-EYPeBPyOXiL6ltLMQRJFbBktnai+RQee4UZk5OcFWbVXii//E8pRF9p4++5ByEiBvDIX4jyj5Mgtxi76Kr12kQ==} engines: {node: '>=16.0.0'} @@ -3984,6 +4064,8 @@ snapshots: '@better-fetch/fetch@1.1.18': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': dependencies: react: 19.2.0 @@ -5393,6 +5475,8 @@ snapshots: '@tiptap/extensions': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5) '@tiptap/pm': 3.6.5 + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -5441,6 +5525,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pdfkit@0.17.3': + dependencies: + '@types/node': 20.19.19 + '@types/react-dom@19.2.0(@types/react@19.2.0)': dependencies: '@types/react': 19.2.0 @@ -5461,6 +5549,18 @@ snapshots: dependencies: htmlparser2: 8.0.2 + '@types/stats.js@0.17.4': {} + + '@types/three@0.180.0': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.65 + fflate: 0.8.2 + meshoptimizer: 0.22.0 + '@types/use-sync-external-store@0.0.6': {} '@types/webxr@0.5.24': {} @@ -5657,6 +5757,8 @@ snapshots: loupe: 3.2.1 tinyrainbow: 1.2.0 + '@webgpu/types@0.1.65': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -5771,6 +5873,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@0.0.8: {} + base64-js@1.5.1: {} better-auth@1.3.26(next@15.5.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -5814,6 +5918,10 @@ snapshots: dependencies: fill-range: 7.1.1 + brotli@1.3.3: + dependencies: + base64-js: 1.5.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -5888,6 +5996,8 @@ snapshots: client-only@0.0.1: {} + clone@2.1.2: {} + clsx@2.1.1: {} color-convert@2.0.1: @@ -5918,6 +6028,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + csstype@3.1.3: {} d3-array@3.2.4: @@ -6018,6 +6130,8 @@ snapshots: detect-node-es@1.1.0: {} + dfa@1.2.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -6471,6 +6585,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6491,6 +6607,18 @@ snapshots: flatted@3.3.3: {} + fontkit@2.0.4: + dependencies: + '@swc/helpers': 0.5.15 + brotli: 1.3.3 + clone: 2.1.2 + dfa: 1.2.0 + fast-deep-equal: 3.1.3 + restructure: 3.0.2 + tiny-inflate: 1.0.3 + unicode-properties: 1.4.1 + unicode-trie: 2.0.0 + for-each@0.3.5: dependencies: is-callable: 1.2.7 @@ -6761,6 +6889,8 @@ snapshots: jose@6.1.0: {} + jpeg-exif@1.1.4: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -6848,6 +6978,11 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + linebreak@1.1.0: + dependencies: + base64-js: 0.0.8 + unicode-trie: 2.0.0 + linkify-it@5.0.0: dependencies: uc.micro: 2.1.0 @@ -6891,6 +7026,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@0.22.0: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7029,6 +7166,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@0.2.9: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -7047,6 +7186,14 @@ snapshots: pathval@2.0.1: {} + pdfkit@0.17.2: + dependencies: + crypto-js: 4.2.0 + fontkit: 2.0.4 + jpeg-exif: 1.1.4 + linebreak: 1.1.0 + png-js: 1.0.0 + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -7061,6 +7208,8 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + png-js@1.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.4.31: @@ -7343,6 +7492,8 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restructure@3.0.2: {} + reusify@1.1.0: {} rollup@4.52.4: @@ -7610,6 +7761,8 @@ snapshots: three@0.180.0: {} + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -7706,6 +7859,16 @@ snapshots: undici-types@6.21.0: {} + unicode-properties@1.4.1: + dependencies: + base64-js: 1.5.1 + unicode-trie: 2.0.0 + + unicode-trie@2.0.0: + dependencies: + pako: 0.2.9 + tiny-inflate: 1.0.3 + unicornstudio-react@1.4.31(next@15.5.4(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3e8bb64..7a7a2ba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,14 +15,13 @@ enum UserRole { CUSTOMER } -enum TicketStatus { - NEW - OPEN - PENDING - ON_HOLD - RESOLVED - CLOSED -} +enum TicketStatus { + PENDING + AWAITING_ATTENDANCE + PAUSED + RESOLVED + CLOSED +} enum TicketPriority { LOW diff --git a/scripts/import-convex-to-prisma.mjs b/scripts/import-convex-to-prisma.mjs index 144485d..1dff888 100644 --- a/scripts/import-convex-to-prisma.mjs +++ b/scripts/import-convex-to-prisma.mjs @@ -53,6 +53,23 @@ function slugify(value) { .toLowerCase() } +const STATUS_MAP = { + NEW: "PENDING", + PENDING: "PENDING", + OPEN: "AWAITING_ATTENDANCE", + AWAITING_ATTENDANCE: "AWAITING_ATTENDANCE", + ON_HOLD: "PAUSED", + PAUSED: "PAUSED", + RESOLVED: "RESOLVED", + CLOSED: "CLOSED", +} + +function normalizeStatus(status) { + if (!status) return "PENDING" + const key = String(status).toUpperCase() + return STATUS_MAP[key] ?? "PENDING" +} + async function upsertCompanies(snapshotCompanies) { const map = new Map() @@ -250,7 +267,7 @@ async function upsertTickets(snapshotTickets, userMap, queueMap, companyMap) { const data = { subject: ticket.subject, summary: ticket.summary ?? null, - status: (ticket.status ?? "NEW").toUpperCase(), + status: normalizeStatus(ticket.status), priority: (ticket.priority ?? "MEDIUM").toUpperCase(), channel: (ticket.channel ?? "MANUAL").toUpperCase(), queueId, diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 0b2ec65..43f9ea6 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,7 +1,7 @@ import { AdminUsersManager } from "@/components/admin/admin-users-manager" import { AppShell } from "@/components/app-shell" import { SiteHeader } from "@/components/site-header" -import { ROLE_OPTIONS, normalizeRole } from "@/lib/authz" +import { ROLE_OPTIONS, normalizeRole, type RoleOption } from "@/lib/authz" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { prisma } from "@/lib/prisma" import { normalizeInvite, type NormalizedInvite } from "@/server/invite-utils" @@ -27,7 +27,7 @@ async function loadUsers() { id: user.id, email: user.email, name: user.name ?? "", - role: normalizeRole(user.role) ?? "agent", + role: (normalizeRole(user.role) ?? "agent") as RoleOption, tenantId: user.tenantId ?? DEFAULT_TENANT_ID, createdAt: user.createdAt.toISOString(), updatedAt: user.updatedAt?.toISOString() ?? null, diff --git a/src/app/api/admin/invites/[id]/route.ts b/src/app/api/admin/invites/[id]/route.ts index 1247d03..a169888 100644 --- a/src/app/api/admin/invites/[id]/route.ts +++ b/src/app/api/admin/invites/[id]/route.ts @@ -1,8 +1,8 @@ import { NextResponse } from "next/server" +import { Prisma } from "@prisma/client" import { ConvexHttpClient } from "convex/browser" -// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes import { api } from "@/convex/_generated/api" import { assertAdminSession } from "@/lib/auth-server" import { env } from "@/lib/env" @@ -36,7 +36,8 @@ async function syncInvite(invite: NormalizedInvite) { }) } -export async function PATCH(request: Request, { params }: { params: { id: string } }) { +export async function PATCH(request: Request, context: { params: Promise<{ id: string }> }) { + const { id } = await context.params const session = await assertAdminSession() if (!session) { return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) @@ -46,7 +47,7 @@ export async function PATCH(request: Request, { params }: { params: { id: string const reason = typeof body?.reason === "string" && body.reason.trim() ? body.reason.trim() : null const invite = await prisma.authInvite.findUnique({ - where: { id: params.id }, + where: { id }, include: { events: { orderBy: { createdAt: "asc" } } }, }) @@ -81,7 +82,7 @@ export async function PATCH(request: Request, { params }: { params: { id: string data: { inviteId: invite.id, type: "revoked", - payload: reason ? { reason } : null, + payload: reason ? { reason } : Prisma.JsonNull, actorId: session.user.id ?? null, }, }) diff --git a/src/app/api/admin/invites/route.ts b/src/app/api/admin/invites/route.ts index f1d2db8..fa67381 100644 --- a/src/app/api/admin/invites/route.ts +++ b/src/app/api/admin/invites/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server" import { randomBytes } from "crypto" +import { Prisma } from "@prisma/client" import { ConvexHttpClient } from "convex/browser" -// @ts-expect-error Convex runtime API lacks generated types at build time in Next routes import { api } from "@/convex/_generated/api" import { assertAdminSession } from "@/lib/auth-server" import { DEFAULT_TENANT_ID } from "@/lib/constants" @@ -14,6 +14,13 @@ import { computeInviteStatus, normalizeInvite, type InviteWithEvents, type Norma const DEFAULT_EXPIRATION_DAYS = 7 +function toJsonPayload(payload: unknown): Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput { + if (payload === null || payload === undefined) { + return Prisma.JsonNull + } + return payload as Prisma.InputJsonValue +} + function normalizeRole(input: string | null | undefined): RoleOption { const role = (input ?? "agent").toLowerCase() as RoleOption return (ROLE_OPTIONS as readonly string[]).includes(role) ? role : "agent" @@ -52,7 +59,7 @@ async function appendEvent(inviteId: string, type: string, actorId: string | nul data: { inviteId, type, - payload, + payload: toJsonPayload(payload), actorId, }, }) diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 4ddb9ef..d4a10b6 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -4,7 +4,6 @@ import { randomBytes } from "crypto" import { hashPassword } from "better-auth/crypto" import { ConvexHttpClient } from "convex/browser" -// @ts-expect-error Convex generated API lacks type declarations in Next API routes import { api } from "@/convex/_generated/api" import { prisma } from "@/lib/prisma" import { DEFAULT_TENANT_ID } from "@/lib/constants" diff --git a/src/app/api/invites/[token]/route.ts b/src/app/api/invites/[token]/route.ts index 0a4d46e..89209b7 100644 --- a/src/app/api/invites/[token]/route.ts +++ b/src/app/api/invites/[token]/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server" +import { Prisma } from "@prisma/client" import { hashPassword } from "better-auth/crypto" import { ConvexHttpClient } from "convex/browser" -// @ts-expect-error Convex generated API lacks types in Next routes import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { env } from "@/lib/env" @@ -47,9 +47,10 @@ async function syncInvite(invite: NormalizedInvite) { }) } -export async function GET(_request: Request, { params }: { params: { token: string } }) { +export async function GET(_request: Request, context: { params: Promise<{ token: string }> }) { + const { token } = await context.params const invite = await prisma.authInvite.findUnique({ - where: { token: params.token }, + where: { token }, include: { events: { orderBy: { createdAt: "asc" } } }, }) @@ -66,7 +67,7 @@ export async function GET(_request: Request, { params }: { params: { token: stri data: { inviteId: invite.id, type: status, - payload: null, + payload: Prisma.JsonNull, actorId: null, }, }) @@ -80,7 +81,8 @@ export async function GET(_request: Request, { params }: { params: { token: stri return NextResponse.json({ invite: normalized }) } -export async function POST(request: Request, { params }: { params: { token: string } }) { +export async function POST(request: Request, context: { params: Promise<{ token: string }> }) { + const { token } = await context.params const payload = (await request.json().catch(() => null)) as Partial | null if (!payload || typeof payload.password !== "string") { return NextResponse.json({ error: "Senha inválida" }, { status: 400 }) @@ -91,7 +93,7 @@ export async function POST(request: Request, { params }: { params: { token: stri } const invite = await prisma.authInvite.findUnique({ - where: { token: params.token }, + where: { token }, include: { events: { orderBy: { createdAt: "asc" } } }, }) @@ -108,7 +110,7 @@ export async function POST(request: Request, { params }: { params: { token: stri data: { inviteId: invite.id, type: "expired", - payload: null, + payload: Prisma.JsonNull, actorId: null, }, }) diff --git a/src/app/api/tickets/[id]/export/pdf/route.ts b/src/app/api/tickets/[id]/export/pdf/route.ts new file mode 100644 index 0000000..d953b7d --- /dev/null +++ b/src/app/api/tickets/[id]/export/pdf/route.ts @@ -0,0 +1,261 @@ +import { NextResponse } from "next/server" +import PDFDocument from "pdfkit" +import { format } from "date-fns" +import { ptBR } from "date-fns/locale" +import { ConvexHttpClient } from "convex/browser" + +import { api } from "@/convex/_generated/api" +import type { Id } from "@/convex/_generated/dataModel" +import { env } from "@/lib/env" +import { assertStaffSession } from "@/lib/auth-server" +import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket" +import { DEFAULT_TENANT_ID } from "@/lib/constants" + +const statusLabel: Record = { + PENDING: "Pendente", + AWAITING_ATTENDANCE: "Aguardando atendimento", + PAUSED: "Pausado", + RESOLVED: "Resolvido", + CLOSED: "Fechado", +} + +const timelineLabel: Record = { + CREATED: "Chamado criado", + STATUS_CHANGED: "Status atualizado", + ASSIGNEE_CHANGED: "Responsável alterado", + COMMENT_ADDED: "Novo comentário", + COMMENT_EDITED: "Comentário editado", + ATTACHMENT_REMOVED: "Anexo removido", + QUEUE_CHANGED: "Fila alterada", + PRIORITY_CHANGED: "Prioridade alterada", + WORK_STARTED: "Atendimento iniciado", + WORK_PAUSED: "Atendimento pausado", + CATEGORY_CHANGED: "Categoria alterada", +} + +function formatDateTime(date: Date | null | undefined) { + if (!date) return "—" + return format(date, "dd/MM/yyyy HH:mm", { locale: ptBR }) +} + +function htmlToPlainText(html?: string | null) { + if (!html) return "" + const withBreaks = html + .replace(/<\s*br\s*\/?>/gi, "\n") + .replace(/<\/p>/gi, "\n\n") + const stripped = withBreaks.replace(/<[^>]+>/g, "") + return decodeHtmlEntities(stripped).replace(/\u00A0/g, " ").trim() +} + +function decodeHtmlEntities(input: string) { + return input + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, " ") +} + +export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) { + const { id: ticketId } = await context.params + const session = await assertStaffSession() + if (!session) { + return NextResponse.json({ error: "Não autorizado" }, { status: 401 }) + } + + const convexUrl = env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return NextResponse.json({ error: "Convex não configurado" }, { status: 500 }) + } + + const client = new ConvexHttpClient(convexUrl) + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + let viewerId: string | null = null + try { + const ensuredUser = await client.mutation(api.users.ensureUser, { + tenantId, + name: session.user.name ?? session.user.email, + email: session.user.email, + avatarUrl: session.user.avatarUrl ?? undefined, + role: session.user.role.toUpperCase(), + }) + viewerId = ensuredUser?._id ?? null + } catch (error) { + console.error("Failed to synchronize user with Convex for PDF export", error) + return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 }) + } + + if (!viewerId) { + return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 }) + } + + let ticketRaw: unknown + try { + ticketRaw = await client.query(api.tickets.getById, { + tenantId, + id: ticketId as unknown as Id<"tickets">, + viewerId: viewerId as unknown as Id<"users">, + }) + } catch (error) { + console.error("Failed to load ticket from Convex for PDF export", error, { + tenantId, + ticketId, + viewerId, + }) + return NextResponse.json({ error: "Falha ao carregar ticket no Convex" }, { status: 500 }) + } + + if (!ticketRaw) { + return NextResponse.json({ error: "Ticket não encontrado" }, { status: 404 }) + } + + const ticket = mapTicketWithDetailsFromServer(ticketRaw) + const doc = new PDFDocument({ size: "A4", margin: 48 }) + const chunks: Buffer[] = [] + + doc.on("data", (chunk) => { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk) + }) + + const pdfBufferPromise = new Promise((resolve, reject) => { + doc.on("end", () => resolve(Buffer.concat(chunks))) + doc.on("error", reject) + }) + + doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference} — ${ticket.subject}`) + doc.moveDown(0.5) + doc + .font("Helvetica") + .fontSize(11) + .text(`Status: ${statusLabel[ticket.status] ?? ticket.status}`) + .moveDown(0.15) + .text(`Prioridade: ${ticket.priority}`) + .moveDown(0.15) + .text(`Canal: ${ticket.channel}`) + .moveDown(0.15) + .text(`Fila: ${ticket.queue ?? "—"}`) + + doc.moveDown(0.75) + doc + .font("Helvetica-Bold") + .fontSize(12) + .text("Solicitante") + doc + .font("Helvetica") + .fontSize(11) + .text(`${ticket.requester.name} (${ticket.requester.email})`) + + doc.moveDown(0.5) + doc.font("Helvetica-Bold").fontSize(12).text("Responsável") + doc + .font("Helvetica") + .fontSize(11) + .text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído") + + doc.moveDown(0.75) + doc.font("Helvetica-Bold").fontSize(12).text("Datas") + doc + .font("Helvetica") + .fontSize(11) + .text(`Criado em: ${formatDateTime(ticket.createdAt)}`) + .moveDown(0.15) + .text(`Atualizado em: ${formatDateTime(ticket.updatedAt)}`) + .moveDown(0.15) + .text(`Resolvido em: ${formatDateTime(ticket.resolvedAt ?? null)}`) + + if (ticket.summary) { + doc.moveDown(0.75) + doc.font("Helvetica-Bold").fontSize(12).text("Resumo") + doc + .font("Helvetica") + .fontSize(11) + .text(ticket.summary, { align: "justify" }) + } + + if (ticket.description) { + doc.moveDown(0.75) + doc.font("Helvetica-Bold").fontSize(12).text("Descrição") + doc + .font("Helvetica") + .fontSize(11) + .text(htmlToPlainText(ticket.description), { align: "justify" }) + } + + if (ticket.comments.length > 0) { + doc.addPage() + doc.font("Helvetica-Bold").fontSize(14).text("Comentários") + doc.moveDown(0.5) + const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + commentsSorted.forEach((comment, index) => { + const visibility = + comment.visibility === "PUBLIC" ? "Público" : "Interno" + doc + .font("Helvetica-Bold") + .fontSize(11) + .text( + `${comment.author.name} • ${visibility} • ${formatDateTime(comment.createdAt)}` + ) + const body = htmlToPlainText(comment.body) + if (body) { + doc + .font("Helvetica") + .fontSize(11) + .text(body, { align: "justify" }) + } + if (comment.attachments.length > 0) { + doc.moveDown(0.25) + doc.font("Helvetica").fontSize(10).text("Anexos:") + comment.attachments.forEach((attachment) => { + doc + .font("Helvetica") + .fontSize(10) + .text(`• ${attachment.name ?? attachment.id}`, { indent: 12 }) + }) + } + if (index < commentsSorted.length - 1) { + doc.moveDown(0.75) + doc + .strokeColor("#E2E8F0") + .moveTo(doc.x, doc.y) + .lineTo(doc.page.width - doc.page.margins.right, doc.y) + .stroke() + doc.moveDown(0.75) + } + }) + } + + if (ticket.timeline.length > 0) { + doc.addPage() + doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo") + doc.moveDown(0.5) + const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) + timelineSorted.forEach((event) => { + const label = timelineLabel[event.type] ?? event.type + doc + .font("Helvetica-Bold") + .fontSize(11) + .text(`${label} • ${formatDateTime(event.createdAt)}`) + if (event.payload) { + const payloadText = JSON.stringify(event.payload, null, 2) + doc + .font("Helvetica") + .fontSize(10) + .text(payloadText, { indent: 12 }) + } + doc.moveDown(0.5) + }) + } + + doc.end() + const pdfBuffer = await pdfBufferPromise + const pdfBytes = new Uint8Array(pdfBuffer) + + return new NextResponse(pdfBytes, { + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="ticket-${ticket.reference}.pdf"`, + "Cache-Control": "no-store", + }, + }) +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 4a0908a..346d821 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,8 +1,8 @@ import { AppShell } from "@/components/app-shell" -import { ChartAreaInteractive } from "@/components/chart-area-interactive" import { SectionCards } from "@/components/section-cards" import { SiteHeader } from "@/components/site-header" import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel" +import { ChartAreaInteractive } from "@/components/chart-area-interactive" export default function Dashboard() { return ( diff --git a/src/app/dev/seed/page.tsx b/src/app/dev/seed/page.tsx index d74cfc5..168e314 100644 --- a/src/app/dev/seed/page.tsx +++ b/src/app/dev/seed/page.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useMutation } from "convex/react"; -// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api"; export default function SeedPage() { diff --git a/src/app/login/login-page-client.tsx b/src/app/login/login-page-client.tsx new file mode 100644 index 0000000..1fd39a4 --- /dev/null +++ b/src/app/login/login-page-client.tsx @@ -0,0 +1,73 @@ +"use client" + +import { useEffect, useState } from "react" +import Image from "next/image" +import Link from "next/link" +import { useRouter, useSearchParams } from "next/navigation" +import dynamic from "next/dynamic" +import { GalleryVerticalEnd } from "lucide-react" + +import { LoginForm } from "@/components/login-form" +import { useSession } from "@/lib/auth-client" + +const ShaderBackground = dynamic( + () => import("@/components/background-paper-shaders-wrapper"), + { ssr: false } +) + +export function LoginPageClient() { + const router = useRouter() + const searchParams = useSearchParams() + const { data: session, isPending } = useSession() + const callbackUrl = searchParams?.get("callbackUrl") ?? undefined + const [isHydrated, setIsHydrated] = useState(false) + + useEffect(() => { + if (isPending) return + if (!session?.user) return + const destination = callbackUrl ?? "/dashboard" + router.replace(destination) + }, [callbackUrl, isPending, router, session?.user]) + + useEffect(() => { + setIsHydrated(true) + }, []) + + const shouldDisable = !isHydrated || isPending + + return ( +
+
+
+ +
+ +
+ Sistema de chamados + +
+
+
+ +
+
+
+ Logotipo Rever Tecnologia +
+
+ Desenvolvido por Esdras Renan +
+
+
+ +
+
+ ) +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b21ded6..17c43d7 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,74 +1,11 @@ -"use client" +import { Suspense } from "react" -import { useEffect, useState } from "react" -import Image from "next/image" -import Link from "next/link" -import { useRouter, useSearchParams } from "next/navigation" -import { GalleryVerticalEnd } from "lucide-react" - -import { LoginForm } from "@/components/login-form" -import { useSession } from "@/lib/auth-client" -import dynamic from "next/dynamic" - -const ShaderBackground = dynamic( - () => import("@/components/background-paper-shaders-wrapper"), - { ssr: false } -) +import { LoginPageClient } from "./login-page-client" export default function LoginPage() { - const router = useRouter() - const searchParams = useSearchParams() - const { data: session, isPending } = useSession() - const callbackUrl = searchParams?.get("callbackUrl") ?? undefined - const [isHydrated, setIsHydrated] = useState(false) - - useEffect(() => { - if (isPending) return - if (!session?.user) return - const destination = callbackUrl ?? "/dashboard" - router.replace(destination) - }, [callbackUrl, isPending, router, session?.user]) - - useEffect(() => { - setIsHydrated(true) - }, []) - - const shouldDisable = !isHydrated || isPending - return ( -
-
-
- -
- -
- Sistema de chamados - -
-
-
- -
-
-
- Logotipo Rever Tecnologia -
-
- Desenvolvido por Esdras Renan -
-
-
- -
-
+ Carregando…}> + + ) } - diff --git a/src/app/tickets/new/page.tsx b/src/app/tickets/new/page.tsx index 57e495b..ab81758 100644 --- a/src/app/tickets/new/page.tsx +++ b/src/app/tickets/new/page.tsx @@ -8,7 +8,6 @@ import type { Doc, Id } from "@/convex/_generated/dataModel" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" -// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" diff --git a/src/components/admin/categories/categories-manager.tsx b/src/components/admin/categories/categories-manager.tsx index 1c0de46..730ff32 100644 --- a/src/components/admin/categories/categories-manager.tsx +++ b/src/components/admin/categories/categories-manager.tsx @@ -3,7 +3,6 @@ import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" -// @ts-expect-error Convex runtime API lacks generated types import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" diff --git a/src/components/admin/fields/fields-manager.tsx b/src/components/admin/fields/fields-manager.tsx index 8e4e280..b46faf0 100644 --- a/src/components/admin/fields/fields-manager.tsx +++ b/src/components/admin/fields/fields-manager.tsx @@ -4,7 +4,6 @@ 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" diff --git a/src/components/admin/queues/queues-manager.tsx b/src/components/admin/queues/queues-manager.tsx index e0b695d..05777c3 100644 --- a/src/components/admin/queues/queues-manager.tsx +++ b/src/components/admin/queues/queues-manager.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { IconInbox, IconHierarchy2, IconLink, IconPlus } 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" diff --git a/src/components/admin/slas/slas-manager.tsx b/src/components/admin/slas/slas-manager.tsx index 17664e9..268ba4f 100644 --- a/src/components/admin/slas/slas-manager.tsx +++ b/src/components/admin/slas/slas-manager.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { IconAlarm, IconBolt, IconTargetArrow } 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" diff --git a/src/components/admin/teams/teams-manager.tsx b/src/components/admin/teams/teams-manager.tsx index 1038f78..b554ac0 100644 --- a/src/components/admin/teams/teams-manager.tsx +++ b/src/components/admin/teams/teams-manager.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from "react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" import { IconUsersGroup, IconCalendarClock, IconSettings, IconUserPlus } 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 { DEFAULT_TENANT_ID } from "@/lib/constants" diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index acf8b32..5af4df4 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react" +import { Suspense, type ReactNode } from "react" import { AppSidebar } from "@/components/app-sidebar" import { AuthGuard } from "@/components/auth/auth-guard" @@ -14,7 +14,9 @@ export function AppShell({ header, children }: AppShellProps) { - + + + {header}
{children} diff --git a/src/components/background-paper-shaders-wrapper.tsx b/src/components/background-paper-shaders-wrapper.tsx index 44af19f..988812e 100644 --- a/src/components/background-paper-shaders-wrapper.tsx +++ b/src/components/background-paper-shaders-wrapper.tsx @@ -2,18 +2,18 @@ import { MeshGradient } from "@paper-design/shaders-react" -export default function BackgroundPaperShadersWrapper() { +import { cn } from "@/lib/utils" + +export default function BackgroundPaperShadersWrapper({ className }: { className?: string }) { const speed = 1.0 return ( -
+
-
) } diff --git a/src/components/background-paper-shaders.tsx b/src/components/background-paper-shaders.tsx index 56fa1d3..2b91e27 100644 --- a/src/components/background-paper-shaders.tsx +++ b/src/components/background-paper-shaders.tsx @@ -103,7 +103,16 @@ export function EnergyRing({ useFrame((state) => { if (mesh.current) { mesh.current.rotation.z = state.clock.elapsedTime - mesh.current.material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3 + const material = mesh.current.material + if (Array.isArray(material)) { + material.forEach((mat) => { + if ("opacity" in mat) { + mat.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3 + } + }) + } else if (material && "opacity" in material) { + material.opacity = 0.5 + Math.sin(state.clock.elapsedTime * 3) * 0.3 + } } }) diff --git a/src/components/chart-area-interactive.tsx b/src/components/chart-area-interactive.tsx index fbe2c06..5a12f10 100644 --- a/src/components/chart-area-interactive.tsx +++ b/src/components/chart-area-interactive.tsx @@ -4,7 +4,6 @@ import * as React from "react" import { Area, AreaChart, CartesianGrid, XAxis } from "recharts" import { useQuery } from "convex/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" @@ -39,17 +38,22 @@ import { export const description = "Distribuição semanal de tickets por canal" -export function ChartAreaInteractive() { - const isMobile = useIsMobile() +export function ChartAreaInteractive() { + const [mounted, setMounted] = React.useState(false) + const isMobile = useIsMobile() const [timeRange, setTimeRange] = React.useState("7d") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID - - React.useEffect(() => { - if (isMobile) { - setTimeRange("7d") - } - }, [isMobile]) + + React.useEffect(() => { + setMounted(true) + }, []) + + React.useEffect(() => { + if (isMobile) { + setTimeRange("7d") + } + }, [isMobile]) const report = useQuery( api.reports.ticketsByChannel, @@ -72,7 +76,7 @@ export function ChartAreaInteractive() { ) const chartConfig = React.useMemo(() => { - const entries = channels.map((channel, index) => [ + const entries = channels.map((channel: string, index: number) => [ channel, { label: channel @@ -87,7 +91,7 @@ export function ChartAreaInteractive() { const chartData = React.useMemo(() => { if (!report?.points) return [] - return report.points.map((point) => { + return report.points.map((point: { date: string; values: Record }) => { const entry: Record = { date: point.date } for (const channel of channels) { entry[channel] = point.values[channel] ?? 0 @@ -95,6 +99,14 @@ export function ChartAreaInteractive() { return entry }) }, [channels, report]) + + if (!mounted) { + return ( +
+ Carregando gráfico... +
+ ) + } return ( @@ -156,7 +168,7 @@ export function ChartAreaInteractive() { > - {channels.map((channel) => ( + {channels.map((channel: string) => ( ( + .map((channel: string) => ( ))} @@ -221,4 +237,6 @@ export function ChartAreaInteractive() { ) -} +} + +export default ChartAreaInteractive diff --git a/src/components/portal/portal-ticket-card.tsx b/src/components/portal/portal-ticket-card.tsx index a54e1ca..1df50cd 100644 --- a/src/components/portal/portal-ticket-card.tsx +++ b/src/components/portal/portal-ticket-card.tsx @@ -12,19 +12,17 @@ import { Card, CardContent, CardHeader } from "@/components/ui/card" import { cn } from "@/lib/utils" const statusLabel: Record = { - NEW: "Novo", - OPEN: "Aberto", PENDING: "Pendente", - ON_HOLD: "Em espera", + AWAITING_ATTENDANCE: "Aguardando atendimento", + PAUSED: "Pausado", RESOLVED: "Resolvido", CLOSED: "Fechado", } const statusTone: Record = { - NEW: "bg-slate-200 text-slate-800", - OPEN: "bg-sky-100 text-sky-700", - PENDING: "bg-amber-100 text-amber-700", - ON_HOLD: "bg-violet-100 text-violet-700", + PENDING: "bg-slate-200 text-slate-800", + AWAITING_ATTENDANCE: "bg-sky-100 text-sky-700", + PAUSED: "bg-violet-100 text-violet-700", RESOLVED: "bg-emerald-100 text-emerald-700", CLOSED: "bg-slate-100 text-slate-600", } diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index f049f80..564b671 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -6,7 +6,6 @@ import { format, formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { MessageCircle } from "lucide-react" import { toast } from "sonner" -// @ts-expect-error Convex runtime API lacks TypeScript definitions import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { DEFAULT_TENANT_ID } from "@/lib/constants" @@ -23,10 +22,9 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { sanitizeEditorHtml } from "@/components/ui/rich-text-editor" const statusLabel: Record = { - NEW: "Novo", - OPEN: "Aberto", PENDING: "Pendente", - ON_HOLD: "Em espera", + AWAITING_ATTENDANCE: "Aguardando atendimento", + PAUSED: "Pausado", RESOLVED: "Resolvido", CLOSED: "Fechado", } @@ -126,7 +124,7 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { async function handleSubmit(event: React.FormEvent) { event.preventDefault() - if (!convexUserId || !comment.trim()) return + if (!convexUserId || !comment.trim() || !ticket) return const toastId = "portal-add-comment" toast.loading("Enviando comentário...", { id: toastId }) try { diff --git a/src/components/portal/portal-ticket-form.tsx b/src/components/portal/portal-ticket-form.tsx index e002042..4d0d576 100644 --- a/src/components/portal/portal-ticket-form.tsx +++ b/src/components/portal/portal-ticket-form.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from "react" import { useRouter } from "next/navigation" import { useMutation } from "convex/react" import { toast } from "sonner" -// @ts-expect-error Convex runtime API lacks TypeScript definitions import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { DEFAULT_TENANT_ID } from "@/lib/constants" diff --git a/src/components/portal/portal-ticket-list.tsx b/src/components/portal/portal-ticket-list.tsx index 9c1d067..ec0a0aa 100644 --- a/src/components/portal/portal-ticket-list.tsx +++ b/src/components/portal/portal-ticket-list.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react" import { useQuery } from "convex/react" -// @ts-expect-error Convex runtime API lacks TypeScript definitions import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { DEFAULT_TENANT_ID } from "@/lib/constants" diff --git a/src/components/reports/backlog-report.tsx b/src/components/reports/backlog-report.tsx index 4ea8507..f54671b 100644 --- a/src/components/reports/backlog-report.tsx +++ b/src/components/reports/backlog-report.tsx @@ -3,7 +3,6 @@ import { useMemo } from "react" import { useQuery } from "convex/react" import { IconInbox, IconAlertTriangle, IconFilter } 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" @@ -20,12 +19,11 @@ const PRIORITY_LABELS: Record = { } const STATUS_LABELS: Record = { - NEW: "Novo", - OPEN: "Em andamento", - PENDING: "Pendente", - ON_HOLD: "Em espera", - RESOLVED: "Resolvido", - CLOSED: "Encerrado", + PENDING: "Pendentes", + AWAITING_ATTENDANCE: "Aguardando atendimento", + PAUSED: "Pausados", + RESOLVED: "Resolvidos", + CLOSED: "Encerrados", } export function BacklogReport() { @@ -38,7 +36,7 @@ export function BacklogReport() { const mostCriticalPriority = useMemo(() => { if (!data) return null - const entries = Object.entries(data.priorityCounts) + const entries = Object.entries(data.priorityCounts) as Array<[string, number]> if (entries.length === 0) return null return entries.reduce((prev, current) => (current[1] > prev[1] ? current : prev)) }, [data]) @@ -104,7 +102,7 @@ export function BacklogReport() {
- {Object.entries(data.statusCounts).map(([status, total]) => ( + {(Object.entries(data.statusCounts) as Array<[string, number]>).map(([status, total]) => (

{STATUS_LABELS[status] ?? status} @@ -125,7 +123,7 @@ export function BacklogReport() {

- {Object.entries(data.priorityCounts).map(([priority, total]) => ( + {(Object.entries(data.priorityCounts) as Array<[string, number]>).map(([priority, total]) => (
{PRIORITY_LABELS[priority] ?? priority} @@ -153,7 +151,7 @@ export function BacklogReport() {

) : (
    - {data.queueCounts.map((queue) => ( + {data.queueCounts.map((queue: { id: string; name: string; total: number }) => (
  • {queue.name} diff --git a/src/components/reports/csat-report.tsx b/src/components/reports/csat-report.tsx index 933b68f..85f7dfc 100644 --- a/src/components/reports/csat-report.tsx +++ b/src/components/reports/csat-report.tsx @@ -2,7 +2,6 @@ import { useQuery } from "convex/react" import { IconMoodSmile, IconStars, IconMessageCircle2 } 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" @@ -68,7 +67,7 @@ export function CsatReport() { {data.recent.length === 0 ? (

    Ainda não coletamos nenhuma avaliação.

    ) : ( - data.recent.map((item) => ( + data.recent.map((item: { ticketId: string; reference: number; score: number; receivedAt: number }) => (
    #{item.reference} @@ -90,7 +89,7 @@ export function CsatReport() {
      - {data.distribution.map((entry) => ( + {data.distribution.map((entry: { score: number; total: number }) => (
    • diff --git a/src/components/reports/sla-report.tsx b/src/components/reports/sla-report.tsx index 396b620..ffbf405 100644 --- a/src/components/reports/sla-report.tsx +++ b/src/components/reports/sla-report.tsx @@ -3,7 +3,6 @@ import { useMemo } from "react" import { useQuery } from "convex/react" import { IconAlertTriangle, IconGraph, IconClockHour4 } 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" @@ -29,7 +28,10 @@ export function SlaReport() { convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" ) - const queueTotal = useMemo(() => data?.queueBreakdown.reduce((acc, queue) => acc + queue.open, 0) ?? 0, [data]) + const queueTotal = useMemo( + () => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0, + [data] + ) if (!data) { return ( @@ -104,7 +106,7 @@ export function SlaReport() {

      ) : (
        - {data.queueBreakdown.map((queue) => ( + {data.queueBreakdown.map((queue: { id: string; name: string; open: number }) => (
      • {queue.name} diff --git a/src/components/section-cards.tsx b/src/components/section-cards.tsx index a104550..f8657ee 100644 --- a/src/components/section-cards.tsx +++ b/src/components/section-cards.tsx @@ -3,7 +3,6 @@ import { useMemo } from "react" import { useQuery } from "convex/react" import { IconClockHour4, IconMessages, IconTrendingDown, IconTrendingUp } 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" diff --git a/src/components/settings/comment-templates-manager.tsx b/src/components/settings/comment-templates-manager.tsx index ec823ab..7c94b8a 100644 --- a/src/components/settings/comment-templates-manager.tsx +++ b/src/components/settings/comment-templates-manager.tsx @@ -4,7 +4,6 @@ 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" diff --git a/src/components/tickets/delete-ticket-dialog.tsx b/src/components/tickets/delete-ticket-dialog.tsx index 92891c0..57b346d 100644 --- a/src/components/tickets/delete-ticket-dialog.tsx +++ b/src/components/tickets/delete-ticket-dialog.tsx @@ -3,7 +3,6 @@ import { useRouter } from "next/navigation" import { useState } from "react" import { useMutation } from "convex/react" -// @ts-expect-error Convex runtime API lacks TS declarations until build import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog" diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx index 60fabb5..d4c5516 100644 --- a/src/components/tickets/new-ticket-dialog.tsx +++ b/src/components/tickets/new-ticket-dialog.tsx @@ -5,7 +5,6 @@ import { useEffect, useMemo, useState } from "react" import type { Doc, Id } from "@/convex/_generated/dataModel" import type { TicketPriority, TicketQueueSummary } from "@/lib/schemas/ticket" import { useMutation, useQuery } from "convex/react" -// @ts-expect-error Convex runtime API lacks TypeScript definitions import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" diff --git a/src/components/tickets/play-next-ticket-card.tsx b/src/components/tickets/play-next-ticket-card.tsx index 2e41e82..0199811 100644 --- a/src/components/tickets/play-next-ticket-card.tsx +++ b/src/components/tickets/play-next-ticket-card.tsx @@ -5,7 +5,6 @@ import { useState } from "react" import { useRouter } from "next/navigation" import { IconArrowRight, IconPlayerPlayFilled } from "@tabler/icons-react" import { useMutation, useQuery } from "convex/react" -// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import { useAuth } from "@/lib/auth-client" diff --git a/src/components/tickets/priority-select.tsx b/src/components/tickets/priority-select.tsx index 4c68426..e244d4f 100644 --- a/src/components/tickets/priority-select.tsx +++ b/src/components/tickets/priority-select.tsx @@ -2,7 +2,6 @@ import { useState } from "react" import { useMutation } from "convex/react" -// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import type { TicketPriority } from "@/lib/schemas/ticket" diff --git a/src/components/tickets/recent-tickets-panel.tsx b/src/components/tickets/recent-tickets-panel.tsx index 3e8fbc4..0f4a271 100644 --- a/src/components/tickets/recent-tickets-panel.tsx +++ b/src/components/tickets/recent-tickets-panel.tsx @@ -5,7 +5,6 @@ import Link from "next/link" import { formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" import { useQuery } from "convex/react" -// @ts-expect-error Convex runtime API lacks TS declarations import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import { DEFAULT_TENANT_ID } from "@/lib/constants" diff --git a/src/components/tickets/status-badge.tsx b/src/components/tickets/status-badge.tsx index 814abf2..21cb54c 100644 --- a/src/components/tickets/status-badge.tsx +++ b/src/components/tickets/status-badge.tsx @@ -5,10 +5,9 @@ import { Badge } from "@/components/ui/badge" import { cn } from "@/lib/utils" const statusStyles: Record = { - NEW: { label: "Novo", className: "border border-slate-200 bg-slate-100 text-slate-700" }, - OPEN: { label: "Aberto", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" }, - PENDING: { label: "Pendente", className: "border border-slate-200 bg-[#fdebd6] text-[#7b4107]" }, - ON_HOLD: { label: "Em espera", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" }, + PENDING: { label: "Pendente", className: "border border-slate-200 bg-slate-100 text-slate-700" }, + AWAITING_ATTENDANCE: { label: "Aguardando atendimento", className: "border border-slate-200 bg-[#dff1fb] text-[#0a4760]" }, + PAUSED: { label: "Pausado", className: "border border-slate-200 bg-[#ede8ff] text-[#4f2f96]" }, RESOLVED: { label: "Resolvido", className: "border border-slate-200 bg-[#dcf4eb] text-[#1f6a45]" }, CLOSED: { label: "Fechado", className: "border border-slate-200 bg-slate-200 text-slate-700" }, } diff --git a/src/components/tickets/status-select.tsx b/src/components/tickets/status-select.tsx index f2f6887..be3b05d 100644 --- a/src/components/tickets/status-select.tsx +++ b/src/components/tickets/status-select.tsx @@ -2,7 +2,6 @@ import { useState } from "react" import { useMutation } from "convex/react" -// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import type { Id } from "@/convex/_generated/dataModel" import type { TicketStatus } from "@/lib/schemas/ticket" @@ -13,14 +12,20 @@ import { toast } from "sonner" import { cn } from "@/lib/utils" import { ChevronDown } from "lucide-react" -const statusStyles: Record = { - NEW: { label: "Novo", badgeClass: "bg-slate-100 text-slate-700" }, - OPEN: { label: "Aberto", badgeClass: "bg-[#dff1fb] text-[#0a4760]" }, - PENDING: { label: "Pendente", badgeClass: "bg-[#fdebd6] text-[#7b4107]" }, - ON_HOLD: { label: "Em espera", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" }, +type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD"; + +const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED", "CLOSED"]; + +const statusStyles: Record = { + PENDING: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" }, + AWAITING_ATTENDANCE: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" }, + PAUSED: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" }, RESOLVED: { label: "Resolvido", badgeClass: "bg-[#dcf4eb] text-[#1f6a45]" }, CLOSED: { label: "Fechado", badgeClass: "bg-slate-200 text-slate-700" }, -} + NEW: { label: "Pendente", badgeClass: "bg-slate-100 text-slate-700" }, + OPEN: { label: "Aguardando atendimento", badgeClass: "bg-[#dff1fb] text-[#0a4760]" }, + ON_HOLD: { label: "Pausado", badgeClass: "bg-[#ede8ff] text-[#4f2f96]" }, +}; const triggerClass = "group inline-flex h-auto w-auto items-center justify-center rounded-full border border-transparent bg-transparent p-0 shadow-none ring-0 ring-offset-0 ring-offset-transparent focus-visible:outline-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none hover:bg-transparent data-[state=open]:bg-transparent data-[state=open]:border-transparent data-[state=open]:shadow-none data-[state=open]:ring-0 data-[state=open]:ring-offset-0 data-[state=open]:ring-offset-transparent [&>*:last-child]:hidden" @@ -53,14 +58,14 @@ export function StatusSelect({ ticketId, value }: { ticketId: string; value: Tic > - - {statusStyles[status]?.label ?? status} + + {statusStyles[status]?.label ?? statusStyles.PENDING.label} - {(["NEW", "OPEN", "PENDING", "ON_HOLD", "RESOLVED", "CLOSED"] as const).map((option) => ( + {STATUS_OPTIONS.map((option) => ( {statusStyles[option].label} diff --git a/src/components/tickets/ticket-comments.rich.tsx b/src/components/tickets/ticket-comments.rich.tsx index b12ef34..4219d96 100644 --- a/src/components/tickets/ticket-comments.rich.tsx +++ b/src/components/tickets/ticket-comments.rich.tsx @@ -6,7 +6,6 @@ import { ptBR } from "date-fns/locale" import { IconLock, IconMessage, IconFileText } from "@tabler/icons-react" import { FileIcon, Image as ImageIcon, PencilLine, Trash2, X } from "lucide-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" import type { Id } from "@/convex/_generated/dataModel" diff --git a/src/components/tickets/ticket-detail-view.tsx b/src/components/tickets/ticket-detail-view.tsx index bdbfb0c..5e9116e 100644 --- a/src/components/tickets/ticket-detail-view.tsx +++ b/src/components/tickets/ticket-detail-view.tsx @@ -1,7 +1,6 @@ "use client"; import { useQuery } from "convex/react"; -// @ts-expect-error Convex runtime API lacks TypeScript definitions import { api } from "@/convex/_generated/api"; import { DEFAULT_TENANT_ID } from "@/lib/constants"; import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"; diff --git a/src/components/tickets/ticket-queue-summary.tsx b/src/components/tickets/ticket-queue-summary.tsx index b833f5c..659fccb 100644 --- a/src/components/tickets/ticket-queue-summary.tsx +++ b/src/components/tickets/ticket-queue-summary.tsx @@ -1,7 +1,6 @@ "use client" import { useQuery } from "convex/react" -// @ts-expect-error Convex runtime API lacks TypeScript declarations import { api } from "@/convex/_generated/api" import { DEFAULT_TENANT_ID } from "@/lib/constants" import type { TicketQueueSummary } from "@/lib/schemas/ticket" diff --git a/src/components/tickets/ticket-summary-header.tsx b/src/components/tickets/ticket-summary-header.tsx index cb8eec0..74298c0 100644 --- a/src/components/tickets/ticket-summary-header.tsx +++ b/src/components/tickets/ticket-summary-header.tsx @@ -1,12 +1,11 @@ "use client" -import { useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { format, formatDistanceToNow } from "date-fns" import { ptBR } from "date-fns/locale" -import { IconClock, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react" +import { IconClock, IconFileDownload, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react" import { useMutation, useQuery } from "convex/react" import { toast } from "sonner" -// @ts-expect-error Convex generates JS module without TS definitions import { api } from "@/convex/_generated/api" import { useAuth } from "@/lib/auth-client" @@ -20,6 +19,9 @@ import { StatusSelect } from "@/components/tickets/status-select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Textarea } from "@/components/ui/textarea" +import { Spinner } from "@/components/ui/spinner" import { useTicketCategories } from "@/hooks/use-ticket-categories" import { useDefaultQueues } from "@/hooks/use-default-queues" @@ -44,6 +46,11 @@ const subtleBadgeClass = const EMPTY_CATEGORY_VALUE = "__none__" const EMPTY_SUBCATEGORY_VALUE = "__none__" +const PAUSE_REASONS = [ + { value: "NO_CONTACT", label: "Falta de contato" }, + { value: "WAITING_THIRD_PARTY", label: "Aguardando terceiro" }, + { value: "IN_PROCEDURE", label: "Em procedimento" }, +] function formatDuration(durationMs: number) { if (durationMs <= 0) return "0s" @@ -104,6 +111,11 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { } ) const [saving, setSaving] = useState(false) + const [pauseDialogOpen, setPauseDialogOpen] = useState(false) + const [pauseReason, setPauseReason] = useState(PAUSE_REASONS[0]?.value ?? "NO_CONTACT") + const [pauseNote, setPauseNote] = useState("") + const [pausing, setPausing] = useState(false) + const [exportingPdf, setExportingPdf] = useState(false) const selectedCategoryId = categorySelection.categoryId const selectedSubcategoryId = categorySelection.subcategoryId const dirty = useMemo( @@ -272,6 +284,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { return () => clearInterval(interval) }, [workSummary?.activeSession]) + useEffect(() => { + if (!pauseDialogOpen) { + setPauseReason(PAUSE_REASONS[0]?.value ?? "NO_CONTACT") + setPauseNote("") + setPausing(false) + } + }, [pauseDialogOpen]) + const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0 const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0 @@ -281,6 +301,74 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { [ticket.updatedAt] ) + const handleStartWork = async () => { + if (!convexUserId) return + toast.dismiss("work") + toast.loading("Iniciando atendimento...", { id: "work" }) + try { + const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users"> }) + if (result?.status === "already_started") { + toast.info("O atendimento já estava em andamento", { id: "work" }) + } else { + toast.success("Atendimento iniciado", { id: "work" }) + } + } catch { + toast.error("Não foi possível atualizar o atendimento", { id: "work" }) + } + } + + const handlePauseConfirm = async () => { + if (!convexUserId) return + toast.dismiss("work") + toast.loading("Pausando atendimento...", { id: "work" }) + setPausing(true) + try { + const result = await pauseWork({ + ticketId: ticket.id as Id<"tickets">, + actorId: convexUserId as Id<"users">, + reason: pauseReason, + note: pauseNote.trim() ? pauseNote.trim() : undefined, + }) + if (result?.status === "already_paused") { + toast.info("O atendimento já estava pausado", { id: "work" }) + } else { + toast.success("Atendimento pausado", { id: "work" }) + } + setPauseDialogOpen(false) + } catch { + toast.error("Não foi possível atualizar o atendimento", { id: "work" }) + } finally { + setPausing(false) + } + } + + const handleExportPdf = useCallback(async () => { + try { + setExportingPdf(true) + toast.dismiss("ticket-export") + toast.loading("Gerando PDF...", { id: "ticket-export" }) + const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`) + if (!response.ok) { + throw new Error(`failed: ${response.status}`) + } + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `ticket-${ticket.reference}.pdf` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + toast.success("PDF exportado com sucesso!", { id: "ticket-export" }) + } catch (error) { + console.error(error) + toast.error("Não foi possível exportar o PDF.", { id: "ticket-export" }) + } finally { + setExportingPdf(false) + } + }, [ticket.id, ticket.reference]) + return (
        @@ -294,6 +382,16 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) { Editar ) : null} + } />
        @@ -305,28 +403,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
        ) : null}
        + + + + Registrar pausa + Informe o motivo da pausa para registrar no histórico do chamado. + +
        +
        + Motivo + +
        +
        + Observações +