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
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 })
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue