feat: CSV exports, PDF improvements, play internal/external with hour split, roles cleanup, admin companies with 'Cliente avulso', ticket list spacing/alignment fixes, status translations and mappings

This commit is contained in:
Esdras Renan 2025-10-07 13:42:45 -03:00
parent addd4ce6e8
commit 3bafcc5a0a
45 changed files with 1401 additions and 256 deletions

View file

@ -0,0 +1,26 @@
import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { prisma } from "@/lib/prisma"
import { AdminCompaniesManager } from "@/components/admin/companies/admin-companies-manager"
export const runtime = "nodejs"
export const dynamic = "force-dynamic"
export default async function AdminCompaniesPage() {
const companiesRaw = await prisma.company.findMany({ orderBy: { name: "asc" } })
const companies = companiesRaw.map((c: any) => ({ ...c, isAvulso: Boolean(c.isAvulso ?? false) }))
return (
<AppShell
header={
<SiteHeader
title="Empresas & Clientes"
lead="Gerencie os dados das empresas e controle o faturamento de clientes avulsos."
/>
}
>
<div className="mx-auto w-full max-w-6xl px-6 lg:px-8">
<AdminCompaniesManager initialCompanies={companies} />
</div>
</AppShell>
)
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { assertAdminSession } from "@/lib/auth-server"
export const runtime = "nodejs"
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const { id } = await params
const body = await request.json()
const updates: Record<string, any> = {}
for (const key of ["name", "slug", "cnpj", "domain", "phone", "description", "address"]) {
if (key in body) updates[key] = body[key] ?? null
}
if ("isAvulso" in body) updates.isAvulso = Boolean(body.isAvulso)
try {
const company = await prisma.company.update({ where: { id }, data: updates as any })
return NextResponse.json({ company })
} catch (error) {
console.error("Failed to update company", error)
return NextResponse.json({ error: "Falha ao atualizar empresa" }, { status: 500 })
}
}

View file

@ -0,0 +1,47 @@
import { NextResponse } from "next/server"
import { prisma } from "@/lib/prisma"
import { assertAdminSession } from "@/lib/auth-server"
export const runtime = "nodejs"
export async function GET() {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const companies = await prisma.company.findMany({
orderBy: { name: "asc" },
})
return NextResponse.json({ companies })
}
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const body = await request.json()
const { name, slug, isAvulso, cnpj, domain, phone, description, address } = body ?? {}
if (!name || !slug) {
return NextResponse.json({ error: "Nome e slug são obrigatórios" }, { status: 400 })
}
try {
const company = await prisma.company.create({
data: ({
tenantId: session.user.tenantId ?? "tenant-atlas",
name: String(name),
slug: String(slug),
isAvulso: Boolean(isAvulso ?? false),
cnpj: cnpj ? String(cnpj) : null,
domain: domain ? String(domain) : null,
phone: phone ? String(phone) : null,
description: description ? String(description) : null,
address: address ? String(address) : null,
} as any),
})
return NextResponse.json({ company })
} catch (error) {
console.error("Failed to create company", error)
return NextResponse.json({ error: "Falha ao criar empresa" }, { status: 500 })
}
}

View file

@ -0,0 +1,37 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { assertAdminSession } from "@/lib/auth-server"
import { api } from "@/convex/_generated/api"
export const runtime = "nodejs"
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const body = await request.json().catch(() => null) as { email?: string; companyId?: string }
const email = body?.email?.trim().toLowerCase()
const companyId = body?.companyId
if (!email || !companyId) {
return NextResponse.json({ error: "Informe e-mail e empresa" }, { status: 400 })
}
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
const client = new ConvexHttpClient(convexUrl)
try {
await client.mutation(api.users.assignCompany, {
tenantId: session.user.tenantId ?? "tenant-atlas",
email,
companyId: companyId as any,
actorId: (session.user as any).convexUserId ?? (session.user.id as any),
})
return NextResponse.json({ ok: true })
} catch (error) {
console.error("Failed to assign company", error)
return NextResponse.json({ error: "Falha ao vincular usuário" }, { status: 500 })
}
}

View file

@ -0,0 +1,113 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
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 backlog CSV", 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 })
}
try {
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
const report = await client.query(api.reports.backlogOverview, {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "Backlog"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"])
rows.push([])
rows.push(["Seção", "Chave", "Valor"]) // header
// Status
const STATUS_PT: Record<string, string> = {
PENDING: "Pendentes",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausados",
RESOLVED: "Resolvidos",
}
for (const [status, total] of Object.entries(report.statusCounts)) {
rows.push(["Status", STATUS_PT[status] ?? status, total])
}
// Prioridade
const PRIORITY_PT: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Crítica",
}
for (const [priority, total] of Object.entries(report.priorityCounts)) {
rows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total])
}
// Filas
for (const q of report.queueCounts) {
rows.push(["Fila", q.name || q.id, q.total])
}
rows.push(["Abertos", "Total", report.totalOpen])
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="backlog-${tenantId}-${report.rangeDays ?? 'all'}d.csv"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate backlog CSV", error)
return NextResponse.json({ error: "Falha ao gerar CSV do backlog" }, { status: 500 })
}
}

View file

@ -0,0 +1,99 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
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 { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
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 CSAT CSV", 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 })
}
try {
const report = await client.query(api.reports.csatOverview, {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "CSAT"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
rows.push([])
rows.push(["Métrica", "Valor"]) // header
rows.push(["CSAT médio", report.averageScore ?? "—"])
rows.push(["Total de respostas", report.totalSurveys ?? 0])
rows.push([])
rows.push(["Distribuição", "Total"])
for (const entry of report.distribution ?? []) {
rows.push([`Nota ${entry.score}`, entry.total])
}
rows.push([])
rows.push(["Recentes", "Nota", "Recebido em"])
for (const item of report.recent ?? []) {
const date = new Date(item.receivedAt).toISOString()
rows.push([`#${item.reference}`, item.score, date])
}
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="csat-${tenantId}-${report.rangeDays ?? '90'}d.csv"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate CSAT CSV", error)
return NextResponse.json({ error: "Falha ao gerar CSV de CSAT" }, { status: 500 })
}
}

View file

@ -0,0 +1,101 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
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 { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
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 SLA CSV", 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 })
}
try {
const report = await client.query(api.reports.slaOverview, {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "SLA e produtividade"])
rows.push(["Período", range ?? "—"])
rows.push([])
rows.push(["Métrica", "Valor"]) // header
rows.push(["Tickets totais", report.totals.total])
rows.push(["Tickets abertos", report.totals.open])
rows.push(["Tickets resolvidos", report.totals.resolved])
rows.push(["Atrasados (SLA)", report.totals.overdue])
rows.push([])
rows.push(["Tempo médio de 1ª resposta (min)", report.response.averageFirstResponseMinutes ?? "—"])
rows.push(["Respostas registradas", report.response.responsesRegistered ?? 0])
rows.push(["Tempo médio de resolução (min)", report.resolution.averageResolutionMinutes ?? "—"])
rows.push(["Tickets resolvidos (amostra)", report.resolution.resolvedCount ?? 0])
rows.push([])
rows.push(["Fila", "Abertos"])
for (const q of report.queueBreakdown ?? []) {
rows.push([q.name || q.id, q.open])
}
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="sla-${tenantId}.csv"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate SLA CSV", error)
return NextResponse.json({ error: "Falha ao gerar CSV de SLA" }, { status: 500 })
}
}

View file

@ -0,0 +1,102 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { env } from "@/lib/env"
import { assertAuthenticatedSession } from "@/lib/auth-server"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
export const runtime = "nodejs"
function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
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 { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined // "7d" | "30d" | undefined(=90d)
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 channel CSV", 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 })
}
try {
const report = await client.query(api.reports.ticketsByChannel, {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
})
const channels = report.channels
const CHANNEL_PT: Record<string, string> = {
EMAIL: "E-mail",
PHONE: "Telefone",
CHAT: "Chat",
WHATSAPP: "WhatsApp",
API: "API",
MANUAL: "Manual",
WEB: "Portal",
PORTAL: "Portal",
}
const header = ["Data", ...channels.map((ch) => CHANNEL_PT[ch] ?? ch)]
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "Tickets por canal"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
rows.push([])
rows.push(header)
for (const point of report.points) {
const values = channels.map((ch) => point.values[ch] ?? 0)
rows.push([point.date, ...values])
}
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}.csv"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate tickets-by-channel CSV", error)
return NextResponse.json({ error: "Falha ao gerar CSV de tickets por canal" }, { status: 500 })
}
}

View file

@ -1,5 +1,9 @@
import { NextResponse } from "next/server"
import PDFDocument from "pdfkit"
// Use the standalone build to avoid AFM filesystem lookups
// and ensure compatibility in serverless/traced environments.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore no ambient types for this path; declared in types/
import PDFDocument from "pdfkit/js/pdfkit.standalone.js"
import { format } from "date-fns"
import { ptBR } from "date-fns/locale"
import { ConvexHttpClient } from "convex/browser"
@ -7,16 +11,44 @@ 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 { assertAuthenticatedSession } from "@/lib/auth-server"
import { mapTicketWithDetailsFromServer } from "@/lib/mappers/ticket"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
// Force Node.js runtime for pdfkit compatibility
export const runtime = "nodejs"
const statusLabel: Record<string, string> = {
PENDING: "Pendente",
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusColors: Record<string, string> = {
PENDING: "#64748B", // slate-500
AWAITING_ATTENDANCE: "#0EA5E9", // sky-500
PAUSED: "#F59E0B", // amber-500
RESOLVED: "#10B981", // emerald-500
}
const priorityLabel: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
HIGH: "Alta",
URGENT: "Urgente",
CRITICAL: "Crítica",
}
const channelLabel: Record<string, string> = {
EMAIL: "E-mail",
PHONE: "Telefone",
CHAT: "Chat",
PORTAL: "Portal",
WEB: "Portal",
API: "API",
SOCIAL: "Redes sociais",
OTHER: "Outro",
}
const timelineLabel: Record<string, string> = {
@ -31,6 +63,12 @@ const timelineLabel: Record<string, string> = {
WORK_STARTED: "Atendimento iniciado",
WORK_PAUSED: "Atendimento pausado",
CATEGORY_CHANGED: "Categoria alterada",
MANAGER_NOTIFIED: "Gestor notificado",
SUBJECT_CHANGED: "Assunto atualizado",
SUMMARY_CHANGED: "Resumo atualizado",
VISIT_SCHEDULED: "Visita agendada",
CSAT_RECEIVED: "CSAT recebido",
CSAT_RATED: "CSAT avaliado",
}
function formatDateTime(date: Date | null | undefined) {
@ -57,9 +95,104 @@ function decodeHtmlEntities(input: string) {
.replace(/&nbsp;/g, " ")
}
function stringifyPayload(payload: unknown): string | null {
if (!payload) return null
if (typeof payload === "object") {
if (Array.isArray(payload)) {
if (payload.length === 0) return null
} else if (payload) {
if (Object.keys(payload as Record<string, unknown>).length === 0) return null
}
}
if (typeof payload === "string" && payload.trim() === "") return null
try {
return JSON.stringify(payload, null, 2)
} catch {
return String(payload)
}
}
function formatDurationMs(ms: number | null | undefined) {
if (!ms || ms <= 0) return null
const totalSeconds = Math.floor(ms / 1000)
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`
return `${seconds}s`
}
function buildTimelineMessage(type: string, payload: any): string | null {
if (!payload || typeof payload !== "object") payload = {}
const to = payload.toLabel ?? payload.to
const assignee = payload.assigneeName ?? payload.assigneeId
const queue = payload.queueName ?? payload.queueId
const requester = payload.requesterName
const author = payload.authorName ?? payload.authorId
const actor = payload.actorName ?? payload.actorId
const attachmentName = payload.attachmentName
const subjectTo = payload.to
const pauseReason = payload.pauseReasonLabel ?? payload.pauseReason
const pauseNote = payload.pauseNote
const sessionDuration = formatDurationMs(payload.sessionDurationMs)
const categoryName = payload.categoryName
const subcategoryName = payload.subcategoryName
switch (type) {
case "STATUS_CHANGED":
return to ? `Status alterado para ${to}` : "Status alterado"
case "ASSIGNEE_CHANGED":
return assignee ? `Responsável alterado para ${assignee}` : "Responsável alterado"
case "QUEUE_CHANGED":
return queue ? `Fila alterada para ${queue}` : "Fila alterada"
case "PRIORITY_CHANGED":
return to ? `Prioridade alterada para ${to}` : "Prioridade alterada"
case "CREATED":
return requester ? `Criado por ${requester}` : "Criado"
case "COMMENT_ADDED":
return author ? `Comentário adicionado por ${author}` : "Comentário adicionado"
case "COMMENT_EDITED": {
const who = actor ?? author
return who ? `Comentário editado por ${who}` : "Comentário editado"
}
case "SUBJECT_CHANGED":
return subjectTo ? `Assunto alterado para "${subjectTo}"` : "Assunto alterado"
case "SUMMARY_CHANGED":
return "Resumo atualizado"
case "ATTACHMENT_REMOVED":
return attachmentName ? `Anexo removido: ${attachmentName}` : "Anexo removido"
case "WORK_PAUSED": {
const parts: string[] = []
if (pauseReason) parts.push(`Motivo: ${pauseReason}`)
if (sessionDuration) parts.push(`Tempo registrado: ${sessionDuration}`)
if (pauseNote) parts.push(`Observação: ${pauseNote}`)
return parts.length > 0 ? parts.join(" • ") : "Atendimento pausado"
}
case "WORK_STARTED":
return "Atendimento iniciado"
case "CATEGORY_CHANGED": {
if (categoryName || subcategoryName) {
return `Categoria alterada para ${categoryName ?? ""}${subcategoryName ? `${subcategoryName}` : ""}`.trim()
}
return "Categoria removida"
}
case "MANAGER_NOTIFIED":
return "Gestor notificado"
case "VISIT_SCHEDULED":
return "Visita agendada"
case "CSAT_RECEIVED":
return "CSAT recebido"
case "CSAT_RATED":
return "CSAT avaliado"
default:
return null
}
}
export async function GET(_request: Request, context: { params: Promise<{ id: string }> }) {
const { id: ticketId } = await context.params
const session = await assertStaffSession()
const session = await assertAuthenticatedSession()
if (!session) {
return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
}
@ -111,10 +244,10 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
}
const ticket = mapTicketWithDetailsFromServer(ticketRaw)
const doc = new PDFDocument({ size: "A4", margin: 48 })
const doc = new PDFDocument({ size: "A4", margin: 56 })
const chunks: Buffer[] = []
doc.on("data", (chunk) => {
doc.on("data", (chunk: any) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
})
@ -123,24 +256,58 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc.on("error", reject)
})
// Título
doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference}${ticket.subject}`)
doc.moveDown(0.5)
doc.moveDown(0.25)
// Linha abaixo do título
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
// Badge de status
doc.moveDown(0.5)
const statusText = statusLabel[ticket.status] ?? ticket.status
const badgeColor = statusColors[ticket.status] ?? "#475569"
const badgeFontSize = 10
const badgePaddingX = 6
const badgePaddingY = 3
const badgeX = doc.page.margins.left
const badgeY = doc.y
doc.save()
doc.font("Helvetica-Bold").fontSize(badgeFontSize)
const badgeTextWidth = doc.widthOfString(statusText)
const badgeHeight = badgeFontSize + badgePaddingY * 2
const badgeWidth = badgeTextWidth + badgePaddingX * 2
;(doc as any).roundedRect?.(badgeX, badgeY, badgeWidth, badgeHeight, 4) ?? doc.rect(badgeX, badgeY, badgeWidth, badgeHeight)
doc.fill(badgeColor)
doc.fillColor("#FFFFFF").text(statusText, badgeX + badgePaddingX, badgeY + badgePaddingY)
doc.restore()
doc.y = badgeY + badgeHeight + 8
// Metadados básicos
doc
.fillColor("#0F172A")
.font("Helvetica")
.fontSize(11)
.text(`Status: ${statusLabel[ticket.status] ?? ticket.status}`)
.text(`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, { lineGap: 2 })
.moveDown(0.15)
.text(`Prioridade: ${ticket.priority}`)
.text(`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, { lineGap: 2 })
.moveDown(0.15)
.text(`Canal: ${ticket.channel}`)
.moveDown(0.15)
.text(`Fila: ${ticket.queue ?? "—"}`)
.text(`Fila: ${ticket.queue ?? "—"}`, { lineGap: 2 })
doc.moveDown(0.75)
doc
.font("Helvetica-Bold")
.fontSize(12)
.text("Solicitante")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc.moveDown(0.3)
doc
.font("Helvetica")
.fontSize(11)
@ -148,6 +315,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc.moveDown(0.5)
doc.font("Helvetica-Bold").fontSize(12).text("Responsável")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc.moveDown(0.3)
doc
.font("Helvetica")
.fontSize(11)
@ -155,6 +328,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Datas")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc.moveDown(0.3)
doc
.font("Helvetica")
.fontSize(11)
@ -167,25 +346,35 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
if (ticket.summary) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Resumo")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc
.font("Helvetica")
.fontSize(11)
.text(ticket.summary, { align: "justify" })
.text(ticket.summary, { align: "justify", lineGap: 2 })
}
if (ticket.description) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Descrição")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc
.font("Helvetica")
.fontSize(11)
.text(htmlToPlainText(ticket.description), { align: "justify" })
.text(htmlToPlainText(ticket.description), { align: "justify", lineGap: 2 })
}
if (ticket.comments.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Comentários")
doc.moveDown(0.5)
doc.moveDown(0.6)
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
commentsSorted.forEach((comment, index) => {
const visibility =
@ -193,15 +382,14 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc
.font("Helvetica-Bold")
.fontSize(11)
.text(
`${comment.author.name}${visibility}${formatDateTime(comment.createdAt)}`
)
.text(`${comment.author.name}${visibility}${formatDateTime(comment.createdAt)}`)
doc.moveDown(0.15)
const body = htmlToPlainText(comment.body)
if (body) {
doc
.font("Helvetica")
.fontSize(11)
.text(body, { align: "justify" })
.text(body, { align: "justify", lineGap: 2, indent: 6 })
}
if (comment.attachments.length > 0) {
doc.moveDown(0.25)
@ -210,17 +398,17 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc
.font("Helvetica")
.fontSize(10)
.text(`${attachment.name ?? attachment.id}`, { indent: 12 })
.text(`${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 })
})
}
if (index < commentsSorted.length - 1) {
doc.moveDown(0.75)
doc.moveDown(1)
doc
.strokeColor("#E2E8F0")
.moveTo(doc.x, doc.y)
.lineTo(doc.page.width - doc.page.margins.right, doc.y)
.stroke()
doc.moveDown(0.75)
doc.moveDown(0.9)
}
})
}
@ -228,7 +416,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
if (ticket.timeline.length > 0) {
doc.addPage()
doc.font("Helvetica-Bold").fontSize(14).text("Linha do tempo")
doc.moveDown(0.5)
doc.moveDown(0.6)
const timelineSorted = [...ticket.timeline].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
timelineSorted.forEach((event) => {
const label = timelineLabel[event.type] ?? event.type
@ -236,14 +424,24 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
.font("Helvetica-Bold")
.fontSize(11)
.text(`${label}${formatDateTime(event.createdAt)}`)
if (event.payload) {
const payloadText = JSON.stringify(event.payload, null, 2)
doc.moveDown(0.15)
const friendly = buildTimelineMessage(event.type, event.payload)
if (friendly) {
doc
.font("Helvetica")
.fontSize(10)
.text(payloadText, { indent: 12 })
.text(friendly, { indent: 16, lineGap: 1 })
} else {
const payloadText = stringifyPayload(event.payload)
if (payloadText) {
doc
.font("Helvetica")
.fontSize(10)
.text(payloadText, { indent: 16, lineGap: 1 })
}
}
doc.moveDown(0.5)
doc.moveDown(0.7)
})
}

View file

@ -11,6 +11,13 @@ export default function ReportsCsatPage() {
<SiteHeader
title="Relatório de CSAT"
lead="Visualize a satisfação dos clientes e identifique pontos de melhoria na entrega."
secondaryAction={
<SiteHeader.SecondaryButton asChild>
<a href="/api/reports/csat.csv" download>
Exportar CSV
</a>
</SiteHeader.SecondaryButton>
}
/>
}
>

View file

@ -11,6 +11,13 @@ export default function ReportsSlaPage() {
<SiteHeader
title="Relatório de SLA"
lead="Acompanhe tempos de resposta, resolução e balanço de filas em tempo real."
secondaryAction={
<SiteHeader.SecondaryButton asChild>
<a href="/api/reports/sla.csv" download>
Exportar CSV
</a>
</SiteHeader.SecondaryButton>
}
/>
}
>

View file

@ -95,9 +95,26 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [lastInviteLink, setLastInviteLink] = useState<string | null>(null)
const [revokingId, setRevokingId] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const [companies, setCompanies] = useState<Array<{ id: string; name: string }>>([])
const [linkEmail, setLinkEmail] = useState("")
const [linkCompanyId, setLinkCompanyId] = useState("")
const normalizedRoles = useMemo(() => roleOptions ?? ROLE_OPTIONS, [roleOptions])
// load companies for association
useMemo(() => {
void (async () => {
try {
const r = await fetch("/api/admin/companies", { credentials: "include" })
const j = await r.json()
const items = (j.companies ?? []).map((c: any) => ({ id: c.id as string, name: c.name as string }))
setCompanies(items)
} catch {
// noop
}
})()
}, [])
async function handleInviteSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!email || !email.includes("@")) {
@ -238,9 +255,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
<SelectContent>
{normalizedRoles.map((item) => (
<SelectItem key={item} value={item}>
{item === "customer"
? "Cliente"
: item === "admin"
{item === "admin"
? "Administrador"
: item === "manager"
? "Gestor"
@ -294,6 +309,63 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vincular usuário a empresa</CardTitle>
<CardDescription>Associe um colaborador à sua empresa (usado para escopo de gestores e relatórios).</CardDescription>
</CardHeader>
<CardContent>
<form
className="grid grid-cols-1 gap-4 md:grid-cols-[1fr_1fr_auto]"
onSubmit={(e) => {
e.preventDefault()
if (!linkEmail || !linkCompanyId) {
toast.error("Informe e-mail e empresa")
return
}
startTransition(async () => {
toast.loading("Vinculando...", { id: "assign-company" })
try {
const r = await fetch("/api/admin/users/assign-company", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: linkEmail, companyId: linkCompanyId }),
credentials: "include",
})
if (!r.ok) throw new Error("failed")
toast.success("Usuário vinculado à empresa!", { id: "assign-company" })
} catch {
toast.error("Não foi possível vincular", { id: "assign-company" })
}
})
}}
>
<div className="grid gap-2">
<Label>E-mail do usuário</Label>
<Input value={linkEmail} onChange={(e) => setLinkEmail(e.target.value)} placeholder="colaborador@empresa.com" />
</div>
<div className="grid gap-2">
<Label>Empresa</Label>
<Select value={linkCompanyId} onValueChange={setLinkCompanyId}>
<SelectTrigger>
<SelectValue placeholder="Selecionar" />
</SelectTrigger>
<SelectContent>
{companies.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button type="submit" disabled={isPending}>Vincular</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Convites emitidos</CardTitle>

View file

@ -0,0 +1,213 @@
"use client"
import { useMemo, useState, useTransition } from "react"
import { toast } from "sonner"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Checkbox } from "@/components/ui/checkbox"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type Company = {
id: string
tenantId: string
name: string
slug: string
isAvulso: boolean
cnpj: string | null
domain: string | null
phone: string | null
description: string | null
address: string | null
}
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
const [companies, setCompanies] = useState<Company[]>(initialCompanies)
const [isPending, startTransition] = useTransition()
const [form, setForm] = useState<Partial<Company>>({})
const [editingId, setEditingId] = useState<string | null>(null)
const resetForm = () => setForm({})
async function refresh() {
const r = await fetch("/api/admin/companies", { credentials: "include" })
const json = (await r.json()) as { companies: Company[] }
setCompanies(json.companies)
}
function handleEdit(c: Company) {
setEditingId(c.id)
setForm({ ...c })
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const payload = {
name: form.name?.trim(),
slug: form.slug?.trim(),
isAvulso: Boolean(form.isAvulso ?? false),
cnpj: form.cnpj?.trim() || null,
domain: form.domain?.trim() || null,
phone: form.phone?.trim() || null,
description: form.description?.trim() || null,
address: form.address?.trim() || null,
}
if (!payload.name || !payload.slug) {
toast.error("Informe nome e slug válidos")
return
}
startTransition(async () => {
toast.loading(editingId ? "Atualizando empresa..." : "Criando empresa...", { id: "companies" })
try {
if (editingId) {
const r = await fetch(`/api/admin/companies/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
})
if (!r.ok) throw new Error("update_failed")
} else {
const r = await fetch(`/api/admin/companies`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "include",
})
if (!r.ok) throw new Error("create_failed")
}
await refresh()
resetForm()
setEditingId(null)
toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" })
} catch {
toast.error("Não foi possível salvar", { id: "companies" })
}
})
}
async function toggleAvulso(c: Company) {
startTransition(async () => {
try {
const r = await fetch(`/api/admin/companies/${c.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isAvulso: !c.isAvulso }),
credentials: "include",
})
if (!r.ok) throw new Error("toggle_failed")
await refresh()
} catch {
toast.error("Não foi possível atualizar o cliente avulso")
}
})
}
return (
<div className="space-y-6">
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Nova empresa</CardTitle>
<CardDescription>Cadastre um cliente/empresa e defina se é avulso.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Nome</Label>
<Input value={form.name ?? ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Slug</Label>
<Input value={form.slug ?? ""} onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>CNPJ</Label>
<Input value={form.cnpj ?? ""} onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Domínio</Label>
<Input value={form.domain ?? ""} onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))} />
</div>
<div className="grid gap-2">
<Label>Telefone</Label>
<Input value={form.phone ?? ""} onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} />
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Endereço</Label>
<Input value={form.address ?? ""} onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))} />
</div>
<div className="flex items-center gap-2 md:col-span-2">
<Checkbox
checked={Boolean(form.isAvulso ?? false)}
onCheckedChange={(v) => setForm((p) => ({ ...p, isAvulso: Boolean(v) }))}
id="is-avulso"
/>
<Label htmlFor="is-avulso">Cliente avulso?</Label>
</div>
<div className="md:col-span-2">
<Button type="submit" disabled={isPending}>{editingId ? "Salvar alterações" : "Cadastrar empresa"}</Button>
{editingId ? (
<Button type="button" variant="ghost" className="ml-2" onClick={() => { resetForm(); setEditingId(null) }}>
Cancelar
</Button>
) : null}
</div>
</form>
</CardContent>
</Card>
<Card className="border-slate-200">
<CardHeader>
<CardTitle>Empresas cadastradas</CardTitle>
<CardDescription>Gerencie empresas e o status de cliente avulso.</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Avulso</TableHead>
<TableHead>Domínio</TableHead>
<TableHead>Telefone</TableHead>
<TableHead>CNPJ</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{companies.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.name}</TableCell>
<TableCell>{c.slug}</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
{c.isAvulso ? "Sim" : "Não"}
</Button>
</TableCell>
<TableCell>{c.domain ?? "—"}</TableCell>
<TableCell>{c.phone ?? "—"}</TableCell>
<TableCell>{c.cnpj ?? "—"}</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => handleEdit(c)}>
Editar
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}

View file

@ -40,7 +40,7 @@ import { useAuth } from "@/lib/auth-client"
import type { LucideIcon } from "lucide-react"
type NavRoleRequirement = "staff" | "admin" | "customer"
type NavRoleRequirement = "staff" | "admin"
type NavigationItem = {
title: string
@ -91,6 +91,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
},
{ title: "Canais & roteamento", url: "/admin/channels", icon: Waypoints, requiredRole: "admin" },
{ title: "Times & papéis", url: "/admin/teams", icon: Users, requiredRole: "admin" },
{ title: "Empresas & clientes", url: "/admin/companies", icon: Users, requiredRole: "admin" },
{ title: "Campos personalizados", url: "/admin/fields", icon: Layers3, requiredRole: "admin" },
{ title: "SLAs", url: "/admin/slas", icon: Timer, requiredRole: "admin" },
],
@ -105,7 +106,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const pathname = usePathname()
const { session, isLoading, isAdmin, isStaff, isCustomer } = useAuth()
const { session, isLoading, isAdmin, isStaff } = useAuth()
const [isHydrated, setIsHydrated] = React.useState(false)
React.useEffect(() => {
@ -128,7 +129,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (!requiredRole) return true
if (requiredRole === "admin") return isAdmin
if (requiredRole === "staff") return isStaff
if (requiredRole === "customer") return isCustomer
return false
}

View file

@ -9,14 +9,15 @@ import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { useIsMobile } from "@/hooks/use-mobile"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import {
ChartConfig,
ChartContainer,
@ -111,19 +112,27 @@ export function ChartAreaInteractive() {
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>Entrada de tickets por canal</CardTitle>
<CardDescription>
<CardTitle>Entrada de tickets por canal</CardTitle>
<CardDescription>
<span className="hidden @[540px]/card:block">
Distribuição dos canais nos últimos {timeRange.replace("d", " dias")}
</span>
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
</CardDescription>
<CardAction>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
<CardAction>
<Button asChild size="sm" variant="outline">
<a
href={`/api/reports/tickets-by-channel.csv?range=${timeRange}`}
download
>
Exportar CSV
</a>
</Button>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>

View file

@ -23,7 +23,7 @@ const navItems = [
export function PortalShell({ children }: PortalShellProps) {
const pathname = usePathname()
const router = useRouter()
const { session, isCustomer } = useAuth()
const { session } = useAuth()
const [isSigningOut, setIsSigningOut] = useState(false)
const initials = useMemo(() => {
@ -107,11 +107,7 @@ export function PortalShell({ children }: PortalShellProps) {
</div>
</header>
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-6 px-6 py-8">
{!isCustomer ? (
<div className="rounded-2xl border border-dashed border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Este portal é voltado a clientes. Algumas ações podem não estar disponíveis para o seu perfil.
</div>
) : null}
{null}
{children}
</main>
<footer className="border-t border-slate-200 bg-white/70">

View file

@ -16,7 +16,6 @@ const statusLabel: Record<Ticket["status"], string> = {
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusTone: Record<Ticket["status"], string> = {
@ -24,7 +23,6 @@ const statusTone: Record<Ticket["status"], string> = {
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",
}
const priorityLabel: Record<Ticket["priority"], string> = {

View file

@ -26,7 +26,6 @@ const statusLabel: Record<TicketWithDetails["status"], string> = {
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const priorityLabel: Record<TicketWithDetails["priority"], string> = {

View file

@ -1,13 +1,15 @@
"use client"
import { useMemo } from "react"
import { useMemo, useState } from "react"
import { useQuery } from "convex/react"
import { IconInbox, IconAlertTriangle, IconFilter } from "@tabler/icons-react"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
@ -23,15 +25,15 @@ const STATUS_LABELS: Record<string, string> = {
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausados",
RESOLVED: "Resolvidos",
CLOSED: "Encerrados",
}
export function BacklogReport() {
const [timeRange, setTimeRange] = useState("90d")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.backlogOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip"
)
const mostCriticalPriority = useMemo(() => {
@ -99,6 +101,24 @@ export function BacklogReport() {
<CardDescription className="text-neutral-600">
Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento.
</CardDescription>
<CardAction>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/backlog.csv?range=${timeRange}`} download>
Exportar CSV
</a>
</Button>
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 md:flex"
>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
</CardAction>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">

View file

@ -9,7 +9,6 @@ const statusStyles: Record<TicketStatus, { label: string; className: string }> =
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" },
}
type TicketStatusBadgeProps = { status: TicketStatus }

View file

@ -14,14 +14,13 @@ import { ChevronDown } from "lucide-react"
type StatusKey = TicketStatus | "NEW" | "OPEN" | "ON_HOLD";
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED", "CLOSED"];
const STATUS_OPTIONS: TicketStatus[] = ["PENDING", "AWAITING_ATTENDANCE", "PAUSED", "RESOLVED"];
const statusStyles: Record<StatusKey, { label: string; badgeClass: string }> = {
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]" },

View file

@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"
import { format, formatDistanceToNow } from "date-fns"
import { ptBR } from "date-fns/locale"
import { IconClock, IconFileDownload, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { IconClock, IconFileTypePdf, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react"
import { useMutation, useQuery } from "convex/react"
import { toast } from "sonner"
import { api } from "@/convex/_generated/api"
@ -24,6 +24,12 @@ 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"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
interface TicketHeaderProps {
ticket: TicketWithDetails
@ -128,6 +134,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return selectedCategoryId !== currentCategoryId || selectedSubcategoryId !== currentSubcategoryId
}, [selectedCategoryId, selectedSubcategoryId, currentCategoryId, currentSubcategoryId])
const currentQueueName = ticket.queue ?? ""
const isAvulso = Boolean((ticket as any).company?.isAvulso ?? false)
const [queueSelection, setQueueSelection] = useState(currentQueueName)
const queueDirty = useMemo(() => queueSelection !== currentQueueName, [queueSelection, currentQueueName])
const formDirty = dirty || categoryDirty || queueDirty
@ -263,11 +270,14 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
return {
ticketId: ticket.id as Id<"tickets">,
totalWorkedMs: ticket.workSummary.totalWorkedMs,
internalWorkedMs: ticket.workSummary.internalWorkedMs ?? 0,
externalWorkedMs: ticket.workSummary.externalWorkedMs ?? 0,
activeSession: ticket.workSummary.activeSession
? {
id: ticket.workSummary.activeSession.id as Id<"ticketWorkSessions">,
agentId: ticket.workSummary.activeSession.agentId as Id<"users">,
startedAt: ticket.workSummary.activeSession.startedAt.getTime(),
workType: (ticket.workSummary.activeSession as any).workType ?? "INTERNAL",
}
: null,
}
@ -294,6 +304,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
const currentSessionMs = workSummary?.activeSession ? Math.max(0, now - workSummary.activeSession.startedAt) : 0
const totalWorkedMs = workSummary ? workSummary.totalWorkedMs + currentSessionMs : 0
const internalWorkedMs = workSummary
? (((workSummary as any).internalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "INTERNAL") ? currentSessionMs : 0))
: 0
const externalWorkedMs = workSummary
? (((workSummary as any).externalWorkedMs ?? 0) + (((workSummary?.activeSession as any)?.workType === "EXTERNAL") ? currentSessionMs : 0))
: 0
const formattedTotalWorked = useMemo(() => formatDuration(totalWorkedMs), [totalWorkedMs])
const updatedRelative = useMemo(
@ -301,12 +317,12 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
[ticket.updatedAt]
)
const handleStartWork = async () => {
const handleStartWork = async (workType: "INTERNAL" | "EXTERNAL") => {
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"> })
const result = await startWork({ ticketId: ticket.id as Id<"tickets">, actorId: convexUserId as Id<"users">, workType } as any)
if (result?.status === "already_started") {
toast.info("O atendimento já estava em andamento", { id: "work" })
} else {
@ -347,7 +363,7 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
setExportingPdf(true)
toast.dismiss("ticket-export")
toast.loading("Gerando PDF...", { id: "ticket-export" })
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`)
const response = await fetch(`/api/tickets/${ticket.id}/export/pdf`, { credentials: "include" })
if (!response.ok) {
throw new Error(`failed: ${response.status}`)
}
@ -373,9 +389,17 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
<div className={cardClass}>
<div className="absolute right-6 top-6 flex items-center gap-3">
{workSummary ? (
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Tempo total: {formattedTotalWorked}
</Badge>
<>
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Interno: {formatDuration(internalWorkedMs)}
</Badge>
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Externo: {formatDuration(externalWorkedMs)}
</Badge>
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-slate-200 bg-white px-3 text-sm font-semibold text-neutral-700">
<IconClock className="size-4 text-neutral-700" /> Total: {formattedTotalWorked}
</Badge>
</>
) : null}
{!editing ? (
<Button size="sm" className={editButtonClass} onClick={() => setEditing(true)}>
@ -383,45 +407,53 @@ export function TicketSummaryHeader({ ticket }: TicketHeaderProps) {
</Button>
) : null}
<Button
size="sm"
size="icon"
variant="outline"
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-1.5 text-sm font-semibold text-neutral-800 hover:bg-slate-50"
aria-label="Exportar PDF"
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white text-neutral-800 hover:bg-slate-50"
onClick={handleExportPdf}
disabled={exportingPdf}
title="Exportar PDF"
>
{exportingPdf ? <Spinner className="size-3 text-neutral-700" /> : <IconFileDownload className="size-4" />}
Exportar PDF
{exportingPdf ? <Spinner className="size-4 text-neutral-700" /> : <IconFileTypePdf className="size-5" />}
</Button>
<DeleteTicketDialog ticketId={ticket.id as Id<"tickets">} />
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<StatusSelect ticketId={ticket.id} value={status} />
<Button
size="sm"
className={isPlaying ? pauseButtonClass : startButtonClass}
onClick={() => {
if (!convexUserId) return
if (isPlaying) {
<div className="flex flex-wrap items-center gap-3">
<Badge className={referenceBadgeClass}>#{ticket.reference}</Badge>
{isAvulso ? (
<Badge className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 text-sm font-semibold text-rose-700">
Cliente avulso
</Badge>
) : null}
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
<StatusSelect ticketId={ticket.id} value={status} />
{isPlaying ? (
<Button
size="sm"
className={pauseButtonClass}
onClick={() => {
if (!convexUserId) return
setPauseDialogOpen(true)
} else {
void handleStartWork()
}
}}
>
{isPlaying ? (
<>
<IconPlayerPause className="size-4 text-white" /> Pausar
</>
) : (
<>
<IconPlayerPlay className="size-4 text-white" /> Iniciar
</>
)}
</Button>
}}
>
<IconPlayerPause className="size-4 text-white" /> Pausar
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className={startButtonClass}>
<IconPlayerPlay className="size-4 text-white" /> Iniciar
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="rounded-lg border border-slate-200 bg-white text-neutral-800 shadow-sm">
<DropdownMenuItem onSelect={() => void handleStartWork("INTERNAL")}>Iniciar (interno)</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleStartWork("EXTERNAL")}>Iniciar (externo)</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{editing ? (
<div className="space-y-2">

View file

@ -29,7 +29,6 @@ const statusOptions: Array<{ value: TicketStatus; label: string }> = [
{ value: "AWAITING_ATTENDANCE", label: "Aguardando atendimento" },
{ value: "PAUSED", label: "Pausado" },
{ value: "RESOLVED", label: "Resolvido" },
{ value: "CLOSED", label: "Fechado" },
]
const statusLabelMap = statusOptions.reduce<Record<TicketStatus, string>>((acc, option) => {

View file

@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { formatDistanceToNow } from "date-fns"
import { format, formatDistanceToNow, formatDistanceToNowStrict } from "date-fns"
import { ptBR } from "date-fns/locale"
import { type LucideIcon, Code, FileText, Mail, MessageCircle, MessageSquare, Phone } from "lucide-react"
@ -42,7 +42,7 @@ const channelIcon: Record<TicketChannel, LucideIcon> = {
MANUAL: FileText,
}
const cellClass = "px-6 py-5 align-top text-sm text-neutral-700 first:pl-8 last:pr-8"
const cellClass = "px-4 py-4 align-middle text-sm text-neutral-700 whitespace-normal first:pl-5 last:pr-6"
const channelIconBadgeClass = "inline-flex size-8 items-center justify-center rounded-full border border-slate-200 bg-slate-50 text-neutral-700"
const categoryChipClass = "inline-flex items-center gap-1 rounded-full bg-slate-200/60 px-2.5 py-1 text-[11px] font-medium text-neutral-700"
const tableRowClass =
@ -53,7 +53,6 @@ const statusLabel: Record<TicketStatus, string> = {
AWAITING_ATTENDANCE: "Aguardando atendimento",
PAUSED: "Pausado",
RESOLVED: "Resolvido",
CLOSED: "Fechado",
}
const statusTone: Record<TicketStatus, string> = {
@ -61,7 +60,6 @@ const statusTone: Record<TicketStatus, string> = {
AWAITING_ATTENDANCE: "text-sky-700",
PAUSED: "text-violet-700",
RESOLVED: "text-emerald-700",
CLOSED: "text-slate-600",
}
function formatDuration(ms?: number) {
@ -135,34 +133,34 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
return (
<Card className="gap-0 rounded-3xl border border-slate-200 bg-white py-0 shadow-sm">
<CardContent className="p-0">
<Table className="min-w-full overflow-hidden rounded-3xl">
<Table className="min-w-full overflow-hidden rounded-3xl table-fixed">
<TableHeader className="bg-slate-100/80">
<TableRow className="bg-transparent text-[11px] uppercase tracking-wide text-neutral-600">
<TableHead className="w-[120px] px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
<TableHead className="w-[120px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
Ticket
</TableHead>
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
<TableHead className="w-[40%] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
Assunto
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
<TableHead className="hidden w-[120px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
Fila
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
<TableHead className="hidden w-[80px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
Canal
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 md:table-cell">
<TableHead className="hidden w-[100px] pl-1 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 md:table-cell">
Prioridade
</TableHead>
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
<TableHead className="w-[230px] pl-14 pr-3 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
Status
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 lg:table-cell">
<TableHead className="hidden w-[110px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 lg:table-cell">
Tempo
</TableHead>
<TableHead className="hidden px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8 xl:table-cell">
<TableHead className="hidden w-[200px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6 xl:table-cell">
Responsável
</TableHead>
<TableHead className="px-6 py-4 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-8 last:pr-8">
<TableHead className="w-[140px] px-4 py-3 text-left text-[11px] font-semibold uppercase tracking-wide text-neutral-600 first:pl-6 last:pr-6">
Atualizado
</TableHead>
</TableRow>
@ -196,11 +194,11 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</div>
</TableCell>
<TableCell className={cellClass}>
<div className="flex flex-col gap-1.5">
<span className="line-clamp-1 text-[15px] font-semibold text-neutral-900">
<div className="flex flex-col gap-1.5 min-w-0">
<span className="text-[15px] font-semibold text-neutral-900 line-clamp-2 md:line-clamp-1 break-words">
{ticket.subject}
</span>
<span className="line-clamp-1 text-sm text-neutral-600">
<span className="text-sm text-neutral-600 line-clamp-1 break-words max-w-[52ch]">
{ticket.summary ?? "Sem resumo"}
</span>
<div className="flex flex-col gap-1 text-xs text-neutral-500">
@ -216,12 +214,12 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</div>
</div>
</TableCell>
<TableCell className={`${cellClass} hidden lg:table-cell`}>
<TableCell className={`${cellClass} hidden lg:table-cell pl-0`}>
<span className="text-sm font-semibold text-neutral-800">
{ticket.queue ?? "Sem fila"}
</span>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<TableCell className={`${cellClass} hidden md:table-cell pl-1`}>
<div className="flex items-center">
<span className="sr-only">Canal {channelLabel[ticket.channel]}</span>
<span
@ -233,7 +231,7 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
</span>
</div>
</TableCell>
<TableCell className={`${cellClass} hidden md:table-cell`}>
<TableCell className={`${cellClass} hidden md:table-cell pl-1 pr-8`}>
<div
className="inline-flex"
onClick={(event) => event.stopPropagation()}
@ -242,9 +240,9 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<PrioritySelect ticketId={ticket.id} value={ticket.priority} />
</div>
</TableCell>
<TableCell className={cellClass}>
<TableCell className={`${cellClass} pl-14`}>
<div className="flex flex-col gap-1">
<span className={cn("text-sm font-semibold", statusTone[ticket.status])}>
<span className={cn("text-sm font-semibold break-words leading-tight max-w-[140px] sm:max-w-[180px]", statusTone[ticket.status])}>
{statusLabel[ticket.status]}
</span>
{ticket.metrics?.timeWaitingMinutes ? (
@ -266,9 +264,14 @@ export function TicketsTable({ tickets = ticketsMock }: TicketsTableProps) {
<AssigneeCell ticket={ticket} />
</TableCell>
<TableCell className={cellClass}>
<span className="text-sm text-neutral-600">
{formatDistanceToNow(ticket.updatedAt, { addSuffix: true, locale: ptBR })}
</span>
<div className="flex flex-col leading-tight">
<span className="text-sm text-neutral-700">
{`há cerca de ${formatDistanceToNowStrict(ticket.updatedAt, { locale: ptBR })}`}
</span>
<span className="text-xs text-neutral-500">
{format(ticket.updatedAt, "dd/MM/yyyy HH:mm")}
</span>
</div>
</TableCell>
</TableRow>
)

View file

@ -41,7 +41,7 @@ export function TicketsView() {
const tickets = useMemo(() => mapTicketsFromServerList((ticketsRaw ?? []) as unknown[]), [ticketsRaw])
const filteredTickets = useMemo(() => {
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED", "CLOSED"])
const completedStatuses = new Set<Ticket["status"]>(["RESOLVED"])
let working = tickets
if (!filters.status) {

View file

@ -8,7 +8,7 @@ import { useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { isAdmin, isCustomer, isStaff } from "@/lib/authz"
import { isAdmin, isStaff } from "@/lib/authz"
export type AppSession = {
user: {
@ -109,7 +109,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
role: normalizedRole,
isAdmin: isAdmin(normalizedRole),
isStaff: isStaff(normalizedRole),
isCustomer: isCustomer(normalizedRole),
isCustomer: false,
}),
[session, isPending, convexUserId, normalizedRole]
)

View file

@ -1,7 +1,6 @@
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator", "customer"] as const
export const ROLE_OPTIONS = ["admin", "manager", "agent", "collaborator"] as const
const ADMIN_ROLE = "admin"
const CUSTOMER_ROLE = "customer"
const STAFF_ROLES = new Set(["admin", "manager", "agent", "collaborator"])
export type RoleOption = (typeof ROLE_OPTIONS)[number]
@ -14,10 +13,6 @@ export function isAdmin(role?: string | null) {
return normalizeRole(role) === ADMIN_ROLE
}
export function isCustomer(role?: string | null) {
return normalizeRole(role) === CUSTOMER_ROLE
}
export function isStaff(role?: string | null) {
return STAFF_ROLES.has(normalizeRole(role) ?? "")
}

View file

@ -11,7 +11,7 @@ const STATUS_MAP: Record<string, NormalizedTicketStatus> = {
ON_HOLD: "PAUSED",
PAUSED: "PAUSED",
RESOLVED: "RESOLVED",
CLOSED: "CLOSED",
CLOSED: "RESOLVED",
};
function normalizeTicketStatus(status: unknown): NormalizedTicketStatus {
@ -135,6 +135,8 @@ export function mapTicketFromServer(input: unknown) {
workSummary: s.workSummary
? {
totalWorkedMs: s.workSummary.totalWorkedMs,
internalWorkedMs: (s.workSummary as any).internalWorkedMs ?? 0,
externalWorkedMs: (s.workSummary as any).externalWorkedMs ?? 0,
activeSession: s.workSummary.activeSession
? {
...s.workSummary.activeSession,
@ -183,6 +185,9 @@ export function mapTicketWithDetailsFromServer(input: unknown) {
dueAt: s.dueAt ? new Date(s.dueAt) : null,
firstResponseAt: s.firstResponseAt ? new Date(s.firstResponseAt) : null,
resolvedAt: s.resolvedAt ? new Date(s.resolvedAt) : null,
company: (s as any).company
? ({ id: (s as any).company.id, name: (s as any).company.name, isAvulso: (s as any).company.isAvulso } as any)
: undefined,
timeline: s.timeline.map((e) => ({ ...e, createdAt: new Date(e.createdAt) })),
comments: s.comments.map((c) => ({
...c,

View file

@ -292,14 +292,13 @@ export function getTicketById(id: string) {
export const queueSummaries = queues as Array<z.infer<typeof ticketQueueSummarySchema>>
export const playContext = {
queue: queueSummaries[0],
nextTicket:
tickets.find(
(ticket) =>
ticket.status !== ticketStatusSchema.enum.RESOLVED &&
ticket.status !== ticketStatusSchema.enum.CLOSED
) ?? null,
} as z.infer<typeof ticketPlayContextSchema>
export const playContext = {
queue: queueSummaries[0],
nextTicket:
tickets.find(
(ticket) =>
ticket.status !== ticketStatusSchema.enum.RESOLVED
) ?? null,
} as z.infer<typeof ticketPlayContextSchema>

View file

@ -5,7 +5,6 @@ export const ticketStatusSchema = z.enum([
"AWAITING_ATTENDANCE",
"PAUSED",
"RESOLVED",
"CLOSED",
])
export type TicketStatus = z.infer<typeof ticketStatusSchema>
@ -26,14 +25,21 @@ export type TicketChannel = z.infer<typeof ticketChannelSchema>
export const commentVisibilitySchema = z.enum(["PUBLIC", "INTERNAL"])
export type CommentVisibility = z.infer<typeof commentVisibilitySchema>
export const userSummarySchema = z.object({
export const userSummarySchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
avatarUrl: z.string().url().optional(),
teams: z.array(z.string()).default([]),
})
export type UserSummary = z.infer<typeof userSummarySchema>
})
export type UserSummary = z.infer<typeof userSummarySchema>
export const ticketCompanySummarySchema = z.object({
id: z.string(),
name: z.string(),
isAvulso: z.boolean().optional(),
})
export type TicketCompanySummary = z.infer<typeof ticketCompanySummarySchema>
export const ticketCategorySummarySchema = z.object({
id: z.string(),
@ -88,7 +94,7 @@ export const ticketCustomFieldValueSchema = z.object({
})
export type TicketCustomFieldValue = z.infer<typeof ticketCustomFieldValueSchema>
export const ticketSchema = z.object({
export const ticketSchema = z.object({
id: z.string(),
reference: z.number(),
tenantId: z.string(),
@ -98,8 +104,9 @@ export const ticketSchema = z.object({
priority: ticketPrioritySchema,
channel: ticketChannelSchema,
queue: z.string().nullable(),
requester: userSummarySchema,
assignee: userSummarySchema.nullable(),
requester: userSummarySchema,
assignee: userSummarySchema.nullable(),
company: ticketCompanySummarySchema.optional().nullable(),
slaPolicy: z
.object({
id: z.string(),
@ -126,11 +133,14 @@ export const ticketSchema = z.object({
workSummary: z
.object({
totalWorkedMs: z.number(),
internalWorkedMs: z.number().optional().default(0),
externalWorkedMs: z.number().optional().default(0),
activeSession: z
.object({
id: z.string(),
agentId: z.string(),
startedAt: z.coerce.date(),
workType: z.string().optional(),
})
.nullable(),
})