diff --git a/convex/tickets.ts b/convex/tickets.ts index 1850bbe..01f30a8 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -53,6 +53,100 @@ function plainTextLength(html: string): number { } } +function escapeHtml(input: string): string { + return input + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function buildAssigneeChangeComment( + reason: string, + context: { previousName: string; nextName: string }, +): string { + const normalized = reason.replace(/\r\n/g, "\n").trim(); + const lines = normalized + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const previous = escapeHtml(context.previousName || "Não atribuído"); + const next = escapeHtml(context.nextName || "Não atribuído"); + const reasonHtml = lines.length + ? lines.map((line) => `

${escapeHtml(line)}

`).join("") + : `

`; + return `

Responsável atualizado: ${previous} → ${next}

Motivo da troca:

${reasonHtml}`; +} + +function truncateSubject(subject: string) { + if (subject.length <= 60) return subject + return `${subject.slice(0, 57)}…` +} + +function buildTicketMentionAnchor(ticket: Doc<"tickets">): string { + const reference = ticket.reference + const subject = escapeHtml(ticket.subject ?? "") + const truncated = truncateSubject(subject) + const status = (ticket.status ?? "PENDING").toString().toUpperCase() + const priority = (ticket.priority ?? "MEDIUM").toString().toUpperCase() + return `#${reference}${truncated}` +} + +function canMentionTicket(viewerRole: string, viewerId: Id<"users">, ticket: Doc<"tickets">) { + if (viewerRole === "ADMIN" || viewerRole === "AGENT") return true + if (viewerRole === "COLLABORATOR") { + return String(ticket.requesterId) === String(viewerId) + } + if (viewerRole === "MANAGER") { + // Gestores compartilham contexto interno; permitem apenas tickets da mesma empresa do solicitante + return String(ticket.requesterId) === String(viewerId) + } + return false +} + +async function normalizeTicketMentions( + ctx: MutationCtx, + html: string, + viewer: { user: Doc<"users">; role: string }, + tenantId: string, +): Promise { + if (!html || html.indexOf("data-ticket-mention") === -1) { + return html + } + + const mentionPattern = /]*data-ticket-mention="true"[^>]*>[\s\S]*?<\/a>/gi + const matches = Array.from(html.matchAll(mentionPattern)) + if (!matches.length) { + return html + } + + let output = html + + for (const match of matches) { + const full = match[0] + const idMatch = /data-ticket-id="([^"]+)"/i.exec(full) + const ticketIdRaw = idMatch?.[1] + let replacement = "" + + if (ticketIdRaw) { + const ticket = await ctx.db.get(ticketIdRaw as Id<"tickets">) + if (ticket && ticket.tenantId === tenantId && canMentionTicket(viewer.role, viewer.user._id, ticket)) { + replacement = buildTicketMentionAnchor(ticket) + } else { + const inner = match[0].replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() + replacement = escapeHtml(inner || `#${ticketIdRaw}`) + } + } else { + replacement = escapeHtml(full.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()) + } + + output = output.replace(full, replacement) + } + + return output +} + function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = LEGACY_STATUS_MAP[status.toUpperCase()]; @@ -1170,11 +1264,6 @@ export const addComment = mutation({ } } - const bodyPlainLen = plainTextLength(args.body) - if (bodyPlainLen > MAX_COMMENT_CHARS) { - throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) - } - const authorSnapshot: CommentAuthorSnapshot = { name: author.name, email: author.email, @@ -1182,12 +1271,18 @@ export const addComment = mutation({ teams: author.teams ?? undefined, }; + const normalizedBody = await normalizeTicketMentions(ctx, args.body, { user: author, role: normalizedRole }, ticketDoc.tenantId) + const bodyPlainLen = plainTextLength(normalizedBody) + if (bodyPlainLen > MAX_COMMENT_CHARS) { + throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) + } + const now = Date.now(); const id = await ctx.db.insert("ticketComments", { ticketId: args.ticketId, authorId: args.authorId, visibility: requestedVisibility, - body: args.body, + body: normalizedBody, authorSnapshot, attachments, createdAt: now, @@ -1240,14 +1335,15 @@ export const updateComment = mutation({ await requireTicketStaff(ctx, actorId, ticketDoc) } - const bodyPlainLen = plainTextLength(body) + const normalizedBody = await normalizeTicketMentions(ctx, body, { user: actor, role: normalizedRole }, ticketDoc.tenantId) + const bodyPlainLen = plainTextLength(normalizedBody) if (bodyPlainLen > MAX_COMMENT_CHARS) { throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) } const now = Date.now(); await ctx.db.patch(commentId, { - body, + body: normalizedBody, updatedAt: now, }); @@ -1356,14 +1452,20 @@ export const updateStatus = mutation({ }); export const changeAssignee = mutation({ - args: { ticketId: v.id("tickets"), assigneeId: v.id("users"), actorId: v.id("users") }, - handler: async (ctx, { ticketId, assigneeId, actorId }) => { + args: { + ticketId: v.id("tickets"), + assigneeId: v.id("users"), + actorId: v.id("users"), + reason: v.string(), + }, + handler: async (ctx, { ticketId, assigneeId, actorId, reason }) => { const ticket = await ctx.db.get(ticketId) if (!ticket) { throw new ConvexError("Ticket não encontrado") } const ticketDoc = ticket as Doc<"tickets"> const viewer = await requireTicketStaff(ctx, actorId, ticketDoc) + const viewerUser = viewer.user const isAdmin = viewer.role === "ADMIN" const assignee = (await ctx.db.get(assigneeId)) as Doc<"users"> | null if (!assignee || assignee.tenantId !== ticketDoc.tenantId) { @@ -1381,6 +1483,26 @@ export const changeAssignee = mutation({ throw new ConvexError("Somente o responsável atual pode reatribuir este chamado") } + const normalizedReason = reason.replace(/\r\n/g, "\n").trim() + if (normalizedReason.length < 5) { + throw new ConvexError("Informe um motivo para registrar a troca de responsável") + } + if (normalizedReason.length > 1000) { + throw new ConvexError("Motivo muito longo (máx. 1000 caracteres)") + } + const previousAssigneeName = + ((ticketDoc.assigneeSnapshot as { name?: string } | null)?.name as string | undefined) ?? + "Não atribuído" + const nextAssigneeName = assignee.name ?? assignee.email ?? "Responsável" + const commentBody = buildAssigneeChangeComment(normalizedReason, { + previousName: previousAssigneeName, + nextName: nextAssigneeName, + }) + const commentPlainLength = plainTextLength(commentBody) + if (commentPlainLength > MAX_COMMENT_CHARS) { + throw new ConvexError(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) + } + const now = Date.now(); const assigneeSnapshot = { name: assignee.name, @@ -1392,9 +1514,39 @@ export const changeAssignee = mutation({ await ctx.db.insert("ticketEvents", { ticketId, type: "ASSIGNEE_CHANGED", - payload: { assigneeId, assigneeName: assignee.name, actorId }, + payload: { + assigneeId, + assigneeName: assignee.name, + actorId, + previousAssigneeId: currentAssigneeId, + previousAssigneeName, + reason: normalizedReason, + }, createdAt: now, }); + + const authorSnapshot: CommentAuthorSnapshot = { + name: viewerUser.name, + email: viewerUser.email, + avatarUrl: viewerUser.avatarUrl ?? undefined, + teams: viewerUser.teams ?? undefined, + } + await ctx.db.insert("ticketComments", { + ticketId, + authorId: actorId, + visibility: "INTERNAL", + body: commentBody, + authorSnapshot, + attachments: [], + createdAt: now, + updatedAt: now, + }) + await ctx.db.insert("ticketEvents", { + ticketId, + type: "COMMENT_ADDED", + payload: { authorId: actorId, authorName: viewerUser.name, authorAvatar: viewerUser.avatarUrl }, + createdAt: now, + }) }, }); diff --git a/docs/propostas/melhoria-plataforma.md b/docs/propostas/melhoria-plataforma.md new file mode 100644 index 0000000..d0b88f1 --- /dev/null +++ b/docs/propostas/melhoria-plataforma.md @@ -0,0 +1,53 @@ +# Propostas de Evolução da Plataforma de Chamados + +## 1. Melhorias de Curto Prazo (baixo esforço) + +- **Filtro unificado em todas as listas** + Criar um componente padrão com busca, chips de filtros ativos, botão de limpar e layout responsivo. Isso remove duplicidade nas páginas de Tickets, Admin ▸ Usuários, Empresas e melhora coerência visual. + +- **Menções internas (@ticket e @usuário)** + Expandir o autocomplete recém-adicionado para permitir citar usuários internos relevantes (solicitantes ou responsáveis frequentes). Facilita handoffs e histórico contextual. + +- **Barra de ações em massa consistente** + Padronizar cabeçalhos de tabelas (seleção em massa, contador, botões como “Excluir”, “Exportar”) com feedback visual claro (toasts + badges). Aplica-se a Usuários, Convites, Empresas etc. + +- **Cards acionáveis no Dashboard** + Introduzir métricas com links para listas pré-filtradas (ex.: “6 tickets aguardando resposta”, “3 chamados com SLA a vencer hoje”) para navegação mais rápida. + +- **Templates de filtro salvos** + Permitir que agentes salvem combinações frequentes (ex.: “Meus tickets urgentes”) e exibí-las como botões rápidos acima da tabela. + +- **Uso do novo recurso de menção para relacionamentos** + Mostrar, no cabeçalho do ticket, os chamados relacionados via menção com contagem e navegação rápida. + +- **Portal do cliente com resumo inteligente** + Para solicitantes, exibir timeline simplificada e botão “Abrir chamado relacionado” reutilizando o autocomplete, mas limitado aos chamados próprios. + +## 2. Projetos de Médio Prazo + +- **Repaginação dos filtros avançados** + Adotar um layout split (filtros à esquerda, resultados à direita), com árvore de categorias, sliders de data e componentes consistentes. Incluir colapsáveis para telas pequenas. + +- **Automação simples no painel Admin** + Novo módulo “Automação” para regras básicas (ex.: “Se SLA crítico + sem responsável → notificar gestor e mover para fila X”). Iniciar com condições pré-definidas para reduzir esforço manual. + +- **Painel de chamados linkados** + No detalhe do ticket, exibir seção “Chamados relacionados” com preview, status e atalhos, alimentada automaticamente pelas menções internas. + +## 3. Iniciativas de Maior Impacto + +- **Modo de trabalho focado para agentes** + Modo “foco” com navegação reduzida, atalhos (“Próximo ticket da minha fila”), indicadores de tempo e layout em painel único para reduzir alternância de contexto. + +- **Integração com calendários externos** + Para tickets com datas de acompanhamento ou SLAs agendados, permitir agendar eventos no Google/Microsoft Calendar diretamente da plataforma. + +- **Construtor de relatórios personalizados** + Ferramenta drag-and-drop que reutiliza métricas já disponíveis, permitindo salvar e compartilhar dashboards internos (ex.: equipe, gestor). + +## Observações Gerais + +- As propostas priorizam reutilizar componentes já existentes (RichTextEditor, API de menções) e alinhar elementos de UI (filtros, tabelas, sidebars). +- As iniciativas estão ordenadas do mais simples ao mais complexo, facilitando entregas incrementais. +- Os itens de médio e longo prazo podem ser fatiados em MVPs para garantir feedback rápido dos usuários internos. + diff --git a/next.config.ts b/next.config.ts index 5944624..b132fec 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,3 +1,7 @@ -const nextConfig = {} +const nextConfig = { + experimental: { + turbopackFileSystemCacheForDev: true, + }, +} export default nextConfig diff --git a/package.json b/package.json index 0bf5490..7af0e28 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@tabler/icons-react": "^3.35.0", "@tanstack/react-table": "^8.21.3", "@tiptap/extension-link": "^3.6.5", + "@tiptap/extension-mention": "^3.6.5", "@tiptap/extension-placeholder": "^3.6.5", "@tiptap/react": "^3.6.5", "@tiptap/starter-kit": "^3.6.5", @@ -67,6 +68,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "three": "^0.180.0", + "tippy.js": "^6.3.7", "unicornstudio-react": "^1.4.31", "vaul": "^1.1.2", "zod": "^4.1.9" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df28ec2..01b65ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@tiptap/extension-link': specifier: ^3.6.5 version: 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5) + '@tiptap/extension-mention': + specifier: ^3.6.5 + version: 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)(@tiptap/suggestion@3.7.2(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)) '@tiptap/extension-placeholder': specifier: ^3.6.5 version: 3.6.5(@tiptap/extensions@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)) @@ -155,6 +158,9 @@ importers: three: specifier: ^0.180.0 version: 0.180.0 + tippy.js: + specifier: ^6.3.7 + version: 6.3.7 unicornstudio-react: specifier: ^1.4.31 version: 1.4.31(next@16.0.0(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) @@ -1143,6 +1149,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + '@prisma/client@6.16.3': resolution: {integrity: sha512-JfNfAtXG+/lIopsvoZlZiH2k5yNx87mcTS4t9/S5oufM1nKdXYxOvpDC1XoTCFBa5cQh7uXnbMPsmZrwZY80xw==} engines: {node: '>=18.18'} @@ -2247,6 +2256,13 @@ packages: '@tiptap/core': ^3.6.5 '@tiptap/pm': ^3.6.5 + '@tiptap/extension-mention@3.6.5': + resolution: {integrity: sha512-ACElkBvemEJGm8gVYI4QKjf6tfNj3m5dC9MkZL4rwZo4CAwjiNQ8oFhj1x7sPO1OVlnjt+FhnItBix5ztTF8Ng==} + peerDependencies: + '@tiptap/core': ^3.6.5 + '@tiptap/pm': ^3.6.5 + '@tiptap/suggestion': ^3.6.5 + '@tiptap/extension-ordered-list@3.6.5': resolution: {integrity: sha512-RiBl0Dkw8QtzS7OqUGm84BOyemw/N+hf8DYWsIqVysMRQAGBGhuklbw+DGpCL0nMHW4lh7WtvfKcb0yxLmhbbA==} peerDependencies: @@ -2299,6 +2315,12 @@ packages: '@tiptap/starter-kit@3.6.5': resolution: {integrity: sha512-LNAJQstB/VazmMlRbUyu3rCNVQ9af25Ywkn3Uyuwt3Ks9ZlliIm/x/zertdXTY2adoig+b36zT5Xcx1O4IdJ3A==} + '@tiptap/suggestion@3.7.2': + resolution: {integrity: sha512-CYmIMeLqeGBotl7+4TrnGux/ov9IJoWTUQN/JcHp0aOoN3z8c/dQ6cziXXknr51jGHSdVYMWEyamLDZfcaGC1w==} + peerDependencies: + '@tiptap/core': ^3.7.2 + '@tiptap/pm': ^3.7.2 + '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} @@ -4676,6 +4698,9 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tippy.js@6.3.7: + resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + tldts-core@7.0.17: resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} @@ -5828,6 +5853,8 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@popperjs/core@2.11.8': {} + '@prisma/client@6.16.3(prisma@6.16.3(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.16.3(typescript@5.9.3) @@ -6985,6 +7012,12 @@ snapshots: '@tiptap/core': 3.6.5(@tiptap/pm@3.6.5) '@tiptap/pm': 3.6.5 + '@tiptap/extension-mention@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)(@tiptap/suggestion@3.7.2(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))': + dependencies: + '@tiptap/core': 3.6.5(@tiptap/pm@3.6.5) + '@tiptap/pm': 3.6.5 + '@tiptap/suggestion': 3.7.2(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5) + '@tiptap/extension-ordered-list@3.6.5(@tiptap/extension-list@3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5))': dependencies: '@tiptap/extension-list': 3.6.5(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5) @@ -7079,6 +7112,11 @@ 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 + '@tiptap/suggestion@3.7.2(@tiptap/core@3.6.5(@tiptap/pm@3.6.5))(@tiptap/pm@3.6.5)': + dependencies: + '@tiptap/core': 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': @@ -9842,6 +9880,10 @@ snapshots: tinyrainbow@3.0.3: {} + tippy.js@6.3.7: + dependencies: + '@popperjs/core': 2.11.8 + tldts-core@7.0.17: {} tldts@7.0.17: diff --git a/src/app/api/tickets/mentions/route.ts b/src/app/api/tickets/mentions/route.ts new file mode 100644 index 0000000..3c3a973 --- /dev/null +++ b/src/app/api/tickets/mentions/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server" + +import { assertAuthenticatedSession } from "@/lib/auth-server" +import { DEFAULT_TENANT_ID } from "@/lib/constants" +import { prisma } from "@/lib/prisma" + +export const runtime = "nodejs" + +const MAX_RESULTS = 10 +const MAX_SCAN = 60 + +function normalizeRole(role?: string | null) { + return (role ?? "").toLowerCase() +} + +export async function GET(request: Request) { + const session = await assertAuthenticatedSession() + if (!session) { + return NextResponse.json({ items: [] }, { status: 401 }) + } + + const normalizedRole = normalizeRole(session.user.role) + const isAgentOrAdmin = normalizedRole === "admin" || normalizedRole === "agent" + const canLinkOwnTickets = normalizedRole === "collaborator" + + if (!isAgentOrAdmin && !canLinkOwnTickets) { + return NextResponse.json({ items: [] }, { status: 403 }) + } + + const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID + const url = new URL(request.url) + const rawQuery = url.searchParams.get("q") ?? "" + const query = rawQuery.trim() + + const whereBase: { + tenantId: string + requesterId?: string + } = { tenantId } + + if (!isAgentOrAdmin) { + whereBase.requesterId = session.user.id + } + + const numericQuery = /^\d+$/.test(query) + + const take = numericQuery ? MAX_RESULTS : MAX_SCAN + + const tickets = await prisma.ticket.findMany({ + where: whereBase, + include: { + assignee: { select: { name: true } }, + requester: { select: { name: true } }, + company: { select: { name: true } }, + }, + orderBy: { updatedAt: "desc" }, + take, + }) + + const lowered = query.toLowerCase() + + const filtered = tickets + .filter((ticket) => { + if (!query) return true + const referenceMatch = String(ticket.reference).includes(query) + if (referenceMatch) return true + const subject = ticket.subject ?? "" + if (subject.toLowerCase().includes(lowered)) return true + const requesterName = ticket.requester?.name ?? "" + if (requesterName.toLowerCase().includes(lowered)) return true + const companyName = ticket.company?.name ?? "" + if (companyName.toLowerCase().includes(lowered)) return true + return false + }) + .slice(0, MAX_RESULTS) + + const basePath = isAgentOrAdmin ? "/tickets" : "/portal/tickets" + + const items = filtered.map((ticket) => { + const subject = ticket.subject ?? "" + return { + id: ticket.id, + reference: ticket.reference, + subject, + status: ticket.status, + priority: ticket.priority, + requesterName: ticket.requester?.name ?? null, + assigneeName: ticket.assignee?.name ?? null, + companyName: ticket.company?.name ?? null, + url: `${basePath}/${ticket.id}`, + updatedAt: ticket.updatedAt.toISOString(), + } + }) + + return NextResponse.json({ items }) +} + diff --git a/src/app/globals.css b/src/app/globals.css index 6c472ad..76a6741 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -160,6 +160,34 @@ } .rich-text p { @apply my-2; } .rich-text a { @apply text-neutral-900 underline; } + .rich-text a[data-ticket-mention="true"], + .rich-text .ProseMirror a[data-ticket-mention="true"] { + @apply inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-100 px-2.5 py-1 text-xs font-semibold text-neutral-800 no-underline transition hover:bg-slate-200; + } + .rich-text a[data-ticket-mention="true"] .ticket-mention-dot { + @apply inline-flex size-2 rounded-full bg-slate-400; + } + .rich-text a[data-ticket-mention="true"] .ticket-mention-ref { + @apply text-neutral-900; + } + .rich-text a[data-ticket-mention="true"] .ticket-mention-sep { + @apply text-neutral-400; + } + .rich-text a[data-ticket-mention="true"] .ticket-mention-subject { + @apply max-w-[220px] truncate text-neutral-700; + } + .rich-text a[data-ticket-mention="true"][data-ticket-status="PENDING"] .ticket-mention-dot { + @apply bg-amber-400; + } + .rich-text a[data-ticket-mention="true"][data-ticket-status="AWAITING_ATTENDANCE"] .ticket-mention-dot { + @apply bg-sky-500; + } + .rich-text a[data-ticket-mention="true"][data-ticket-status="PAUSED"] .ticket-mention-dot { + @apply bg-violet-500; + } + .rich-text a[data-ticket-mention="true"][data-ticket-status="RESOLVED"] .ticket-mention-dot { + @apply bg-emerald-500; + } .rich-text ul { @apply my-2 list-disc ps-5; } .rich-text ol { @apply my-2 list-decimal ps-5; } .rich-text li { @apply my-1; } diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 4aba10d..e6b0a79 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -20,6 +20,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination" import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useQuery } from "convex/react" @@ -356,6 +364,74 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d }) }, [combinedBaseUsers, usersSearch, usersCompanyFilter]) + const [teamPageSize, setTeamPageSize] = useState(10) + const [teamPageIndex, setTeamPageIndex] = useState(0) + const teamTotal = filteredTeamUsers.length + const teamPageCount = Math.max(1, Math.ceil(teamTotal / teamPageSize)) + const teamPaginated = useMemo( + () => filteredTeamUsers.slice(teamPageIndex * teamPageSize, teamPageIndex * teamPageSize + teamPageSize), + [filteredTeamUsers, teamPageIndex, teamPageSize] + ) + const teamStart = teamTotal === 0 ? 0 : teamPageIndex * teamPageSize + 1 + const teamEnd = teamTotal === 0 ? 0 : Math.min(teamTotal, teamPageIndex * teamPageSize + teamPageSize) + + useEffect(() => { + setTeamPageIndex(0) + }, [teamSearch, teamRoleFilter, teamCompanyFilter]) + + useEffect(() => { + if (teamPageIndex > teamPageCount - 1) { + setTeamPageIndex(Math.max(0, teamPageCount - 1)) + } + }, [teamPageIndex, teamPageCount]) + + const [usersPageSize, setUsersPageSize] = useState(10) + const [usersPageIndex, setUsersPageIndex] = useState(0) + const usersTotal = filteredCombinedUsers.length + const usersPageCount = Math.max(1, Math.ceil(usersTotal / usersPageSize)) + const usersPaginated = useMemo( + () => + filteredCombinedUsers.slice( + usersPageIndex * usersPageSize, + usersPageIndex * usersPageSize + usersPageSize + ), + [filteredCombinedUsers, usersPageIndex, usersPageSize] + ) + const usersStart = usersTotal === 0 ? 0 : usersPageIndex * usersPageSize + 1 + const usersEnd = usersTotal === 0 ? 0 : Math.min(usersTotal, usersPageIndex * usersPageSize + usersPageSize) + + useEffect(() => { + setUsersPageIndex(0) + }, [usersSearch, usersTypeFilter, usersCompanyFilter]) + + useEffect(() => { + if (usersPageIndex > usersPageCount - 1) { + setUsersPageIndex(Math.max(0, usersPageCount - 1)) + } + }, [usersPageIndex, usersPageCount]) + + const [invitesPageSize, setInvitesPageSize] = useState(10) + const [invitesPageIndex, setInvitesPageIndex] = useState(0) + const invitesTotal = invites.length + const invitesPageCount = Math.max(1, Math.ceil(invitesTotal / invitesPageSize)) + const paginatedInvites = useMemo( + () => invites.slice(invitesPageIndex * invitesPageSize, invitesPageIndex * invitesPageSize + invitesPageSize), + [invites, invitesPageIndex, invitesPageSize] + ) + const invitesStart = invitesTotal === 0 ? 0 : invitesPageIndex * invitesPageSize + 1 + const invitesEnd = + invitesTotal === 0 ? 0 : Math.min(invitesTotal, invitesPageIndex * invitesPageSize + invitesPageSize) + + useEffect(() => { + setInvitesPageIndex(0) + }, [invitesTotal]) + + useEffect(() => { + if (invitesPageIndex > invitesPageCount - 1) { + setInvitesPageIndex(Math.max(0, invitesPageCount - 1)) + } + }, [invitesPageIndex, invitesPageCount]) + useEffect(() => { void (async () => { try { @@ -1008,104 +1084,168 @@ async function handleDeleteUser() { Equipe cadastrada Usuários com acesso ao sistema. Edite papéis, dados pessoais ou gere uma nova senha. - - - - - - - - - - - {/* Espaço removido */} - - - - - - {filteredTeamUsers.map((user) => ( - - - - - - - - {/* Espaço removido */} - - - - ))} - {filteredTeamUsers.length === 0 ? ( - - - - ) : null} - -
-
- toggleTeamSelectAll(!!value)} - aria-label="Selecionar todos" + +
+
+ + + + + + + + + + + + + + + {teamPaginated.length > 0 ? ( + teamPaginated.map((user) => ( + + + + + + + + + + + )) + ) : ( + + + + )} + +
+
+ toggleTeamSelectAll(!!value)} + aria-label="Selecionar todos" + /> +
+
NomeE-mailPapelEmpresaMáquinasCriado emAções
+
+ { + setTeamSelection((prev) => { + const next = new Set(prev) + if (checked) next.add(user.id) + else next.delete(user.id) + return next + }) + }} + aria-label="Selecionar linha" + /> +
+
{user.name || "—"}{user.email}{formatRole(user.role)}{user.companyName ?? "—"} + {(() => { + const list = machinesByUserEmail.get((user.email ?? "").toLowerCase()) ?? [] + if (list.length === 0) return "—" + const names = list.map((m) => m.hostname || m.id) + const head = names.slice(0, 2).join(", ") + const extra = names.length > 2 ? ` +${names.length - 2}` : "" + return ( + + {head} + {extra} + + ) + })()} + {formatDate(user.createdAt)} +
+ + +
+
+ {teamUsers.length === 0 + ? "Nenhum usuário cadastrado até o momento." + : "Nenhum usuário corresponde aos filtros atuais."} +
+
+
+
+
{teamTotal === 0 ? "Nenhum registro" : `Mostrando ${teamStart}-${teamEnd} de ${teamTotal}`}
+
+
+ Itens por página + +
+ + + + setTeamPageIndex((previous) => Math.max(0, previous - 1))} /> -
-
NomeE-mailPapelEmpresaMáquinasCriado emAções
-
- { - setTeamSelection((prev) => { - const next = new Set(prev) - if (checked) next.add(user.id) - else next.delete(user.id) - return next - }) - }} - aria-label="Selecionar linha" - /> -
-
{user.name || "—"}{user.email}{formatRole(user.role)}{user.companyName ?? "—"} - {(() => { - const list = machinesByUserEmail.get((user.email ?? '').toLowerCase()) ?? [] - if (list.length === 0) return '—' - const names = list.map((m) => m.hostname || m.id) - const head = names.slice(0, 2).join(', ') - const extra = names.length > 2 ? ` +${names.length - 2}` : '' - return {head}{extra} - })()} - {formatDate(user.createdAt)} -
- - -
-
- {teamUsers.length === 0 - ? "Nenhum usuário cadastrado até o momento." - : "Nenhum usuário corresponde aos filtros atuais."} -
+ + + { + event.preventDefault() + }} + > + {teamPageIndex + 1} + + + + = teamPageCount - 1} + onClick={() => + setTeamPageIndex((previous) => Math.min(teamPageCount - 1, previous + 1)) + } + /> + + + + +
@@ -1226,112 +1366,193 @@ async function handleDeleteUser() { Usuários Pessoas e máquinas com acesso ao sistema. - - - - - - - - - - - {/* Espaço removido */} - - - - - - {filteredCombinedUsers.map((user) => ( - - - - - - - - {/* Espaço removido */} - - - - ))} - {filteredCombinedUsers.length === 0 ? ( - - - - ) : null} - -
-
- 0 && usersSelection.size === filteredCombinedUsers.length || (usersSelection.size > 0 && usersSelection.size < filteredCombinedUsers.length && "indeterminate")} - onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)} - aria-label="Selecionar todos" + +
+
+ + + + + + + + + + + + + + + {usersPaginated.length > 0 ? ( + usersPaginated.map((user) => ( + + + + + + + + + + + )) + ) : ( + + + + )} + +
+
+ 0 && + usersSelection.size === filteredCombinedUsers.length + ? true + : usersSelection.size > 0 + ? "indeterminate" + : false + } + onCheckedChange={(value) => toggleUsersCombinedSelectAll(!!value)} + aria-label="Selecionar todos" + /> +
+
NomeE-mailTipoPerfilEmpresaCriado emAções
+
+ { + setUsersSelection((prev) => { + const next = new Set(prev) + if (checked) next.add(user.id) + else next.delete(user.id) + return next + }) + }} + aria-label="Selecionar linha" + /> +
+
+ {user.name || (user.role === "machine" ? "Máquina" : "—")} + {user.email} + {user.role === "machine" ? "Máquina" : "Pessoa"} + + {user.role === "machine" ? ( + user.machinePersona ? ( + + {formatMachinePersona(user.machinePersona)} + + ) : ( + Sem persona + ) + ) : ( + formatRole(user.role) + )} + {user.companyName ?? "—"}{formatDate(user.createdAt)} +
+ + {user.role === "machine" ? ( + + ) : null} + +
+
+ {combinedBaseUsers.length === 0 + ? "Nenhum usuário cadastrado até o momento." + : "Nenhum usuário corresponde aos filtros atuais."} +
+
+
+
+
{usersTotal === 0 ? "Nenhum registro" : `Mostrando ${usersStart}-${usersEnd} de ${usersTotal}`}
+
+
+ Itens por página + +
+ + + + setUsersPageIndex((previous) => Math.max(0, previous - 1))} /> -
-
NomeE-mailTipoPerfilEmpresaCriado emAções
-
- { - setUsersSelection((prev) => { - const next = new Set(prev) - if (checked) next.add(user.id) - else next.delete(user.id) - return next - }) - }} - aria-label="Selecionar linha" - /> -
-
{user.name || (user.role === "machine" ? "Máquina" : "—")}{user.email}{user.role === "machine" ? "Máquina" : "Pessoa"} - {user.role === "machine" ? ( - user.machinePersona ? ( - - {formatMachinePersona(user.machinePersona)} - - ) : ( - Sem persona - ) - ) : ( - formatRole(user.role) - )} - {user.companyName ?? "—"}{formatDate(user.createdAt)} -
- - {user.role === "machine" ? ( - - ) : null} - -
-
- {combinedBaseUsers.length === 0 - ? "Nenhum usuário cadastrado até o momento." - : "Nenhum usuário corresponde aos filtros atuais."} -
+ + + { + event.preventDefault() + }} + > + {usersPageIndex + 1} + + + + = usersPageCount - 1} + onClick={() => + setUsersPageIndex((previous) => Math.min(usersPageCount - 1, previous + 1)) + } + /> + + + + +
@@ -1422,113 +1643,172 @@ async function handleDeleteUser() { Convites emitidos Histórico e status atual de todos os convites enviados para o workspace. - - - - - - - - {/* Espaço removido */} - - - - - - - {invites.map((invite) => ( - - - - - {/* Espaço removido */} - - - - - ))} - {invites.length === 0 ? ( - - - - ) : null} - -
-
- toggleInvitesSelectAll(!!value)} - aria-label="Selecionar todos" - /> -
-
ColaboradorPapelExpira emStatusAções
-
- { - setInviteSelection((prev) => { - const next = new Set(prev) - if (checked) next.add(invite.id) - else next.delete(invite.id) - return next - }) - }} - aria-label="Selecionar linha" + +
+
+ + + + + + + + + + + + + {paginatedInvites.length > 0 ? ( + paginatedInvites.map((invite) => ( + + + + + + + + + )) + ) : ( + + + + )} + +
+
+ toggleInvitesSelectAll(!!value)} + aria-label="Selecionar todos" + /> +
+
ColaboradorPapelExpira emStatusAções
+
+ { + setInviteSelection((prev) => { + const next = new Set(prev) + if (checked) next.add(invite.id) + else next.delete(invite.id) + return next + }) + }} + aria-label="Selecionar linha" + /> +
+
+
+ {invite.name || invite.email} + {invite.email} +
+
{formatRole(invite.role)}{formatDate(invite.expiresAt)} + + {formatStatus(invite.status)} + + +
+ + {invite.status === "pending" && canManageInvite(invite.role) ? ( + + ) : null} + {invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? ( + + ) : null} +
+
+ Nenhum convite emitido até o momento. +
+
+
+
+
{invitesTotal === 0 ? "Nenhum registro" : `Mostrando ${invitesStart}-${invitesEnd} de ${invitesTotal}`}
+
+ +
+
+ Itens por página + +
+ + + + setInvitesPageIndex((previous) => Math.max(0, previous - 1))} /> -
-
-
- {invite.name || invite.email} - {invite.email} -
-
{formatRole(invite.role)}{formatDate(invite.expiresAt)} - - {formatStatus(invite.status)} - - -
- - {invite.status === "pending" && canManageInvite(invite.role) ? ( - - ) : null} - {invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? ( - - ) : null} -
-
- Nenhum convite emitido até o momento. -
-
- + + + { + event.preventDefault() + }} + > + {invitesPageIndex + 1} + + + + = invitesPageCount - 1} + onClick={() => + setInvitesPageIndex((previous) => Math.min(invitesPageCount - 1, previous + 1)) + } + /> + + + +
+
diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 6aa7001..cf738f9 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -758,7 +758,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi
- + Empresa Contratos ativos @@ -787,7 +787,7 @@ function TableView({ companies, machineCountsBySlug, onEdit, onDelete }: TableVi return ( -
+

{company.name}

{company.isAvulso ? Avulso : null} diff --git a/src/components/admin/users/admin-users-workspace.tsx b/src/components/admin/users/admin-users-workspace.tsx index a0b8e71..23a8353 100644 --- a/src/components/admin/users/admin-users-workspace.tsx +++ b/src/components/admin/users/admin-users-workspace.tsx @@ -91,6 +91,8 @@ const ROLE_LABEL: Record = { COLLABORATOR: "Colaborador", } +const NO_CONTACT_VALUE = "__none__" + function createId(prefix: string) { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return `${prefix}-${crypto.randomUUID()}` @@ -612,16 +614,16 @@ function CompanySectionSheet({ editor, onClose, onUpdated }: CompanySectionSheet return ( (!value ? onClose() : null)}> - - - + + + {editor?.section === "contacts" ? "Contatos da empresa" : null} {editor?.section === "locations" ? "Localizações e unidades" : null} {editor?.section === "contracts" ? "Contratos ativos" : null} -
{content}
- +
{content}
+
{isSubmitting ? ( Salvando... @@ -674,11 +676,11 @@ function ContactsEditor({ return ( -
-
-
-

Contatos estratégicos

-

+ +

+
+

Contatos estratégicos

+

Cadastre responsáveis por aprovação, faturamento e comunicação.

@@ -686,6 +688,7 @@ function ContactsEditor({ type="button" variant="outline" size="sm" + className="self-start sm:self-auto" onClick={() => fieldArray.append({ id: createId("contact"), @@ -703,18 +706,18 @@ function ContactsEditor({ }) } > - + Novo contato
-
+
{fieldArray.fields.map((field, index) => { const errors = form.formState.errors.contacts?.[index] return ( - - - Contato #{index + 1} + + + Contato #{index + 1} - -
- + +
+
-
- +
+
-
- +
+
-
- +
+
-
- +
+
-
- +
+
-
- +
+
-
+
form.setValue(`contacts.${index}.canAuthorizeTickets` as const, Boolean(checked)) } /> - Pode autorizar tickets + Pode autorizar tickets
-
+
form.setValue(`contacts.${index}.canApproveCosts` as const, Boolean(checked)) } /> - Pode aprovar custos + Pode aprovar custos
-
- +
+