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:
parent
2cf399dcb1
commit
08cc8037d5
151 changed files with 1404 additions and 214 deletions
123
src/app/admin/alerts/page.tsx
Normal file
123
src/app/admin/alerts/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
src/app/api/admin/alerts/hours-usage/route.ts
Normal file
127
src/app/api/admin/alerts/hours-usage/route.ts
Normal 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 })
|
||||
}
|
||||
37
src/app/api/admin/companies/last-alerts/route.ts
Normal file
37
src/app/api/admin/companies/last-alerts/route.ts
Normal 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 })
|
||||
}
|
||||
|
|
@ -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>> = []
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
12
src/lib/csv.ts
Normal 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"
|
||||
}
|
||||
|
||||
|
|
@ -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
35
src/lib/time.ts
Normal 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
69
src/server/email-smtp.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue