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

@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
import type { Id } from "@/convex/_generated/dataModel"
import type { UserRole } from "@prisma/client"
import type { Prisma, UserRole } from "@prisma/client"
import { api } from "@/convex/_generated/api"
import { ConvexHttpClient } from "convex/browser"
import { prisma } from "@/lib/prisma"
@ -57,6 +57,9 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string
select: {
companyId: true,
company: { select: { name: true } },
jobTitle: true,
managerId: true,
manager: { select: { id: true, name: true, email: true } },
},
})
@ -72,6 +75,10 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string
companyId: domain?.companyId ?? null,
companyName: domain?.company?.name ?? null,
machinePersona: user.machinePersona ?? null,
jobTitle: domain?.jobTitle ?? null,
managerId: domain?.managerId ?? null,
managerName: domain?.manager?.name ?? null,
managerEmail: domain?.manager?.email ?? null,
},
})
}
@ -90,6 +97,8 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
role?: RoleOption
tenantId?: string
companyId?: string | null
jobTitle?: string | null
managerId?: string | null
} | null
if (!payload || typeof payload !== "object") {
@ -106,6 +115,39 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const nextRole = normalizeRole(payload.role ?? user.role)
const nextTenant = (payload.tenantId ?? user.tenantId ?? DEFAULT_TENANT_ID).trim() || DEFAULT_TENANT_ID
const companyId = payload.companyId ? payload.companyId : null
const hasJobTitleField = Object.prototype.hasOwnProperty.call(payload, "jobTitle")
let jobTitle: string | null | undefined
if (hasJobTitleField) {
if (typeof payload.jobTitle === "string") {
const trimmed = payload.jobTitle.trim()
jobTitle = trimmed.length > 0 ? trimmed : null
} else {
jobTitle = null
}
}
const hasManagerField = Object.prototype.hasOwnProperty.call(payload, "managerId")
let managerIdValue: string | null | undefined
if (hasManagerField) {
if (typeof payload.managerId === "string") {
const trimmed = payload.managerId.trim()
managerIdValue = trimmed.length > 0 ? trimmed : null
} else {
managerIdValue = null
}
}
let managerRecord: { id: string; email: string; tenantId: string; name: string } | null = null
if (managerIdValue && managerIdValue !== null) {
managerRecord = await prisma.user.findUnique({
where: { id: managerIdValue },
select: { id: true, email: true, tenantId: true, name: true },
})
if (!managerRecord) {
return NextResponse.json({ error: "Gestor informado não foi encontrado." }, { status: 400 })
}
if (managerRecord.tenantId !== nextTenant) {
return NextResponse.json({ error: "Gestor pertence a outro cliente." }, { status: 400 })
}
}
if (!nextEmail || !nextEmail.includes("@")) {
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
@ -159,33 +201,58 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
return NextResponse.json({ error: "Empresa não encontrada" }, { status: 400 })
}
if (domainUser && managerRecord && managerRecord.id === domainUser.id) {
return NextResponse.json({ error: "Um usuário não pode ser gestor de si mesmo." }, { status: 400 })
}
if (domainUser) {
const updateData: Prisma.UserUncheckedUpdateInput = {
email: nextEmail,
name: nextName || domainUser.name,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
}
if (hasJobTitleField) {
updateData.jobTitle = jobTitle ?? null
}
if (hasManagerField) {
updateData.managerId = managerRecord?.id ?? null
}
await prisma.user.update({
where: { id: domainUser.id },
data: {
email: nextEmail,
name: nextName || domainUser.name,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
},
data: updateData,
})
} else {
const upsertUpdate: Prisma.UserUncheckedUpdateInput = {
name: nextName || nextEmail,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
}
if (hasJobTitleField) {
upsertUpdate.jobTitle = jobTitle ?? null
}
if (hasManagerField) {
upsertUpdate.managerId = managerRecord?.id ?? null
}
const upsertCreate: Prisma.UserUncheckedCreateInput = {
email: nextEmail,
name: nextName || nextEmail,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
}
if (hasJobTitleField) {
upsertCreate.jobTitle = jobTitle ?? null
}
if (hasManagerField) {
upsertCreate.managerId = managerRecord?.id ?? null
}
await prisma.user.upsert({
where: { email: nextEmail },
update: {
name: nextName || nextEmail,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
},
create: {
email: nextEmail,
name: nextName || nextEmail,
role: mapToUserRole(nextRole),
tenantId: nextTenant,
companyId: companyId ?? null,
},
update: upsertUpdate,
create: upsertCreate,
})
}
@ -193,19 +260,60 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
if (convexUrl) {
try {
const convex = new ConvexHttpClient(convexUrl)
await convex.mutation(api.users.ensureUser, {
let managerConvexId: Id<"users"> | undefined
if (hasManagerField && managerRecord?.email) {
try {
const managerUser = await convex.query(api.users.findByEmail, {
tenantId: nextTenant,
email: managerRecord.email,
})
if (managerUser?._id) {
managerConvexId = managerUser._id as Id<"users">
}
} catch (error) {
console.warn("Falha ao localizar gestor no Convex", error)
}
}
const ensurePayload: {
tenantId: string
email: string
name: string
avatarUrl?: string
role: string
companyId?: Id<"companies">
jobTitle?: string | undefined
managerId?: Id<"users">
} = {
tenantId: nextTenant,
email: nextEmail,
name: nextName || nextEmail,
avatarUrl: updated.avatarUrl ?? undefined,
role: nextRole.toUpperCase(),
companyId: companyId ? (companyId as Id<"companies">) : undefined,
})
}
if (companyId) {
ensurePayload.companyId = companyId as Id<"companies">
}
if (hasJobTitleField) {
ensurePayload.jobTitle = jobTitle ?? undefined
}
if (hasManagerField) {
ensurePayload.managerId = managerConvexId
}
await convex.mutation(api.users.ensureUser, ensurePayload)
} catch (error) {
console.warn("Falha ao sincronizar usuário no Convex", error)
}
}
const updatedDomain = await prisma.user.findUnique({
where: { email: nextEmail },
select: {
jobTitle: true,
managerId: true,
manager: { select: { name: true, email: true } },
},
})
return NextResponse.json({
user: {
id: updated.id,
@ -218,6 +326,10 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
companyId,
companyName: companyData?.name ?? null,
machinePersona: updated.machinePersona ?? null,
jobTitle: updatedDomain?.jobTitle ?? null,
managerId: updatedDomain?.managerId ?? null,
managerName: updatedDomain?.manager?.name ?? null,
managerEmail: updatedDomain?.manager?.email ?? null,
},
})
}

View file

@ -3,6 +3,7 @@ import { NextResponse } from "next/server"
import { hashPassword } from "better-auth/crypto"
import { ConvexHttpClient } from "convex/browser"
import type { UserRole } from "@prisma/client"
import type { Id } from "@/convex/_generated/dataModel"
import { prisma } from "@/lib/prisma"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
@ -52,6 +53,13 @@ export async function GET() {
name: true,
},
},
manager: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: { createdAt: "desc" },
})
@ -102,6 +110,10 @@ export async function GET() {
role: normalizeRole(user.role),
companyId: user.companyId,
companyName: user.company?.name ?? null,
jobTitle: user.jobTitle ?? null,
managerId: user.managerId,
managerName: user.manager?.name ?? null,
managerEmail: user.manager?.email ?? null,
tenantId: user.tenantId,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
@ -124,6 +136,8 @@ export async function POST(request: Request) {
email?: string
role?: string
tenantId?: string
jobTitle?: string | null
managerId?: string | null
} | null
if (!body || typeof body !== "object") {
@ -141,6 +155,25 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Informe um e-mail válido" }, { status: 400 })
}
const rawJobTitle = typeof body.jobTitle === "string" ? body.jobTitle.trim() : null
const jobTitle = rawJobTitle ? rawJobTitle : null
const rawManagerId = typeof body.managerId === "string" ? body.managerId.trim() : ""
const managerId = rawManagerId.length > 0 ? rawManagerId : null
let managerRecord: { id: string; email: string; tenantId: string; name: string } | null = null
if (managerId) {
managerRecord = await prisma.user.findUnique({
where: { id: managerId },
select: { id: true, email: true, tenantId: true, name: true },
})
if (!managerRecord) {
return NextResponse.json({ error: "Gestor informado não foi encontrado." }, { status: 400 })
}
if (managerRecord.tenantId !== tenantId) {
return NextResponse.json({ error: "Gestor pertence a outro cliente." }, { status: 400 })
}
}
const normalizedRole = normalizeRole(body.role)
const authRole = normalizedRole.toLowerCase()
const userRole = normalizedRole as UserRole
@ -176,12 +209,16 @@ export async function POST(request: Request) {
name,
role: userRole,
tenantId,
jobTitle,
managerId: managerRecord?.id ?? null,
},
create: {
name,
email,
role: userRole,
tenantId,
jobTitle,
managerId: managerRecord?.id ?? null,
},
})
@ -192,12 +229,28 @@ export async function POST(request: Request) {
if (convexUrl) {
try {
const convex = new ConvexHttpClient(convexUrl)
let managerConvexId: Id<"users"> | undefined
if (managerRecord?.email) {
try {
const convexManager = await convex.query(api.users.findByEmail, {
tenantId,
email: managerRecord.email,
})
if (convexManager?._id) {
managerConvexId = convexManager._id as Id<"users">
}
} catch (error) {
console.warn("[admin/users] Falha ao localizar gestor no Convex", error)
}
}
await convex.mutation(api.users.ensureUser, {
tenantId,
email,
name,
avatarUrl: authUser.avatarUrl ?? undefined,
role: userRole,
jobTitle: jobTitle ?? undefined,
managerId: managerConvexId,
})
} catch (error) {
console.error("[admin/users] ensureUser failed", error)
@ -213,6 +266,10 @@ export async function POST(request: Request) {
role: authRole,
tenantId: domainUser.tenantId,
createdAt: domainUser.createdAt.toISOString(),
jobTitle,
managerId: managerRecord?.id ?? null,
managerName: managerRecord?.name ?? null,
managerEmail: managerRecord?.email ?? null,
},
temporaryPassword,
})

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 })
}
}

View file

@ -14,7 +14,7 @@ import { renderTicketPdfBuffer } from "@/server/pdf/ticket-pdf-template"
export const runtime = "nodejs"
async function readLogoAsDataUrl() {
const logoPath = path.join(process.cwd(), "public", "raven.png")
const logoPath = path.join(process.cwd(), "public", "logo-raven.png")
try {
const buffer = await fs.promises.readFile(logoPath)
const base64 = buffer.toString("base64")