ui(admin/alerts): envolver página com AppShell + SiteHeader e mover lógica para AdminAlertsManager (client); docs: agents.md reforça uso do wrapper em páginas administrativas

This commit is contained in:
Esdras Renan 2025-10-07 16:25:37 -03:00
parent 48f8952079
commit 13eb53c3cf
4 changed files with 149 additions and 119 deletions

4
.gitignore vendored
View file

@ -43,3 +43,7 @@ next-env.d.ts
# backups locais # backups locais
.archive/ .archive/
# arquivos locais temporários
Captura de tela *.png
Screenshot*.png

View file

@ -39,7 +39,9 @@
- Autenticação Better Auth com guardas client-side (`AuthGuard`) bloqueando rotas protegidas. - Autenticação Better Auth com guardas client-side (`AuthGuard`) bloqueando rotas protegidas.
- Menu de usuário no rodapé da sidebar com link para `/settings` e logout confiável. - Menu de usuário no rodapé da sidebar com link para `/settings` e logout confiável.
- Formulários de novo ticket (dialog, página e portal) com seleção de responsável, placeholders claros e validação obrigatória de assunto/descrição/categorias. - Formulários de novo ticket (dialog, página e portal) com seleção de responsável, placeholders claros e validação obrigatória de assunto/descrição/categorias.
- Relatórios e dashboards utilizam `AppShell`, garantindo header/sidebar consistentes. - Relatórios, dashboards e páginas administrativas utilizam `AppShell`, garantindo header/sidebar consistentes.
- Use `SiteHeader` no `header` do `AppShell` para título/lead e ações.
- O conteúdo deve ficar dentro de `<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6">`.
## Entregas recentes ## Entregas recentes
- Exportações CSV (Backlog, Canais, CSAT, SLA e Horas por cliente) com parâmetros de período. - Exportações CSV (Backlog, Canais, CSAT, SLA e Horas por cliente) com parâmetros de período.

View file

@ -1,123 +1,20 @@
"use client" import { AppShell } from "@/components/app-shell"
import { SiteHeader } from "@/components/site-header"
import { useQuery } from "convex/react" import { AdminAlertsManager } from "@/components/admin/alerts/admin-alerts-manager"
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() { 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 ( return (
<div className="space-y-6"> <AppShell
<Card className="border-slate-200"> header={
<CardHeader> <SiteHeader
<CardTitle className="text-lg font-semibold text-neutral-900">Alertas enviados</CardTitle> title="Alertas enviados"
<CardDescription className="text-neutral-600"> lead="Histórico de e-mails de alerta de uso de horas."
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"> <div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6 space-y-6">
<Select value={companyId} onValueChange={setCompanyId}> <AdminAlertsManager />
<SelectTrigger className="w-full min-w-56 sm:w-64"> </div>
<SelectValue placeholder="Todas as empresas" /> </AppShell>
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="all">Todas as empresas</SelectItem>
{(companies ?? []).map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={range} onValueChange={setRange}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Período" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="7d">Últimos 7 dias</SelectItem>
<SelectItem value="30d">Últimos 30 dias</SelectItem>
<SelectItem value="90d">Últimos 90 dias</SelectItem>
<SelectItem value="all">Todos</SelectItem>
</SelectContent>
</Select>
<Button asChild size="sm" variant="outline">
<a href="/api/admin/alerts/hours-usage?range=30d&threshold=90">Disparar manualmente</a>
</Button>
</div>
</CardAction>
</CardHeader>
<CardContent>
{!alerts ? (
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full rounded-lg" />
))}
</div>
) : alerts.length === 0 ? (
<p className="rounded-lg border border-dashed border-slate-200 p-6 text-sm text-neutral-500">
Nenhum alerta enviado ainda.
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full border-separate border-spacing-y-2">
<thead>
<tr className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-500">
<th className="px-2 py-1">Quando</th>
<th className="px-2 py-1">Empresa</th>
<th className="px-2 py-1">Uso</th>
<th className="px-2 py-1">Limite</th>
<th className="px-2 py-1">Período</th>
<th className="px-2 py-1">Destinatários</th>
<th className="px-2 py-1">Entregues</th>
</tr>
</thead>
<tbody>
{alerts.map((a) => (
<tr key={a._id} className="rounded-xl border border-slate-200 bg-white">
<td className="px-2 py-2 text-sm text-neutral-700">
{new Date(a.createdAt).toLocaleString("pt-BR")}
</td>
<td className="px-2 py-2 text-sm font-medium text-neutral-900">{a.companyName}</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.usagePct.toFixed(1)}%</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.threshold}%</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.range}</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.recipients.join(", ")}</td>
<td className="px-2 py-2 text-sm text-neutral-700">{a.deliveredCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
) )
} }

View file

@ -0,0 +1,127 @@
"use client"
import { useMemo, useState } from "react"
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 { Button } from "@/components/ui/button"
export function AdminAlertsManager() {
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 (
<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>
)
}