feat: export reports as xlsx and add machine inventory

This commit is contained in:
Esdras Renan 2025-10-27 18:00:28 -03:00
parent 29b865885c
commit 714b199879
34 changed files with 2304 additions and 245 deletions

View file

@ -6,7 +6,7 @@ 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"
import { rowsToCsv } from "@/lib/csv"
import { buildXlsxWorkbook } from "@/lib/xlsx"
export const runtime = "nodejs"
@ -35,7 +35,7 @@ export async function GET(request: Request) {
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for backlog CSV", error)
console.error("Failed to synchronize user with Convex for backlog export", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
@ -54,14 +54,17 @@ export async function GET(request: Request) {
companyId: companyId as unknown as Id<"companies">,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "Backlog"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"])
if (companyId) rows.push(["EmpresaId", companyId])
rows.push([])
rows.push(["Seção", "Chave", "Valor"]) // header
const summaryRows: Array<Array<unknown>> = [
["Relatório", "Backlog"],
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : "—"],
]
if (companyId) {
summaryRows.push(["EmpresaId", companyId])
}
summaryRows.push(["Chamados em aberto", report.totalOpen])
const distributionRows: Array<Array<unknown>> = []
// Status
const STATUS_PT: Record<string, string> = {
PENDING: "Pendentes",
AWAITING_ATTENDANCE: "Em andamento",
@ -69,10 +72,9 @@ export async function GET(request: Request) {
RESOLVED: "Resolvidos",
}
for (const [status, total] of Object.entries(report.statusCounts)) {
rows.push(["Status", STATUS_PT[status] ?? status, total])
distributionRows.push(["Status", STATUS_PT[status] ?? status, total])
}
// Prioridade
const PRIORITY_PT: Record<string, string> = {
LOW: "Baixa",
MEDIUM: "Média",
@ -80,26 +82,37 @@ export async function GET(request: Request) {
URGENT: "Crítica",
}
for (const [priority, total] of Object.entries(report.priorityCounts)) {
rows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total])
distributionRows.push(["Prioridade", PRIORITY_PT[priority] ?? priority, total])
}
// Filas
for (const q of report.queueCounts) {
rows.push(["Fila", q.name || q.id, q.total])
distributionRows.push(["Fila", q.name || q.id, q.total])
}
rows.push(["Abertos", "Total", report.totalOpen])
const workbook = buildXlsxWorkbook([
{
name: "Resumo",
headers: ["Item", "Valor"],
rows: summaryRows,
},
{
name: "Distribuições",
headers: ["Categoria", "Chave", "Total"],
rows: distributionRows,
},
])
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
const body = new Uint8Array(workbook)
return new NextResponse(body, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="backlog-${tenantId}-${report.rangeDays ?? 'all'}d.csv"`,
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="backlog-${tenantId}-${report.rangeDays ?? 'all'}d.xlsx"`,
"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 })
console.error("Failed to generate backlog export", error)
return NextResponse.json({ error: "Falha ao gerar planilha do backlog" }, { status: 500 })
}
}

View file

@ -6,7 +6,7 @@ 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"
import { rowsToCsv } from "@/lib/csv"
import { buildXlsxWorkbook } from "@/lib/xlsx"
export const runtime = "nodejs"
@ -39,7 +39,7 @@ export async function GET(request: Request) {
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for CSAT CSV", error)
console.error("Failed to synchronize user with Convex for CSAT export", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
@ -55,36 +55,56 @@ export async function GET(request: Request) {
companyId: companyId as unknown as Id<"companies">,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "CSAT"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
if (companyId) rows.push(["EmpresaId", companyId])
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 summaryRows: Array<Array<unknown>> = [
["Relatório", "CSAT"],
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"],
]
if (companyId) {
summaryRows.push(["EmpresaId", companyId])
}
summaryRows.push(["CSAT médio", report.averageScore ?? "—"])
summaryRows.push(["Total de respostas", report.totalSurveys ?? 0])
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
const distributionRows: Array<Array<unknown>> = (report.distribution ?? []).map((entry) => [
entry.score,
entry.total,
])
const recentRows: Array<Array<unknown>> = (report.recent ?? []).map((item) => [
`#${item.reference}`,
item.score,
new Date(item.receivedAt).toISOString(),
])
const workbook = buildXlsxWorkbook([
{
name: "Resumo",
headers: ["Métrica", "Valor"],
rows: summaryRows,
},
{
name: "Distribuição",
headers: ["Nota", "Total"],
rows: distributionRows.length > 0 ? distributionRows : [["—", 0]],
},
{
name: "Respostas recentes",
headers: ["Ticket", "Nota", "Recebido em"],
rows: recentRows.length > 0 ? recentRows : [["—", "—", "—"]],
},
])
const body = new Uint8Array(workbook)
return new NextResponse(body, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="csat-${tenantId}-${report.rangeDays ?? '90'}d.csv"`,
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="csat-${tenantId}-${report.rangeDays ?? '90'}d.xlsx"`,
"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 })
console.error("Failed to generate CSAT export", error)
return NextResponse.json({ error: "Falha ao gerar planilha de CSAT" }, { status: 500 })
}
}

View file

@ -6,12 +6,9 @@ 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"
import { rowsToCsv } from "@/lib/csv"
import { buildXlsxWorkbook } from "@/lib/xlsx"
export const runtime = "nodejs"
function msToHours(ms: number) {
return (ms / 3600000).toFixed(2)
}
export async function GET(request: Request) {
const session = await assertAuthenticatedSession()
@ -49,33 +46,59 @@ export async function GET(request: Request) {
range,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "Horas por cliente"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? '90d')])
if (q) rows.push(["Filtro", q])
rows.push([])
rows.push(["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"])
const summaryRows: Array<Array<unknown>> = [
["Relatório", "Horas por cliente"],
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"],
]
if (q) summaryRows.push(["Filtro", q])
if (companyId) summaryRows.push(["EmpresaId", companyId])
summaryRows.push(["Total de clientes", (report.items as Array<unknown>).length])
type Item = { companyId: string; name: string; isAvulso: boolean; internalMs: number; externalMs: number; totalMs: number; contractedHoursPerMonth: number | null }
let items = (report.items as Item[])
if (companyId) items = items.filter((i) => String(i.companyId) === companyId)
if (q) items = items.filter((i) => i.name.toLowerCase().includes(q))
for (const item of items) {
const internalH = msToHours(item.internalMs)
const externalH = msToHours(item.externalMs)
const totalH = msToHours(item.totalMs)
const contracted = item.contractedHoursPerMonth ?? "—"
const pct = item.contractedHoursPerMonth ? ((item.totalMs / 3600000) / item.contractedHoursPerMonth * 100).toFixed(1) + "%" : "—"
rows.push([item.name, item.isAvulso ? "Sim" : "Não", internalH, externalH, totalH, contracted, pct])
}
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
const dataRows = items.map((item) => {
const internalHours = item.internalMs / 3600000
const externalHours = item.externalMs / 3600000
const totalHours = item.totalMs / 3600000
const contracted = item.contractedHoursPerMonth
const usagePct = contracted ? (totalHours / contracted) * 100 : null
return [
item.name,
item.isAvulso ? "Sim" : "Não",
Number(internalHours.toFixed(2)),
Number(externalHours.toFixed(2)),
Number(totalHours.toFixed(2)),
contracted ?? null,
usagePct !== null ? Number(usagePct.toFixed(1)) : null,
]
})
const workbook = buildXlsxWorkbook([
{
name: "Resumo",
headers: ["Item", "Valor"],
rows: summaryRows,
},
{
name: "Clientes",
headers: ["Cliente", "Avulso", "Horas internas", "Horas externas", "Horas totais", "Horas contratadas/mês", "% uso"],
rows: dataRows.length > 0 ? dataRows : [["—", "—", 0, 0, 0, null, null]],
},
])
const body = new Uint8Array(workbook)
return new NextResponse(body, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${companyId ? `-${companyId}` : ''}${q ? `-${encodeURIComponent(q)}` : ''}.csv"`,
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${companyId ? `-${companyId}` : ''}${q ? `-${encodeURIComponent(q)}` : ''}.xlsx"`,
"Cache-Control": "no-store",
},
})
} catch {
return NextResponse.json({ error: "Falha ao gerar CSV de horas por cliente" }, { status: 500 })
return NextResponse.json({ error: "Falha ao gerar planilha de horas por cliente" }, { status: 500 })
}
}

View file

@ -0,0 +1,266 @@
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"
import { buildXlsxWorkbook } from "@/lib/xlsx"
export const runtime = "nodejs"
type MachineListEntry = {
id: Id<"machines">
tenantId: string
hostname: string
companyId: Id<"companies"> | null
companySlug: string | null
companyName: string | null
status: string | null
isActive: boolean
lastHeartbeatAt: number | null
persona: string | null
assignedUserName: string | null
assignedUserEmail: string | null
authEmail: string | null
osName: string
osVersion: string | null
architecture: string | null
macAddresses: string[]
serialNumbers: string[]
registeredBy: string | null
createdAt: number
updatedAt: number
token: { expiresAt: number; usageCount: number; lastUsedAt: number | null } | null
inventory: Record<string, unknown> | null
linkedUsers?: Array<{ id: string; email: string; name: string }>
}
function formatIso(value: number | null | undefined): string | null {
if (typeof value !== "number") return null
try {
return new Date(value).toISOString()
} catch {
return null
}
}
function formatMemory(bytes: unknown): number | null {
if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes <= 0) return null
const gib = bytes / (1024 ** 3)
return Number(gib.toFixed(2))
}
function extractPrimaryIp(inventory: Record<string, unknown> | null): string | null {
if (!inventory) return null
const network = inventory.network
if (!network) return null
if (Array.isArray(network)) {
for (const entry of network) {
if (entry && typeof entry === "object") {
const candidate = (entry as { ip?: unknown }).ip
if (typeof candidate === "string" && candidate.trim().length > 0) return candidate.trim()
}
}
} else if (typeof network === "object") {
const record = network as Record<string, unknown>
const ip =
typeof record.primaryIp === "string"
? record.primaryIp
: typeof record.publicIp === "string"
? record.publicIp
: null
if (ip && ip.trim().length > 0) return ip.trim()
}
return null
}
function extractHardware(inventory: Record<string, unknown> | null) {
if (!inventory) return {}
const hardware = inventory.hardware
if (!hardware || typeof hardware !== "object") return {}
const hw = hardware as Record<string, unknown>
return {
vendor: typeof hw.vendor === "string" ? hw.vendor : null,
model: typeof hw.model === "string" ? hw.model : null,
serial: typeof hw.serial === "string" ? hw.serial : null,
cpuType: typeof hw.cpuType === "string" ? hw.cpuType : null,
physicalCores: typeof hw.physicalCores === "number" ? hw.physicalCores : null,
logicalCores: typeof hw.logicalCores === "number" ? hw.logicalCores : null,
memoryBytes: typeof hw.memoryBytes === "number" ? hw.memoryBytes : null,
}
}
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 companyId = searchParams.get("companyId") ?? 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 machines export", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
if (!viewerId) {
return NextResponse.json({ error: "Usuário não encontrado no Convex" }, { status: 403 })
}
try {
const machines = (await client.query(api.machines.listByTenant, {
tenantId,
includeMetadata: true,
})) as MachineListEntry[]
const filtered = machines.filter((machine) => {
if (!companyId) return true
return String(machine.companyId ?? "") === companyId || machine.companySlug === companyId
})
const statusCounts = filtered.reduce<Record<string, number>>((acc, machine) => {
const key = machine.status ?? "unknown"
acc[key] = (acc[key] ?? 0) + 1
return acc
}, {})
const summaryRows: Array<Array<unknown>> = [
["Tenant", tenantId],
["Total de máquinas", filtered.length],
]
if (companyId) summaryRows.push(["Filtro de empresa", companyId])
Object.entries(statusCounts).forEach(([status, total]) => {
summaryRows.push([`Status: ${status}`, total])
})
const inventorySheetRows = filtered.map((machine) => {
const inventory =
machine.inventory && typeof machine.inventory === "object"
? (machine.inventory as Record<string, unknown>)
: null
const hardware = extractHardware(inventory)
const primaryIp = extractPrimaryIp(inventory)
const memoryGiB = formatMemory(hardware.memoryBytes)
return [
machine.hostname,
machine.companyName ?? "—",
machine.status ?? "unknown",
machine.isActive ? "Sim" : "Não",
formatIso(machine.lastHeartbeatAt),
machine.persona ?? null,
machine.assignedUserName ?? null,
machine.assignedUserEmail ?? null,
machine.authEmail ?? null,
machine.osName,
machine.osVersion ?? null,
machine.architecture ?? null,
machine.macAddresses.join(", "),
machine.serialNumbers.join(", "),
machine.registeredBy ?? null,
formatIso(machine.createdAt),
formatIso(machine.updatedAt),
hardware.vendor,
hardware.model,
hardware.serial,
hardware.cpuType,
hardware.physicalCores,
hardware.logicalCores,
memoryGiB,
primaryIp,
machine.token?.expiresAt ? formatIso(machine.token.expiresAt) : null,
machine.token?.usageCount ?? null,
]
})
const linksSheetRows: Array<Array<unknown>> = []
filtered.forEach((machine) => {
if (!machine.linkedUsers || machine.linkedUsers.length === 0) return
machine.linkedUsers.forEach((user) => {
linksSheetRows.push([
machine.hostname,
machine.companyName ?? "—",
user.name ?? user.email ?? "—",
user.email ?? "—",
])
})
})
const workbook = buildXlsxWorkbook([
{
name: "Resumo",
headers: ["Item", "Valor"],
rows: summaryRows,
},
{
name: "Máquinas",
headers: [
"Hostname",
"Empresa",
"Status",
"Ativa",
"Último heartbeat",
"Persona",
"Responsável",
"E-mail responsável",
"E-mail autenticado",
"Sistema operacional",
"Versão SO",
"Arquitetura",
"Endereços MAC",
"Seriais",
"Registrada via",
"Criada em",
"Atualizada em",
"Fabricante",
"Modelo",
"Serial hardware",
"Processador",
"Cores físicas",
"Cores lógicas",
"Memória (GiB)",
"IP principal",
"Token expira em",
"Uso do token",
],
rows: inventorySheetRows.length > 0 ? inventorySheetRows : [["—"]],
},
{
name: "Vínculos",
headers: ["Hostname", "Empresa", "Usuário", "E-mail"],
rows: linksSheetRows.length > 0 ? linksSheetRows : [["—", "—", "—", "—"]],
},
])
const body = new Uint8Array(workbook)
return new NextResponse(body, {
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="machines-inventory-${tenantId}${companyId ? `-${companyId}` : ""}.xlsx"`,
"Cache-Control": "no-store",
},
})
} catch (error) {
console.error("Failed to generate machines inventory export", error)
return NextResponse.json({ error: "Falha ao gerar planilha de inventário" }, { status: 500 })
}
}

View file

@ -6,21 +6,10 @@ 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"
import { buildXlsxWorkbook } from "@/lib/xlsx"
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) {
@ -50,7 +39,7 @@ export async function GET(request: Request) {
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for SLA CSV", error)
console.error("Failed to synchronize user with Convex for SLA export", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
@ -66,42 +55,55 @@ export async function GET(request: Request) {
companyId: companyId as unknown as Id<"companies">,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "Produtividade"])
rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")])
if (companyId) rows.push(["EmpresaId", companyId])
rows.push([])
const summaryRows: Array<Array<unknown>> = [
["Relatório", "Produtividade"],
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")],
]
if (companyId) {
summaryRows.push(["EmpresaId", companyId])
}
summaryRows.push(["Tickets totais", report.totals.total])
summaryRows.push(["Tickets abertos", report.totals.open])
summaryRows.push(["Tickets resolvidos", report.totals.resolved])
summaryRows.push(["Atrasados (SLA)", report.totals.overdue])
summaryRows.push(["Tempo médio de 1ª resposta (min)", report.response.averageFirstResponseMinutes ?? "—"])
summaryRows.push(["Respostas registradas", report.response.responsesRegistered ?? 0])
summaryRows.push(["Tempo médio de resolução (min)", report.resolution.averageResolutionMinutes ?? "—"])
summaryRows.push(["Tickets resolvidos (amostra)", report.resolution.resolvedCount ?? 0])
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 queueRows: Array<Array<unknown>> = []
for (const queue of report.queueBreakdown ?? []) {
queueRows.push([queue.name || queue.id, queue.open])
}
const csv = rowsToCsv(rows)
const workbook = buildXlsxWorkbook([
{
name: "Resumo",
headers: ["Indicador", "Valor"],
rows: summaryRows,
},
{
name: "Filas",
headers: ["Fila", "Chamados abertos"],
rows: queueRows.length > 0 ? queueRows : [["—", 0]],
},
])
const daysLabel = (() => {
const raw = (range ?? "90d").replace("d", "")
return /^(7|30|90)$/.test(raw) ? `${raw}d` : "all"
})()
return new NextResponse(csv, {
const body = new Uint8Array(workbook)
return new NextResponse(body, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="sla-${tenantId}-${daysLabel}.csv"`,
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="sla-${tenantId}-${daysLabel}.xlsx"`,
"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 })
console.error("Failed to generate SLA export", error)
return NextResponse.json({ error: "Falha ao gerar planilha de SLA" }, { status: 500 })
}
}

View file

@ -6,7 +6,7 @@ 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"
import { rowsToCsv } from "@/lib/csv"
import { buildXlsxWorkbook } from "@/lib/xlsx"
export const runtime = "nodejs"
@ -39,7 +39,7 @@ export async function GET(request: Request) {
})
viewerId = ensuredUser?._id ?? null
} catch (error) {
console.error("Failed to synchronize user with Convex for channel CSV", error)
console.error("Failed to synchronize user with Convex for channel export", error)
return NextResponse.json({ error: "Falha ao sincronizar usuário com Convex" }, { status: 500 })
}
@ -66,28 +66,43 @@ export async function GET(request: Request) {
WEB: "Portal",
PORTAL: "Portal",
}
const summaryRows: Array<Array<unknown>> = [
["Relatório", "Tickets por canal"],
["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : range ?? "90d"],
]
if (companyId) summaryRows.push(["EmpresaId", companyId])
summaryRows.push(["Total de linhas", report.points.length])
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 dataRows: Array<Array<unknown>> = report.points.map((point) => {
const values = channels.map((ch) => point.values[ch] ?? 0)
rows.push([point.date, ...values])
}
return [point.date, ...values]
})
const csv = rowsToCsv(rows)
return new NextResponse(csv, {
const workbook = buildXlsxWorkbook([
{
name: "Resumo",
headers: ["Item", "Valor"],
rows: summaryRows,
},
{
name: "Distribuição",
headers: header,
rows: dataRows.length > 0 ? dataRows : [[new Date().toISOString().slice(0, 10), ...channels.map(() => 0)]],
},
])
const body = new Uint8Array(workbook)
return new NextResponse(body, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}${companyId ? `-${companyId}` : ''}.csv"`,
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="tickets-by-channel-${tenantId}-${range ?? '90d'}${companyId ? `-${companyId}` : ''}.xlsx"`,
"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 })
console.error("Failed to generate tickets-by-channel export", error)
return NextResponse.json({ error: "Falha ao gerar planilha de tickets por canal" }, { status: 500 })
}
}