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:
parent
48f8952079
commit
13eb53c3cf
4 changed files with 149 additions and 119 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -43,3 +43,7 @@ next-env.d.ts
|
|||
|
||||
# backups locais
|
||||
.archive/
|
||||
|
||||
# arquivos locais temporários
|
||||
Captura de tela *.png
|
||||
Screenshot*.png
|
||||
|
|
|
|||
|
|
@ -39,7 +39,9 @@
|
|||
- 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.
|
||||
- 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
|
||||
- Exportações CSV (Backlog, Canais, CSAT, SLA e Horas por cliente) com parâmetros de período.
|
||||
|
|
|
|||
|
|
@ -1,123 +1,20 @@
|
|||
"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"
|
||||
import { AppShell } from "@/components/app-shell"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { AdminAlertsManager } from "@/components/admin/alerts/admin-alerts-manager"
|
||||
|
||||
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>
|
||||
<AppShell
|
||||
header={
|
||||
<SiteHeader
|
||||
title="Alertas enviados"
|
||||
lead="Histórico de e-mails de alerta de uso de horas."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-12 lg:px-6 space-y-6">
|
||||
<AdminAlertsManager />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
127
src/components/admin/alerts/admin-alerts-manager.tsx
Normal file
127
src/components/admin/alerts/admin-alerts-manager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue