From 384d4411b65a01780346641f6d9f2055ee1eced9 Mon Sep 17 00:00:00 2001 From: Esdras Renan Date: Tue, 7 Oct 2025 16:46:52 -0300 Subject: [PATCH] =?UTF-8?q?reports(SLA):=20aplica=20filtro=20de=20per?= =?UTF-8?q?=C3=ADodo=20(7d/30d/90d)=20no=20Convex=20e=20inclui=20per=C3=AD?= =?UTF-8?q?odo=20no=20filename=20do=20CSV;=20admin(alerts):=20filtros=20no?= =?UTF-8?q?=20servidor;=20alerts:=20batch=20de=20=C3=BAltimos=20alertas=20?= =?UTF-8?q?por=20slugs;=20filtros=20persistentes=20de=20empresa=20(localSt?= =?UTF-8?q?orage)=20em=20relat=C3=B3rios;=20prisma:=20Company.contractedHo?= =?UTF-8?q?ursPerMonth;=20smtp:=20suporte=20a=20m=C3=BAltiplos=20destinat?= =?UTF-8?q?=C3=A1rios=20e=20timeout=20opcional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- convex/alerts.ts | 30 ++++++++++++++++++ convex/reports.ts | 18 ++++++++--- prisma/schema.prisma | 1 + .../api/admin/companies/last-alerts/route.ts | 18 ++++------- src/app/api/reports/sla.csv/route.ts | 8 +++-- .../admin/alerts/admin-alerts-manager.tsx | 18 ++++++----- src/components/chart-area-interactive.tsx | 5 +-- src/components/reports/backlog-report.tsx | 3 +- src/components/reports/csat-report.tsx | 3 +- src/components/reports/hours-report.tsx | 3 +- src/components/reports/sla-report.tsx | 3 +- src/lib/use-company-filter.ts | 31 +++++++++++++++++++ src/server/email-smtp.ts | 30 ++++++++++++++---- 13 files changed, 133 insertions(+), 38 deletions(-) create mode 100644 src/lib/use-company-filter.ts diff --git a/convex/alerts.ts b/convex/alerts.ts index fa4f556..14ccc24 100644 --- a/convex/alerts.ts +++ b/convex/alerts.ts @@ -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 = {} + // 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({ args: {}, handler: async (ctx) => { diff --git a/convex/reports.ts b/convex/reports.ts index 4a4d6e7..698f6f8 100644 --- a/convex/reports.ts +++ b/convex/reports.ts @@ -129,21 +129,28 @@ function formatDateKey(timestamp: number) { export const slaOverview = query({ 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); let tickets = await fetchScopedTickets(ctx, tenantId, viewer); 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 now = Date.now(); - const openTickets = tickets.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); - const resolvedTickets = tickets.filter((ticket) => { + const openTickets = inRange.filter((ticket) => OPEN_STATUSES.has(normalizeStatus(ticket.status))); + const resolvedTickets = inRange.filter((ticket) => { const status = normalizeStatus(ticket.status); return status === "RESOLVED"; }); const overdueTickets = openTickets.filter((ticket) => ticket.dueAt && ticket.dueAt < now); - const firstResponseTimes = tickets + const firstResponseTimes = inRange .filter((ticket) => ticket.firstResponseAt) .map((ticket) => (ticket.firstResponseAt! - ticket.createdAt) / 60000); const resolutionTimes = resolvedTickets @@ -161,7 +168,7 @@ export const slaOverview = query({ return { totals: { - total: tickets.length, + total: inRange.length, open: openTickets.length, resolved: resolvedTickets.length, overdue: overdueTickets.length, @@ -175,6 +182,7 @@ export const slaOverview = query({ resolvedCount: resolutionTimes.length, }, queueBreakdown, + rangeDays: days, }; }, }); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e129fb0..d3edebe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -73,6 +73,7 @@ model Company { name String slug String isAvulso Boolean @default(false) + contractedHoursPerMonth Float? cnpj String? domain String? phone String? diff --git a/src/app/api/admin/companies/last-alerts/route.ts b/src/app/api/admin/companies/last-alerts/route.ts index 1524538..390e394 100644 --- a/src/app/api/admin/companies/last-alerts/route.ts +++ b/src/app/api/admin/companies/last-alerts/route.ts @@ -21,17 +21,11 @@ export async function GET(request: Request) { const slugs = slugsParam.split(",").map((s) => s.trim()).filter(Boolean) const tenantId = session.user.tenantId ?? "tenant-atlas" - const result: Record = {} - 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 - } + try { + const result = (await client.query(api.alerts.lastForCompaniesBySlugs, { tenantId, slugs })) as Record + return NextResponse.json({ items: result }) + } catch (error) { + console.error("Failed to fetch last alerts by slugs", error) + return NextResponse.json({ items: {} }) } - - return NextResponse.json({ items: result }) } diff --git a/src/app/api/reports/sla.csv/route.ts b/src/app/api/reports/sla.csv/route.ts index e0c53d0..cc8ed0d 100644 --- a/src/app/api/reports/sla.csv/route.ts +++ b/src/app/api/reports/sla.csv/route.ts @@ -68,7 +68,7 @@ export async function GET(request: Request) { const rows: Array> = [] 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]) rows.push([]) @@ -89,10 +89,14 @@ export async function GET(request: Request) { } 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, { headers: { "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", }, }) diff --git a/src/components/admin/alerts/admin-alerts-manager.tsx b/src/components/admin/alerts/admin-alerts-manager.tsx index e9afc04..8484f02 100644 --- a/src/components/admin/alerts/admin-alerts-manager.tsx +++ b/src/components/admin/alerts/admin-alerts-manager.tsx @@ -24,16 +24,21 @@ export function AdminAlertsManager() { const alertsRaw = useQuery( 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 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) + const list = alertsRaw ?? [] return list.sort((a, b) => b.createdAt - a.createdAt) - }, [alertsRaw, companyId, start, end]) + }, [alertsRaw]) const companies = useQuery( api.companies.list, @@ -124,4 +129,3 @@ export function AdminAlertsManager() { ) } - diff --git a/src/components/chart-area-interactive.tsx b/src/components/chart-area-interactive.tsx index 959d342..b53cd06 100644 --- a/src/components/chart-area-interactive.tsx +++ b/src/components/chart-area-interactive.tsx @@ -33,6 +33,7 @@ import { SelectValue, } from "@/components/ui/select" import { Input } from "@/components/ui/input" +import { usePersistentCompanyFilter } from "@/lib/use-company-filter" import { ToggleGroup, ToggleGroupItem, @@ -44,8 +45,8 @@ 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("all") + // Persistir seleção de empresa globalmente + const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [companyQuery, setCompanyQuery] = React.useState("") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID diff --git a/src/components/reports/backlog-report.tsx b/src/components/reports/backlog-report.tsx index b5c1528..1a88a14 100644 --- a/src/components/reports/backlog-report.tsx +++ b/src/components/reports/backlog-report.tsx @@ -13,6 +13,7 @@ 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" +import { usePersistentCompanyFilter } from "@/lib/use-company-filter" const PRIORITY_LABELS: Record = { LOW: "Baixa", @@ -30,7 +31,7 @@ const STATUS_LABELS: Record = { export function BacklogReport() { const [timeRange, setTimeRange] = useState("90d") - const [companyId, setCompanyId] = useState("all") + const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID const data = useQuery( diff --git a/src/components/reports/csat-report.tsx b/src/components/reports/csat-report.tsx index 61e33d9..b1fe88f 100644 --- a/src/components/reports/csat-report.tsx +++ b/src/components/reports/csat-report.tsx @@ -13,6 +13,7 @@ import { useState } from "react" import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { usePersistentCompanyFilter } from "@/lib/use-company-filter" function formatScore(value: number | null) { if (value === null) return "—" @@ -20,7 +21,7 @@ function formatScore(value: number | null) { } export function CsatReport() { - const [companyId, setCompanyId] = useState("all") + const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [timeRange, setTimeRange] = useState("90d") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID diff --git a/src/components/reports/hours-report.tsx b/src/components/reports/hours-report.tsx index 796cfb7..ceb4805 100644 --- a/src/components/reports/hours-report.tsx +++ b/src/components/reports/hours-report.tsx @@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { usePersistentCompanyFilter } from "@/lib/use-company-filter" function formatHours(ms: number) { const hours = ms / 3600000 @@ -32,7 +33,7 @@ type HoursItem = { export function HoursReport() { const [timeRange, setTimeRange] = useState("90d") const [query, setQuery] = useState("") - const [companyId, setCompanyId] = useState("all") + const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID diff --git a/src/components/reports/sla-report.tsx b/src/components/reports/sla-report.tsx index 2735bb0..de0ff71 100644 --- a/src/components/reports/sla-report.tsx +++ b/src/components/reports/sla-report.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { useState } from "react" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { usePersistentCompanyFilter } from "@/lib/use-company-filter" function formatMinutes(value: number | null) { if (value === null) return "—" @@ -25,7 +26,7 @@ function formatMinutes(value: number | null) { } export function SlaReport() { - const [companyId, setCompanyId] = useState("all") + const [companyId, setCompanyId] = usePersistentCompanyFilter("all") const [timeRange, setTimeRange] = useState("90d") const { session, convexUserId } = useAuth() const tenantId = session?.user.tenantId ?? DEFAULT_TENANT_ID diff --git a/src/lib/use-company-filter.ts b/src/lib/use-company-filter.ts new file mode 100644 index 0000000..acdf4c6 --- /dev/null +++ b/src/lib/use-company-filter.ts @@ -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(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 +} + diff --git a/src/server/email-smtp.ts b/src/server/email-smtp.ts index 42c87af..851b707 100644 --- a/src/server/email-smtp.ts +++ b/src/server/email-smtp.ts @@ -7,6 +7,8 @@ type SmtpConfig = { password: string from: string tls?: boolean + rejectUnauthorized?: boolean + timeoutMs?: number } function b64(input: string) { @@ -27,24 +29,32 @@ function extractEnvelopeAddress(from: string): string { 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((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 = "" const send = (line: string) => socket.write(line + "\r\n") const wait = (expected: string | RegExp) => new Promise((res, rej) => { + const timeout = setTimeout(() => { + socket.removeListener("data", onData) + rej(new Error("smtp_timeout")) + }, Math.max(1000, cfg.timeoutMs ?? 10000)) 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) + clearTimeout(timeout) res() } } socket.on("data", onData) - socket.on("error", rej) + socket.on("error", (e) => { + clearTimeout(timeout) + rej(e) + }) }) ;(async () => { @@ -61,13 +71,21 @@ export async function sendSmtpMail(cfg: SmtpConfig, to: string, subject: string, const envelopeFrom = extractEnvelopeAddress(cfg.from) send(`MAIL FROM:<${envelopeFrom}>`) await wait(/^250 /) - send(`RCPT TO:<${to}>`) - await wait(/^250 /) + const rcpts: string[] = Array.isArray(to) + ? to + : String(to) + .split(/[;,]/) + .map((s) => s.trim()) + .filter(Boolean) + for (const rcpt of rcpts) { + send(`RCPT TO:<${rcpt}>`) + await wait(/^250 /) + } send("DATA") await wait(/^354 /) const headers = [ `From: ${cfg.from}`, - `To: ${to}`, + `To: ${Array.isArray(to) ? to.join(", ") : to}`, `Subject: ${subject}`, "MIME-Version: 1.0", "Content-Type: text/html; charset=UTF-8",