Hours by client: add search and CSV filtering; add alerts cron (BRT 08:00 guard) + alerts panel filters; admin companies shows last alert; PDF Inter font from public/fonts; fix Select empty value; type cleanups; tests for CSV/TZ; remove Knowledge Base nav

This commit is contained in:
Esdras Renan 2025-10-07 15:39:55 -03:00
parent 2cf399dcb1
commit 08cc8037d5
151 changed files with 1404 additions and 214 deletions

View file

@ -0,0 +1,123 @@
"use client"
import { useQuery } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Id, Doc } from "@/convex/_generated/dataModel"
import { useAuth } from "@/lib/auth-client"
import { DEFAULT_TENANT_ID } from "@/lib/constants"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
export default function AdminAlertsPage() {
const [companyId, setCompanyId] = useState<string>("all")
const [range, setRange] = useState<string>("30d")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const now = new Date()
const days = range === "7d" ? 7 : range === "30d" ? 30 : range === "90d" ? 90 : null
const end = now.getTime()
const start = days ? end - days * 24 * 60 * 60 * 1000 : undefined
const alertsRaw = useQuery(
api.alerts.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
) as Doc<"alerts">[] | undefined
const alerts = useMemo(() => {
let list = alertsRaw ?? []
if (companyId !== "all") {
list = list.filter((a) => String(a.companyId) === companyId)
}
if (typeof start === "number") list = list.filter((a) => a.createdAt >= start)
if (typeof end === "number") list = list.filter((a) => a.createdAt < end)
return list.sort((a, b) => b.createdAt - a.createdAt)
}, [alertsRaw, companyId, start, end])
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
return (
<div className="space-y-6">
<Card className="border-slate-200">
<CardHeader>
<CardTitle className="text-lg font-semibold text-neutral-900">Alertas enviados</CardTitle>
<CardDescription className="text-neutral-600">
Histórico dos e-mails de alerta de uso de horas disparados automaticamente.
</CardDescription>
<CardAction>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:gap-2">
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-full min-w-56 sm:w-64">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={range} onValueChange={setRange}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="7d">Últimos 7 dias</SelectItem>
<SelectItem value="30d">Últimos 30 dias</SelectItem>
<SelectItem value="90d">Últimos 90 dias</SelectItem>
<SelectItem value="all">Todos</SelectItem>
</SelectContent>
</Select>
<Button asChild size="sm" variant="outline">
<a href="/api/admin/alerts/hours-usage?range=30d&threshold=90">Disparar manualmente</a>
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent>
{!alerts ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : alerts.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhum alerta enviado ainda.
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full border-separate border-spacing-y-2">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-500">
<th className="px-2 py-1">Quando</th>
<th className="px-2 py-1">Empresa</th>
<th className="px-2 py-1">Uso</th>
<th className="px-2 py-1">Limite</th>
<th className="px-2 py-1">Período</th>
<th className="px-2 py-1">Destinatários</th>
<th className="px-2 py-1">Entregues</th>
</tr>
</thead>
<tbody>
{alerts.map((a) => (
<tr key={a._id} className="rounded-xl border border-slate-200 bg-white">
<td className="px-2 py-2 text-sm text-neutral-700">
{new Date(a.createdAt).toLocaleString("pt-BR")}
</td>
<td className="px-2 py-2 text-sm font-medium text-neutral-900">{a.companyName}</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.usagePct.toFixed(1)}%</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.threshold}%</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.range}</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.recipients.join(", ")}</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.deliveredCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,127 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import type { Id } from "@/convex/_generated/dataModel"
import { assertAdminSession } from "@/lib/auth-server"
import { env } from "@/lib/env"
import { prisma } from "@/lib/prisma"
import { sendSmtpMail } from "@/server/email-smtp"
export const runtime = "nodejs"
function fmtHours(ms: number) {
return (ms / 3600000).toFixed(2)
}
export async function POST(request: Request) {
const session = await assertAdminSession()
if (!session) return NextResponse.json({ error: "Não autorizado" }, { status: 401 })
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL
if (!convexUrl) return NextResponse.json({ error: "Convex não configurado" }, { status: 500 })
if (!env.SMTP) return NextResponse.json({ error: "SMTP não configurado" }, { status: 500 })
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? "30d"
const threshold = Number(searchParams.get("threshold") ?? 90)
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? "tenant-atlas"
// Ensure user exists in Convex to obtain a typed viewerId
let viewerId: Id<"users"> | null = null
try {
const ensured = 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 = (ensured?._id ?? null) as Id<"users"> | null
} catch (error) {
console.error("Failed to synchronize user with Convex for alerts", 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 })
const report = await client.query(api.reports.hoursByClient, {
tenantId,
viewerId,
range,
})
type HoursByClientItem = {
companyId: Id<"companies">
name: string
internalMs: number
externalMs: number
totalMs: number
contractedHoursPerMonth: number | null
}
const items = (report.items ?? []) as HoursByClientItem[]
const alerts = items.filter((i) => i.contractedHoursPerMonth != null && (i.totalMs / 3600000) / (i.contractedHoursPerMonth || 1) * 100 >= threshold)
for (const item of alerts) {
// Find managers of the company in Prisma
const managers = await prisma.user.findMany({
where: {
tenantId,
companyId: item.companyId,
role: "MANAGER",
},
select: { email: true, name: true },
})
if (managers.length === 0) continue
const subject = `Alerta: uso de horas em ${item.name} acima de ${threshold}%`
const body = `
<p>Olá,</p>
<p>O uso de horas contratadas para <strong>${item.name}</strong> atingiu <strong>${(((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100).toFixed(1)}%</strong>.</p>
<ul>
<li>Horas internas: <strong>${fmtHours(item.internalMs)}</strong></li>
<li>Horas externas: <strong>${fmtHours(item.externalMs)}</strong></li>
<li>Total: <strong>${fmtHours(item.totalMs)}</strong></li>
<li>Contratadas/mês: <strong>${item.contractedHoursPerMonth}</strong></li>
</ul>
<p>Reveja a alocação da equipe e, se necessário, ajuste o atendimento.</p>
`
let delivered = 0
for (const m of managers) {
try {
await sendSmtpMail(
{
host: env.SMTP!.host,
port: env.SMTP!.port,
username: env.SMTP!.username,
password: env.SMTP!.password,
from: env.SMTP!.from!,
},
m.email,
subject,
body
)
delivered += 1
} catch (error) {
console.error("Failed to send alert to", m.email, error)
}
}
try {
await client.mutation(api.alerts.log, {
tenantId,
companyId: item.companyId,
companyName: item.name,
usagePct: (((item.totalMs/3600000)/(item.contractedHoursPerMonth || 1))*100),
threshold,
range,
recipients: managers.map((m) => m.email),
deliveredCount: delivered,
})
} catch (error) {
console.error("Failed to log alert in Convex", error)
}
}
return NextResponse.json({ sent: alerts.length })
}

View file

@ -0,0 +1,37 @@
import { NextResponse } from "next/server"
import { ConvexHttpClient } from "convex/browser"
import { api } from "@/convex/_generated/api"
import { env } from "@/lib/env"
import { assertAdminSession } from "@/lib/auth-server"
export const runtime = "nodejs"
export async function GET(request: Request) {
const session = await assertAdminSession()
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 { searchParams } = new URL(request.url)
const slugsParam = searchParams.get("slugs")
if (!slugsParam) return NextResponse.json({ items: {} })
const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean)
const tenantId = session.user.tenantId ?? "tenant-atlas"
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {}
for (const slug of slugs) {
try {
const last = (await client.query(api.alerts.lastForCompanyBySlug, { tenantId, slug })) as
| { createdAt: number; usagePct: number; threshold: number }
| null
result[slug] = last
} catch {
result[slug] = null
}
}
return NextResponse.json({ items: result })
}

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 { rowsToCsv } from "@/lib/csv"
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) {
@ -62,7 +51,7 @@ export async function GET(request: Request) {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
companyId: companyId as any,
companyId: companyId as unknown as Id<"companies">,
})
const rows: Array<Array<unknown>> = []

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 { rowsToCsv } from "@/lib/csv"
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) {
@ -34,6 +23,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
const companyId = searchParams.get("companyId") ?? undefined
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
@ -62,11 +52,13 @@ export async function GET(request: Request) {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
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 ?? "—"])
@ -96,4 +88,3 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Falha ao gerar CSV de CSAT" }, { status: 500 })
}
}

View file

@ -6,17 +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"
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"
}
function msToHours(ms: number) {
return (ms / 3600000).toFixed(2)
}
@ -29,6 +21,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
const q = searchParams.get("q")?.toLowerCase().trim() ?? ""
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
@ -58,9 +51,11 @@ export async function GET(request: Request) {
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"])
for (const item of report.items) {
const items = q ? report.items.filter((i: any) => String(i.name).toLowerCase().includes(q)) : report.items
for (const item of items) {
const internalH = msToHours(item.internalMs)
const externalH = msToHours(item.externalMs)
const totalH = msToHours(item.totalMs)
@ -72,7 +67,7 @@ export async function GET(request: Request) {
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d.csv"`,
"Content-Disposition": `attachment; filename="hours-by-client-${tenantId}-${report.rangeDays ?? '90'}d${q ? `-${encodeURIComponent(q)}` : ''}.csv"`,
"Cache-Control": "no-store",
},
})
@ -80,4 +75,3 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Falha ao gerar CSV de horas por cliente" }, { status: 500 })
}
}

View file

@ -34,6 +34,7 @@ export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const range = searchParams.get("range") ?? undefined
const companyId = searchParams.get("companyId") ?? undefined
const client = new ConvexHttpClient(convexUrl)
const tenantId = session.user.tenantId ?? DEFAULT_TENANT_ID
@ -62,11 +63,13 @@ export async function GET(request: Request) {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
companyId: companyId as unknown as Id<"companies">,
})
const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "SLA e produtividade"])
rows.push(["Período", range ?? "—"])
if (companyId) rows.push(["EmpresaId", companyId])
rows.push([])
rows.push(["Métrica", "Valor"]) // header
@ -98,4 +101,3 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Falha ao gerar CSV de SLA" }, { 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 { rowsToCsv } from "@/lib/csv"
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) {
@ -63,7 +52,7 @@ export async function GET(request: Request) {
tenantId,
viewerId: viewerId as unknown as Id<"users">,
range,
companyId: companyId as any,
companyId: companyId as unknown as Id<"companies">,
})
const channels = report.channels

View file

@ -5,6 +5,8 @@ import { NextResponse } from "next/server"
// @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 fs from "fs"
import path from "path"
import { ptBR } from "date-fns/locale"
import { ConvexHttpClient } from "convex/browser"
@ -123,21 +125,21 @@ function formatDurationMs(ms: number | null | undefined) {
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
function buildTimelineMessage(type: string, payload: Record<string, unknown> | null | undefined): string | null {
const p = payload ?? {}
const to = (p.toLabel as string | undefined) ?? (p.to as string | undefined)
const assignee = (p.assigneeName as string | undefined) ?? (p.assigneeId as string | undefined)
const queue = (p.queueName as string | undefined) ?? (p.queueId as string | undefined)
const requester = p.requesterName as string | undefined
const author = (p.authorName as string | undefined) ?? (p.authorId as string | undefined)
const actor = (p.actorName as string | undefined) ?? (p.actorId as string | undefined)
const attachmentName = p.attachmentName as string | undefined
const subjectTo = p.to as string | undefined
const pauseReason = (p.pauseReasonLabel as string | undefined) ?? (p.pauseReason as string | undefined)
const pauseNote = p.pauseNote as string | undefined
const sessionDuration = formatDurationMs((p.sessionDurationMs as number | undefined) ?? null)
const categoryName = p.categoryName as string | undefined
const subcategoryName = p.subcategoryName as string | undefined
switch (type) {
case "STATUS_CHANGED":
@ -247,8 +249,9 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
const doc = new PDFDocument({ size: "A4", margin: 56 })
const chunks: Buffer[] = []
doc.on("data", (chunk: any) => {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk)
doc.on("data", (chunk: unknown) => {
const buf = typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer)
chunks.push(buf)
})
const pdfBufferPromise = new Promise<Buffer>((resolve, reject) => {
@ -256,8 +259,48 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
doc.on("error", reject)
})
// Register custom fonts (Inter) if available
try {
const pubRegular = path.join(process.cwd(), "public", "fonts", "Inter-Regular.ttf")
const pubBold = path.join(process.cwd(), "public", "fonts", "Inter-Bold.ttf")
const fontRegular = fs.existsSync(pubRegular)
? pubRegular
: path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Regular.ttf")
const fontBold = fs.existsSync(pubBold)
? pubBold
: path.join(process.cwd(), "Inter,Manrope", "Inter", "static", "Inter_24pt-Bold.ttf")
const D = doc as unknown as {
registerFont?: (name: string, src: string) => void
_fontFamilies?: Record<string, unknown>
roundedRect?: (x: number, y: number, w: number, h: number, r: number) => void
}
if (fs.existsSync(fontRegular)) {
D.registerFont?.("Inter", fontRegular)
}
if (fs.existsSync(fontBold)) {
D.registerFont?.("Inter-Bold", fontBold)
}
} catch {}
const D = doc as unknown as { _fontFamilies?: Record<string, unknown>; roundedRect?: (x:number,y:number,w:number,h:number,r:number)=>void }
const hasInter = Boolean(D._fontFamilies && (D._fontFamilies as Record<string, unknown>)["Inter-Bold"])
// Header with logo and brand bar
try {
const logoPath = path.join(process.cwd(), "public", "rever-8.png")
if (fs.existsSync(logoPath)) {
doc.image(logoPath, doc.page.margins.left, doc.y, { width: 120 })
}
} catch {}
doc.moveDown(0.5)
doc
.fillColor("#00e8ff")
.rect(doc.page.margins.left, doc.y, doc.page.width - doc.page.margins.left - doc.page.margins.right, 3)
.fill()
doc.moveDown(0.5)
// Título
doc.font("Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference}${ticket.subject}`)
doc.fillColor("#0F172A").font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(18).text(`Ticket #${ticket.reference}${ticket.subject}`)
doc.moveDown(0.25)
// Linha abaixo do título
doc
@ -276,30 +319,53 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
const badgeX = doc.page.margins.left
const badgeY = doc.y
doc.save()
doc.font("Helvetica-Bold").fontSize(badgeFontSize)
doc.font(hasInter ? "Inter-Bold" : "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)
D.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(`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`, { lineGap: 2 })
.moveDown(0.15)
.text(`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`, { lineGap: 2 })
.moveDown(0.15)
.text(`Fila: ${ticket.queue ?? "—"}`, { lineGap: 2 })
// Metadados em duas colunas
const leftX = doc.page.margins.left
const colGap = 24
const colWidth = (doc.page.width - doc.page.margins.left - doc.page.margins.right - colGap) / 2
const rightX = leftX + colWidth + colGap
const startY = doc.y
const drawMeta = (x: number, lines: string[]) => {
doc.save()
doc.x = x
doc.fillColor("#0F172A").font(hasInter ? "Inter" : "Helvetica").fontSize(11)
for (const line of lines) {
doc.text(line, { width: colWidth, lineGap: 2 })
}
const currY = doc.y
doc.restore()
return currY
}
const leftLines = [
`Status: ${statusText}`,
`Prioridade: ${priorityLabel[ticket.priority] ?? ticket.priority}`,
`Canal: ${channelLabel[ticket.channel] ?? ticket.channel ?? "—"}`,
`Fila: ${ticket.queue ?? "—"}`,
]
const rightLines = [
`Solicitante: ${ticket.requester.name} (${ticket.requester.email})`,
`Responsável: ${ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído"}`,
`Criado em: ${formatDateTime(ticket.createdAt)}`,
`Atualizado em: ${formatDateTime(ticket.updatedAt)}`,
]
const leftY = drawMeta(leftX, leftLines)
const rightY = drawMeta(rightX, rightLines)
doc.y = Math.max(leftY, rightY)
doc.moveDown(0.5)
doc.moveDown(0.75)
doc
.font("Helvetica-Bold")
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
.fontSize(12)
.text("Solicitante")
doc
@ -309,12 +375,12 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
.stroke()
doc.moveDown(0.3)
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(`${ticket.requester.name} (${ticket.requester.email})`)
doc.moveDown(0.5)
doc.font("Helvetica-Bold").fontSize(12).text("Responsável")
doc.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(12).text("Responsável")
doc
.strokeColor("#E2E8F0")
.moveTo(doc.page.margins.left, doc.y)
@ -322,7 +388,7 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
.stroke()
doc.moveDown(0.3)
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(ticket.assignee ? `${ticket.assignee.name} (${ticket.assignee.email})` : "Não atribuído")
@ -345,58 +411,58 @@ 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.font(hasInter ? "Inter-Bold" : "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")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(ticket.summary, { align: "justify", lineGap: 2 })
}
if (ticket.description) {
doc.moveDown(0.75)
doc.font("Helvetica-Bold").fontSize(12).text("Descrição")
doc.font(hasInter ? "Inter-Bold" : "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")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.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.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Comentários")
doc.moveDown(0.6)
const commentsSorted = [...ticket.comments].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
commentsSorted.forEach((comment, index) => {
const visibility =
comment.visibility === "PUBLIC" ? "Público" : "Interno"
doc
.font("Helvetica-Bold")
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
.fontSize(11)
.text(`${comment.author.name}${visibility}${formatDateTime(comment.createdAt)}`)
doc.moveDown(0.15)
const body = htmlToPlainText(comment.body)
if (body) {
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(11)
.text(body, { align: "justify", lineGap: 2, indent: 6 })
}
if (comment.attachments.length > 0) {
doc.moveDown(0.25)
doc.font("Helvetica").fontSize(10).text("Anexos:")
doc.font(hasInter ? "Inter" : "Helvetica").fontSize(10).text("Anexos:")
comment.attachments.forEach((attachment) => {
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(10)
.text(`${attachment.name ?? attachment.id}`, { indent: 16, lineGap: 1 })
})
@ -415,13 +481,13 @@ 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.font(hasInter ? "Inter-Bold" : "Helvetica-Bold").fontSize(14).text("Linha do tempo")
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
doc
.font("Helvetica-Bold")
.font(hasInter ? "Inter-Bold" : "Helvetica-Bold")
.fontSize(11)
.text(`${label}${formatDateTime(event.createdAt)}`)
doc.moveDown(0.15)
@ -429,14 +495,14 @@ export async function GET(_request: Request, context: { params: Promise<{ id: st
const friendly = buildTimelineMessage(event.type, event.payload)
if (friendly) {
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(10)
.text(friendly, { indent: 16, lineGap: 1 })
} else {
const payloadText = stringifyPayload(event.payload)
if (payloadText) {
doc
.font("Helvetica")
.font(hasInter ? "Inter" : "Helvetica")
.fontSize(10)
.text(payloadText, { indent: 16, lineGap: 1 })
}

View file

@ -2,12 +2,7 @@ import { AppShell } from "@/components/app-shell"
import { SectionCards } from "@/components/section-cards"
import { SiteHeader } from "@/components/site-header"
import { RecentTicketsPanel } from "@/components/tickets/recent-tickets-panel"
import dynamic from "next/dynamic"
const TicketQueueSummaryCards = dynamic(
() => import("@/components/tickets/ticket-queue-summary").then((m) => ({ default: m.TicketQueueSummaryCards })),
{ ssr: false }
)
import { TicketQueueSummaryCards } from "@/components/tickets/ticket-queue-summary"
import { ChartAreaInteractive } from "@/components/chart-area-interactive"
export default function Dashboard() {

View file

@ -11,13 +11,6 @@ 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,13 +11,6 @@ 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

@ -36,6 +36,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
const [isPending, startTransition] = useTransition()
const [form, setForm] = useState<Partial<Company>>({})
const [editingId, setEditingId] = useState<string | null>(null)
const [lastAlerts, setLastAlerts] = useState<Record<string, { createdAt: number; usagePct: number; threshold: number } | null>>({})
const resetForm = () => setForm({})
@ -43,6 +44,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
const r = await fetch("/api/admin/companies", { credentials: "include" })
const json = (await r.json()) as { companies: Company[] }
setCompanies(json.companies)
void loadLastAlerts(json.companies)
}
function handleEdit(c: Company) {
@ -50,6 +52,20 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
setForm({ ...c })
}
async function loadLastAlerts(list: Company[] = companies) {
if (!list || list.length === 0) return
const params = new URLSearchParams({ slugs: list.map((c) => c.slug).join(",") })
try {
const r = await fetch(`/api/admin/companies/last-alerts?${params.toString()}`, { credentials: "include" })
const json = (await r.json()) as { items: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> }
setLastAlerts(json.items ?? {})
} catch {
// ignore
}
}
useMemo(() => { void loadLastAlerts(companies) }, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const payload = {
@ -191,6 +207,7 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<TableHead>Domínio</TableHead>
<TableHead>Telefone</TableHead>
<TableHead>CNPJ</TableHead>
<TableHead>Último alerta</TableHead>
<TableHead>Ações</TableHead>
</TableRow>
</TableHeader>
@ -207,6 +224,11 @@ export function AdminCompaniesManager({ initialCompanies }: { initialCompanies:
<TableCell>{c.domain ?? "—"}</TableCell>
<TableCell>{c.phone ?? "—"}</TableCell>
<TableCell>{c.cnpj ?? "—"}</TableCell>
<TableCell>
{lastAlerts[c.slug]
? `${new Date(lastAlerts[c.slug]!.createdAt).toLocaleString("pt-BR")}`
: "—"}
</TableCell>
<TableCell>
<Button size="sm" variant="outline" onClick={() => handleEdit(c)}>
Editar

View file

@ -1,22 +1,21 @@
"use client"
import * as React from "react"
import {
LayoutDashboard,
LifeBuoy,
Ticket,
PlayCircle,
BookOpen,
BarChart3,
Gauge,
PanelsTopLeft,
Users,
Waypoints,
import {
LayoutDashboard,
LifeBuoy,
Ticket,
PlayCircle,
BarChart3,
Gauge,
PanelsTopLeft,
Users,
Waypoints,
Timer,
Layers3,
UserPlus,
Settings,
} from "lucide-react"
} from "lucide-react"
import { usePathname } from "next/navigation"
import { SearchForm } from "@/components/search-form"
@ -66,7 +65,6 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
{ title: "Tickets", url: "/tickets", icon: Ticket, requiredRole: "staff" },
{ title: "Visualizações", url: "/views", icon: PanelsTopLeft, requiredRole: "staff" },
{ title: "Modo Play", url: "/play", icon: PlayCircle, requiredRole: "staff" },
{ title: "Base de conhecimento", url: "/knowledge", icon: BookOpen, requiredRole: "staff" },
],
},
{
@ -95,6 +93,7 @@ const navigation: { versions: string[]; navMain: NavigationGroup[] } = {
{ 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" },
{ title: "Alertas enviados", url: "/admin/alerts", icon: Gauge, requiredRole: "admin" },
],
},
{

View file

@ -25,13 +25,14 @@ import {
ChartTooltipContent,
} from "@/components/ui/chart"
import { Skeleton } from "@/components/ui/skeleton"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import {
ToggleGroup,
ToggleGroupItem,
@ -43,6 +44,9 @@ export function ChartAreaInteractive() {
const [mounted, setMounted] = React.useState(false)
const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("7d")
// Use a non-empty sentinel value for "all" to satisfy Select.Item requirements
const [companyId, setCompanyId] = React.useState<string>("all")
const [companyQuery, setCompanyQuery] = React.useState("")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
@ -59,9 +63,15 @@ export function ChartAreaInteractive() {
const report = useQuery(
api.reports.ticketsByChannel,
convexUserId
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange })
? ({ tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
: "skip"
)
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
const filteredCompanies = React.useMemo(() => {
const q = companyQuery.trim().toLowerCase()
if (!q) return companies ?? []
return (companies ?? []).filter((c) => c.name.toLowerCase().includes(q))
}, [companies, companyQuery])
const channels = React.useMemo(() => report?.channels ?? [], [report])
@ -120,46 +130,68 @@ export function ChartAreaInteractive() {
<span className="@[540px]/card:hidden">Período: {timeRange}</span>
</CardDescription>
<CardAction>
<Button asChild size="sm" variant="outline">
<a
href={`/api/reports/tickets-by-channel.csv?range=${timeRange}`}
download
<div className="flex w-full flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end sm:gap-2">
{/* Company picker with search */}
<Select value={companyId} onValueChange={(v) => { setCompanyId(v); }}>
<SelectTrigger className="w-full min-w-56 sm:w-64">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<div className="p-2">
<Input
placeholder="Pesquisar empresa..."
value={companyQuery}
onChange={(e) => setCompanyQuery(e.target.value)}
className="h-8"
/>
</div>
<SelectItem value="all">Todas as empresas</SelectItem>
{filteredCompanies.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
{/* Desktop time range toggles */}
<ToggleGroup
type="single"
value={timeRange}
onValueChange={setTimeRange}
variant="outline"
className="hidden *:data-[slot=toggle-group-item]:!px-4 @[767px]/card:flex"
>
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>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-40 **:data-[slot=select-value]:block **:data-[slot=select-value]:truncate @[767px]/card:hidden"
size="sm"
aria-label="Selecionar período"
>
<SelectValue placeholder="Selecionar período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">
Últimos 90 dias
</SelectItem>
<SelectItem value="30d" className="rounded-lg">
Últimos 30 dias
</SelectItem>
<SelectItem value="7d" className="rounded-lg">
Últimos 7 dias
</SelectItem>
</SelectContent>
</Select>
</CardAction>
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
{/* Mobile time range select */}
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="flex w-full min-w-40 @[767px]/card:hidden"
size="sm"
aria-label="Selecionar período"
>
<SelectValue placeholder="Selecionar período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90d" className="rounded-lg">Últimos 90 dias</SelectItem>
<SelectItem value="30d" className="rounded-lg">Últimos 30 dias</SelectItem>
<SelectItem value="7d" className="rounded-lg">Últimos 7 dias</SelectItem>
</SelectContent>
</Select>
{/* Export button aligned at the end */}
<Button asChild size="sm" variant="outline" className="sm:ml-1">
<a
href={`/api/reports/tickets-by-channel.csv?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`}
download
>
Exportar CSV
</a>
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
{report === undefined ? (

View file

@ -12,6 +12,7 @@ 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"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const PRIORITY_LABELS: Record<string, string> = {
LOW: "Baixa",
@ -29,12 +30,14 @@ const STATUS_LABELS: Record<string, string> = {
export function BacklogReport() {
const [timeRange, setTimeRange] = useState("90d")
const [companyId, setCompanyId] = useState<string>("all")
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">, range: timeRange } : "skip"
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) } : "skip"
)
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
const mostCriticalPriority = useMemo(() => {
if (!data) return null
@ -102,8 +105,19 @@ export function BacklogReport() {
Acompanhe a evolução dos tickets pelas fases do fluxo de atendimento.
</CardDescription>
<CardAction>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="hidden w-56 md:flex">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/backlog.csv?range=${timeRange}`} download>
<a href={`/api/reports/backlog.csv?range=${timeRange}${companyId !== "all" ? `&companyId=${companyId}` : ""}`} download>
Exportar CSV
</a>
</Button>

View file

@ -6,9 +6,12 @@ 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 { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
function formatScore(value: number | null) {
if (value === null) return "—"
@ -16,12 +19,16 @@ function formatScore(value: number | null) {
}
export function CsatReport() {
const [companyId, setCompanyId] = useState<string>("all")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.csatOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
convexUserId
? ({ tenantId, viewerId: convexUserId as Id<"users">, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
: "skip"
)
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
if (!data) {
return (
@ -42,6 +49,24 @@ export function CsatReport() {
<IconMoodSmile className="size-4 text-teal-500" /> CSAT médio
</CardTitle>
<CardDescription className="text-neutral-600">Média das respostas recebidas.</CardDescription>
<CardAction>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/csat.csv${companyId !== "all" ? `?companyId=${companyId}` : ""}`} download>
Exportar CSV
</a>
</Button>
</CardAction>
</CardHeader>
<CardContent className="text-3xl font-semibold text-neutral-900">
{formatScore(data.averageScore)}

View file

@ -11,23 +11,40 @@ import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle }
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Input } from "@/components/ui/input"
function formatHours(ms: number) {
const hours = ms / 3600000
return hours.toFixed(2)
}
type HoursItem = {
companyId: string
name: string
isAvulso: boolean
internalMs: number
externalMs: number
totalMs: number
contractedHoursPerMonth?: number | null
}
export function HoursReport() {
const [timeRange, setTimeRange] = useState("90d")
const [query, setQuery] = useState("")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.hoursByClient,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users">, range: timeRange } : "skip"
) as { rangeDays: number; items: Array<{ companyId: string; name: string; isAvulso: boolean; internalMs: number; externalMs: number; totalMs: number; contractedHoursPerMonth?: number | null }> } | undefined
) as { rangeDays: number; items: HoursItem[] } | undefined
const items = data?.items ?? []
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return items
return items.filter((it) => it.name.toLowerCase().includes(q))
}, [items, query])
return (
<div className="space-y-6">
@ -35,17 +52,25 @@ export function HoursReport() {
<CardHeader>
<CardTitle>Horas por cliente</CardTitle>
<CardDescription>Horas internas e externas registradas por empresa.</CardDescription>
<CardAction className="flex items-center gap-2">
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/hours-by-client.csv?range=${timeRange}`} download>
<CardAction>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-end">
<Input
placeholder="Pesquisar cliente..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 w-full min-w-56 sm:w-72"
/>
<ToggleGroup type="single" value={timeRange} onValueChange={setTimeRange} variant="outline" className="hidden md:flex">
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/hours-by-client.csv?range=${timeRange}${query ? `&q=${encodeURIComponent(query)}` : ""}`} download>
Exportar CSV
</a>
</Button>
<ToggleGroup type="single" value={timeRange} onValueChange={setTimeRange} variant="outline" className="hidden md:flex">
<ToggleGroupItem value="90d">90 dias</ToggleGroupItem>
<ToggleGroupItem value="30d">30 dias</ToggleGroupItem>
<ToggleGroupItem value="7d">7 dias</ToggleGroupItem>
</ToggleGroup>
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent>
@ -63,11 +88,11 @@ export function HoursReport() {
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{items.map((row) => {
{filtered.map((row) => {
const totalH = Number(formatHours(row.totalMs))
const contracted = row.contractedHoursPerMonth ?? null
const pct = contracted ? Math.round((totalH / contracted) * 100) : null
const pctBadgeVariant = pct !== null && pct >= 90 ? "destructive" : "secondary"
const pctBadgeVariant: "secondary" | "destructive" = pct !== null && pct >= 90 ? "destructive" : "secondary"
return (
<tr key={row.companyId}>
<td className="py-2 pr-4 font-medium text-neutral-900">{row.name}</td>
@ -78,7 +103,7 @@ export function HoursReport() {
<td className="py-2 pr-4">{contracted ?? "—"}</td>
<td className="py-2 pr-4">
{pct !== null ? (
<Badge variant={pctBadgeVariant as any} className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide">
<Badge variant={pctBadgeVariant} className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide">
{pct}%
</Badge>
) : (
@ -96,4 +121,3 @@ export function HoursReport() {
</div>
)
}

View file

@ -7,9 +7,12 @@ 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 { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useState } from "react"
function formatMinutes(value: number | null) {
if (value === null) return "—"
@ -21,12 +24,16 @@ function formatMinutes(value: number | null) {
}
export function SlaReport() {
const [companyId, setCompanyId] = useState<string>("all")
const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery(
api.reports.slaOverview,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip"
convexUserId
? ({ tenantId, viewerId: convexUserId as Id<"users">, companyId: companyId === "all" ? undefined : (companyId as Id<"companies">) })
: "skip"
)
const companies = useQuery(api.companies.list, convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip") as Array<{ id: Id<"companies">; name: string }> | undefined
const queueTotal = useMemo(
() => data?.queueBreakdown.reduce((acc: number, queue: { open: number }) => acc + queue.open, 0) ?? 0,
@ -97,6 +104,24 @@ export function SlaReport() {
Distribuição dos {queueTotal} tickets abertos por fila de atendimento.
</CardDescription>
</div>
<CardAction>
<Select value={companyId} onValueChange={setCompanyId}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Todas as empresas" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Button asChild size="sm" variant="outline">
<a href={`/api/reports/sla.csv${companyId !== "all" ? `?companyId=${companyId}` : ""}`} download>
Exportar CSV
</a>
</Button>
</CardAction>
</div>
</CardHeader>
<CardContent>

12
src/lib/csv.ts Normal file
View file

@ -0,0 +1,12 @@
export function csvEscape(value: unknown): string {
const s = value == null ? "" : String(value)
if (/[",\n]/.test(s)) {
return '"' + s.replace(/"/g, '""') + '"'
}
return s
}
export function rowsToCsv(rows: Array<Array<unknown>>): string {
return rows.map((row) => row.map(csvEscape).join(",")).join("\n") + "\n"
}

View file

@ -6,6 +6,12 @@ const envSchema = z.object({
NEXT_PUBLIC_CONVEX_URL: z.string().url().optional(),
DATABASE_URL: z.string().min(1).optional(),
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
SMTP_ADDRESS: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
SMTP_USERNAME: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
SMTP_TLS: z.string().optional(),
MAILER_SENDER_EMAIL: z.string().optional(),
})
const parsed = envSchema.safeParse(process.env)
@ -21,4 +27,14 @@ export const env = {
NEXT_PUBLIC_CONVEX_URL: parsed.data.NEXT_PUBLIC_CONVEX_URL,
DATABASE_URL: parsed.data.DATABASE_URL,
NEXT_PUBLIC_APP_URL: parsed.data.NEXT_PUBLIC_APP_URL,
SMTP: parsed.data.SMTP_ADDRESS && parsed.data.SMTP_USERNAME && parsed.data.SMTP_PASSWORD
? {
host: parsed.data.SMTP_ADDRESS,
port: parsed.data.SMTP_PORT ?? 465,
username: parsed.data.SMTP_USERNAME,
password: parsed.data.SMTP_PASSWORD,
tls: (parsed.data.SMTP_TLS ?? "true").toLowerCase() === "true",
from: parsed.data.MAILER_SENDER_EMAIL ?? "no-reply@example.com",
}
: null,
}

35
src/lib/time.ts Normal file
View file

@ -0,0 +1,35 @@
export function getTZParts(date: Date, timeZone: string) {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
})
const parts = Object.fromEntries(fmt.formatToParts(date).map((p) => [p.type, p.value])) as Record<string, string>
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
hour: Number(parts.hour),
minute: Number(parts.minute),
second: Number(parts.second),
}
}
export function dateKeyTZ(date: Date, timeZone: string) {
const p = getTZParts(date, timeZone)
const y = String(p.year).padStart(4, "0")
const m = String(p.month).padStart(2, "0")
const d = String(p.day).padStart(2, "0")
return `${y}-${m}-${d}`
}
export function isAtHourTZ(date: Date, timeZone: string, hour: number) {
const p = getTZParts(date, timeZone)
return p.hour === hour
}

69
src/server/email-smtp.ts Normal file
View file

@ -0,0 +1,69 @@
import tls from "tls"
type SmtpConfig = {
host: string
port: number
username: string
password: string
from: string
}
function b64(input: string) {
return Buffer.from(input, "utf8").toString("base64")
}
export async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: string) {
return new Promise<void>((resolve, reject) => {
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => {
let buffer = ""
const send = (line: string) => socket.write(line + "\r\n")
const wait = (expected: string | RegExp) =>
new Promise<void>((res, rej) => {
const onData = (data: Buffer) => {
buffer += data.toString()
const lines = buffer.split(/\r?\n/)
const last = lines.filter(Boolean).slice(-1)[0] ?? ""
if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) {
socket.removeListener("data", onData)
res()
}
}
socket.on("data", onData)
socket.on("error", rej)
})
;(async () => {
await wait(/^220 /)
send(`EHLO ${cfg.host}`)
await wait(/^250-/)
await wait(/^250 /)
send("AUTH LOGIN")
await wait(/^334 /)
send(b64(cfg.username))
await wait(/^334 /)
send(b64(cfg.password))
await wait(/^235 /)
send(`MAIL FROM:<${cfg.from.match(/<(.+)>/)?.[1] ?? cfg.from}>`)
await wait(/^250 /)
send(`RCPT TO:<${to}>`)
await wait(/^250 /)
send("DATA")
await wait(/^354 /)
const headers = [
`From: ${cfg.from}`,
`To: ${to}`,
`Subject: ${subject}`,
"MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8",
].join("\r\n")
send(headers + "\r\n\r\n" + html + "\r\n.")
await wait(/^250 /)
send("QUIT")
socket.end()
resolve()
})().catch(reject)
})
socket.on("error", reject)
})
}