reports(SLA): aplica filtro de período (7d/30d/90d) no Convex e inclui período no filename do CSV; admin(alerts): filtros no servidor; alerts: batch de últimos alertas por slugs; filtros persistentes de empresa (localStorage) em relatórios; prisma: Company.contractedHoursPerMonth; smtp: suporte a múltiplos destinatários e timeout opcional

This commit is contained in:
Esdras Renan 2025-10-07 16:46:52 -03:00
parent a23b429e4d
commit 384d4411b6
13 changed files with 133 additions and 38 deletions

View file

@ -150,6 +150,36 @@ export const lastForCompanyBySlug = query({
}, },
}) })
export const lastForCompaniesBySlugs = query({
args: { tenantId: v.string(), slugs: v.array(v.string()) },
handler: async (ctx, { tenantId, slugs }) => {
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {}
// Fetch all alerts once for the tenant
const alerts = await ctx.db
.query("alerts")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.collect()
for (const slug of slugs) {
const company = await ctx.db
.query("companies")
.withIndex("by_tenant_slug", (q) => q.eq("tenantId", tenantId).eq("slug", slug))
.first()
if (!company) {
result[slug] = null
continue
}
const matches = alerts.filter((a) => a.companyId === company._id)
if (matches.length === 0) {
result[slug] = null
continue
}
const last = matches.sort((a, b) => b.createdAt - a.createdAt)[0]
result[slug] = { createdAt: last.createdAt, usagePct: last.usagePct, threshold: last.threshold }
}
return result
},
})
export const tenantIds = query({ export const tenantIds = query({
args: {}, args: {},
handler: async (ctx) => { handler: async (ctx) => {

View file

@ -129,21 +129,28 @@ function formatDateKey(timestamp: number) {
export const slaOverview = query({ export const slaOverview = query({
args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) }, args: { tenantId: v.string(), viewerId: v.id("users"), range: v.optional(v.string()), companyId: v.optional(v.id("companies")) },
handler: async (ctx, { tenantId, viewerId, companyId }) => { handler: async (ctx, { tenantId, viewerId, range, companyId }) => {
const viewer = await requireStaff(ctx, viewerId, tenantId); const viewer = await requireStaff(ctx, viewerId, tenantId);
let tickets = await fetchScopedTickets(ctx, tenantId, viewer); let tickets = await fetchScopedTickets(ctx, tenantId, viewer);
if (companyId) tickets = tickets.filter((t) => t.companyId === companyId) if (companyId) tickets = tickets.filter((t) => t.companyId === companyId)
// Optional range filter (createdAt) for reporting purposes, similar ao backlog/csat
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
const end = new Date();
end.setUTCHours(0, 0, 0, 0);
const endMs = end.getTime() + ONE_DAY_MS;
const startMs = endMs - days * ONE_DAY_MS;
const inRange = tickets.filter((t) => t.createdAt >= startMs && t.createdAt < endMs);
const queues = await fetchQueues(ctx, tenantId); const queues = await fetchQueues(ctx, tenantId);
const now = Date.now(); const now = Date.now();
const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status)));
const resolvedTickets = tickets.filter((ticket) => { const resolvedTickets = inRange.filter((ticket) => {
const status = normalizeStatus(ticket.status); const status = normalizeStatus(ticket.status);
return status === "RESOLVED"; return status === "RESOLVED";
}); });
const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now);
const firstResponseTimes = tickets const firstResponseTimes = inRange
.filter((ticket) => ticket.firstResponseAt) .filter((ticket) => ticket.firstResponseAt)
.map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000);
const resolutionTimes = resolvedTickets const resolutionTimes = resolvedTickets
@ -161,7 +168,7 @@ export const slaOverview = query({
return { return {
totals: { totals: {
total: tickets.length, total: inRange.length,
open: openTickets.length, open: openTickets.length,
resolved: resolvedTickets.length, resolved: resolvedTickets.length,
overdue: overdueTickets.length, overdue: overdueTickets.length,
@ -175,6 +182,7 @@ export const slaOverview = query({
resolvedCount: resolutionTimes.length, resolvedCount: resolutionTimes.length,
}, },
queueBreakdown, queueBreakdown,
rangeDays: days,
}; };
}, },
}); });

View file

@ -73,6 +73,7 @@ model Company {
name String name String
slug String slug String
isAvulso Boolean @default(false) isAvulso Boolean @default(false)
contractedHoursPerMonth Float?
cnpj String? cnpj String?
domain String? domain String?
phone String? phone String?

View file

@ -21,17 +21,11 @@ export async function GET(request: Request) {
const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean) const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean)
const tenantId = session.user.tenantId ?? "tenant-atlas" const tenantId = session.user.tenantId ?? "tenant-atlas"
const result: Record<string, { createdAt: number; usagePct: number; threshold: number } | null> = {} try {
for (const slug of slugs) { const result = (await client.query(api.alerts.lastForCompaniesBySlugs, { tenantId, slugs })) as Record<string, { createdAt: number; usagePct: number; threshold: number } | null>
try { return NextResponse.json({ items: result })
const last = (await client.query(api.alerts.lastForCompanyBySlug, { tenantId, slug })) as } catch (error) {
| { createdAt: number; usagePct: number; threshold: number } console.error("Failed to fetch last alerts by slugs", error)
| null return NextResponse.json({ items: {} })
result[slug] = last
} catch {
result[slug] = null
}
} }
return NextResponse.json({ items: result })
} }

View file

@ -68,7 +68,7 @@ export async function GET(request: Request) {
const rows: Array<Array<unknown>> = [] const rows: Array<Array<unknown>> = []
rows.push(["Relatório", "SLA e produtividade"]) rows.push(["Relatório", "SLA e produtividade"])
rows.push(["Período", range ?? "—"]) rows.push(["Período", report.rangeDays ? `Últimos ${report.rangeDays} dias` : (range ?? "90d")])
if (companyId) rows.push(["EmpresaId", companyId]) if (companyId) rows.push(["EmpresaId", companyId])
rows.push([]) rows.push([])
@ -89,10 +89,14 @@ export async function GET(request: Request) {
} }
const csv = rowsToCsv(rows) const csv = rowsToCsv(rows)
const daysLabel = (() => {
const raw = (range ?? "90d").replace("d", "")
return /^(7|30|90)$/.test(raw) ? `${raw}d` : "all"
})()
return new NextResponse(csv, { return new NextResponse(csv, {
headers: { headers: {
"Content-Type": "text/csv; charset=UTF-8", "Content-Type": "text/csv; charset=UTF-8",
"Content-Disposition": `attachment; filename="sla-${tenantId}.csv"`, "Content-Disposition": `attachment; filename="sla-${tenantId}-${daysLabel}.csv"`,
"Cache-Control": "no-store", "Cache-Control": "no-store",
}, },
}) })

View file

@ -24,16 +24,21 @@ export function AdminAlertsManager() {
const alertsRaw = useQuery( const alertsRaw = useQuery(
api.alerts.list, api.alerts.list,
convexUserId ? { tenantId, viewerId: convexUserId as Id<"users"> } : "skip" convexUserId
? ({
tenantId,
viewerId: convexUserId as Id<"users">,
start,
end,
companyId: companyId === "all" ? undefined : (companyId as Id<"companies">),
})
: "skip"
) as Doc<"alerts">[] | undefined ) as Doc<"alerts">[] | undefined
const alerts = useMemo(() => { const alerts = useMemo(() => {
let list = alertsRaw ?? [] const 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) return list.sort((a, b) => b.createdAt - a.createdAt)
}, [alertsRaw, companyId, start, end]) }, [alertsRaw])
const companies = useQuery( const companies = useQuery(
api.companies.list, api.companies.list,
@ -124,4 +129,3 @@ export function AdminAlertsManager() {
</Card> </Card>
) )
} }

View file

@ -33,6 +33,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
import { import {
ToggleGroup, ToggleGroup,
ToggleGroupItem, ToggleGroupItem,
@ -44,8 +45,8 @@ export function ChartAreaInteractive() {
const [mounted, setMounted] = React.useState(false) const [mounted, setMounted] = React.useState(false)
const isMobile = useIsMobile() const isMobile = useIsMobile()
const [timeRange, setTimeRange] = React.useState("7d") const [timeRange, setTimeRange] = React.useState("7d")
// Use a non-empty sentinel value for "all" to satisfy Select.Item requirements // Persistir seleção de empresa globalmente
const [companyId, setCompanyId] = React.useState<string>("all") const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [companyQuery, setCompanyQuery] = React.useState("") const [companyQuery, setCompanyQuery] = React.useState("")
const { session, convexUserId } = useAuth() const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID

View file

@ -13,6 +13,7 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
const PRIORITY_LABELS: Record<string, string> = { const PRIORITY_LABELS: Record<string, string> = {
LOW: "Baixa", LOW: "Baixa",
@ -30,7 +31,7 @@ const STATUS_LABELS: Record<string, string> = {
export function BacklogReport() { export function BacklogReport() {
const [timeRange, setTimeRange] = useState("90d") const [timeRange, setTimeRange] = useState("90d")
const [companyId, setCompanyId] = useState<string>("all") const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const { session, convexUserId } = useAuth() const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID
const data = useQuery( const data = useQuery(

View file

@ -13,6 +13,7 @@ import { useState } from "react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
function formatScore(value: number | null) { function formatScore(value: number | null) {
if (value === null) return "—" if (value === null) return "—"
@ -20,7 +21,7 @@ function formatScore(value: number | null) {
} }
export function CsatReport() { export function CsatReport() {
const [companyId, setCompanyId] = useState<string>("all") const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [timeRange, setTimeRange] = useState<string>("90d") const [timeRange, setTimeRange] = useState<string>("90d")
const { session, convexUserId } = useAuth() const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID

View file

@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
function formatHours(ms: number) { function formatHours(ms: number) {
const hours = ms / 3600000 const hours = ms / 3600000
@ -32,7 +33,7 @@ type HoursItem = {
export function HoursReport() { export function HoursReport() {
const [timeRange, setTimeRange] = useState("90d") const [timeRange, setTimeRange] = useState("90d")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [companyId, setCompanyId] = useState<string>("all") const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const { session, convexUserId } = useAuth() const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID

View file

@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { useState } from "react" import { useState } from "react"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { usePersistentCompanyFilter } from "@/lib/use-company-filter"
function formatMinutes(value: number | null) { function formatMinutes(value: number | null) {
if (value === null) return "—" if (value === null) return "—"
@ -25,7 +26,7 @@ function formatMinutes(value: number | null) {
} }
export function SlaReport() { export function SlaReport() {
const [companyId, setCompanyId] = useState<string>("all") const [companyId, setCompanyId] = usePersistentCompanyFilter("all")
const [timeRange, setTimeRange] = useState<string>("90d") const [timeRange, setTimeRange] = useState<string>("90d")
const { session, convexUserId } = useAuth() const { session, convexUserId } = useAuth()
const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID

View file

@ -0,0 +1,31 @@
"use client"
import { useEffect, useState } from "react"
const STORAGE_KEY = "ui:selectedCompanyId"
export function usePersistentCompanyFilter(initial: string = "all") {
const [companyId, setCompanyId] = useState<string>(initial)
useEffect(() => {
try {
const saved = window.localStorage.getItem(STORAGE_KEY)
if (saved) setCompanyId(saved)
} catch {
// ignore
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const update = (value: string) => {
setCompanyId(value)
try {
window.localStorage.setItem(STORAGE_KEY, value)
} catch {
// ignore
}
}
return [companyId, update] as const
}

View file

@ -7,6 +7,8 @@ type SmtpConfig = {
password: string password: string
from: string from: string
tls?: boolean tls?: boolean
rejectUnauthorized?: boolean
timeoutMs?: number
} }
function b64(input: string) { function b64(input: string) {
@ -27,24 +29,32 @@ function extractEnvelopeAddress(from: string): string {
return from return from
} }
export async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, html: string) { export async function sendSmtpMail(cfg: SmtpConfig, to: string | string[], subject: string, html: string) {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: false }, () => { const socket = tls.connect(cfg.port, cfg.host, { rejectUnauthorized: cfg.rejectUnauthorized ?? false }, () => {
let buffer = "" let buffer = ""
const send = (line: string) => socket.write(line + "\r\n") const send = (line: string) => socket.write(line + "\r\n")
const wait = (expected: string | RegExp) => const wait = (expected: string | RegExp) =>
new Promise<void>((res, rej) => { new Promise<void>((res, rej) => {
const timeout = setTimeout(() => {
socket.removeListener("data", onData)
rej(new Error("smtp_timeout"))
}, Math.max(1000, cfg.timeoutMs ?? 10000))
const onData = (data: Buffer) => { const onData = (data: Buffer) => {
buffer += data.toString() buffer += data.toString()
const lines = buffer.split(/\r?\n/) const lines = buffer.split(/\r?\n/)
const last = lines.filter(Boolean).slice(-1)[0] ?? "" const last = lines.filter(Boolean).slice(-1)[0] ?? ""
if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) { if (typeof expected === "string" ? last.startsWith(expected) : expected.test(last)) {
socket.removeListener("data", onData) socket.removeListener("data", onData)
clearTimeout(timeout)
res() res()
} }
} }
socket.on("data", onData) socket.on("data", onData)
socket.on("error", rej) socket.on("error", (e) => {
clearTimeout(timeout)
rej(e)
})
}) })
;(async () => { ;(async () => {
@ -61,13 +71,21 @@ export async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string,
const envelopeFrom = extractEnvelopeAddress(cfg.from) const envelopeFrom = extractEnvelopeAddress(cfg.from)
send(`MAIL FROM:<${envelopeFrom}>`) send(`MAIL FROM:<${envelopeFrom}>`)
await wait(/^250 /) await wait(/^250 /)
send(`RCPT TO:<${to}>`) const rcpts: string[] = Array.isArray(to)
await wait(/^250 /) ? to
: String(to)
.split(/[;,]/)
.map((s) => s.trim())
.filter(Boolean)
for (const rcpt of rcpts) {
send(`RCPT TO:<${rcpt}>`)
await wait(/^250 /)
}
send("DATA") send("DATA")
await wait(/^354 /) await wait(/^354 /)
const headers = [ const headers = [
`From: ${cfg.from}`, `From: ${cfg.from}`,
`To: ${to}`, `To: ${Array.isArray(to) ? to.join(", ") : to}`,
`Subject: ${subject}`, `Subject: ${subject}`,
"MIME-Version: 1.0", "MIME-Version: 1.0",
"Content-Type: text/html; charset=UTF-8", "Content-Type: text/html; charset=UTF-8",