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
|
# backups locais
|
||||||
.archive/
|
.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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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">
|
|
||||||
<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>
|
</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