From 5535ba81e6d9f30d0dc88576452df5cce6bfe26a Mon Sep 17 00:00:00 2001 From: codex-bot Date: Mon, 20 Oct 2025 14:57:22 -0300 Subject: [PATCH] feat: status + queue updates, filters e UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status renomeados e cores (Em andamento azul, Pausado amarelo) - Transições automáticas: iniciar=Em andamento, pausar=Pausado - Fila padrão: Chamados ao criar ticket - Admin/Empresas: renomeia ‘Slug’ → ‘Apelido’ + mensagens - Dashboard: últimos tickets priorizam sem responsável (mais antigos) - Tickets: filtro por responsável + salvar filtro por usuário - Encerrar ticket: adiciona botão ‘Cancelar’ - Strings atualizadas (PDF, relatórios, badges) --- convex/tickets.ts | 190 +++++++++++++++++- src/app/api/reports/backlog.csv/route.ts | 2 +- src/app/tickets/new/page.tsx | 7 + .../companies/admin-companies-manager.tsx | 6 +- src/components/portal/portal-ticket-card.tsx | 2 +- .../portal/portal-ticket-detail.tsx | 5 + src/components/portal/portal-ticket-form.tsx | 7 + src/components/reports/backlog-report.tsx | 2 +- src/components/tickets/new-ticket-dialog.tsx | 26 ++- .../tickets/recent-tickets-panel.tsx | 18 +- src/components/tickets/status-badge.tsx | 4 +- src/components/tickets/status-select.tsx | 78 +++---- .../tickets/ticket-comments.rich.tsx | 10 +- .../tickets/ticket-summary-header.tsx | 16 +- src/components/tickets/tickets-filters.tsx | 29 ++- src/components/tickets/tickets-table.tsx | 2 +- src/components/tickets/tickets-view.tsx | 78 ++++++- src/lib/ticket-timeline-labels.ts | 1 + src/server/pdf/ticket-pdf-template.tsx | 2 +- 19 files changed, 399 insertions(+), 86 deletions(-) diff --git a/convex/tickets.ts b/convex/tickets.ts index a6dba88..acc26b1 100644 --- a/convex/tickets.ts +++ b/convex/tickets.ts @@ -17,7 +17,7 @@ type TicketStatusNormalized = "PENDING" | "AWAITING_ATTENDANCE" | "PAUSED" | "RE const STATUS_LABELS: Record = { PENDING: "Pendente", - AWAITING_ATTENDANCE: "Aguardando atendimento", + AWAITING_ATTENDANCE: "Em andamento", PAUSED: "Pausado", RESOLVED: "Resolvido", }; @@ -36,6 +36,22 @@ const LEGACY_STATUS_MAP: Record = { const missingRequesterLogCache = new Set(); const missingCommentAuthorLogCache = new Set(); +// Character limits (generous but bounded) +const MAX_SUMMARY_CHARS = 600; +const MAX_COMMENT_CHARS = 20000; + +function plainTextLength(html: string): number { + try { + const text = String(html) + .replace(/<[^>]*>/g, "") // strip tags + .replace(/ /g, " ") + .trim(); + return text.length; + } catch { + return String(html ?? "").length; + } +} + function normalizeStatus(status: string | null | undefined): TicketStatusNormalized { if (!status) return "PENDING"; const normalized = LEGACY_STATUS_MAP[status.toUpperCase()]; @@ -419,6 +435,8 @@ export const list = query({ priority: v.optional(v.string()), channel: v.optional(v.string()), queueId: v.optional(v.id("queues")), + assigneeId: v.optional(v.id("users")), + requesterId: v.optional(v.id("users")), search: v.optional(v.string()), limit: v.optional(v.number()), }, @@ -434,10 +452,21 @@ export const list = query({ if (!user.companyId) { throw new ConvexError("Gestor não possui empresa vinculada") } + // Managers are scoped to company; allow secondary narrowing by requester/assignee base = await ctx.db .query("tickets") .withIndex("by_tenant_company", (q) => q.eq("tenantId", args.tenantId).eq("companyId", user.companyId!)) .collect(); + } else if (args.assigneeId) { + base = await ctx.db + .query("tickets") + .withIndex("by_tenant_assignee", (q) => q.eq("tenantId", args.tenantId).eq("assigneeId", args.assigneeId!)) + .collect(); + } else if (args.requesterId) { + base = await ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", args.tenantId).eq("requesterId", args.requesterId!)) + .collect(); } else if (args.queueId) { base = await ctx.db .query("tickets") @@ -445,11 +474,18 @@ export const list = query({ .collect(); } else if (role === "COLLABORATOR") { // Colaborador: exibir apenas tickets onde ele é o solicitante + // Compatibilidade por e-mail: inclui tickets com requesterSnapshot.email == e-mail do viewer const all = await ctx.db .query("tickets") .withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) .collect() - base = all.filter((t) => t.requesterId === args.viewerId) + const viewerEmail = user.email.trim().toLowerCase() + base = all.filter((t) => { + if (t.requesterId === args.viewerId) return true + const rs = t.requesterSnapshot as { email?: string } | undefined + const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null + return Boolean(email && email === viewerEmail) + }) } else { base = await ctx.db .query("tickets") @@ -468,6 +504,8 @@ export const list = query({ if (args.priority) filtered = filtered.filter((t) => t.priority === args.priority); if (args.channel) filtered = filtered.filter((t) => t.channel === args.channel); + if (args.assigneeId) filtered = filtered.filter((t) => String(t.assigneeId ?? "") === String(args.assigneeId)); + if (args.requesterId) filtered = filtered.filter((t) => String(t.requesterId) === String(args.requesterId)); if (normalizedStatusFilter) { filtered = filtered.filter((t) => normalizeStatus(t.status) === normalizedStatusFilter); } @@ -585,6 +623,15 @@ export const getById = query({ const { user, role } = await requireUser(ctx, viewerId, tenantId) const t = await ctx.db.get(id); if (!t || t.tenantId !== tenantId) return null; + if (role === "COLLABORATOR") { + const isOwnerById = String(t.requesterId) === String(viewerId) + const snapshotEmail = (t.requesterSnapshot as { email?: string } | undefined)?.email?.trim().toLowerCase?.() ?? null + const viewerEmail = user.email.trim().toLowerCase() + const isOwnerByEmail = Boolean(snapshotEmail && snapshotEmail === viewerEmail) + if (!isOwnerById && !isOwnerByEmail) { + return null + } + } // no customer role; managers are constrained to company via ensureManagerTicketAccess let requester: Doc<"users"> | null = null if (role === "MANAGER") { @@ -841,6 +888,9 @@ export const create = mutation({ if (subject.length < 3) { throw new ConvexError("Informe um assunto com pelo menos 3 caracteres"); } + if (args.summary && args.summary.trim().length > MAX_SUMMARY_CHARS) { + throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`); + } const category = await ctx.db.get(args.categoryId); if (!category || category.tenantId !== args.tenantId) { throw new ConvexError("Categoria inválida"); @@ -893,6 +943,19 @@ export const create = mutation({ } : undefined + // default queue: if none provided, prefer "Chamados" + let resolvedQueueId = args.queueId as Id<"queues"> | undefined + if (!resolvedQueueId) { + const queues = await ctx.db + .query("queues") + .withIndex("by_tenant", (q) => q.eq("tenantId", args.tenantId)) + .collect() + const preferred = queues.find((q) => q.slug === "chamados") || queues.find((q) => q.name === "Chamados") || null + if (preferred) { + resolvedQueueId = preferred._id as Id<"queues"> + } + } + const id = await ctx.db.insert("tickets", { tenantId: args.tenantId, reference: nextRef, @@ -901,7 +964,7 @@ export const create = mutation({ status: initialStatus, priority: args.priority, channel: args.channel, - queueId: args.queueId, + queueId: resolvedQueueId, categoryId: args.categoryId, subcategoryId: args.subcategoryId, requesterId: args.requesterId, @@ -1019,6 +1082,11 @@ 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, @@ -1084,6 +1152,11 @@ export const updateComment = mutation({ await requireTicketStaff(ctx, actorId, ticketDoc) } + const bodyPlainLen = plainTextLength(body) + 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, @@ -1453,6 +1526,7 @@ export const startWork = mutation({ await ctx.db.patch(ticketId, { working: true, activeSessionId: sessionId, + status: "AWAITING_ATTENDANCE", updatedAt: now, }) @@ -1532,6 +1606,7 @@ export const pauseWork = mutation({ await ctx.db.patch(ticketId, { working: false, activeSessionId: undefined, + status: "PAUSED", totalWorkedMs: (ticket.totalWorkedMs ?? 0) + durationMs, internalWorkedMs: (ticket.internalWorkedMs ?? 0) + deltaInternal, externalWorkedMs: (ticket.externalWorkedMs ?? 0) + deltaExternal, @@ -1599,6 +1674,9 @@ export const updateSummary = mutation({ throw new ConvexError("Ticket não encontrado") } await requireStaff(ctx, actorId, t.tenantId) + if (summary && summary.trim().length > MAX_SUMMARY_CHARS) { + throw new ConvexError(`Resumo muito longo (máx. ${MAX_SUMMARY_CHARS} caracteres)`) + } await ctx.db.patch(ticketId, { summary, updatedAt: now }); const actor = await ctx.db.get(actorId); await ctx.db.insert("ticketEvents", { @@ -1743,3 +1821,109 @@ export const remove = mutation({ return true; }, }); + +export const reassignTicketsByEmail = mutation({ + args: { + tenantId: v.string(), + actorId: v.id("users"), + fromEmail: v.string(), + toUserId: v.id("users"), + dryRun: v.optional(v.boolean()), + limit: v.optional(v.number()), + updateSnapshot: v.optional(v.boolean()), + }, + handler: async (ctx, { tenantId, actorId, fromEmail, toUserId, dryRun, limit, updateSnapshot }) => { + await requireAdmin(ctx, actorId, tenantId) + + const normalizedFrom = fromEmail.trim().toLowerCase() + if (!normalizedFrom || !normalizedFrom.includes("@")) { + throw new ConvexError("E-mail de origem inválido") + } + + const toUser = await ctx.db.get(toUserId) + if (!toUser || toUser.tenantId !== tenantId) { + throw new ConvexError("Usuário de destino inválido para o tenant") + } + + // Coletar tickets por requesterId (quando possível via usuário antigo) + const fromUser = await ctx.db + .query("users") + .withIndex("by_tenant_email", (q) => q.eq("tenantId", tenantId).eq("email", normalizedFrom)) + .first() + + const byRequesterId: Doc<"tickets">[] = fromUser + ? await ctx.db + .query("tickets") + .withIndex("by_tenant_requester", (q) => q.eq("tenantId", tenantId).eq("requesterId", fromUser._id)) + .collect() + : [] + + // Coletar tickets por e-mail no snapshot para cobrir casos sem user antigo + const allTenant = await ctx.db + .query("tickets") + .withIndex("by_tenant", (q) => q.eq("tenantId", tenantId)) + .collect() + + const bySnapshotEmail = allTenant.filter((t) => { + const rs = t.requesterSnapshot as { email?: string } | undefined + const email = typeof rs?.email === "string" ? rs.email.trim().toLowerCase() : null + if (!email || email !== normalizedFrom) return false + // Evita duplicar os já coletados por requesterId + if (fromUser && t.requesterId === fromUser._id) return false + return true + }) + + const candidatesMap = new Map>() + for (const t of byRequesterId) candidatesMap.set(String(t._id), t) + for (const t of bySnapshotEmail) candidatesMap.set(String(t._id), t) + const candidates = Array.from(candidatesMap.values()) + + const maxToProcess = Math.max(0, Math.min(limit && limit > 0 ? limit : candidates.length, candidates.length)) + const toProcess = candidates.slice(0, maxToProcess) + + if (dryRun) { + return { + dryRun: true as const, + fromEmail: normalizedFrom, + toUserId, + candidates: candidates.length, + willUpdate: toProcess.length, + } + } + + const now = Date.now() + let updated = 0 + for (const t of toProcess) { + const patch: Record = { requesterId: toUserId, updatedAt: now } + if (updateSnapshot) { + patch.requesterSnapshot = { + name: toUser.name, + email: toUser.email, + avatarUrl: toUser.avatarUrl ?? undefined, + teams: toUser.teams ?? undefined, + } + } + await ctx.db.patch(t._id, patch) + await ctx.db.insert("ticketEvents", { + ticketId: t._id, + type: "REQUESTER_CHANGED", + payload: { + fromUserId: fromUser?._id ?? null, + fromEmail: normalizedFrom, + toUserId, + toUserName: toUser.name, + }, + createdAt: now, + }) + updated += 1 + } + + return { + dryRun: false as const, + fromEmail: normalizedFrom, + toUserId, + candidates: candidates.length, + updated, + } + }, +}) diff --git a/src/app/api/reports/backlog.csv/route.ts b/src/app/api/reports/backlog.csv/route.ts index 2fba522..8b35d23 100644 --- a/src/app/api/reports/backlog.csv/route.ts +++ b/src/app/api/reports/backlog.csv/route.ts @@ -64,7 +64,7 @@ export async function GET(request: Request) { // Status const STATUS_PT: Record = { PENDING: "Pendentes", - AWAITING_ATTENDANCE: "Aguardando atendimento", + AWAITING_ATTENDANCE: "Em andamento", PAUSED: "Pausados", RESOLVED: "Resolvidos", } diff --git a/src/app/tickets/new/page.tsx b/src/app/tickets/new/page.tsx index 220e21b..3777c0e 100644 --- a/src/app/tickets/new/page.tsx +++ b/src/app/tickets/new/page.tsx @@ -72,6 +72,13 @@ export default function NewTicketPage() { setAssigneeInitialized(true) }, [assigneeInitialized, convexUserId]) + // Default queue to "Chamados" if available + useEffect(() => { + if (queueName) return + const hasChamados = queueOptions.includes("Chamados") + if (hasChamados) setQueueName("Chamados") + }, [queueOptions, queueName]) + async function submit(event: React.FormEvent) { event.preventDefault() if (!convexUserId || loading) return diff --git a/src/components/admin/companies/admin-companies-manager.tsx b/src/components/admin/companies/admin-companies-manager.tsx index 8a6fafd..3ed5b1c 100644 --- a/src/components/admin/companies/admin-companies-manager.tsx +++ b/src/components/admin/companies/admin-companies-manager.tsx @@ -228,7 +228,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: contractedHoursPerMonth: contractedHours, } if (!payload.name || !payload.slug) { - toast.error("Informe nome e slug válidos") + toast.error("Informe nome e apelido válidos") return } startTransition(async () => { @@ -388,7 +388,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: />
- + setSearchTerm(event.target.value)} - placeholder="Buscar por nome, slug ou domínio..." + placeholder="Buscar por nome, apelido ou domínio..." className="h-9 pl-9" />
diff --git a/src/components/portal/portal-ticket-card.tsx b/src/components/portal/portal-ticket-card.tsx index 9f86c10..5b0d5ae 100644 --- a/src/components/portal/portal-ticket-card.tsx +++ b/src/components/portal/portal-ticket-card.tsx @@ -14,7 +14,7 @@ import { cn } from "@/lib/utils" const statusLabel: Record = { PENDING: "Pendente", - AWAITING_ATTENDANCE: "Aguardando atendimento", + AWAITING_ATTENDANCE: "Em andamento", PAUSED: "Pausado", RESOLVED: "Resolvido", } diff --git a/src/components/portal/portal-ticket-detail.tsx b/src/components/portal/portal-ticket-detail.tsx index 5bbc7c7..54c5740 100644 --- a/src/components/portal/portal-ticket-detail.tsx +++ b/src/components/portal/portal-ticket-detail.tsx @@ -239,6 +239,11 @@ export function PortalTicketDetail({ ticketId }: PortalTicketDetailProps) { const plainText = typeof window !== "undefined" ? new DOMParser().parseFromString(sanitizedHtml, "text/html").body.textContent?.replace(/\u00a0/g, " ").trim() ?? "" : sanitizedHtml.replace(/<[^>]*>/g, "").replace(/ /g, " ").trim() + const MAX_COMMENT_CHARS = 20000 + if (plainText.length > MAX_COMMENT_CHARS) { + toast.error(`Comentário muito longo (máx. ${MAX_COMMENT_CHARS} caracteres)`) + return + } const hasMeaningfulText = plainText.length > 0 if (!hasMeaningfulText && attachments.length === 0) { toast.error("Adicione uma mensagem ou anexe ao menos um arquivo antes de enviar.") diff --git a/src/components/portal/portal-ticket-form.tsx b/src/components/portal/portal-ticket-form.tsx index f966e9d..d2254a6 100644 --- a/src/components/portal/portal-ticket-form.tsx +++ b/src/components/portal/portal-ticket-form.tsx @@ -102,6 +102,12 @@ export function PortalTicketForm() { }) if (plainDescription.length > 0) { + const MAX_COMMENT_CHARS = 20000 + if (plainDescription.length > MAX_COMMENT_CHARS) { + toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "portal-new-ticket" }) + setIsSubmitting(false) + return + } const htmlBody = sanitizedDescription || toHtml(trimmedSummary || trimmedSubject) const typedAttachments = attachments.map((file) => ({ @@ -178,6 +184,7 @@ export function PortalTicketForm() { value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="Descreva rapidamente o que está acontecendo" + maxLength={600} disabled={machineInactive || isSubmitting} /> diff --git a/src/components/reports/backlog-report.tsx b/src/components/reports/backlog-report.tsx index 1a88a14..daa8f19 100644 --- a/src/components/reports/backlog-report.tsx +++ b/src/components/reports/backlog-report.tsx @@ -24,7 +24,7 @@ const PRIORITY_LABELS: Record = { const STATUS_LABELS: Record = { PENDING: "Pendentes", - AWAITING_ATTENDANCE: "Aguardando atendimento", + AWAITING_ATTENDANCE: "Em andamento", PAUSED: "Pausados", RESOLVED: "Resolvidos", } diff --git a/src/components/tickets/new-ticket-dialog.tsx b/src/components/tickets/new-ticket-dialog.tsx index 45bcd12..436506a 100644 --- a/src/components/tickets/new-ticket-dialog.tsx +++ b/src/components/tickets/new-ticket-dialog.tsx @@ -104,6 +104,17 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin setAssigneeInitialized(true) }, [open, assigneeInitialized, convexUserId, form]) + // Default queue to "Chamados" if available when opening + useEffect(() => { + if (!open) return + const current = form.getValues("queueName") + if (current) return + const hasChamados = queues.some((q) => q.name === "Chamados") + if (hasChamados) { + form.setValue("queueName", "Chamados", { shouldDirty: false, shouldTouch: false }) + } + }, [open, queues, form]) + const handleCategoryChange = (value: string) => { const previous = form.getValues("categoryId") ?? "" const next = value ?? "" @@ -166,6 +177,13 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin }) const summaryFallback = values.summary?.trim() ?? "" const bodyHtml = plainDescription.length > 0 ? sanitizedDescription : summaryFallback + const MAX_COMMENT_CHARS = 20000 + const plainForLimit = (plainDescription.length > 0 ? plainDescription : summaryFallback).trim() + if (plainForLimit.length > MAX_COMMENT_CHARS) { + toast.error(`Descrição muito longa (máx. ${MAX_COMMENT_CHARS} caracteres)`, { id: "new-ticket" }) + setLoading(false) + return + } if (attachments.length > 0 || bodyHtml.trim().length > 0) { const typedAttachments = attachments.map((a) => ({ storageId: a.storageId as unknown as Id<"_storage">, @@ -254,9 +272,15 @@ export function NewTicketDialog({ triggerClassName }: { triggerClassName?: strin Resumo