` with no preceding ``, skipping heading level (h1→h3). Same for chart `` elements (h3 directly under h1). WCAG 2.2 SC 1.3.1 (G141/F43).
+
+**Files:**
+- Modify: `src/app/dashboard/_components/empty-state.tsx:19` — `` → ``
+- Modify: `src/app/dashboard/page.tsx:59` — add sr-only `` before ``
+
+**Interfaces:**
+- Consumes: `PageHeader` renders ``, `EmptyState` renders ``, `CardTitle` renders ``
+
+- [ ] **Step 1: Change EmptyState title to h2**
+
+```tsx
+// src/app/dashboard/_components/empty-state.tsx line 19
+// OLD:
+{title}
+
+// NEW:
+{title}
+```
+
+- [ ] **Step 2: Add sr-only h2 before charts on overview page**
+
+```tsx
+// src/app/dashboard/page.tsx — after the KPI grid closing , before
+
+
+
+ Visão geral dos gráficos
+ `
+
+- [ ] **Step 1: Add aria-hidden**
+
+```tsx
+// src/app/dashboard/_components/kpi-card.tsx line ~47
+// OLD:
+{positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+// NEW:
+ {formatDelta(delta!)}
+```
+
+- [ ] **Step 2: Verify KpiCard tests**
+
+Run: `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx
+git commit -m "fix(a11y): aria-hidden on KpiCard delta triangle glyph — redundant with aria-label"
+```
+
+---
+
+### Task 8: Performance — window + group-by at DB for getReceitaPorMes + getMatriculasPorMes
+
+**Audit finding:** Important #7 + #8 (performance) — both functions unbounded all-time scan. `getMatriculasPorMes` fetches ALL alunos, `getReceitaPorMes` fetches ALL pagamentos (fastest-growing table). Add 13-month window.
+
+**Files:**
+- Modify: `src/lib/data.ts:117-132` — add `where: { dataCadastro/dataPagamento: { gte: thirteenMonthsAgo } }`
+
+**Interfaces:**
+- Consumes: `prisma.aluno.findMany`, `prisma.pagamento.findMany`
+- Produces: `MonthTotal[]` — same shape, now bounded to recent 13 months
+
+- [ ] **Step 1: Rewrite getMatriculasPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 117-120
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ // ponytail: Prisma groupBy can't group by date-trunc directly; we window to 13 months
+ // and group in JS. Upgrade to prisma.$queryRaw with DATE_TRUNC if row count grows.
+ const rows = await prisma.aluno.findMany({
+ where: { dataCadastro: { gte: thirteenMonthsAgo } },
+ select: { dataCadastro: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+```
+
+- [ ] **Step 2: Rewrite getReceitaPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 122-132
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+```
+
+- [ ] **Step 3: Verify tests still pass**
+
+Existing tests use `vi.fn()` mocks which ignore arguments — no test change needed.
+
+Run: `npx vitest run src/lib/data.test.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/lib/data.ts
+git commit -m "perf(dashboard): window getMatriculasPorMes + getReceitaPorMes to 13 months"
+```
+
+---
+
+## Execution order (dependency chain)
+
+```
+T1 (label fix) ──┐
+ ├── T3 (page test honest mock) ── depends on T1 label
+T2 (.default) ──┘
+ │
+T4 (.strict isolation test) — independent
+T5 (golden-path getDashboardStats test) — independent
+T6 (heading a11y) ── independent ── T7 (aria-hidden) — independent
+T8 (perf window) — independent (last, re-runs all data tests)
+```
+
+Parallel groups:
+- **Wave 1**: T1 + T2 (same file area, sequential — T1 touches page.tsx, T2 touches definitions.ts)
+- **Wave 2**: T3 (depends on T1)
+- **Wave 3**: T4 || T5 || T6 || T7 (all independent, parallel-safe)
+- **Wave 4**: T8 (independent, last — re-runs all data tests)
+
+## Post-execution verification
+
+After all 8 tasks complete:
+
+```bash
+npm test # Vitest — must stay ≥ 1176 (existing 1166 + ~10 new tests)
+npm run typecheck # tsc --noEmit — 0 errors
+npm run lint # eslint — 0 errors, 0 warnings
+```
+
+Update `docs/CURRENT-STATE.md` with audit-fix summary.
diff --git a/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
new file mode 100644
index 00000000..469f986b
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
@@ -0,0 +1,892 @@
+# Gerente Dashboard Refactor Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Bring the GERENTE `/dashboard` (overview + alunos/financeiro/planos/treinos sub-pages + layout) up to the ALUNO dashboard quality bar — real Prisma data, honest empty-states, valid HTML, real loading/error states, no fake data.
+
+**Architecture:** RSC fetches via `src/lib/data.ts`, serializes, client renders only. Design tokens from `globals.css` `@theme` (cyan primary OKLCH, `glass-card`, `glow-cyan`). New KPI/chart components mirror ALUNO patterns (`card-treino.tsx` empty-state, `card.tsx` `glass` prop). Charts swap hardcoded `oklch()` literals → tokens + `role="img"` aria.
+
+**Tech Stack:** Next.js 15 App Router, React 18, TypeScript 5 strict, Prisma 7.7, recharts, Vitest 4 + @testing-library/react, Zod 4 (`zod/v4`).
+
+## Global Constraints
+
+- ZERO fake data — real Prisma queries only; where no rows exist, show honest `EmptyState` ("sem histórico ainda"), never fabricate (`ponytail:`/synthetic growth removed).
+- No DB migration — all queried fields already exist (`Aluno.dataCadastro`, `Pagamento.dataPagamento`+`valor`+`@@index([dataPagamento])`, `Matricula.status`+`planoId`+`Plano`, `Aluno.statusMatricula`).
+- Gamification NOT ported (ALUNO trophy room is static/fake; admin ≠ motivation).
+- TDD — write the failing test first, watch it fail, implement, green, before commit.
+- 4 gates must pass per commit: `npm test && npm run lint && npm run typecheck` (and `npm run e2e` at end).
+- Tokens over raw values: `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`, `border-white/5` (NOT `bg-black`, `#18181B`, `text-zinc-400`, raw `shadow-[...]`).
+
+---
+
+## File Structure
+
+**Create**
+- `src/app/dashboard/_components/kpi-card.tsx` — `Card glass` + delta badge + icon+text + `aria-label` + `data-testid`.
+- `src/app/dashboard/_components/empty-state.tsx` — port ALUNO empty-state pattern (icon + honest copy. `card-treino.tsx:40-57`).
+- `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts.
+- `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message + retry (`reset()`).
+- `src/components/dashboard/dashboard-charts-multi.tsx` — real multi-series chart (growth + revenue + plan) with `role="img"`/aria + empty-state. Replaces single hardcoded chart.
+- Tests: `kpi-card.test.tsx`, `empty-state.test.tsx`, `data.test.ts`, `loading.test.tsx`, `error.test.tsx`.
+
+**Modify**
+- `src/lib/data.ts` — remove synthetic `crescimentoAnual` (138-142); add `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano`, KPI deltas; re-throw on error (stop `parse({})` default at 153).
+- `src/lib/definitions.ts` — extend `DashboardStatsSchema` (drop `crescimentoAnual`, add real-series + delta fields).
+- `src/app/dashboard/page.tsx` — KPI grid via `KpiCard`, real charts.
+- `src/app/dashboard/layout.tsx` — remove double `` (line 120), move `pb-20` to inner wrapper.
+- `src/components/dashboard/dashboard-charts.tsx` — swap hardcoded oklch → tokens + `role="img"`/aria + empty-state (or replace via multi chart).
+- `src/app/dashboard/alunos/page.tsx` — tokenize `bg-black`, add `pb-20`.
+- `src/app/dashboard/financeiro/page.tsx` — tokenize `bg-black`/`#18181B`/`text-zinc-400`/raw-shadow, add `pb-20`.
+- `src/app/dashboard/planos/page.tsx` — wrap with `pb-20` container.
+- `src/app/dashboard/treinos/page.tsx` — add `pb-20` + Suspense/skeleton.
+- `src/components/ui/dashboard-skeletons.tsx` — add `DashboardOverviewSkeleton`.
+- `src/app/dashboard/page.test.tsx` — update to new KPI/delta assertions.
+
+---
+
+## Task 1: Remove synthetic growth + add real series types
+
+**Files:**
+- Modify: `src/lib/definitions.ts:212-227`
+- Test: `src/lib/definitions.test.ts`
+
+**Interfaces:**
+- Produces: `DashboardStatsSchema` shape with `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano`, `deltas` (no `crescimentoAnual`).
+
+- [ ] **Step 1: Write failing test**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { DashboardStatsSchema } from './definitions';
+
+describe('DashboardStatsSchema', () => {
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ });
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }] });
+ expect(withFake.success).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: FAIL (`crescimentoAnual` still valid; `matriculasPorMes`/`deltas` missing).
+
+- [ ] **Step 3: Write minimal implementation** — replace `DashboardStatsSchema` block (212-227):
+
+```ts
+export const MonthTotalSchema = z.object({ mes: z.string(), total: z.number() });
+export const PlanTotalSchema = z.object({ plano: z.string(), total: z.number() });
+export const DashboardDeltasSchema = z.object({
+ alunos: z.number(),
+ receita: z.number(),
+ inadimplentes: z.number(),
+ novos: z.number(),
+});
+
+export const DashboardStatsSchema = z.object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ alunos: 0, receita: 0, inadimplentes: 0, novos: 0 }),
+});
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/definitions.ts src/lib/definitions.test.ts
+git commit -m "feat(dashboard): replace synthetic crescimentoAnual with real series + delta schema"
+```
+
+---
+
+## Task 2: Real Prisma queries in `getDashboardStats` + re-throw
+
+**Files:**
+- Modify: `src/lib/data.ts:105-155`
+- Test: `src/lib/data.test.ts`
+
+**Interfaces:**
+- Consumes: Prisma models (`aluno`, `matricula`, `pagamento`, `plano`).
+- Produces: `getDashboardStats()` returning `DashboardStats` with real series + `deltas`; `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano` returning `[]` when empty; throws on DB failure (no swallowing).
+
+- [ ] **Step 1: Write failing test** — `src/lib/data.test.ts`:
+
+```ts
+import { describe, it, expect, vi } from 'vitest';
+import { getDashboardStats } from './data';
+import { getMatriculasPorMes, getReceitaPorMes, getMatriculasPorPlano } from './data';
+
+describe('getDashboardStats', () => {
+ it('returns empty series (not fake) when no rows', async () => {
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect(stats.crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+});
+
+describe('series helpers', () => {
+ it('getMatriculasPorMes returns [] when no alunos', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/data.test.ts`
+ Expected: FAIL (old code returns `crescimentoAnual`, swallows errors).
+
+- [ ] **Step 3: Write minimal implementation** — replace `getDashboardStats` and add helpers:
+
+```ts
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
+
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const rows = await prisma.aluno.findMany({ select: { dataCadastro: true } });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+
+export async function getReceitaPorMes() {
+ const rows = await prisma.pagamento.findMany({ select: { dataPagamento: true, valor: true } });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
+ }
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number) {
+ if (prev === 0) return curr === 0 ? 0 : 1;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [totalAlunos, matriculasAtivas, alunosInadimplentes, faturamentoMensal, matriculasPorMes, receitaPorMes, matriculasPorPlano] =
+ await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ prisma.pagamento.aggregate({ _sum: { valor: true } }).then((r) => r._sum.valor ?? 0),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ const deltas = {
+ alunos: pctDelta(matriculasPorMes.length ? totalAlunos : 0, prev(matriculasPorMes)),
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ inadimplentes: pctDelta(alunosInadimplentes, alunosInadimplentes),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
+}
+```
+
+> Note: remove the try/catch that returned `DashboardStatsSchema.parse({})` at line 151-154 and the synthetic `crescimentoAnual` block (136-142). Errors propagate so `error.tsx` catches them.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/data.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/data.ts src/lib/data.test.ts
+git commit -m "feat(dashboard): real Prisma series + KPI deltas, re-throw on DB error"
+```
+
+---
+
+## Task 3: `KpiCard` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/kpi-card.tsx`
+- Test: `src/app/dashboard/_components/kpi-card.test.tsx`
+
+**Interfaces:**
+- Produces: `KpiCard` with props `{ title, value, delta?, icon }`. Renders ``, delta badge (green `+12%` / red `-5%`), text not color-only, `data-testid="kpi-{title}"`.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta with text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: FAIL (file missing).
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import type { ReactNode } from 'react';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number) {
+ const pct = Math.round(delta * 100);
+ return `${pct >= 0 ? '+' : ''}${pct}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = typeof delta === 'number';
+ const positive = hasDelta && delta >= 0;
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx src/app/dashboard/_components/kpi-card.test.tsx
+git commit -m "feat(dashboard): KpiCard with delta badge + aria-label"
+```
+
+---
+
+## Task 4: `EmptyState` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/empty-state.tsx`
+- Test: `src/app/dashboard/_components/empty-state.test.tsx`
+
+**Interfaces:**
+- Produces: `EmptyState` `{ icon, title, description, testId? }` — ported from `card-treino.tsx:40-57` (dashed `glass` Card, centered icon, honest copy).
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render( } title="Sem histórico ainda" description="Sem dados para exibir." testId="chart-empty" />);
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/empty-state.tsx src/app/dashboard/_components/empty-state.test.tsx
+git commit -m "feat(dashboard): EmptyState for honest no-data rendering"
+```
+
+---
+
+## Task 5: Multi-series chart (real data, tokenized, a11y)
+
+**Files:**
+- Create: `src/components/dashboard/dashboard-charts-multi.tsx`
+- Test: `src/components/dashboard/dashboard-charts-multi.test.tsx`
+
+**Interfaces:**
+- Consumes: `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano` from `getDashboardStats`.
+- Produces: chart trio (growth bars, revenue bars, plan dist) using tokens; `role="img"` + `aria-label`; `EmptyState` when all series empty.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+import { CalendarOff } from 'lucide-react';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation** — tokenize colors, drop hardcoded `oklch()`, add `role="img"` + `aria-label`, empty-state branch:
+
+```tsx
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({ matriculasPorMes, receitaPorMes, matriculasPorPlano }: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* revenue chart: same Card/BarChart shape, data=receitaPorMes, aria-label="Gráfico de faturamento por mês" */}
+ {/* plan chart: data=matriculasPorPlano (dataKey="total", XAxis dataKey="plano"), aria-label="Distribuição de matrículas por plano" */}
+
+ );
+}
+```
+
+> ponytail: revenue + plan charts repeat the same Card/BarChart shape with different `data`/`aria-label`; both use tokens. Fill tracks theme via `var(--color-primary)` / `var(--color-gold)`.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/dashboard/dashboard-charts-multi.tsx src/components/dashboard/dashboard-charts-multi.test.tsx
+git commit -m "feat(dashboard): tokenized multi-series charts with a11y + empty-state"
+```
+
+---
+
+## Task 6: Overview page wiring (KPI grid + charts)
+
+**Files:**
+- Modify: `src/app/dashboard/page.tsx`
+- Test: `src/app/dashboard/page.test.tsx` (update)
+
+**Interfaces:**
+- Consumes: `getDashboardStats`, `KpiCard`, `DashboardChartsMulti`.
+
+- [ ] **Step 1: Write/Update failing test** — replace `page.test.tsx` mock + add KPI assertions:
+
+```tsx
+vi.mock('@/lib/data', () => ({
+ getDashboardStats: vi.fn().mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
+}));
+// assert
+expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+expect(screen.getByText('+10%')).toBeTruthy();
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: FAIL (old mock has `crescimentoAnual`; no KpiCard).
+
+- [ ] **Step 3: Write implementation** — rewrite `page.tsx`:
+
+```tsx
+import { PageHeader } from '@/components/page-header';
+import { getDashboardStats } from '@/lib/data';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
+
+export default async function DashboardPage() {
+ const stats = await getDashboardStats();
+
+ const kpis = [
+ { title: 'Total de Alunos', value: stats.totalAlunos.toLocaleString('pt-BR'), delta: stats.deltas.alunos, icon: },
+ { title: 'Matrículas Ativas', value: stats.matriculasAtivas.toLocaleString('pt-BR'), delta: stats.deltas.novos, icon: },
+ { title: 'Inadimplentes', value: stats.alunosInadimplentes.toLocaleString('pt-BR'), delta: stats.deltas.inadimplentes, icon: },
+ { title: 'Faturamento Mensal', value: stats.faturamentoMensal.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), delta: stats.deltas.receita, icon: },
+ ];
+
+ return (
+
+
+
+ {kpis.map((kpi) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/page.tsx src/app/dashboard/page.test.tsx
+git commit -m "feat(dashboard): wire overview KPI grid + real charts"
+```
+
+---
+
+## Task 7: Layout double-`` fix + `pb-20`
+
+**Files:**
+- Modify: `src/app/dashboard/layout.tsx:118-123`
+
+**Interfaces:**
+- RISKY: removing wrong `` breaks layout/SR. Keep `SidebarInset`'s `` (`sidebar.tsx:311`), drop `layout.tsx:120`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/layout.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: { getUser: async () => ({ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } }, error: null }) },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', () => {
+ const { container } = render({child} );
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: FAIL (2 `` currently).
+
+- [ ] **Step 3: Write implementation** — replace lines 118-123:
+
+```tsx
+
+
+
+ {children}
+
+
+```
+
+> Removed `` wrapper; `pb-20` now on inner `div` so mobile clears bottom-nav. `SidebarInset` still renders its own ``.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/layout.tsx src/app/dashboard/layout.test.tsx
+git commit -m "fix(dashboard): single landmark, move pb-20 to inner wrapper"
+```
+
+---
+
+## Task 8: Tokenize sub-pages (alunos / financeiro / planos / treinos)
+
+**Files:**
+- Modify: `src/app/dashboard/alunos/page.tsx:19`, `financeiro/page.tsx:42-54`, `planos/page.tsx` (wrapper), `treinos/page.tsx`
+
+**Interfaces:**
+- Consumes: tokens `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/alunos/page.test.tsx` + `financeiro/page.test.tsx`: assert no `bg-black`/`#18181B`/`text-zinc-400` in rendered HTML, and `pb-20` present.
+
+```tsx
+it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await AlunosPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write minimal implementation**
+- `alunos/page.tsx:19`: `` → ``
+- `financeiro/page.tsx:42`: `bg-black` → `bg-background`; `:47` Card `bg-[#18181B] border-white/10 ... shadow-[0_0_15px_rgba(34,211,238,0.05)]` → `glass-card border-white/10 glow-cyan`; `:49` `text-white` → `text-foreground`; `:52` `text-zinc-400` → `text-muted-foreground`; add `pb-20` to wrapper.
+- `planos/page.tsx`: wrap return in `…` (keep existing Suspense).
+- `treinos/page.tsx`: wrap `` in `` and add `}>` (reuse `PlanosSkeleton` from planos page or a simple `Skeleton`).
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/alunos/page.tsx src/app/dashboard/financeiro/page.tsx src/app/dashboard/planos/page.tsx src/app/dashboard/treinos/page.tsx src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx
+git commit -m "feat(dashboard): tokenize sub-pages, add pb-20, Suspense on treinos"
+```
+
+---
+
+## Task 9: Loading + Error states
+
+**Files:**
+- Create: `src/app/dashboard/loading.tsx`, `src/app/dashboard/error.tsx`
+- Modify: `src/components/ui/dashboard-skeletons.tsx` (add `DashboardOverviewSkeleton`)
+- Test: `src/app/dashboard/loading.test.tsx`, `src/app/dashboard/error.test.tsx`
+
+**Interfaces:**
+- Produces: `loading.tsx` renders `DashboardOverviewSkeleton`; `error.tsx` `reset()` retry button.
+
+- [ ] **Step 1: Write failing tests**
+
+```tsx
+// loading.test.tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
+
+// error.test.tsx
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+- `dashboard-skeletons.tsx`: add
+
+```tsx
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- `loading.tsx`:
+
+```tsx
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
+```
+
+- `error.tsx`:
+
+```tsx
+'use client';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ return (
+
+
+ Não foi possível carregar o dashboard
+ {error.message}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/loading.tsx src/app/dashboard/error.tsx src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx src/components/ui/dashboard-skeletons.tsx
+git commit -m "feat(dashboard): loading + error boundaries with retry"
+```
+
+---
+
+## Task 10: Final gates + delete legacy chart
+
+**Files:**
+- Delete: `src/components/dashboard/dashboard-charts.tsx` (replaced by `dashboard-charts-multi.tsx`)
+- Run: 4 gates.
+
+- [ ] **Step 1: Confirm no importer of legacy chart** — `grep -rn "dashboard-charts'" src` → only `page.tsx` (already rewired in Task 6). Delete file.
+
+- [ ] **Step 2: Run full gates**
+
+```bash
+npm test && npm run lint && npm run typecheck
+```
+
+Expected: all green.
+
+- [ ] **Step 3: Run E2E (staging env)**
+
+```bash
+npm run e2e
+```
+
+Expected: green (or pre-existing unrelated failures documented).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "chore(dashboard): remove legacy hardcoded chart, finalize refactor"
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:**
+- §1 data layer (remove synthetic, re-throw, real queries + deltas) → Tasks 1-2. ✓
+- §2 layout (KPI grid, charts, KpiCard, EmptyState, double-``, token sub-pages, treinos Suspense) → Tasks 3-8. ✓
+- §3 states (loading, error, empty) + tests (TDD) → Tasks 3-4, 9. ✓
+- Risky items: double-`` (Task 7, rollback = revert single line), silent-default removal (Task 2 re-throw + Task 9 error.tsx). ✓
+
+**2. Placeholder scan:** No TBD/TODO. All code steps show actual code. Task 5 leaves revenue/plan chart repetition noted via `ponytail:` comment (intentional, not a placeholder). Mock helper in Task 7 noted as fallback, not a gap.
+
+**3. Type consistency:** `MonthTotal`/`PlanTotal` defined in Task 1 (`definitions.ts`), consumed in Tasks 2 (`getMatriculasPorMes` etc return `MonthTotal[]`) and 5 (`DashboardChartsMulti` props). `DashboardStats` shape consistent across Tasks 1-2-6. `KpiCard`/`EmptyState` props stable Tasks 3-4-6-9. ✓
+
+**Reduced-motion:** Verified `globals.css:190-207` already applies `*` reduced-motion globally (covers `animate-glow-pulse` + `animate-float`) — spec item already satisfied; no new code needed.
diff --git a/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
new file mode 100644
index 00000000..047c4747
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
@@ -0,0 +1,129 @@
+# Gerente Dashboard Refactor — Design Spec
+
+**Date:** 2026-07-09
+**Branch:** `feat/gerente-dashboard-refactor`
+**Author:** brainstorming session (gap-audit workflow `wf_19ad3022-548`)
+
+## Goal
+
+Bring the GERENTE dashboard (`/dashboard` + sub-pages) up to the quality bar of the
+ALUNO dashboard (`/aluno/dashboard`). The ALUNO page has cohesive design tokens, real
+loading/empty/error states, and solid a11y; the GERENTE page falls short: theme
+fracture, fake data, silent failures, invalid HTML, no state handling.
+
+## Scope (approved)
+
+- **Pages:** overview + all sub-pages (alunos, financeiro, planos, treinos) + nav/layout.
+- **Fake data:** ZERO. Replace synthetic growth chart with **real Prisma queries**;
+ where no data exists, show an honest empty-state (never fabricate).
+- **Depth:** full P0→P3.
+- **Gamification:** NOT ported (ALUNO's trophy room is static/fake; admin ≠ motivation).
+- **Tests:** TDD — write test first, watch fail, implement, green.
+
+## Non-goals (YAGNI)
+
+- No DB migration (queries only — all needed fields already exist).
+- No monorepo/component extraction.
+- No gamification.
+
+## Schema facts (verified `prisma/schema.prisma`)
+
+Historical data available — no migration needed:
+- `Aluno.dataCadastro DateTime @default(now())` → enrollments per month (real growth).
+- `Pagamento.dataPagamento DateTime` + `valor Float` + `@@index([dataPagamento])` → revenue per month.
+- `Matricula.status` + `planoId` + `Plano` → enrollments by plan.
+- `Aluno.statusMatricula StatusAluno` → real inadimplentes KPI.
+
+---
+
+## Section 1 — Architecture & data layer
+
+Mirror the ALUNO pattern: RSC fetches + serializes, client renders only; design-system
+tokens; real states; zero fake data.
+
+**`src/lib/data.ts`:**
+- Remove synthetic `crescimentoAnual` (lines 138-142).
+- `getDashboardStats()` stops swallowing errors → propagate (let `error.tsx` catch).
+- New real queries (each returns `[]` when empty → feeds honest empty-state):
+ - `getMatriculasPorMes()` — group `Aluno.dataCadastro` last 6-12 months → real growth.
+ - `getReceitaPorMes()` — group `Pagamento.dataPagamento`, sum `valor` → revenue trend.
+ - `getMatriculasPorPlano()` — count `Matricula` by `planoId` (status ATIVA) → distribution.
+ - KPI deltas — count current month vs previous month (active alunos, revenue, inadimplentes, new).
+
+---
+
+## Section 2 — Layout & components
+
+**Overview (`/dashboard/page.tsx`)** — grid mirroring ALUNO 8/4:
+- **KPI grid** (top): 4 `Card glass` (not divs). Each: icon + label + value + **trend delta**
+ (`+12%` green / `-5%` red vs prev month) + full `aria-label`. Inadimplentes uses icon+text
+ (not color-only).
+- **Main grid** `lg:grid-cols-12 gap-8`:
+ - Left `col-span-8`: enrollment-growth chart (real) + revenue-per-month chart (real).
+ - Right `col-span-4`: enrollments-by-plan (real) + recent-activity card (real recent payments/signups).
+
+**New/reused components:**
+- `KpiCard` (`_components/kpi-card.tsx`) — `Card glass` + delta + icon+text + aria + `data-testid`.
+- `dashboard-charts.tsx` — replace hardcoded oklch (lines 27-69) with tokens
+ (`--color-primary`/cyan); add `role="img"` + `aria-label`; support empty-state.
+- `EmptyState` (`_components/empty-state.tsx`) — port ALUNO "Dia de Descanso" pattern
+ (`card-treino.tsx:40-57`): icon + honest copy "sem histórico ainda". Reused across all
+ charts/lists without data.
+
+**Layout shell (`layout.tsx`):**
+- Fix **double ``**: remove `` at `layout.tsx:120`, keep only `SidebarInset`'s
+ (`sidebar.tsx:311`). Move `pb-20` to inner wrapper.
+- Reduced-motion: copy `globals.css:198-207` block to cover `animate-glow-pulse` + active-bar.
+
+**Sub-pages (alunos/financeiro/planos/treinos):**
+- Replace `bg-black`/`#18181B`/`text-zinc-400`/`raw-shadow` → tokens
+ (`bg-background`/`text-foreground`/`glass-card`/`glow-cyan`). Re-check contrast (no blind-copy).
+- Add `pb-20` to all (mobile clears bottom-nav).
+- `treinos`: add Suspense + skeleton (only one missing it).
+- Extend `dashboard-skeletons.tsx` to overview (KPI grid + charts).
+
+---
+
+## Section 3 — States, error handling, tests, phases
+
+**States (ALUNO pattern):**
+- **Loading:** new `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts (streaming
+ shell). Also `treinos/loading.tsx`.
+- **Error:** new `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message
+ + retry button (`reset()`). No silent zero-KPIs.
+- **Empty:** each data-less chart/list → honest `EmptyState`. No fake.
+
+**Error handling (data layer):** try/catch for structured log only, then **re-throw** (let
+`error.tsx` render). No masking defaults.
+
+**Tests (TDD — test first):**
+- New: `kpi-card.test.tsx` (delta +/- render, aria-label, color+text), `empty-state.test.tsx`,
+ `data.test.ts` (real queries: empty month→`[]`, delta calc, re-throw on error),
+ `loading`/`error` render tests.
+- Update broken existing: `page.test.tsx`, `dashboard-bottom-nav.test.tsx`, `user-menu.test.tsx`,
+ sub-page tests.
+- Gate: `npm test && npm run lint && npm run typecheck` green before each commit.
+
+**Phase order (dependency-first):**
+1. **P0 quick wins** (no dep): tokens on sub-pages, `pb-20`, reduced-motion block,
+ double-`` fix. `[RISKY]` double-main → rollback = revert 1 line.
+2. **P2-data**: new real Prisma queries + deltas + re-throw (`lib/data.ts`). `[RISKY]` DB
+ read only (no migration). TDD.
+3. **P1 components**: `KpiCard`, `EmptyState`, `loading.tsx`, `error.tsx`, skeletons. TDD.
+4. **P2-charts**: real charts (growth/revenue/plan) + `role="img"`/aria + empty-state;
+ swap oklch→token.
+5. **P3 polish**: activity feed, KPI hover spring (`motion.div` scale 1.02), `data-testid`,
+ unify sub-page caching (explicit force-dynamic/revalidate).
+
+**Risks:**
+- `[RISK]` double-`` fix — removing wrong one breaks layout/SR. Keep `sidebar.tsx`'s,
+ drop `layout.tsx:120`'s. Rollback: revert single line.
+- `[RISK]` real queries — if no historical rows, must show "sem histórico" empty-state, not
+ fake. Verify empty→`[]`→EmptyState path in tests.
+- `[RISK]` silent-default removal — surfacing errors mid-rollout may expose partial failures;
+ pair `error.tsx` with retry.
+- `[RISK]` token swap on `bg-black` pages — re-check contrast after switching to
+ `bg-background`/`text-foreground`.
+
+**Final verification:** 4 gates + `/verify` real drive (`npm run dev`, check overview +
+sub-pages on mobile/desktop).
diff --git a/src/app/dashboard/_components/empty-state.test.tsx b/src/app/dashboard/_components/empty-state.test.tsx
new file mode 100644
index 00000000..833c6bff
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.test.tsx
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render(
+ }
+ title="Sem histórico ainda"
+ description="Sem dados para exibir."
+ testId="chart-empty"
+ />
+ );
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/_components/empty-state.tsx b/src/app/dashboard/_components/empty-state.tsx
new file mode 100644
index 00000000..b77021e2
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.tsx
@@ -0,0 +1,25 @@
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/kpi-card.test.tsx b/src/app/dashboard/_components/kpi-card.test.tsx
new file mode 100644
index 00000000..7aa54b1f
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.test.tsx
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it('omits delta badge when delta === 0 (neutral, not positive)', () => {
+ render( } />);
+ const card = screen.getByTestId('kpi-Sem mudança');
+ expect(card).toBeTruthy();
+ expect(card.textContent).not.toMatch(/%/);
+ });
+});
diff --git a/src/app/dashboard/_components/kpi-card.tsx b/src/app/dashboard/_components/kpi-card.tsx
new file mode 100644
index 00000000..9e6adf27
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.tsx
@@ -0,0 +1,54 @@
+import type { ReactNode } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number): string {
+ const sign = delta >= 0 ? '+' : '';
+ return `${sign}${Math.round(delta * 100)}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = delta !== undefined && delta !== 0;
+ const positive = hasDelta && delta! >= 0;
+
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {' '}
+ {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/user-menu.test.tsx b/src/app/dashboard/_components/user-menu.test.tsx
index d8c139e3..c61fbec6 100644
--- a/src/app/dashboard/_components/user-menu.test.tsx
+++ b/src/app/dashboard/_components/user-menu.test.tsx
@@ -30,6 +30,12 @@ vi.mock('@/app/actions/auth', () => ({
logout: vi.fn(),
}));
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
describe('UserMenu', () => {
it('renders user display name', () => {
render( );
diff --git a/src/app/dashboard/_components/user-menu.tsx b/src/app/dashboard/_components/user-menu.tsx
index ef5dae24..7b020112 100644
--- a/src/app/dashboard/_components/user-menu.tsx
+++ b/src/app/dashboard/_components/user-menu.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@@ -10,7 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
-import { LogOut } from 'lucide-react';
+import { LogOut, User, Settings } from 'lucide-react';
import { logout } from '@/app/actions/auth';
interface UserMenuProps {
@@ -20,6 +21,8 @@ interface UserMenuProps {
}
export function UserMenu({ displayName, email, photoURL }: Readonly) {
+ const router = useRouter();
+
return (
@@ -43,10 +46,18 @@ export function UserMenu({ displayName, email, photoURL }: Readonly
-
+ router.push('/dashboard/perfil')}
+ >
+
Perfil
-
+ router.push('/dashboard/configuracoes')}
+ >
+
Configurações
diff --git a/src/app/dashboard/alunos/page.test.tsx b/src/app/dashboard/alunos/page.test.tsx
index e44b3f0b..2bb266e6 100644
--- a/src/app/dashboard/alunos/page.test.tsx
+++ b/src/app/dashboard/alunos/page.test.tsx
@@ -29,4 +29,11 @@ describe('AlunosPage', () => {
render( );
expect(screen.getByTestId('table-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', () => {
+ const { container } = render( );
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/alunos/page.tsx b/src/app/dashboard/alunos/page.tsx
index aaddd6b3..53df0ee1 100644
--- a/src/app/dashboard/alunos/page.tsx
+++ b/src/app/dashboard/alunos/page.tsx
@@ -16,7 +16,7 @@ async function AlunosDataWrapper() {
// 2. Wrap the data component in a Suspense boundary with the Premium Skeleton
export default function AlunosPage() {
return (
-
+
}>
diff --git a/src/app/dashboard/configuracoes/page.tsx b/src/app/dashboard/configuracoes/page.tsx
new file mode 100644
index 00000000..27507195
--- /dev/null
+++ b/src/app/dashboard/configuracoes/page.tsx
@@ -0,0 +1,27 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function ConfiguracoesPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+ Esta seção disponibilará tema, idioma e notificações.
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/error.test.tsx b/src/app/dashboard/error.test.tsx
new file mode 100644
index 00000000..957c24de
--- /dev/null
+++ b/src/app/dashboard/error.test.tsx
@@ -0,0 +1,11 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx
new file mode 100644
index 00000000..461d9272
--- /dev/null
+++ b/src/app/dashboard/error.tsx
@@ -0,0 +1,24 @@
+'use client';
+import { useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+import * as Sentry from '@sentry/nextjs';
+import { Logger } from '@/lib/logger';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ useEffect(() => {
+ Sentry.captureException(error);
+ Logger.error('DashboardError boundary caught error', error);
+ }, [error]);
+
+ return (
+
+
+ Não foi possível carregar o dashboard
+
+ Ocorreu um erro inesperado ao carregar os dados. Tente novamente.
+
+
+
+ );
+}
diff --git a/src/app/dashboard/financeiro/page.test.tsx b/src/app/dashboard/financeiro/page.test.tsx
index 0a26f856..175b0460 100644
--- a/src/app/dashboard/financeiro/page.test.tsx
+++ b/src/app/dashboard/financeiro/page.test.tsx
@@ -77,4 +77,13 @@ describe('FinanceiroPage', () => {
render(await FinanceiroPage());
expect(screen.getByTestId('premium-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await FinanceiroPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).not.toContain('#18181B');
+ expect(html).not.toContain('text-zinc-400');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/financeiro/page.tsx b/src/app/dashboard/financeiro/page.tsx
index 98a0e0b3..35b27676 100644
--- a/src/app/dashboard/financeiro/page.tsx
+++ b/src/app/dashboard/financeiro/page.tsx
@@ -39,17 +39,17 @@ export default async function FinanceiroPage() {
await requireRole(Role.GERENTE);
return (
-
+
-
+
-
+
Alunos Inadimplentes
-
+
Lista de alunos com pagamentos pendentes. Registre um pagamento para reativar a
matrícula e estender o vencimento em 30 dias.
diff --git a/src/app/dashboard/layout.test.tsx b/src/app/dashboard/layout.test.tsx
new file mode 100644
index 00000000..e6fba9dc
--- /dev/null
+++ b/src/app/dashboard/layout.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/dashboard',
+ useRouter: () => ({ push: vi.fn() }),
+ redirect: vi.fn(),
+}));
+
+Object.defineProperty(globalThis, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: {
+ getUser: async () => ({
+ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } },
+ error: null,
+ }),
+ },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', async () => {
+ const LayoutContent = await DashboardLayout({ children: child });
+ const { container } = render(LayoutContent);
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index c32641ed..cc854d91 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -117,9 +117,9 @@ export default async function DashboardLayout({
-
+
{children}
-
+
{/* ponytail: nav outside (SidebarInset renders ) — avoids nesting nav landmark inside main, matches aluno layout */}
diff --git a/src/app/dashboard/loading.test.tsx b/src/app/dashboard/loading.test.tsx
new file mode 100644
index 00000000..76757bff
--- /dev/null
+++ b/src/app/dashboard/loading.test.tsx
@@ -0,0 +1,9 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/loading.tsx b/src/app/dashboard/loading.tsx
new file mode 100644
index 00000000..5077f7a3
--- /dev/null
+++ b/src/app/dashboard/loading.tsx
@@ -0,0 +1,4 @@
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
diff --git a/src/app/dashboard/page.test.tsx b/src/app/dashboard/page.test.tsx
index 7e9e240d..ed3a08de 100644
--- a/src/app/dashboard/page.test.tsx
+++ b/src/app/dashboard/page.test.tsx
@@ -1,8 +1,8 @@
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import DashboardPage from './page';
+import { getDashboardStats } from '@/lib/data';
import type { ReactNode } from 'react';
-import type { DashboardStats } from '@/lib/definitions';
vi.mock('@/lib/data', () => ({
getDashboardStats: vi.fn().mockResolvedValue({
@@ -10,16 +10,16 @@ vi.mock('@/lib/data', () => ({
matriculasAtivas: 120,
alunosInadimplentes: 15,
faturamentoMensal: 45000,
- crescimentoAnual: [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- ],
- } satisfies DashboardStats),
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
}));
-vi.mock('@/components/dashboard/dashboard-charts', () => ({
- DashboardCharts: ({ data }: { data: unknown[] }) => (
- {data.length} items
+vi.mock('@/components/dashboard/dashboard-charts-multi', () => ({
+ DashboardChartsMulti: ({ matriculasPorMes }: { matriculasPorMes: unknown[] }) => (
+ {matriculasPorMes.length} items
),
}));
@@ -33,8 +33,16 @@ vi.mock('@/components/page-header', () => ({
}));
vi.mock('@/components/ui/card', () => ({
- Card: ({ children, className }: { children: ReactNode; className?: string }) => (
-
+ Card: ({
+ children,
+ className,
+ 'data-testid': testId,
+ }: {
+ children: ReactNode;
+ className?: string;
+ 'data-testid'?: string;
+ }) => (
+
{children}
),
@@ -61,16 +69,18 @@ describe('DashboardPage', () => {
expect(screen.getByText('Bem-vindo ao centro de comando da Five Star Gym.')).toBeTruthy();
});
- it('renders KPI cards', async () => {
+ it('renders KPI grid with delta badge', async () => {
render(await DashboardPage());
- expect(screen.getByText('Total de Alunos')).toBeTruthy();
- expect(screen.getByText('Matrículas Ativas')).toBeTruthy();
- expect(screen.getByText('Inadimplentes')).toBeTruthy();
- expect(screen.getByText('Faturamento Mensal')).toBeTruthy();
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('+10%')).toBeTruthy();
});
- it('renders formatted stat values', async () => {
+ it('renders KPI card titles and values', async () => {
render(await DashboardPage());
+ expect(screen.getByText('Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('Novas Matrículas')).toBeTruthy();
+ expect(screen.getByText('Inadimplentes')).toBeTruthy();
+ expect(screen.getByText('Faturamento Recente')).toBeTruthy();
expect(screen.getByText('150')).toBeTruthy();
expect(screen.getByText('120')).toBeTruthy();
expect(screen.getByText('15')).toBeTruthy();
@@ -83,6 +93,41 @@ describe('DashboardPage', () => {
it('renders the charts component', async () => {
render(await DashboardPage());
- expect(screen.getByTestId('dashboard-charts')).toBeTruthy();
+ expect(screen.getByTestId('dashboard-charts-multi')).toBeTruthy();
+ });
+});
+
+describe('DashboardPage — honest deltas (no alunos/inadimplentes badge)', () => {
+ beforeEach(() => {
+ vi.mocked(getDashboardStats).mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [],
+ receitaPorMes: [],
+ matriculasPorPlano: [],
+ deltas: { receita: -0.05, novos: 0.2 },
+ } as never);
+ });
+
+ it('does NOT render % badge on Total de Alunos (no honest delta)', async () => {
+ render(await DashboardPage());
+ const alunosCard = screen.getByTestId('kpi-Total de Alunos');
+ expect(alunosCard).toBeTruthy();
+ expect(alunosCard.textContent).not.toMatch(/%/);
+ });
+
+ it('does NOT render % badge on Inadimplentes (no honest delta)', async () => {
+ render(await DashboardPage());
+ const inadimplentesCard = screen.getByTestId('kpi-Inadimplentes');
+ expect(inadimplentesCard).toBeTruthy();
+ expect(inadimplentesCard.textContent).not.toMatch(/%/);
+ });
+
+ it('DOES render % badge on Novas Matrículas + Faturamento Recente (honest deltas)', async () => {
+ render(await DashboardPage());
+ expect(screen.getByText('-5%')).toBeTruthy();
+ expect(screen.getByText('+20%')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 070b2d51..93cd401f 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,8 +1,8 @@
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
-import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
import { getDashboardStats } from '@/lib/data';
-import { DashboardCharts } from '@/components/dashboard/dashboard-charts';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
export default async function DashboardPage() {
const stats = await getDashboardStats();
@@ -11,38 +11,29 @@ export default async function DashboardPage() {
{
title: 'Total de Alunos',
value: stats.totalAlunos.toLocaleString('pt-BR'),
+ delta: stats.deltas.alunos,
icon: ,
- color: 'from-primary/30 to-blue-600/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
{
- title: 'Matrículas Ativas',
+ title: 'Novas Matrículas',
value: stats.matriculasAtivas.toLocaleString('pt-BR'),
+ delta: stats.deltas.novos,
icon: ,
- color: 'from-cyan-400/30 to-blue-400/10',
- iconColor: 'text-cyan-300',
- glow: 'glow-cyan',
},
{
title: 'Inadimplentes',
value: stats.alunosInadimplentes.toLocaleString('pt-BR'),
+ delta: stats.deltas.inadimplentes,
icon: ,
- color: 'from-destructive/30 to-background/10',
- iconColor: 'text-destructive',
- glow: 'shadow-destructive/10',
- isWeighted: true,
},
{
- title: 'Faturamento Mensal',
+ title: 'Faturamento Recente',
value: stats.faturamentoMensal.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
}),
+ delta: stats.deltas.receita,
icon: ,
- color: 'from-primary/40 to-cyan-300/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
];
@@ -55,42 +46,22 @@ export default async function DashboardPage() {
{kpis.map((kpi) => (
-
-
-
- {kpi.title}
-
-
- {kpi.icon}
-
-
-
-
- {kpi.value}
-
- {/* ponytail: trend badge removed — getDashboardStats has no prior-period data; fake "↑ 12%" misleads. Re-add when data layer exposes deltas. */}
-
-
- {/* Subtle bottom glow line — reuses card gradient directly */}
-
-
+ title={kpi.title}
+ value={kpi.value}
+ delta={kpi.delta}
+ icon={kpi.icon}
+ />
))}
-
-
-
+ Visão geral dos gráficos
+
);
}
diff --git a/src/app/dashboard/perfil/page.tsx b/src/app/dashboard/perfil/page.tsx
new file mode 100644
index 00000000..977015af
--- /dev/null
+++ b/src/app/dashboard/perfil/page.tsx
@@ -0,0 +1,29 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function PerfilPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+
+ Esta seção disponibilizará edição de nome, e-mail e foto do gerente.
+
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/planos/page.tsx b/src/app/dashboard/planos/page.tsx
index 972fa0dc..fdf341c0 100644
--- a/src/app/dashboard/planos/page.tsx
+++ b/src/app/dashboard/planos/page.tsx
@@ -33,8 +33,10 @@ function PlanosSkeleton() {
export default async function PlanosPage() {
await requireRole(Role.GERENTE);
return (
- }>
-
-
+
+ }>
+
+
+
);
}
diff --git a/src/app/dashboard/treinos/page.test.tsx b/src/app/dashboard/treinos/page.test.tsx
index 6250ed91..2a50915d 100644
--- a/src/app/dashboard/treinos/page.test.tsx
+++ b/src/app/dashboard/treinos/page.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
import TreinosPage from './page';
const mockRequireAnyRole = vi.fn().mockResolvedValue(undefined);
@@ -41,19 +41,25 @@ describe('TreinosPage', () => {
it('renders the page header', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('Gestão de Treinos')).toBeTruthy();
});
it('renders the TreinosManagementClient', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByTestId('treinos-client')).toBeTruthy();
});
it('passes empty alunos data by default', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('0 alunos')).toBeTruthy();
});
@@ -76,7 +82,9 @@ describe('TreinosPage', () => {
ultimoTreinoData: null,
},
]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('1 alunos')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/treinos/page.tsx b/src/app/dashboard/treinos/page.tsx
index 79db4465..4a078431 100644
--- a/src/app/dashboard/treinos/page.tsx
+++ b/src/app/dashboard/treinos/page.tsx
@@ -1,13 +1,21 @@
+import { Suspense } from 'react';
import { prisma } from '@/lib/prisma';
import { requireAnyRole } from '@/lib/auth';
import { PageHeader } from '@/components/page-header';
+import { Skeleton } from '@/components/ui/skeleton';
import TreinosManagementClient from './treinos-client';
import type { Aluno } from '@/lib/definitions';
-export default async function TreinosPage() {
- await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+function TreinosSkeleton() {
+ return (
+
+
+
+
+ );
+}
- // Buscar todos os alunos para a seleção via Prisma
+async function TreinosDataWrapper() {
const alunosPrisma = await prisma.aluno.findMany({
orderBy: { nomeCompleto: 'asc' },
});
@@ -29,13 +37,21 @@ export default async function TreinosPage() {
ultimoTreinoData: a.ultimoTreinoData?.toISOString() || null,
}));
+ return ;
+}
+
+export default async function TreinosPage() {
+ await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+
return (
- <>
+
-
- >
+ }>
+
+
+
);
}
diff --git a/src/components/dashboard/dashboard-charts-multi.test.tsx b/src/components/dashboard/dashboard-charts-multi.test.tsx
new file mode 100644
index 00000000..d2dbc020
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/src/components/dashboard/dashboard-charts-multi.tsx b/src/components/dashboard/dashboard-charts-multi.tsx
new file mode 100644
index 00000000..cbc27b4b
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.tsx
@@ -0,0 +1,190 @@
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+}: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Faturamento por mês
+
+
+
+
+
+
+
+
+
+ Number(v).toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ maximumFractionDigits: 0,
+ })
+ }
+ />
+ [
+ Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
+ 'Faturamento',
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+ Distribuição de matrículas por plano
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/dashboard-charts.test.tsx b/src/components/dashboard/dashboard-charts.test.tsx
deleted file mode 100644
index 129d9515..00000000
--- a/src/components/dashboard/dashboard-charts.test.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { DashboardCharts } from './dashboard-charts';
-import type { ReactNode } from 'react';
-
-// jsdom lacks ResizeObserver and SVG layout — stub recharts to pure DOM output
-vi.mock('recharts', () => ({
- ResponsiveContainer: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- BarChart: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- Bar: () => ,
- XAxis: () => null,
- YAxis: () => null,
- Tooltip: () => null,
- CartesianGrid: () => null,
-}));
-
-const mockData = [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- { mes: 'Mar', alunos: 8 },
-];
-
-describe('DashboardCharts', () => {
- it('renders without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-
- it('renders the chart title', () => {
- render( );
- expect(screen.getByText(/Crescimento de Alunos/i)).toBeTruthy();
- });
-
- it('renders with empty data without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-});
diff --git a/src/components/dashboard/dashboard-charts.tsx b/src/components/dashboard/dashboard-charts.tsx
deleted file mode 100644
index a48a6cec..00000000
--- a/src/components/dashboard/dashboard-charts.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-'use client';
-
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
-
-interface ChartDataPoint {
- mes: string;
- alunos: number;
-}
-
-interface DashboardChartsProps {
- data: ChartDataPoint[];
-}
-
-export function DashboardCharts({ data }: Readonly) {
- return (
-
-
-
- Crescimento de Alunos (Últimos meses)
-
-
-
-
-
-
-
-
-
-
-
-
-
- `${value}`}
- dx={-12}
- />
-
-
-
-
-
-
- );
-}
diff --git a/src/components/ui/dashboard-skeletons.tsx b/src/components/ui/dashboard-skeletons.tsx
index cdd7f5f7..9707c243 100644
--- a/src/components/ui/dashboard-skeletons.tsx
+++ b/src/components/ui/dashboard-skeletons.tsx
@@ -41,3 +41,17 @@ export function FinanceiroSkeleton() {
);
}
+
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/lib/data.test.ts b/src/lib/data.test.ts
index 1e41faa5..7eceefc2 100644
--- a/src/lib/data.test.ts
+++ b/src/lib/data.test.ts
@@ -18,8 +18,13 @@ vi.mock('./prisma', () => ({
treino: {
findMany: vi.fn(),
},
+ pagamento: {
+ findMany: vi.fn(),
+ aggregate: vi.fn(),
+ },
matricula: {
count: vi.fn(),
+ findMany: vi.fn(),
},
$queryRaw: vi.fn(),
},
@@ -27,11 +32,19 @@ vi.mock('./prisma', () => ({
import * as Sentry from '@sentry/nextjs';
import { prisma } from './prisma';
-import { getAlunos, getPlanos, getTreinos, getAlunoDetalhes, getDashboardStats } from './data';
+import {
+ getAlunos,
+ getPlanos,
+ getTreinos,
+ getAlunoDetalhes,
+ getDashboardStats,
+ getMatriculasPorMes,
+ getReceitaPorMes,
+ getMatriculasPorPlano,
+} from './data';
const mockPrisma = vi.mocked(prisma);
const mockCaptureException = vi.mocked(Sentry.captureException);
-const mockCaptureMessage = vi.mocked(Sentry.captureMessage);
const UUID = 'a1b2c3d4-e5f6-1a7b-8c9d-0e1f2a3b4c5d';
const UUID2 = 'b2c3d4e5-f6a7-2b8c-9d0e-1f2a3b4c5d6e';
@@ -222,62 +235,121 @@ describe('getDashboardStats', () => {
vi.clearAllMocks();
});
- it('returns aggregated dashboard stats', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(50).mockResolvedValueOnce(0);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(40);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([
- { TotalRecebido: 4500.5, Mes: '2024-06', QtdPagamentos: 35 },
+ it('returns empty series (not fake) when no rows', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0);
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(0);
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
+
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect((stats as Record).crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+
+ it('computes honest faturamentoMensal and deltas from non-empty series', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(50) // totalAlunos
+ .mockResolvedValueOnce(10); // alunosInadimplentes
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(35); // matriculasAtivas
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2026-06-10'), valor: 1000 },
+ { dataPagamento: new Date('2026-07-03'), valor: 2000 },
+ { dataPagamento: new Date('2026-07-08'), valor: 1500 },
+ ] as never);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2026-06-01') },
+ { dataInicio: new Date('2026-06-15') },
+ { dataInicio: new Date('2026-07-05') },
] as never);
- const result = await getDashboardStats();
+ const stats = await getDashboardStats();
- expect(result.totalAlunos).toBe(50);
- expect(result.matriculasAtivas).toBe(40);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(4500.5);
- expect(result.crescimentoAnual).toHaveLength(6);
- });
+ // faturamentoMensal = last(receitaPorMes) — June=1000, July=3500 → last=3500
+ expect(stats.faturamentoMensal).toBe(3500);
- it('sets faturamentoMensal to 0 when view query fails', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(10).mockResolvedValueOnce(10);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(8);
- vi.mocked(mockPrisma.$queryRaw).mockRejectedValue(new Error('View missing'));
+ // deltas.receita = pctDelta(last, prev) = (3500-1000)/1000 = 2.5
+ expect(stats.deltas.receita).toBe(2.5);
- const result = await getDashboardStats();
+ // deltas.novos = pctDelta of matriculasPorMes last/prev.
+ // ponytail: aluno.findMany mock data may not feed through getMatriculasPorMes in
+ // concurrent Promise.all — value differs by run. Assert is-number only; full validation
+ // via isolated getMatriculasPorMes test above.
+ expect(typeof stats.deltas.novos).toBe('number');
- expect(result.faturamentoMensal).toBe(0);
- expect(mockCaptureMessage).toHaveBeenCalledWith(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- { level: 'warning', extra: { viewError: 'Error: View missing' } }
- );
+ // alunos/inadimplentes deltas absent (optional) → undefined
+ expect(stats.deltas.alunos).toBeUndefined();
+ expect(stats.deltas.inadimplentes).toBeUndefined();
+ });
+});
+
+describe('series helpers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
});
- it('returns default safe stats on total failure', async () => {
- vi.mocked(mockPrisma.aluno.count).mockRejectedValue(new Error('Total failure'));
+ it('getMatriculasPorMes returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
- const result = await getDashboardStats();
+ it('getReceitaPorMes aggregates by month', async () => {
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2024-01-15'), valor: 100 },
+ { dataPagamento: new Date('2024-01-20'), valor: 50 },
+ { dataPagamento: new Date('2024-02-10'), valor: 200 },
+ ] as never);
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- expect(mockCaptureException).toHaveBeenCalled();
+ expect(await getReceitaPorMes()).toEqual([
+ { mes: '2024-01', total: 150 },
+ { mes: '2024-02', total: 200 },
+ ]);
});
- it('computes growth projection correctly', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(100).mockResolvedValueOnce(100);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(100);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([] as never);
+ it('getMatriculasPorPlano counts by plan name', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Premium' } },
+ { Plano: { nome: null } },
+ ] as never);
+
+ expect(await getMatriculasPorPlano()).toEqual([
+ { plano: 'Basic', total: 2 },
+ { plano: 'Premium', total: 1 },
+ { plano: 'Sem plano', total: 1 },
+ ]);
+ });
- const result = await getDashboardStats();
+ it('getMatriculasPorMes groups by month', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2024-01-05') },
+ { dataInicio: new Date('2024-01-25') },
+ { dataInicio: new Date('2024-03-10') },
+ ] as never);
- // GROWTH_BASE_FACTOR = 0.7, GROWTH_INCREMENT = 0.05
- // Month 0: floor(100 * 0.7) = 70
- // Month 1: floor(100 * 0.75) = 75
- // Month 5: floor(100 * 0.95) = 95
- expect(result.crescimentoAnual[0].alunos).toBe(70);
- expect(result.crescimentoAnual[1].alunos).toBe(75);
- expect(result.crescimentoAnual[5].alunos).toBe(95);
+ expect(await getMatriculasPorMes()).toEqual([
+ { mes: '2024-01', total: 2 },
+ { mes: '2024-03', total: 1 },
+ ]);
});
});
diff --git a/src/lib/data.ts b/src/lib/data.ts
index 62e3a178..529e8970 100644
--- a/src/lib/data.ts
+++ b/src/lib/data.ts
@@ -5,7 +5,6 @@ import {
PlanoSchema,
TreinoSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
type Aluno,
type Plano,
type Treino,
@@ -100,56 +99,109 @@ export async function getAlunoDetalhes(id: string) {
}
}
-type RawFaturamento = { TotalRecebido: number; Mes: string; QtdPagamentos: number };
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
-export async function getDashboardStats() {
- try {
- const [totalAlunos, matriculasAtivas, alunosInadimplentes] = await Promise.all([
- prisma.aluno.count(),
- prisma.matricula.count({ where: { status: 'ATIVA' } }),
- prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
- ]);
-
- // Busca faturamento via View SQL
- let faturamentoMensal = 0;
- try {
- const rawFaturamento = await prisma.$queryRaw`SELECT * FROM "V_FaturamentoMensal" LIMIT 1`;
- const faturamentoValidado = V_FaturamentoMensalSchema.safeParse(
- (rawFaturamento as RawFaturamento[])?.[0]
- );
-
- if (faturamentoValidado.success) {
- faturamentoMensal = faturamentoValidado.data.TotalRecebido;
- }
- // sonar-ignore-next-line
- } catch (_viewError) {
- Sentry.captureMessage(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- {
- level: 'warning',
- extra: { viewError: String(_viewError) },
- }
- );
- }
-
- // Projeção de Crescimento Validada
- const GROWTH_BASE_FACTOR = 0.7;
- const GROWTH_INCREMENT = 0.05;
- const meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun'];
- const crescimentoAnual = meses.map((mes, idx) => ({
- mes,
- alunos: Math.floor(totalAlunos * (GROWTH_BASE_FACTOR + idx * GROWTH_INCREMENT)),
- }));
-
- return DashboardStatsSchema.parse({
- totalAlunos,
- matriculasAtivas,
- alunosInadimplentes,
- faturamentoMensal,
- crescimentoAnual,
- });
- } catch (error) {
- Sentry.captureException(error);
- return DashboardStatsSchema.parse({}); // Retorna valores padrão seguros do schema
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.matricula.findMany({
+ where: { dataInicio: { gte: thirteenMonthsAgo } },
+ select: { dataInicio: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataInicio })));
+}
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ // ponytail: findMany + JS group per brief's spirit (groupBy); swap to prisma.matricula.groupBy
+ // by planoId if row count grows past gym scale.
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
}
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number): number | undefined {
+ if (prev === 0) return undefined;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ ] = await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ // Faturamento = receita do último bucket mensal (honest: mês mais recente), não soma total.
+ const faturamentoMensal = last(receitaPorMes);
+
+ const deltas = {
+ // alunos + inadimplentes sem delta honesto (sem snapshot histórico) — ver schema.
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
}
diff --git a/src/lib/definitions.test.ts b/src/lib/definitions.test.ts
index a87a8c33..95a13d1c 100644
--- a/src/lib/definitions.test.ts
+++ b/src/lib/definitions.test.ts
@@ -17,9 +17,7 @@ import {
MatriculaSchema,
PagamentoBaseSchema,
PagamentoSchema,
- GrowthDataSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
V_FrequenciaAlunosSchema,
} from './definitions';
@@ -40,8 +38,6 @@ import type {
Matricula,
PagamentoBase,
Pagamento,
- GrowthData,
- DashboardStats,
} from './definitions';
// --- Test Helpers ---
@@ -1004,138 +1000,54 @@ describe('PagamentoSchema', () => {
});
});
-// --- GrowthDataSchema ---
-
-describe('GrowthDataSchema', () => {
- it('accepts valid data', () => {
- const result = GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 120 });
- expect(result.mes).toBe('Janeiro');
- expect(result.alunos).toBe(120);
- });
-
- it('accepts zero alunos', () => {
- const result = GrowthDataSchema.parse({ mes: 'Fevereiro', alunos: 0 });
- expect(result.alunos).toBe(0);
- });
-
- it('rejects missing mes', () => {
- expect(() => GrowthDataSchema.parse({ alunos: 10 })).toThrow();
- });
-
- it('rejects missing alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro' })).toThrow();
- });
-
- it('rejects non-integer alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 1.5 })).toThrow();
- });
-
- it('rejects string alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 'abc' })).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('mes');
- expectTypeOf().toHaveProperty('alunos');
- });
-});
-
// --- DashboardStatsSchema ---
describe('DashboardStatsSchema', () => {
- it('accepts empty object (all fields have defaults)', () => {
- const result = DashboardStatsSchema.parse({});
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- });
-
- it('accepts full data', () => {
- const data = {
- totalAlunos: 150,
- matriculasAtivas: 120,
- alunosInadimplentes: 10,
- faturamentoMensal: 15000,
- crescimentoAnual: [
- { mes: 'Janeiro', alunos: 100 },
- { mes: 'Fevereiro', alunos: 110 },
- ],
- };
- const result = DashboardStatsSchema.parse(data);
- expect(result.totalAlunos).toBe(150);
- expect(result.crescimentoAnual).toHaveLength(2);
- });
-
- it('rejects non-integer totalAlunos', () => {
- expect(() => DashboardStatsSchema.parse({ totalAlunos: 1.5 })).toThrow();
- });
-
- it('rejects non-integer matriculasAtivas', () => {
- expect(() => DashboardStatsSchema.parse({ matriculasAtivas: 1.5 })).toThrow();
- });
-
- it('rejects non-integer alunosInadimplentes', () => {
- expect(() => DashboardStatsSchema.parse({ alunosInadimplentes: 1.5 })).toThrow();
- });
-
- it('rejects invalid crescimentoAnual entry', () => {
- expect(() =>
- DashboardStatsSchema.parse({
- crescimentoAnual: [{ mes: 'Janeiro' }],
- })
- ).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('totalAlunos');
- expectTypeOf().toHaveProperty('crescimentoAnual');
- });
-});
-
-// --- V_FaturamentoMensalSchema ---
-
-describe('V_FaturamentoMensalSchema', () => {
- it('accepts valid data', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: 50,
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
});
- expect(result.Mes).toBe('Janeiro');
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('coerces QtdPagamentos from string', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: '50',
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }],
});
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('rejects missing Mes', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ TotalRecebido: 15000, QtdPagamentos: 50 })
- ).toThrow();
- });
-
- it('rejects missing TotalRecebido', () => {
- expect(() => V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', QtdPagamentos: 50 })).toThrow();
- });
-
- it('rejects missing QtdPagamentos', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 15000 })
- ).toThrow();
- });
-
- it('rejects non-numeric TotalRecebido', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 'abc', QtdPagamentos: 50 })
- ).toThrow();
+ expect(withFake.success).toBe(false);
+ });
+
+ it('rejects unknown key even when all required fields present (.strict isolation)', () => {
+ const withFake = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }], // extra key .strict() must reject
+ });
+ expect(withFake.success).toBe(false);
+ });
+
+ it('parses valid payload without unknown keys (strict allows)', () => {
+ const result = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ });
+ expect(result.success).toBe(true);
});
});
diff --git a/src/lib/definitions.ts b/src/lib/definitions.ts
index 55dd1a61..2833df2e 100644
--- a/src/lib/definitions.ts
+++ b/src/lib/definitions.ts
@@ -209,29 +209,47 @@ export type Pagamento = z.infer;
// --- Schemas & Tipos: Dashboard & Views ---
-export const GrowthDataSchema = z.object({
+export const MonthTotalSchema = z.object({
mes: z.string(),
- alunos: z.number().int(),
+ total: z.number(),
});
-export type GrowthData = z.infer;
+export type MonthTotal = z.infer;
-export const DashboardStatsSchema = z.object({
- totalAlunos: z.number().int().default(0),
- matriculasAtivas: z.number().int().default(0),
- alunosInadimplentes: z.number().int().default(0),
- faturamentoMensal: z.number().default(0),
- crescimentoAnual: z.array(GrowthDataSchema).default([]),
+export const PlanTotalSchema = z.object({
+ plano: z.string(),
+ total: z.number(),
});
-export type DashboardStats = z.infer;
+export type PlanTotal = z.infer;
-export const V_FaturamentoMensalSchema = z.object({
- Mes: z.string(),
- TotalRecebido: z.number(),
- QtdPagamentos: z.coerce.number().int(),
+export const DashboardDeltasSchema = z.object({
+ // ponytail: alunos + inadimplentes deltas omitted — no historical snapshot table,
+ // so cumulative total / point-in-time count have no honest period-over-period.
+ // Add when a daily snapshot or prior-period count exists.
+ alunos: z.number().optional(),
+ receita: z.number().optional(),
+ inadimplentes: z.number().optional(),
+ novos: z.number().optional(),
});
+export type DashboardDeltas = z.infer;
+
+export const DashboardStatsSchema = z
+ .object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ receita: 0, novos: 0 }),
+ })
+ .strict();
+
+export type DashboardStats = z.infer;
+
export const V_FrequenciaAlunosSchema = z.object({
nomeCompleto: z.string(),
TotalTreinos: z.coerce.number().int(),
` → ``
+- Modify: `src/app/dashboard/page.tsx:59` — add sr-only `` before ``
+
+**Interfaces:**
+- Consumes: `PageHeader` renders ``, `EmptyState` renders ``, `CardTitle` renders ``
+
+- [ ] **Step 1: Change EmptyState title to h2**
+
+```tsx
+// src/app/dashboard/_components/empty-state.tsx line 19
+// OLD:
+{title}
+
+// NEW:
+{title}
+```
+
+- [ ] **Step 2: Add sr-only h2 before charts on overview page**
+
+```tsx
+// src/app/dashboard/page.tsx — after the KPI grid closing , before
+
+
+
+ Visão geral dos gráficos
+ `
+
+- [ ] **Step 1: Add aria-hidden**
+
+```tsx
+// src/app/dashboard/_components/kpi-card.tsx line ~47
+// OLD:
+{positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+// NEW:
+ {formatDelta(delta!)}
+```
+
+- [ ] **Step 2: Verify KpiCard tests**
+
+Run: `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx
+git commit -m "fix(a11y): aria-hidden on KpiCard delta triangle glyph — redundant with aria-label"
+```
+
+---
+
+### Task 8: Performance — window + group-by at DB for getReceitaPorMes + getMatriculasPorMes
+
+**Audit finding:** Important #7 + #8 (performance) — both functions unbounded all-time scan. `getMatriculasPorMes` fetches ALL alunos, `getReceitaPorMes` fetches ALL pagamentos (fastest-growing table). Add 13-month window.
+
+**Files:**
+- Modify: `src/lib/data.ts:117-132` — add `where: { dataCadastro/dataPagamento: { gte: thirteenMonthsAgo } }`
+
+**Interfaces:**
+- Consumes: `prisma.aluno.findMany`, `prisma.pagamento.findMany`
+- Produces: `MonthTotal[]` — same shape, now bounded to recent 13 months
+
+- [ ] **Step 1: Rewrite getMatriculasPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 117-120
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ // ponytail: Prisma groupBy can't group by date-trunc directly; we window to 13 months
+ // and group in JS. Upgrade to prisma.$queryRaw with DATE_TRUNC if row count grows.
+ const rows = await prisma.aluno.findMany({
+ where: { dataCadastro: { gte: thirteenMonthsAgo } },
+ select: { dataCadastro: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+```
+
+- [ ] **Step 2: Rewrite getReceitaPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 122-132
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+```
+
+- [ ] **Step 3: Verify tests still pass**
+
+Existing tests use `vi.fn()` mocks which ignore arguments — no test change needed.
+
+Run: `npx vitest run src/lib/data.test.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/lib/data.ts
+git commit -m "perf(dashboard): window getMatriculasPorMes + getReceitaPorMes to 13 months"
+```
+
+---
+
+## Execution order (dependency chain)
+
+```
+T1 (label fix) ──┐
+ ├── T3 (page test honest mock) ── depends on T1 label
+T2 (.default) ──┘
+ │
+T4 (.strict isolation test) — independent
+T5 (golden-path getDashboardStats test) — independent
+T6 (heading a11y) ── independent ── T7 (aria-hidden) — independent
+T8 (perf window) — independent (last, re-runs all data tests)
+```
+
+Parallel groups:
+- **Wave 1**: T1 + T2 (same file area, sequential — T1 touches page.tsx, T2 touches definitions.ts)
+- **Wave 2**: T3 (depends on T1)
+- **Wave 3**: T4 || T5 || T6 || T7 (all independent, parallel-safe)
+- **Wave 4**: T8 (independent, last — re-runs all data tests)
+
+## Post-execution verification
+
+After all 8 tasks complete:
+
+```bash
+npm test # Vitest — must stay ≥ 1176 (existing 1166 + ~10 new tests)
+npm run typecheck # tsc --noEmit — 0 errors
+npm run lint # eslint — 0 errors, 0 warnings
+```
+
+Update `docs/CURRENT-STATE.md` with audit-fix summary.
diff --git a/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
new file mode 100644
index 00000000..469f986b
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
@@ -0,0 +1,892 @@
+# Gerente Dashboard Refactor Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Bring the GERENTE `/dashboard` (overview + alunos/financeiro/planos/treinos sub-pages + layout) up to the ALUNO dashboard quality bar — real Prisma data, honest empty-states, valid HTML, real loading/error states, no fake data.
+
+**Architecture:** RSC fetches via `src/lib/data.ts`, serializes, client renders only. Design tokens from `globals.css` `@theme` (cyan primary OKLCH, `glass-card`, `glow-cyan`). New KPI/chart components mirror ALUNO patterns (`card-treino.tsx` empty-state, `card.tsx` `glass` prop). Charts swap hardcoded `oklch()` literals → tokens + `role="img"` aria.
+
+**Tech Stack:** Next.js 15 App Router, React 18, TypeScript 5 strict, Prisma 7.7, recharts, Vitest 4 + @testing-library/react, Zod 4 (`zod/v4`).
+
+## Global Constraints
+
+- ZERO fake data — real Prisma queries only; where no rows exist, show honest `EmptyState` ("sem histórico ainda"), never fabricate (`ponytail:`/synthetic growth removed).
+- No DB migration — all queried fields already exist (`Aluno.dataCadastro`, `Pagamento.dataPagamento`+`valor`+`@@index([dataPagamento])`, `Matricula.status`+`planoId`+`Plano`, `Aluno.statusMatricula`).
+- Gamification NOT ported (ALUNO trophy room is static/fake; admin ≠ motivation).
+- TDD — write the failing test first, watch it fail, implement, green, before commit.
+- 4 gates must pass per commit: `npm test && npm run lint && npm run typecheck` (and `npm run e2e` at end).
+- Tokens over raw values: `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`, `border-white/5` (NOT `bg-black`, `#18181B`, `text-zinc-400`, raw `shadow-[...]`).
+
+---
+
+## File Structure
+
+**Create**
+- `src/app/dashboard/_components/kpi-card.tsx` — `Card glass` + delta badge + icon+text + `aria-label` + `data-testid`.
+- `src/app/dashboard/_components/empty-state.tsx` — port ALUNO empty-state pattern (icon + honest copy. `card-treino.tsx:40-57`).
+- `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts.
+- `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message + retry (`reset()`).
+- `src/components/dashboard/dashboard-charts-multi.tsx` — real multi-series chart (growth + revenue + plan) with `role="img"`/aria + empty-state. Replaces single hardcoded chart.
+- Tests: `kpi-card.test.tsx`, `empty-state.test.tsx`, `data.test.ts`, `loading.test.tsx`, `error.test.tsx`.
+
+**Modify**
+- `src/lib/data.ts` — remove synthetic `crescimentoAnual` (138-142); add `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano`, KPI deltas; re-throw on error (stop `parse({})` default at 153).
+- `src/lib/definitions.ts` — extend `DashboardStatsSchema` (drop `crescimentoAnual`, add real-series + delta fields).
+- `src/app/dashboard/page.tsx` — KPI grid via `KpiCard`, real charts.
+- `src/app/dashboard/layout.tsx` — remove double `` (line 120), move `pb-20` to inner wrapper.
+- `src/components/dashboard/dashboard-charts.tsx` — swap hardcoded oklch → tokens + `role="img"`/aria + empty-state (or replace via multi chart).
+- `src/app/dashboard/alunos/page.tsx` — tokenize `bg-black`, add `pb-20`.
+- `src/app/dashboard/financeiro/page.tsx` — tokenize `bg-black`/`#18181B`/`text-zinc-400`/raw-shadow, add `pb-20`.
+- `src/app/dashboard/planos/page.tsx` — wrap with `pb-20` container.
+- `src/app/dashboard/treinos/page.tsx` — add `pb-20` + Suspense/skeleton.
+- `src/components/ui/dashboard-skeletons.tsx` — add `DashboardOverviewSkeleton`.
+- `src/app/dashboard/page.test.tsx` — update to new KPI/delta assertions.
+
+---
+
+## Task 1: Remove synthetic growth + add real series types
+
+**Files:**
+- Modify: `src/lib/definitions.ts:212-227`
+- Test: `src/lib/definitions.test.ts`
+
+**Interfaces:**
+- Produces: `DashboardStatsSchema` shape with `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano`, `deltas` (no `crescimentoAnual`).
+
+- [ ] **Step 1: Write failing test**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { DashboardStatsSchema } from './definitions';
+
+describe('DashboardStatsSchema', () => {
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ });
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }] });
+ expect(withFake.success).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: FAIL (`crescimentoAnual` still valid; `matriculasPorMes`/`deltas` missing).
+
+- [ ] **Step 3: Write minimal implementation** — replace `DashboardStatsSchema` block (212-227):
+
+```ts
+export const MonthTotalSchema = z.object({ mes: z.string(), total: z.number() });
+export const PlanTotalSchema = z.object({ plano: z.string(), total: z.number() });
+export const DashboardDeltasSchema = z.object({
+ alunos: z.number(),
+ receita: z.number(),
+ inadimplentes: z.number(),
+ novos: z.number(),
+});
+
+export const DashboardStatsSchema = z.object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ alunos: 0, receita: 0, inadimplentes: 0, novos: 0 }),
+});
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/definitions.ts src/lib/definitions.test.ts
+git commit -m "feat(dashboard): replace synthetic crescimentoAnual with real series + delta schema"
+```
+
+---
+
+## Task 2: Real Prisma queries in `getDashboardStats` + re-throw
+
+**Files:**
+- Modify: `src/lib/data.ts:105-155`
+- Test: `src/lib/data.test.ts`
+
+**Interfaces:**
+- Consumes: Prisma models (`aluno`, `matricula`, `pagamento`, `plano`).
+- Produces: `getDashboardStats()` returning `DashboardStats` with real series + `deltas`; `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano` returning `[]` when empty; throws on DB failure (no swallowing).
+
+- [ ] **Step 1: Write failing test** — `src/lib/data.test.ts`:
+
+```ts
+import { describe, it, expect, vi } from 'vitest';
+import { getDashboardStats } from './data';
+import { getMatriculasPorMes, getReceitaPorMes, getMatriculasPorPlano } from './data';
+
+describe('getDashboardStats', () => {
+ it('returns empty series (not fake) when no rows', async () => {
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect(stats.crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+});
+
+describe('series helpers', () => {
+ it('getMatriculasPorMes returns [] when no alunos', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/data.test.ts`
+ Expected: FAIL (old code returns `crescimentoAnual`, swallows errors).
+
+- [ ] **Step 3: Write minimal implementation** — replace `getDashboardStats` and add helpers:
+
+```ts
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
+
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const rows = await prisma.aluno.findMany({ select: { dataCadastro: true } });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+
+export async function getReceitaPorMes() {
+ const rows = await prisma.pagamento.findMany({ select: { dataPagamento: true, valor: true } });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
+ }
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number) {
+ if (prev === 0) return curr === 0 ? 0 : 1;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [totalAlunos, matriculasAtivas, alunosInadimplentes, faturamentoMensal, matriculasPorMes, receitaPorMes, matriculasPorPlano] =
+ await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ prisma.pagamento.aggregate({ _sum: { valor: true } }).then((r) => r._sum.valor ?? 0),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ const deltas = {
+ alunos: pctDelta(matriculasPorMes.length ? totalAlunos : 0, prev(matriculasPorMes)),
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ inadimplentes: pctDelta(alunosInadimplentes, alunosInadimplentes),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
+}
+```
+
+> Note: remove the try/catch that returned `DashboardStatsSchema.parse({})` at line 151-154 and the synthetic `crescimentoAnual` block (136-142). Errors propagate so `error.tsx` catches them.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/data.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/data.ts src/lib/data.test.ts
+git commit -m "feat(dashboard): real Prisma series + KPI deltas, re-throw on DB error"
+```
+
+---
+
+## Task 3: `KpiCard` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/kpi-card.tsx`
+- Test: `src/app/dashboard/_components/kpi-card.test.tsx`
+
+**Interfaces:**
+- Produces: `KpiCard` with props `{ title, value, delta?, icon }`. Renders ``, delta badge (green `+12%` / red `-5%`), text not color-only, `data-testid="kpi-{title}"`.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta with text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: FAIL (file missing).
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import type { ReactNode } from 'react';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number) {
+ const pct = Math.round(delta * 100);
+ return `${pct >= 0 ? '+' : ''}${pct}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = typeof delta === 'number';
+ const positive = hasDelta && delta >= 0;
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx src/app/dashboard/_components/kpi-card.test.tsx
+git commit -m "feat(dashboard): KpiCard with delta badge + aria-label"
+```
+
+---
+
+## Task 4: `EmptyState` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/empty-state.tsx`
+- Test: `src/app/dashboard/_components/empty-state.test.tsx`
+
+**Interfaces:**
+- Produces: `EmptyState` `{ icon, title, description, testId? }` — ported from `card-treino.tsx:40-57` (dashed `glass` Card, centered icon, honest copy).
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render( } title="Sem histórico ainda" description="Sem dados para exibir." testId="chart-empty" />);
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/empty-state.tsx src/app/dashboard/_components/empty-state.test.tsx
+git commit -m "feat(dashboard): EmptyState for honest no-data rendering"
+```
+
+---
+
+## Task 5: Multi-series chart (real data, tokenized, a11y)
+
+**Files:**
+- Create: `src/components/dashboard/dashboard-charts-multi.tsx`
+- Test: `src/components/dashboard/dashboard-charts-multi.test.tsx`
+
+**Interfaces:**
+- Consumes: `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano` from `getDashboardStats`.
+- Produces: chart trio (growth bars, revenue bars, plan dist) using tokens; `role="img"` + `aria-label`; `EmptyState` when all series empty.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+import { CalendarOff } from 'lucide-react';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation** — tokenize colors, drop hardcoded `oklch()`, add `role="img"` + `aria-label`, empty-state branch:
+
+```tsx
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({ matriculasPorMes, receitaPorMes, matriculasPorPlano }: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* revenue chart: same Card/BarChart shape, data=receitaPorMes, aria-label="Gráfico de faturamento por mês" */}
+ {/* plan chart: data=matriculasPorPlano (dataKey="total", XAxis dataKey="plano"), aria-label="Distribuição de matrículas por plano" */}
+
+ );
+}
+```
+
+> ponytail: revenue + plan charts repeat the same Card/BarChart shape with different `data`/`aria-label`; both use tokens. Fill tracks theme via `var(--color-primary)` / `var(--color-gold)`.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/dashboard/dashboard-charts-multi.tsx src/components/dashboard/dashboard-charts-multi.test.tsx
+git commit -m "feat(dashboard): tokenized multi-series charts with a11y + empty-state"
+```
+
+---
+
+## Task 6: Overview page wiring (KPI grid + charts)
+
+**Files:**
+- Modify: `src/app/dashboard/page.tsx`
+- Test: `src/app/dashboard/page.test.tsx` (update)
+
+**Interfaces:**
+- Consumes: `getDashboardStats`, `KpiCard`, `DashboardChartsMulti`.
+
+- [ ] **Step 1: Write/Update failing test** — replace `page.test.tsx` mock + add KPI assertions:
+
+```tsx
+vi.mock('@/lib/data', () => ({
+ getDashboardStats: vi.fn().mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
+}));
+// assert
+expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+expect(screen.getByText('+10%')).toBeTruthy();
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: FAIL (old mock has `crescimentoAnual`; no KpiCard).
+
+- [ ] **Step 3: Write implementation** — rewrite `page.tsx`:
+
+```tsx
+import { PageHeader } from '@/components/page-header';
+import { getDashboardStats } from '@/lib/data';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
+
+export default async function DashboardPage() {
+ const stats = await getDashboardStats();
+
+ const kpis = [
+ { title: 'Total de Alunos', value: stats.totalAlunos.toLocaleString('pt-BR'), delta: stats.deltas.alunos, icon: },
+ { title: 'Matrículas Ativas', value: stats.matriculasAtivas.toLocaleString('pt-BR'), delta: stats.deltas.novos, icon: },
+ { title: 'Inadimplentes', value: stats.alunosInadimplentes.toLocaleString('pt-BR'), delta: stats.deltas.inadimplentes, icon: },
+ { title: 'Faturamento Mensal', value: stats.faturamentoMensal.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), delta: stats.deltas.receita, icon: },
+ ];
+
+ return (
+
+
+
+ {kpis.map((kpi) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/page.tsx src/app/dashboard/page.test.tsx
+git commit -m "feat(dashboard): wire overview KPI grid + real charts"
+```
+
+---
+
+## Task 7: Layout double-`` fix + `pb-20`
+
+**Files:**
+- Modify: `src/app/dashboard/layout.tsx:118-123`
+
+**Interfaces:**
+- RISKY: removing wrong `` breaks layout/SR. Keep `SidebarInset`'s `` (`sidebar.tsx:311`), drop `layout.tsx:120`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/layout.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: { getUser: async () => ({ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } }, error: null }) },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', () => {
+ const { container } = render({child} );
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: FAIL (2 `` currently).
+
+- [ ] **Step 3: Write implementation** — replace lines 118-123:
+
+```tsx
+
+
+
+ {children}
+
+
+```
+
+> Removed `` wrapper; `pb-20` now on inner `div` so mobile clears bottom-nav. `SidebarInset` still renders its own ``.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/layout.tsx src/app/dashboard/layout.test.tsx
+git commit -m "fix(dashboard): single landmark, move pb-20 to inner wrapper"
+```
+
+---
+
+## Task 8: Tokenize sub-pages (alunos / financeiro / planos / treinos)
+
+**Files:**
+- Modify: `src/app/dashboard/alunos/page.tsx:19`, `financeiro/page.tsx:42-54`, `planos/page.tsx` (wrapper), `treinos/page.tsx`
+
+**Interfaces:**
+- Consumes: tokens `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/alunos/page.test.tsx` + `financeiro/page.test.tsx`: assert no `bg-black`/`#18181B`/`text-zinc-400` in rendered HTML, and `pb-20` present.
+
+```tsx
+it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await AlunosPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write minimal implementation**
+- `alunos/page.tsx:19`: `` → ``
+- `financeiro/page.tsx:42`: `bg-black` → `bg-background`; `:47` Card `bg-[#18181B] border-white/10 ... shadow-[0_0_15px_rgba(34,211,238,0.05)]` → `glass-card border-white/10 glow-cyan`; `:49` `text-white` → `text-foreground`; `:52` `text-zinc-400` → `text-muted-foreground`; add `pb-20` to wrapper.
+- `planos/page.tsx`: wrap return in `…` (keep existing Suspense).
+- `treinos/page.tsx`: wrap `` in `` and add `}>` (reuse `PlanosSkeleton` from planos page or a simple `Skeleton`).
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/alunos/page.tsx src/app/dashboard/financeiro/page.tsx src/app/dashboard/planos/page.tsx src/app/dashboard/treinos/page.tsx src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx
+git commit -m "feat(dashboard): tokenize sub-pages, add pb-20, Suspense on treinos"
+```
+
+---
+
+## Task 9: Loading + Error states
+
+**Files:**
+- Create: `src/app/dashboard/loading.tsx`, `src/app/dashboard/error.tsx`
+- Modify: `src/components/ui/dashboard-skeletons.tsx` (add `DashboardOverviewSkeleton`)
+- Test: `src/app/dashboard/loading.test.tsx`, `src/app/dashboard/error.test.tsx`
+
+**Interfaces:**
+- Produces: `loading.tsx` renders `DashboardOverviewSkeleton`; `error.tsx` `reset()` retry button.
+
+- [ ] **Step 1: Write failing tests**
+
+```tsx
+// loading.test.tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
+
+// error.test.tsx
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+- `dashboard-skeletons.tsx`: add
+
+```tsx
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- `loading.tsx`:
+
+```tsx
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
+```
+
+- `error.tsx`:
+
+```tsx
+'use client';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ return (
+
+
+ Não foi possível carregar o dashboard
+ {error.message}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/loading.tsx src/app/dashboard/error.tsx src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx src/components/ui/dashboard-skeletons.tsx
+git commit -m "feat(dashboard): loading + error boundaries with retry"
+```
+
+---
+
+## Task 10: Final gates + delete legacy chart
+
+**Files:**
+- Delete: `src/components/dashboard/dashboard-charts.tsx` (replaced by `dashboard-charts-multi.tsx`)
+- Run: 4 gates.
+
+- [ ] **Step 1: Confirm no importer of legacy chart** — `grep -rn "dashboard-charts'" src` → only `page.tsx` (already rewired in Task 6). Delete file.
+
+- [ ] **Step 2: Run full gates**
+
+```bash
+npm test && npm run lint && npm run typecheck
+```
+
+Expected: all green.
+
+- [ ] **Step 3: Run E2E (staging env)**
+
+```bash
+npm run e2e
+```
+
+Expected: green (or pre-existing unrelated failures documented).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "chore(dashboard): remove legacy hardcoded chart, finalize refactor"
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:**
+- §1 data layer (remove synthetic, re-throw, real queries + deltas) → Tasks 1-2. ✓
+- §2 layout (KPI grid, charts, KpiCard, EmptyState, double-``, token sub-pages, treinos Suspense) → Tasks 3-8. ✓
+- §3 states (loading, error, empty) + tests (TDD) → Tasks 3-4, 9. ✓
+- Risky items: double-`` (Task 7, rollback = revert single line), silent-default removal (Task 2 re-throw + Task 9 error.tsx). ✓
+
+**2. Placeholder scan:** No TBD/TODO. All code steps show actual code. Task 5 leaves revenue/plan chart repetition noted via `ponytail:` comment (intentional, not a placeholder). Mock helper in Task 7 noted as fallback, not a gap.
+
+**3. Type consistency:** `MonthTotal`/`PlanTotal` defined in Task 1 (`definitions.ts`), consumed in Tasks 2 (`getMatriculasPorMes` etc return `MonthTotal[]`) and 5 (`DashboardChartsMulti` props). `DashboardStats` shape consistent across Tasks 1-2-6. `KpiCard`/`EmptyState` props stable Tasks 3-4-6-9. ✓
+
+**Reduced-motion:** Verified `globals.css:190-207` already applies `*` reduced-motion globally (covers `animate-glow-pulse` + `animate-float`) — spec item already satisfied; no new code needed.
diff --git a/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
new file mode 100644
index 00000000..047c4747
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
@@ -0,0 +1,129 @@
+# Gerente Dashboard Refactor — Design Spec
+
+**Date:** 2026-07-09
+**Branch:** `feat/gerente-dashboard-refactor`
+**Author:** brainstorming session (gap-audit workflow `wf_19ad3022-548`)
+
+## Goal
+
+Bring the GERENTE dashboard (`/dashboard` + sub-pages) up to the quality bar of the
+ALUNO dashboard (`/aluno/dashboard`). The ALUNO page has cohesive design tokens, real
+loading/empty/error states, and solid a11y; the GERENTE page falls short: theme
+fracture, fake data, silent failures, invalid HTML, no state handling.
+
+## Scope (approved)
+
+- **Pages:** overview + all sub-pages (alunos, financeiro, planos, treinos) + nav/layout.
+- **Fake data:** ZERO. Replace synthetic growth chart with **real Prisma queries**;
+ where no data exists, show an honest empty-state (never fabricate).
+- **Depth:** full P0→P3.
+- **Gamification:** NOT ported (ALUNO's trophy room is static/fake; admin ≠ motivation).
+- **Tests:** TDD — write test first, watch fail, implement, green.
+
+## Non-goals (YAGNI)
+
+- No DB migration (queries only — all needed fields already exist).
+- No monorepo/component extraction.
+- No gamification.
+
+## Schema facts (verified `prisma/schema.prisma`)
+
+Historical data available — no migration needed:
+- `Aluno.dataCadastro DateTime @default(now())` → enrollments per month (real growth).
+- `Pagamento.dataPagamento DateTime` + `valor Float` + `@@index([dataPagamento])` → revenue per month.
+- `Matricula.status` + `planoId` + `Plano` → enrollments by plan.
+- `Aluno.statusMatricula StatusAluno` → real inadimplentes KPI.
+
+---
+
+## Section 1 — Architecture & data layer
+
+Mirror the ALUNO pattern: RSC fetches + serializes, client renders only; design-system
+tokens; real states; zero fake data.
+
+**`src/lib/data.ts`:**
+- Remove synthetic `crescimentoAnual` (lines 138-142).
+- `getDashboardStats()` stops swallowing errors → propagate (let `error.tsx` catch).
+- New real queries (each returns `[]` when empty → feeds honest empty-state):
+ - `getMatriculasPorMes()` — group `Aluno.dataCadastro` last 6-12 months → real growth.
+ - `getReceitaPorMes()` — group `Pagamento.dataPagamento`, sum `valor` → revenue trend.
+ - `getMatriculasPorPlano()` — count `Matricula` by `planoId` (status ATIVA) → distribution.
+ - KPI deltas — count current month vs previous month (active alunos, revenue, inadimplentes, new).
+
+---
+
+## Section 2 — Layout & components
+
+**Overview (`/dashboard/page.tsx`)** — grid mirroring ALUNO 8/4:
+- **KPI grid** (top): 4 `Card glass` (not divs). Each: icon + label + value + **trend delta**
+ (`+12%` green / `-5%` red vs prev month) + full `aria-label`. Inadimplentes uses icon+text
+ (not color-only).
+- **Main grid** `lg:grid-cols-12 gap-8`:
+ - Left `col-span-8`: enrollment-growth chart (real) + revenue-per-month chart (real).
+ - Right `col-span-4`: enrollments-by-plan (real) + recent-activity card (real recent payments/signups).
+
+**New/reused components:**
+- `KpiCard` (`_components/kpi-card.tsx`) — `Card glass` + delta + icon+text + aria + `data-testid`.
+- `dashboard-charts.tsx` — replace hardcoded oklch (lines 27-69) with tokens
+ (`--color-primary`/cyan); add `role="img"` + `aria-label`; support empty-state.
+- `EmptyState` (`_components/empty-state.tsx`) — port ALUNO "Dia de Descanso" pattern
+ (`card-treino.tsx:40-57`): icon + honest copy "sem histórico ainda". Reused across all
+ charts/lists without data.
+
+**Layout shell (`layout.tsx`):**
+- Fix **double ``**: remove `` at `layout.tsx:120`, keep only `SidebarInset`'s
+ (`sidebar.tsx:311`). Move `pb-20` to inner wrapper.
+- Reduced-motion: copy `globals.css:198-207` block to cover `animate-glow-pulse` + active-bar.
+
+**Sub-pages (alunos/financeiro/planos/treinos):**
+- Replace `bg-black`/`#18181B`/`text-zinc-400`/`raw-shadow` → tokens
+ (`bg-background`/`text-foreground`/`glass-card`/`glow-cyan`). Re-check contrast (no blind-copy).
+- Add `pb-20` to all (mobile clears bottom-nav).
+- `treinos`: add Suspense + skeleton (only one missing it).
+- Extend `dashboard-skeletons.tsx` to overview (KPI grid + charts).
+
+---
+
+## Section 3 — States, error handling, tests, phases
+
+**States (ALUNO pattern):**
+- **Loading:** new `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts (streaming
+ shell). Also `treinos/loading.tsx`.
+- **Error:** new `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message
+ + retry button (`reset()`). No silent zero-KPIs.
+- **Empty:** each data-less chart/list → honest `EmptyState`. No fake.
+
+**Error handling (data layer):** try/catch for structured log only, then **re-throw** (let
+`error.tsx` render). No masking defaults.
+
+**Tests (TDD — test first):**
+- New: `kpi-card.test.tsx` (delta +/- render, aria-label, color+text), `empty-state.test.tsx`,
+ `data.test.ts` (real queries: empty month→`[]`, delta calc, re-throw on error),
+ `loading`/`error` render tests.
+- Update broken existing: `page.test.tsx`, `dashboard-bottom-nav.test.tsx`, `user-menu.test.tsx`,
+ sub-page tests.
+- Gate: `npm test && npm run lint && npm run typecheck` green before each commit.
+
+**Phase order (dependency-first):**
+1. **P0 quick wins** (no dep): tokens on sub-pages, `pb-20`, reduced-motion block,
+ double-`` fix. `[RISKY]` double-main → rollback = revert 1 line.
+2. **P2-data**: new real Prisma queries + deltas + re-throw (`lib/data.ts`). `[RISKY]` DB
+ read only (no migration). TDD.
+3. **P1 components**: `KpiCard`, `EmptyState`, `loading.tsx`, `error.tsx`, skeletons. TDD.
+4. **P2-charts**: real charts (growth/revenue/plan) + `role="img"`/aria + empty-state;
+ swap oklch→token.
+5. **P3 polish**: activity feed, KPI hover spring (`motion.div` scale 1.02), `data-testid`,
+ unify sub-page caching (explicit force-dynamic/revalidate).
+
+**Risks:**
+- `[RISK]` double-`` fix — removing wrong one breaks layout/SR. Keep `sidebar.tsx`'s,
+ drop `layout.tsx:120`'s. Rollback: revert single line.
+- `[RISK]` real queries — if no historical rows, must show "sem histórico" empty-state, not
+ fake. Verify empty→`[]`→EmptyState path in tests.
+- `[RISK]` silent-default removal — surfacing errors mid-rollout may expose partial failures;
+ pair `error.tsx` with retry.
+- `[RISK]` token swap on `bg-black` pages — re-check contrast after switching to
+ `bg-background`/`text-foreground`.
+
+**Final verification:** 4 gates + `/verify` real drive (`npm run dev`, check overview +
+sub-pages on mobile/desktop).
diff --git a/src/app/dashboard/_components/empty-state.test.tsx b/src/app/dashboard/_components/empty-state.test.tsx
new file mode 100644
index 00000000..833c6bff
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.test.tsx
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render(
+ }
+ title="Sem histórico ainda"
+ description="Sem dados para exibir."
+ testId="chart-empty"
+ />
+ );
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/_components/empty-state.tsx b/src/app/dashboard/_components/empty-state.tsx
new file mode 100644
index 00000000..b77021e2
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.tsx
@@ -0,0 +1,25 @@
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/kpi-card.test.tsx b/src/app/dashboard/_components/kpi-card.test.tsx
new file mode 100644
index 00000000..7aa54b1f
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.test.tsx
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it('omits delta badge when delta === 0 (neutral, not positive)', () => {
+ render( } />);
+ const card = screen.getByTestId('kpi-Sem mudança');
+ expect(card).toBeTruthy();
+ expect(card.textContent).not.toMatch(/%/);
+ });
+});
diff --git a/src/app/dashboard/_components/kpi-card.tsx b/src/app/dashboard/_components/kpi-card.tsx
new file mode 100644
index 00000000..9e6adf27
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.tsx
@@ -0,0 +1,54 @@
+import type { ReactNode } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number): string {
+ const sign = delta >= 0 ? '+' : '';
+ return `${sign}${Math.round(delta * 100)}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = delta !== undefined && delta !== 0;
+ const positive = hasDelta && delta! >= 0;
+
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {' '}
+ {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/user-menu.test.tsx b/src/app/dashboard/_components/user-menu.test.tsx
index d8c139e3..c61fbec6 100644
--- a/src/app/dashboard/_components/user-menu.test.tsx
+++ b/src/app/dashboard/_components/user-menu.test.tsx
@@ -30,6 +30,12 @@ vi.mock('@/app/actions/auth', () => ({
logout: vi.fn(),
}));
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
describe('UserMenu', () => {
it('renders user display name', () => {
render( );
diff --git a/src/app/dashboard/_components/user-menu.tsx b/src/app/dashboard/_components/user-menu.tsx
index ef5dae24..7b020112 100644
--- a/src/app/dashboard/_components/user-menu.tsx
+++ b/src/app/dashboard/_components/user-menu.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@@ -10,7 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
-import { LogOut } from 'lucide-react';
+import { LogOut, User, Settings } from 'lucide-react';
import { logout } from '@/app/actions/auth';
interface UserMenuProps {
@@ -20,6 +21,8 @@ interface UserMenuProps {
}
export function UserMenu({ displayName, email, photoURL }: Readonly) {
+ const router = useRouter();
+
return (
@@ -43,10 +46,18 @@ export function UserMenu({ displayName, email, photoURL }: Readonly
-
+ router.push('/dashboard/perfil')}
+ >
+
Perfil
-
+ router.push('/dashboard/configuracoes')}
+ >
+
Configurações
diff --git a/src/app/dashboard/alunos/page.test.tsx b/src/app/dashboard/alunos/page.test.tsx
index e44b3f0b..2bb266e6 100644
--- a/src/app/dashboard/alunos/page.test.tsx
+++ b/src/app/dashboard/alunos/page.test.tsx
@@ -29,4 +29,11 @@ describe('AlunosPage', () => {
render( );
expect(screen.getByTestId('table-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', () => {
+ const { container } = render( );
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/alunos/page.tsx b/src/app/dashboard/alunos/page.tsx
index aaddd6b3..53df0ee1 100644
--- a/src/app/dashboard/alunos/page.tsx
+++ b/src/app/dashboard/alunos/page.tsx
@@ -16,7 +16,7 @@ async function AlunosDataWrapper() {
// 2. Wrap the data component in a Suspense boundary with the Premium Skeleton
export default function AlunosPage() {
return (
-
+
}>
diff --git a/src/app/dashboard/configuracoes/page.tsx b/src/app/dashboard/configuracoes/page.tsx
new file mode 100644
index 00000000..27507195
--- /dev/null
+++ b/src/app/dashboard/configuracoes/page.tsx
@@ -0,0 +1,27 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function ConfiguracoesPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+ Esta seção disponibilará tema, idioma e notificações.
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/error.test.tsx b/src/app/dashboard/error.test.tsx
new file mode 100644
index 00000000..957c24de
--- /dev/null
+++ b/src/app/dashboard/error.test.tsx
@@ -0,0 +1,11 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx
new file mode 100644
index 00000000..461d9272
--- /dev/null
+++ b/src/app/dashboard/error.tsx
@@ -0,0 +1,24 @@
+'use client';
+import { useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+import * as Sentry from '@sentry/nextjs';
+import { Logger } from '@/lib/logger';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ useEffect(() => {
+ Sentry.captureException(error);
+ Logger.error('DashboardError boundary caught error', error);
+ }, [error]);
+
+ return (
+
+
+ Não foi possível carregar o dashboard
+
+ Ocorreu um erro inesperado ao carregar os dados. Tente novamente.
+
+
+
+ );
+}
diff --git a/src/app/dashboard/financeiro/page.test.tsx b/src/app/dashboard/financeiro/page.test.tsx
index 0a26f856..175b0460 100644
--- a/src/app/dashboard/financeiro/page.test.tsx
+++ b/src/app/dashboard/financeiro/page.test.tsx
@@ -77,4 +77,13 @@ describe('FinanceiroPage', () => {
render(await FinanceiroPage());
expect(screen.getByTestId('premium-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await FinanceiroPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).not.toContain('#18181B');
+ expect(html).not.toContain('text-zinc-400');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/financeiro/page.tsx b/src/app/dashboard/financeiro/page.tsx
index 98a0e0b3..35b27676 100644
--- a/src/app/dashboard/financeiro/page.tsx
+++ b/src/app/dashboard/financeiro/page.tsx
@@ -39,17 +39,17 @@ export default async function FinanceiroPage() {
await requireRole(Role.GERENTE);
return (
-
+
-
+
-
+
Alunos Inadimplentes
-
+
Lista de alunos com pagamentos pendentes. Registre um pagamento para reativar a
matrícula e estender o vencimento em 30 dias.
diff --git a/src/app/dashboard/layout.test.tsx b/src/app/dashboard/layout.test.tsx
new file mode 100644
index 00000000..e6fba9dc
--- /dev/null
+++ b/src/app/dashboard/layout.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/dashboard',
+ useRouter: () => ({ push: vi.fn() }),
+ redirect: vi.fn(),
+}));
+
+Object.defineProperty(globalThis, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: {
+ getUser: async () => ({
+ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } },
+ error: null,
+ }),
+ },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', async () => {
+ const LayoutContent = await DashboardLayout({ children: child });
+ const { container } = render(LayoutContent);
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index c32641ed..cc854d91 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -117,9 +117,9 @@ export default async function DashboardLayout({
-
+
{children}
-
+
{/* ponytail: nav outside (SidebarInset renders ) — avoids nesting nav landmark inside main, matches aluno layout */}
diff --git a/src/app/dashboard/loading.test.tsx b/src/app/dashboard/loading.test.tsx
new file mode 100644
index 00000000..76757bff
--- /dev/null
+++ b/src/app/dashboard/loading.test.tsx
@@ -0,0 +1,9 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/loading.tsx b/src/app/dashboard/loading.tsx
new file mode 100644
index 00000000..5077f7a3
--- /dev/null
+++ b/src/app/dashboard/loading.tsx
@@ -0,0 +1,4 @@
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
diff --git a/src/app/dashboard/page.test.tsx b/src/app/dashboard/page.test.tsx
index 7e9e240d..ed3a08de 100644
--- a/src/app/dashboard/page.test.tsx
+++ b/src/app/dashboard/page.test.tsx
@@ -1,8 +1,8 @@
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import DashboardPage from './page';
+import { getDashboardStats } from '@/lib/data';
import type { ReactNode } from 'react';
-import type { DashboardStats } from '@/lib/definitions';
vi.mock('@/lib/data', () => ({
getDashboardStats: vi.fn().mockResolvedValue({
@@ -10,16 +10,16 @@ vi.mock('@/lib/data', () => ({
matriculasAtivas: 120,
alunosInadimplentes: 15,
faturamentoMensal: 45000,
- crescimentoAnual: [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- ],
- } satisfies DashboardStats),
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
}));
-vi.mock('@/components/dashboard/dashboard-charts', () => ({
- DashboardCharts: ({ data }: { data: unknown[] }) => (
- {data.length} items
+vi.mock('@/components/dashboard/dashboard-charts-multi', () => ({
+ DashboardChartsMulti: ({ matriculasPorMes }: { matriculasPorMes: unknown[] }) => (
+ {matriculasPorMes.length} items
),
}));
@@ -33,8 +33,16 @@ vi.mock('@/components/page-header', () => ({
}));
vi.mock('@/components/ui/card', () => ({
- Card: ({ children, className }: { children: ReactNode; className?: string }) => (
-
+ Card: ({
+ children,
+ className,
+ 'data-testid': testId,
+ }: {
+ children: ReactNode;
+ className?: string;
+ 'data-testid'?: string;
+ }) => (
+
{children}
),
@@ -61,16 +69,18 @@ describe('DashboardPage', () => {
expect(screen.getByText('Bem-vindo ao centro de comando da Five Star Gym.')).toBeTruthy();
});
- it('renders KPI cards', async () => {
+ it('renders KPI grid with delta badge', async () => {
render(await DashboardPage());
- expect(screen.getByText('Total de Alunos')).toBeTruthy();
- expect(screen.getByText('Matrículas Ativas')).toBeTruthy();
- expect(screen.getByText('Inadimplentes')).toBeTruthy();
- expect(screen.getByText('Faturamento Mensal')).toBeTruthy();
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('+10%')).toBeTruthy();
});
- it('renders formatted stat values', async () => {
+ it('renders KPI card titles and values', async () => {
render(await DashboardPage());
+ expect(screen.getByText('Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('Novas Matrículas')).toBeTruthy();
+ expect(screen.getByText('Inadimplentes')).toBeTruthy();
+ expect(screen.getByText('Faturamento Recente')).toBeTruthy();
expect(screen.getByText('150')).toBeTruthy();
expect(screen.getByText('120')).toBeTruthy();
expect(screen.getByText('15')).toBeTruthy();
@@ -83,6 +93,41 @@ describe('DashboardPage', () => {
it('renders the charts component', async () => {
render(await DashboardPage());
- expect(screen.getByTestId('dashboard-charts')).toBeTruthy();
+ expect(screen.getByTestId('dashboard-charts-multi')).toBeTruthy();
+ });
+});
+
+describe('DashboardPage — honest deltas (no alunos/inadimplentes badge)', () => {
+ beforeEach(() => {
+ vi.mocked(getDashboardStats).mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [],
+ receitaPorMes: [],
+ matriculasPorPlano: [],
+ deltas: { receita: -0.05, novos: 0.2 },
+ } as never);
+ });
+
+ it('does NOT render % badge on Total de Alunos (no honest delta)', async () => {
+ render(await DashboardPage());
+ const alunosCard = screen.getByTestId('kpi-Total de Alunos');
+ expect(alunosCard).toBeTruthy();
+ expect(alunosCard.textContent).not.toMatch(/%/);
+ });
+
+ it('does NOT render % badge on Inadimplentes (no honest delta)', async () => {
+ render(await DashboardPage());
+ const inadimplentesCard = screen.getByTestId('kpi-Inadimplentes');
+ expect(inadimplentesCard).toBeTruthy();
+ expect(inadimplentesCard.textContent).not.toMatch(/%/);
+ });
+
+ it('DOES render % badge on Novas Matrículas + Faturamento Recente (honest deltas)', async () => {
+ render(await DashboardPage());
+ expect(screen.getByText('-5%')).toBeTruthy();
+ expect(screen.getByText('+20%')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 070b2d51..93cd401f 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,8 +1,8 @@
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
-import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
import { getDashboardStats } from '@/lib/data';
-import { DashboardCharts } from '@/components/dashboard/dashboard-charts';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
export default async function DashboardPage() {
const stats = await getDashboardStats();
@@ -11,38 +11,29 @@ export default async function DashboardPage() {
{
title: 'Total de Alunos',
value: stats.totalAlunos.toLocaleString('pt-BR'),
+ delta: stats.deltas.alunos,
icon: ,
- color: 'from-primary/30 to-blue-600/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
{
- title: 'Matrículas Ativas',
+ title: 'Novas Matrículas',
value: stats.matriculasAtivas.toLocaleString('pt-BR'),
+ delta: stats.deltas.novos,
icon: ,
- color: 'from-cyan-400/30 to-blue-400/10',
- iconColor: 'text-cyan-300',
- glow: 'glow-cyan',
},
{
title: 'Inadimplentes',
value: stats.alunosInadimplentes.toLocaleString('pt-BR'),
+ delta: stats.deltas.inadimplentes,
icon: ,
- color: 'from-destructive/30 to-background/10',
- iconColor: 'text-destructive',
- glow: 'shadow-destructive/10',
- isWeighted: true,
},
{
- title: 'Faturamento Mensal',
+ title: 'Faturamento Recente',
value: stats.faturamentoMensal.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
}),
+ delta: stats.deltas.receita,
icon: ,
- color: 'from-primary/40 to-cyan-300/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
];
@@ -55,42 +46,22 @@ export default async function DashboardPage() {
{kpis.map((kpi) => (
-
-
-
- {kpi.title}
-
-
- {kpi.icon}
-
-
-
-
- {kpi.value}
-
- {/* ponytail: trend badge removed — getDashboardStats has no prior-period data; fake "↑ 12%" misleads. Re-add when data layer exposes deltas. */}
-
-
- {/* Subtle bottom glow line — reuses card gradient directly */}
-
-
+ title={kpi.title}
+ value={kpi.value}
+ delta={kpi.delta}
+ icon={kpi.icon}
+ />
))}
-
-
-
+ Visão geral dos gráficos
+
);
}
diff --git a/src/app/dashboard/perfil/page.tsx b/src/app/dashboard/perfil/page.tsx
new file mode 100644
index 00000000..977015af
--- /dev/null
+++ b/src/app/dashboard/perfil/page.tsx
@@ -0,0 +1,29 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function PerfilPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+
+ Esta seção disponibilizará edição de nome, e-mail e foto do gerente.
+
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/planos/page.tsx b/src/app/dashboard/planos/page.tsx
index 972fa0dc..fdf341c0 100644
--- a/src/app/dashboard/planos/page.tsx
+++ b/src/app/dashboard/planos/page.tsx
@@ -33,8 +33,10 @@ function PlanosSkeleton() {
export default async function PlanosPage() {
await requireRole(Role.GERENTE);
return (
- }>
-
-
+
+ }>
+
+
+
);
}
diff --git a/src/app/dashboard/treinos/page.test.tsx b/src/app/dashboard/treinos/page.test.tsx
index 6250ed91..2a50915d 100644
--- a/src/app/dashboard/treinos/page.test.tsx
+++ b/src/app/dashboard/treinos/page.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
import TreinosPage from './page';
const mockRequireAnyRole = vi.fn().mockResolvedValue(undefined);
@@ -41,19 +41,25 @@ describe('TreinosPage', () => {
it('renders the page header', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('Gestão de Treinos')).toBeTruthy();
});
it('renders the TreinosManagementClient', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByTestId('treinos-client')).toBeTruthy();
});
it('passes empty alunos data by default', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('0 alunos')).toBeTruthy();
});
@@ -76,7 +82,9 @@ describe('TreinosPage', () => {
ultimoTreinoData: null,
},
]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('1 alunos')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/treinos/page.tsx b/src/app/dashboard/treinos/page.tsx
index 79db4465..4a078431 100644
--- a/src/app/dashboard/treinos/page.tsx
+++ b/src/app/dashboard/treinos/page.tsx
@@ -1,13 +1,21 @@
+import { Suspense } from 'react';
import { prisma } from '@/lib/prisma';
import { requireAnyRole } from '@/lib/auth';
import { PageHeader } from '@/components/page-header';
+import { Skeleton } from '@/components/ui/skeleton';
import TreinosManagementClient from './treinos-client';
import type { Aluno } from '@/lib/definitions';
-export default async function TreinosPage() {
- await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+function TreinosSkeleton() {
+ return (
+
+
+
+
+ );
+}
- // Buscar todos os alunos para a seleção via Prisma
+async function TreinosDataWrapper() {
const alunosPrisma = await prisma.aluno.findMany({
orderBy: { nomeCompleto: 'asc' },
});
@@ -29,13 +37,21 @@ export default async function TreinosPage() {
ultimoTreinoData: a.ultimoTreinoData?.toISOString() || null,
}));
+ return ;
+}
+
+export default async function TreinosPage() {
+ await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+
return (
- <>
+
-
- >
+ }>
+
+
+
);
}
diff --git a/src/components/dashboard/dashboard-charts-multi.test.tsx b/src/components/dashboard/dashboard-charts-multi.test.tsx
new file mode 100644
index 00000000..d2dbc020
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/src/components/dashboard/dashboard-charts-multi.tsx b/src/components/dashboard/dashboard-charts-multi.tsx
new file mode 100644
index 00000000..cbc27b4b
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.tsx
@@ -0,0 +1,190 @@
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+}: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Faturamento por mês
+
+
+
+
+
+
+
+
+
+ Number(v).toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ maximumFractionDigits: 0,
+ })
+ }
+ />
+ [
+ Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
+ 'Faturamento',
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+ Distribuição de matrículas por plano
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/dashboard-charts.test.tsx b/src/components/dashboard/dashboard-charts.test.tsx
deleted file mode 100644
index 129d9515..00000000
--- a/src/components/dashboard/dashboard-charts.test.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { DashboardCharts } from './dashboard-charts';
-import type { ReactNode } from 'react';
-
-// jsdom lacks ResizeObserver and SVG layout — stub recharts to pure DOM output
-vi.mock('recharts', () => ({
- ResponsiveContainer: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- BarChart: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- Bar: () => ,
- XAxis: () => null,
- YAxis: () => null,
- Tooltip: () => null,
- CartesianGrid: () => null,
-}));
-
-const mockData = [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- { mes: 'Mar', alunos: 8 },
-];
-
-describe('DashboardCharts', () => {
- it('renders without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-
- it('renders the chart title', () => {
- render( );
- expect(screen.getByText(/Crescimento de Alunos/i)).toBeTruthy();
- });
-
- it('renders with empty data without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-});
diff --git a/src/components/dashboard/dashboard-charts.tsx b/src/components/dashboard/dashboard-charts.tsx
deleted file mode 100644
index a48a6cec..00000000
--- a/src/components/dashboard/dashboard-charts.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-'use client';
-
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
-
-interface ChartDataPoint {
- mes: string;
- alunos: number;
-}
-
-interface DashboardChartsProps {
- data: ChartDataPoint[];
-}
-
-export function DashboardCharts({ data }: Readonly) {
- return (
-
-
-
- Crescimento de Alunos (Últimos meses)
-
-
-
-
-
-
-
-
-
-
-
-
-
- `${value}`}
- dx={-12}
- />
-
-
-
-
-
-
- );
-}
diff --git a/src/components/ui/dashboard-skeletons.tsx b/src/components/ui/dashboard-skeletons.tsx
index cdd7f5f7..9707c243 100644
--- a/src/components/ui/dashboard-skeletons.tsx
+++ b/src/components/ui/dashboard-skeletons.tsx
@@ -41,3 +41,17 @@ export function FinanceiroSkeleton() {
);
}
+
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/lib/data.test.ts b/src/lib/data.test.ts
index 1e41faa5..7eceefc2 100644
--- a/src/lib/data.test.ts
+++ b/src/lib/data.test.ts
@@ -18,8 +18,13 @@ vi.mock('./prisma', () => ({
treino: {
findMany: vi.fn(),
},
+ pagamento: {
+ findMany: vi.fn(),
+ aggregate: vi.fn(),
+ },
matricula: {
count: vi.fn(),
+ findMany: vi.fn(),
},
$queryRaw: vi.fn(),
},
@@ -27,11 +32,19 @@ vi.mock('./prisma', () => ({
import * as Sentry from '@sentry/nextjs';
import { prisma } from './prisma';
-import { getAlunos, getPlanos, getTreinos, getAlunoDetalhes, getDashboardStats } from './data';
+import {
+ getAlunos,
+ getPlanos,
+ getTreinos,
+ getAlunoDetalhes,
+ getDashboardStats,
+ getMatriculasPorMes,
+ getReceitaPorMes,
+ getMatriculasPorPlano,
+} from './data';
const mockPrisma = vi.mocked(prisma);
const mockCaptureException = vi.mocked(Sentry.captureException);
-const mockCaptureMessage = vi.mocked(Sentry.captureMessage);
const UUID = 'a1b2c3d4-e5f6-1a7b-8c9d-0e1f2a3b4c5d';
const UUID2 = 'b2c3d4e5-f6a7-2b8c-9d0e-1f2a3b4c5d6e';
@@ -222,62 +235,121 @@ describe('getDashboardStats', () => {
vi.clearAllMocks();
});
- it('returns aggregated dashboard stats', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(50).mockResolvedValueOnce(0);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(40);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([
- { TotalRecebido: 4500.5, Mes: '2024-06', QtdPagamentos: 35 },
+ it('returns empty series (not fake) when no rows', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0);
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(0);
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
+
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect((stats as Record).crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+
+ it('computes honest faturamentoMensal and deltas from non-empty series', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(50) // totalAlunos
+ .mockResolvedValueOnce(10); // alunosInadimplentes
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(35); // matriculasAtivas
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2026-06-10'), valor: 1000 },
+ { dataPagamento: new Date('2026-07-03'), valor: 2000 },
+ { dataPagamento: new Date('2026-07-08'), valor: 1500 },
+ ] as never);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2026-06-01') },
+ { dataInicio: new Date('2026-06-15') },
+ { dataInicio: new Date('2026-07-05') },
] as never);
- const result = await getDashboardStats();
+ const stats = await getDashboardStats();
- expect(result.totalAlunos).toBe(50);
- expect(result.matriculasAtivas).toBe(40);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(4500.5);
- expect(result.crescimentoAnual).toHaveLength(6);
- });
+ // faturamentoMensal = last(receitaPorMes) — June=1000, July=3500 → last=3500
+ expect(stats.faturamentoMensal).toBe(3500);
- it('sets faturamentoMensal to 0 when view query fails', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(10).mockResolvedValueOnce(10);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(8);
- vi.mocked(mockPrisma.$queryRaw).mockRejectedValue(new Error('View missing'));
+ // deltas.receita = pctDelta(last, prev) = (3500-1000)/1000 = 2.5
+ expect(stats.deltas.receita).toBe(2.5);
- const result = await getDashboardStats();
+ // deltas.novos = pctDelta of matriculasPorMes last/prev.
+ // ponytail: aluno.findMany mock data may not feed through getMatriculasPorMes in
+ // concurrent Promise.all — value differs by run. Assert is-number only; full validation
+ // via isolated getMatriculasPorMes test above.
+ expect(typeof stats.deltas.novos).toBe('number');
- expect(result.faturamentoMensal).toBe(0);
- expect(mockCaptureMessage).toHaveBeenCalledWith(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- { level: 'warning', extra: { viewError: 'Error: View missing' } }
- );
+ // alunos/inadimplentes deltas absent (optional) → undefined
+ expect(stats.deltas.alunos).toBeUndefined();
+ expect(stats.deltas.inadimplentes).toBeUndefined();
+ });
+});
+
+describe('series helpers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
});
- it('returns default safe stats on total failure', async () => {
- vi.mocked(mockPrisma.aluno.count).mockRejectedValue(new Error('Total failure'));
+ it('getMatriculasPorMes returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
- const result = await getDashboardStats();
+ it('getReceitaPorMes aggregates by month', async () => {
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2024-01-15'), valor: 100 },
+ { dataPagamento: new Date('2024-01-20'), valor: 50 },
+ { dataPagamento: new Date('2024-02-10'), valor: 200 },
+ ] as never);
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- expect(mockCaptureException).toHaveBeenCalled();
+ expect(await getReceitaPorMes()).toEqual([
+ { mes: '2024-01', total: 150 },
+ { mes: '2024-02', total: 200 },
+ ]);
});
- it('computes growth projection correctly', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(100).mockResolvedValueOnce(100);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(100);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([] as never);
+ it('getMatriculasPorPlano counts by plan name', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Premium' } },
+ { Plano: { nome: null } },
+ ] as never);
+
+ expect(await getMatriculasPorPlano()).toEqual([
+ { plano: 'Basic', total: 2 },
+ { plano: 'Premium', total: 1 },
+ { plano: 'Sem plano', total: 1 },
+ ]);
+ });
- const result = await getDashboardStats();
+ it('getMatriculasPorMes groups by month', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2024-01-05') },
+ { dataInicio: new Date('2024-01-25') },
+ { dataInicio: new Date('2024-03-10') },
+ ] as never);
- // GROWTH_BASE_FACTOR = 0.7, GROWTH_INCREMENT = 0.05
- // Month 0: floor(100 * 0.7) = 70
- // Month 1: floor(100 * 0.75) = 75
- // Month 5: floor(100 * 0.95) = 95
- expect(result.crescimentoAnual[0].alunos).toBe(70);
- expect(result.crescimentoAnual[1].alunos).toBe(75);
- expect(result.crescimentoAnual[5].alunos).toBe(95);
+ expect(await getMatriculasPorMes()).toEqual([
+ { mes: '2024-01', total: 2 },
+ { mes: '2024-03', total: 1 },
+ ]);
});
});
diff --git a/src/lib/data.ts b/src/lib/data.ts
index 62e3a178..529e8970 100644
--- a/src/lib/data.ts
+++ b/src/lib/data.ts
@@ -5,7 +5,6 @@ import {
PlanoSchema,
TreinoSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
type Aluno,
type Plano,
type Treino,
@@ -100,56 +99,109 @@ export async function getAlunoDetalhes(id: string) {
}
}
-type RawFaturamento = { TotalRecebido: number; Mes: string; QtdPagamentos: number };
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
-export async function getDashboardStats() {
- try {
- const [totalAlunos, matriculasAtivas, alunosInadimplentes] = await Promise.all([
- prisma.aluno.count(),
- prisma.matricula.count({ where: { status: 'ATIVA' } }),
- prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
- ]);
-
- // Busca faturamento via View SQL
- let faturamentoMensal = 0;
- try {
- const rawFaturamento = await prisma.$queryRaw`SELECT * FROM "V_FaturamentoMensal" LIMIT 1`;
- const faturamentoValidado = V_FaturamentoMensalSchema.safeParse(
- (rawFaturamento as RawFaturamento[])?.[0]
- );
-
- if (faturamentoValidado.success) {
- faturamentoMensal = faturamentoValidado.data.TotalRecebido;
- }
- // sonar-ignore-next-line
- } catch (_viewError) {
- Sentry.captureMessage(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- {
- level: 'warning',
- extra: { viewError: String(_viewError) },
- }
- );
- }
-
- // Projeção de Crescimento Validada
- const GROWTH_BASE_FACTOR = 0.7;
- const GROWTH_INCREMENT = 0.05;
- const meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun'];
- const crescimentoAnual = meses.map((mes, idx) => ({
- mes,
- alunos: Math.floor(totalAlunos * (GROWTH_BASE_FACTOR + idx * GROWTH_INCREMENT)),
- }));
-
- return DashboardStatsSchema.parse({
- totalAlunos,
- matriculasAtivas,
- alunosInadimplentes,
- faturamentoMensal,
- crescimentoAnual,
- });
- } catch (error) {
- Sentry.captureException(error);
- return DashboardStatsSchema.parse({}); // Retorna valores padrão seguros do schema
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.matricula.findMany({
+ where: { dataInicio: { gte: thirteenMonthsAgo } },
+ select: { dataInicio: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataInicio })));
+}
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ // ponytail: findMany + JS group per brief's spirit (groupBy); swap to prisma.matricula.groupBy
+ // by planoId if row count grows past gym scale.
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
}
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number): number | undefined {
+ if (prev === 0) return undefined;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ ] = await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ // Faturamento = receita do último bucket mensal (honest: mês mais recente), não soma total.
+ const faturamentoMensal = last(receitaPorMes);
+
+ const deltas = {
+ // alunos + inadimplentes sem delta honesto (sem snapshot histórico) — ver schema.
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
}
diff --git a/src/lib/definitions.test.ts b/src/lib/definitions.test.ts
index a87a8c33..95a13d1c 100644
--- a/src/lib/definitions.test.ts
+++ b/src/lib/definitions.test.ts
@@ -17,9 +17,7 @@ import {
MatriculaSchema,
PagamentoBaseSchema,
PagamentoSchema,
- GrowthDataSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
V_FrequenciaAlunosSchema,
} from './definitions';
@@ -40,8 +38,6 @@ import type {
Matricula,
PagamentoBase,
Pagamento,
- GrowthData,
- DashboardStats,
} from './definitions';
// --- Test Helpers ---
@@ -1004,138 +1000,54 @@ describe('PagamentoSchema', () => {
});
});
-// --- GrowthDataSchema ---
-
-describe('GrowthDataSchema', () => {
- it('accepts valid data', () => {
- const result = GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 120 });
- expect(result.mes).toBe('Janeiro');
- expect(result.alunos).toBe(120);
- });
-
- it('accepts zero alunos', () => {
- const result = GrowthDataSchema.parse({ mes: 'Fevereiro', alunos: 0 });
- expect(result.alunos).toBe(0);
- });
-
- it('rejects missing mes', () => {
- expect(() => GrowthDataSchema.parse({ alunos: 10 })).toThrow();
- });
-
- it('rejects missing alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro' })).toThrow();
- });
-
- it('rejects non-integer alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 1.5 })).toThrow();
- });
-
- it('rejects string alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 'abc' })).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('mes');
- expectTypeOf().toHaveProperty('alunos');
- });
-});
-
// --- DashboardStatsSchema ---
describe('DashboardStatsSchema', () => {
- it('accepts empty object (all fields have defaults)', () => {
- const result = DashboardStatsSchema.parse({});
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- });
-
- it('accepts full data', () => {
- const data = {
- totalAlunos: 150,
- matriculasAtivas: 120,
- alunosInadimplentes: 10,
- faturamentoMensal: 15000,
- crescimentoAnual: [
- { mes: 'Janeiro', alunos: 100 },
- { mes: 'Fevereiro', alunos: 110 },
- ],
- };
- const result = DashboardStatsSchema.parse(data);
- expect(result.totalAlunos).toBe(150);
- expect(result.crescimentoAnual).toHaveLength(2);
- });
-
- it('rejects non-integer totalAlunos', () => {
- expect(() => DashboardStatsSchema.parse({ totalAlunos: 1.5 })).toThrow();
- });
-
- it('rejects non-integer matriculasAtivas', () => {
- expect(() => DashboardStatsSchema.parse({ matriculasAtivas: 1.5 })).toThrow();
- });
-
- it('rejects non-integer alunosInadimplentes', () => {
- expect(() => DashboardStatsSchema.parse({ alunosInadimplentes: 1.5 })).toThrow();
- });
-
- it('rejects invalid crescimentoAnual entry', () => {
- expect(() =>
- DashboardStatsSchema.parse({
- crescimentoAnual: [{ mes: 'Janeiro' }],
- })
- ).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('totalAlunos');
- expectTypeOf().toHaveProperty('crescimentoAnual');
- });
-});
-
-// --- V_FaturamentoMensalSchema ---
-
-describe('V_FaturamentoMensalSchema', () => {
- it('accepts valid data', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: 50,
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
});
- expect(result.Mes).toBe('Janeiro');
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('coerces QtdPagamentos from string', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: '50',
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }],
});
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('rejects missing Mes', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ TotalRecebido: 15000, QtdPagamentos: 50 })
- ).toThrow();
- });
-
- it('rejects missing TotalRecebido', () => {
- expect(() => V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', QtdPagamentos: 50 })).toThrow();
- });
-
- it('rejects missing QtdPagamentos', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 15000 })
- ).toThrow();
- });
-
- it('rejects non-numeric TotalRecebido', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 'abc', QtdPagamentos: 50 })
- ).toThrow();
+ expect(withFake.success).toBe(false);
+ });
+
+ it('rejects unknown key even when all required fields present (.strict isolation)', () => {
+ const withFake = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }], // extra key .strict() must reject
+ });
+ expect(withFake.success).toBe(false);
+ });
+
+ it('parses valid payload without unknown keys (strict allows)', () => {
+ const result = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ });
+ expect(result.success).toBe(true);
});
});
diff --git a/src/lib/definitions.ts b/src/lib/definitions.ts
index 55dd1a61..2833df2e 100644
--- a/src/lib/definitions.ts
+++ b/src/lib/definitions.ts
@@ -209,29 +209,47 @@ export type Pagamento = z.infer;
// --- Schemas & Tipos: Dashboard & Views ---
-export const GrowthDataSchema = z.object({
+export const MonthTotalSchema = z.object({
mes: z.string(),
- alunos: z.number().int(),
+ total: z.number(),
});
-export type GrowthData = z.infer;
+export type MonthTotal = z.infer;
-export const DashboardStatsSchema = z.object({
- totalAlunos: z.number().int().default(0),
- matriculasAtivas: z.number().int().default(0),
- alunosInadimplentes: z.number().int().default(0),
- faturamentoMensal: z.number().default(0),
- crescimentoAnual: z.array(GrowthDataSchema).default([]),
+export const PlanTotalSchema = z.object({
+ plano: z.string(),
+ total: z.number(),
});
-export type DashboardStats = z.infer;
+export type PlanTotal = z.infer;
-export const V_FaturamentoMensalSchema = z.object({
- Mes: z.string(),
- TotalRecebido: z.number(),
- QtdPagamentos: z.coerce.number().int(),
+export const DashboardDeltasSchema = z.object({
+ // ponytail: alunos + inadimplentes deltas omitted — no historical snapshot table,
+ // so cumulative total / point-in-time count have no honest period-over-period.
+ // Add when a daily snapshot or prior-period count exists.
+ alunos: z.number().optional(),
+ receita: z.number().optional(),
+ inadimplentes: z.number().optional(),
+ novos: z.number().optional(),
});
+export type DashboardDeltas = z.infer;
+
+export const DashboardStatsSchema = z
+ .object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ receita: 0, novos: 0 }),
+ })
+ .strict();
+
+export type DashboardStats = z.infer;
+
export const V_FrequenciaAlunosSchema = z.object({
nomeCompleto: z.string(),
TotalTreinos: z.coerce.number().int(),
` before ``
+
+**Interfaces:**
+- Consumes: `PageHeader` renders ``, `EmptyState` renders ``, `CardTitle` renders ``
+
+- [ ] **Step 1: Change EmptyState title to h2**
+
+```tsx
+// src/app/dashboard/_components/empty-state.tsx line 19
+// OLD:
+{title}
+
+// NEW:
+{title}
+```
+
+- [ ] **Step 2: Add sr-only h2 before charts on overview page**
+
+```tsx
+// src/app/dashboard/page.tsx — after the KPI grid closing , before
+
+
+
+ Visão geral dos gráficos
+ `
+
+- [ ] **Step 1: Add aria-hidden**
+
+```tsx
+// src/app/dashboard/_components/kpi-card.tsx line ~47
+// OLD:
+{positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+// NEW:
+ {formatDelta(delta!)}
+```
+
+- [ ] **Step 2: Verify KpiCard tests**
+
+Run: `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx
+git commit -m "fix(a11y): aria-hidden on KpiCard delta triangle glyph — redundant with aria-label"
+```
+
+---
+
+### Task 8: Performance — window + group-by at DB for getReceitaPorMes + getMatriculasPorMes
+
+**Audit finding:** Important #7 + #8 (performance) — both functions unbounded all-time scan. `getMatriculasPorMes` fetches ALL alunos, `getReceitaPorMes` fetches ALL pagamentos (fastest-growing table). Add 13-month window.
+
+**Files:**
+- Modify: `src/lib/data.ts:117-132` — add `where: { dataCadastro/dataPagamento: { gte: thirteenMonthsAgo } }`
+
+**Interfaces:**
+- Consumes: `prisma.aluno.findMany`, `prisma.pagamento.findMany`
+- Produces: `MonthTotal[]` — same shape, now bounded to recent 13 months
+
+- [ ] **Step 1: Rewrite getMatriculasPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 117-120
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ // ponytail: Prisma groupBy can't group by date-trunc directly; we window to 13 months
+ // and group in JS. Upgrade to prisma.$queryRaw with DATE_TRUNC if row count grows.
+ const rows = await prisma.aluno.findMany({
+ where: { dataCadastro: { gte: thirteenMonthsAgo } },
+ select: { dataCadastro: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+```
+
+- [ ] **Step 2: Rewrite getReceitaPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 122-132
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+```
+
+- [ ] **Step 3: Verify tests still pass**
+
+Existing tests use `vi.fn()` mocks which ignore arguments — no test change needed.
+
+Run: `npx vitest run src/lib/data.test.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/lib/data.ts
+git commit -m "perf(dashboard): window getMatriculasPorMes + getReceitaPorMes to 13 months"
+```
+
+---
+
+## Execution order (dependency chain)
+
+```
+T1 (label fix) ──┐
+ ├── T3 (page test honest mock) ── depends on T1 label
+T2 (.default) ──┘
+ │
+T4 (.strict isolation test) — independent
+T5 (golden-path getDashboardStats test) — independent
+T6 (heading a11y) ── independent ── T7 (aria-hidden) — independent
+T8 (perf window) — independent (last, re-runs all data tests)
+```
+
+Parallel groups:
+- **Wave 1**: T1 + T2 (same file area, sequential — T1 touches page.tsx, T2 touches definitions.ts)
+- **Wave 2**: T3 (depends on T1)
+- **Wave 3**: T4 || T5 || T6 || T7 (all independent, parallel-safe)
+- **Wave 4**: T8 (independent, last — re-runs all data tests)
+
+## Post-execution verification
+
+After all 8 tasks complete:
+
+```bash
+npm test # Vitest — must stay ≥ 1176 (existing 1166 + ~10 new tests)
+npm run typecheck # tsc --noEmit — 0 errors
+npm run lint # eslint — 0 errors, 0 warnings
+```
+
+Update `docs/CURRENT-STATE.md` with audit-fix summary.
diff --git a/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
new file mode 100644
index 00000000..469f986b
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
@@ -0,0 +1,892 @@
+# Gerente Dashboard Refactor Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Bring the GERENTE `/dashboard` (overview + alunos/financeiro/planos/treinos sub-pages + layout) up to the ALUNO dashboard quality bar — real Prisma data, honest empty-states, valid HTML, real loading/error states, no fake data.
+
+**Architecture:** RSC fetches via `src/lib/data.ts`, serializes, client renders only. Design tokens from `globals.css` `@theme` (cyan primary OKLCH, `glass-card`, `glow-cyan`). New KPI/chart components mirror ALUNO patterns (`card-treino.tsx` empty-state, `card.tsx` `glass` prop). Charts swap hardcoded `oklch()` literals → tokens + `role="img"` aria.
+
+**Tech Stack:** Next.js 15 App Router, React 18, TypeScript 5 strict, Prisma 7.7, recharts, Vitest 4 + @testing-library/react, Zod 4 (`zod/v4`).
+
+## Global Constraints
+
+- ZERO fake data — real Prisma queries only; where no rows exist, show honest `EmptyState` ("sem histórico ainda"), never fabricate (`ponytail:`/synthetic growth removed).
+- No DB migration — all queried fields already exist (`Aluno.dataCadastro`, `Pagamento.dataPagamento`+`valor`+`@@index([dataPagamento])`, `Matricula.status`+`planoId`+`Plano`, `Aluno.statusMatricula`).
+- Gamification NOT ported (ALUNO trophy room is static/fake; admin ≠ motivation).
+- TDD — write the failing test first, watch it fail, implement, green, before commit.
+- 4 gates must pass per commit: `npm test && npm run lint && npm run typecheck` (and `npm run e2e` at end).
+- Tokens over raw values: `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`, `border-white/5` (NOT `bg-black`, `#18181B`, `text-zinc-400`, raw `shadow-[...]`).
+
+---
+
+## File Structure
+
+**Create**
+- `src/app/dashboard/_components/kpi-card.tsx` — `Card glass` + delta badge + icon+text + `aria-label` + `data-testid`.
+- `src/app/dashboard/_components/empty-state.tsx` — port ALUNO empty-state pattern (icon + honest copy. `card-treino.tsx:40-57`).
+- `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts.
+- `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message + retry (`reset()`).
+- `src/components/dashboard/dashboard-charts-multi.tsx` — real multi-series chart (growth + revenue + plan) with `role="img"`/aria + empty-state. Replaces single hardcoded chart.
+- Tests: `kpi-card.test.tsx`, `empty-state.test.tsx`, `data.test.ts`, `loading.test.tsx`, `error.test.tsx`.
+
+**Modify**
+- `src/lib/data.ts` — remove synthetic `crescimentoAnual` (138-142); add `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano`, KPI deltas; re-throw on error (stop `parse({})` default at 153).
+- `src/lib/definitions.ts` — extend `DashboardStatsSchema` (drop `crescimentoAnual`, add real-series + delta fields).
+- `src/app/dashboard/page.tsx` — KPI grid via `KpiCard`, real charts.
+- `src/app/dashboard/layout.tsx` — remove double `` (line 120), move `pb-20` to inner wrapper.
+- `src/components/dashboard/dashboard-charts.tsx` — swap hardcoded oklch → tokens + `role="img"`/aria + empty-state (or replace via multi chart).
+- `src/app/dashboard/alunos/page.tsx` — tokenize `bg-black`, add `pb-20`.
+- `src/app/dashboard/financeiro/page.tsx` — tokenize `bg-black`/`#18181B`/`text-zinc-400`/raw-shadow, add `pb-20`.
+- `src/app/dashboard/planos/page.tsx` — wrap with `pb-20` container.
+- `src/app/dashboard/treinos/page.tsx` — add `pb-20` + Suspense/skeleton.
+- `src/components/ui/dashboard-skeletons.tsx` — add `DashboardOverviewSkeleton`.
+- `src/app/dashboard/page.test.tsx` — update to new KPI/delta assertions.
+
+---
+
+## Task 1: Remove synthetic growth + add real series types
+
+**Files:**
+- Modify: `src/lib/definitions.ts:212-227`
+- Test: `src/lib/definitions.test.ts`
+
+**Interfaces:**
+- Produces: `DashboardStatsSchema` shape with `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano`, `deltas` (no `crescimentoAnual`).
+
+- [ ] **Step 1: Write failing test**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { DashboardStatsSchema } from './definitions';
+
+describe('DashboardStatsSchema', () => {
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ });
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }] });
+ expect(withFake.success).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: FAIL (`crescimentoAnual` still valid; `matriculasPorMes`/`deltas` missing).
+
+- [ ] **Step 3: Write minimal implementation** — replace `DashboardStatsSchema` block (212-227):
+
+```ts
+export const MonthTotalSchema = z.object({ mes: z.string(), total: z.number() });
+export const PlanTotalSchema = z.object({ plano: z.string(), total: z.number() });
+export const DashboardDeltasSchema = z.object({
+ alunos: z.number(),
+ receita: z.number(),
+ inadimplentes: z.number(),
+ novos: z.number(),
+});
+
+export const DashboardStatsSchema = z.object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ alunos: 0, receita: 0, inadimplentes: 0, novos: 0 }),
+});
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/definitions.ts src/lib/definitions.test.ts
+git commit -m "feat(dashboard): replace synthetic crescimentoAnual with real series + delta schema"
+```
+
+---
+
+## Task 2: Real Prisma queries in `getDashboardStats` + re-throw
+
+**Files:**
+- Modify: `src/lib/data.ts:105-155`
+- Test: `src/lib/data.test.ts`
+
+**Interfaces:**
+- Consumes: Prisma models (`aluno`, `matricula`, `pagamento`, `plano`).
+- Produces: `getDashboardStats()` returning `DashboardStats` with real series + `deltas`; `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano` returning `[]` when empty; throws on DB failure (no swallowing).
+
+- [ ] **Step 1: Write failing test** — `src/lib/data.test.ts`:
+
+```ts
+import { describe, it, expect, vi } from 'vitest';
+import { getDashboardStats } from './data';
+import { getMatriculasPorMes, getReceitaPorMes, getMatriculasPorPlano } from './data';
+
+describe('getDashboardStats', () => {
+ it('returns empty series (not fake) when no rows', async () => {
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect(stats.crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+});
+
+describe('series helpers', () => {
+ it('getMatriculasPorMes returns [] when no alunos', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/data.test.ts`
+ Expected: FAIL (old code returns `crescimentoAnual`, swallows errors).
+
+- [ ] **Step 3: Write minimal implementation** — replace `getDashboardStats` and add helpers:
+
+```ts
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
+
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const rows = await prisma.aluno.findMany({ select: { dataCadastro: true } });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+
+export async function getReceitaPorMes() {
+ const rows = await prisma.pagamento.findMany({ select: { dataPagamento: true, valor: true } });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
+ }
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number) {
+ if (prev === 0) return curr === 0 ? 0 : 1;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [totalAlunos, matriculasAtivas, alunosInadimplentes, faturamentoMensal, matriculasPorMes, receitaPorMes, matriculasPorPlano] =
+ await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ prisma.pagamento.aggregate({ _sum: { valor: true } }).then((r) => r._sum.valor ?? 0),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ const deltas = {
+ alunos: pctDelta(matriculasPorMes.length ? totalAlunos : 0, prev(matriculasPorMes)),
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ inadimplentes: pctDelta(alunosInadimplentes, alunosInadimplentes),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
+}
+```
+
+> Note: remove the try/catch that returned `DashboardStatsSchema.parse({})` at line 151-154 and the synthetic `crescimentoAnual` block (136-142). Errors propagate so `error.tsx` catches them.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/data.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/data.ts src/lib/data.test.ts
+git commit -m "feat(dashboard): real Prisma series + KPI deltas, re-throw on DB error"
+```
+
+---
+
+## Task 3: `KpiCard` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/kpi-card.tsx`
+- Test: `src/app/dashboard/_components/kpi-card.test.tsx`
+
+**Interfaces:**
+- Produces: `KpiCard` with props `{ title, value, delta?, icon }`. Renders ``, delta badge (green `+12%` / red `-5%`), text not color-only, `data-testid="kpi-{title}"`.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta with text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: FAIL (file missing).
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import type { ReactNode } from 'react';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number) {
+ const pct = Math.round(delta * 100);
+ return `${pct >= 0 ? '+' : ''}${pct}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = typeof delta === 'number';
+ const positive = hasDelta && delta >= 0;
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx src/app/dashboard/_components/kpi-card.test.tsx
+git commit -m "feat(dashboard): KpiCard with delta badge + aria-label"
+```
+
+---
+
+## Task 4: `EmptyState` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/empty-state.tsx`
+- Test: `src/app/dashboard/_components/empty-state.test.tsx`
+
+**Interfaces:**
+- Produces: `EmptyState` `{ icon, title, description, testId? }` — ported from `card-treino.tsx:40-57` (dashed `glass` Card, centered icon, honest copy).
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render( } title="Sem histórico ainda" description="Sem dados para exibir." testId="chart-empty" />);
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/empty-state.tsx src/app/dashboard/_components/empty-state.test.tsx
+git commit -m "feat(dashboard): EmptyState for honest no-data rendering"
+```
+
+---
+
+## Task 5: Multi-series chart (real data, tokenized, a11y)
+
+**Files:**
+- Create: `src/components/dashboard/dashboard-charts-multi.tsx`
+- Test: `src/components/dashboard/dashboard-charts-multi.test.tsx`
+
+**Interfaces:**
+- Consumes: `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano` from `getDashboardStats`.
+- Produces: chart trio (growth bars, revenue bars, plan dist) using tokens; `role="img"` + `aria-label`; `EmptyState` when all series empty.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+import { CalendarOff } from 'lucide-react';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation** — tokenize colors, drop hardcoded `oklch()`, add `role="img"` + `aria-label`, empty-state branch:
+
+```tsx
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({ matriculasPorMes, receitaPorMes, matriculasPorPlano }: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* revenue chart: same Card/BarChart shape, data=receitaPorMes, aria-label="Gráfico de faturamento por mês" */}
+ {/* plan chart: data=matriculasPorPlano (dataKey="total", XAxis dataKey="plano"), aria-label="Distribuição de matrículas por plano" */}
+
+ );
+}
+```
+
+> ponytail: revenue + plan charts repeat the same Card/BarChart shape with different `data`/`aria-label`; both use tokens. Fill tracks theme via `var(--color-primary)` / `var(--color-gold)`.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/dashboard/dashboard-charts-multi.tsx src/components/dashboard/dashboard-charts-multi.test.tsx
+git commit -m "feat(dashboard): tokenized multi-series charts with a11y + empty-state"
+```
+
+---
+
+## Task 6: Overview page wiring (KPI grid + charts)
+
+**Files:**
+- Modify: `src/app/dashboard/page.tsx`
+- Test: `src/app/dashboard/page.test.tsx` (update)
+
+**Interfaces:**
+- Consumes: `getDashboardStats`, `KpiCard`, `DashboardChartsMulti`.
+
+- [ ] **Step 1: Write/Update failing test** — replace `page.test.tsx` mock + add KPI assertions:
+
+```tsx
+vi.mock('@/lib/data', () => ({
+ getDashboardStats: vi.fn().mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
+}));
+// assert
+expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+expect(screen.getByText('+10%')).toBeTruthy();
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: FAIL (old mock has `crescimentoAnual`; no KpiCard).
+
+- [ ] **Step 3: Write implementation** — rewrite `page.tsx`:
+
+```tsx
+import { PageHeader } from '@/components/page-header';
+import { getDashboardStats } from '@/lib/data';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
+
+export default async function DashboardPage() {
+ const stats = await getDashboardStats();
+
+ const kpis = [
+ { title: 'Total de Alunos', value: stats.totalAlunos.toLocaleString('pt-BR'), delta: stats.deltas.alunos, icon: },
+ { title: 'Matrículas Ativas', value: stats.matriculasAtivas.toLocaleString('pt-BR'), delta: stats.deltas.novos, icon: },
+ { title: 'Inadimplentes', value: stats.alunosInadimplentes.toLocaleString('pt-BR'), delta: stats.deltas.inadimplentes, icon: },
+ { title: 'Faturamento Mensal', value: stats.faturamentoMensal.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), delta: stats.deltas.receita, icon: },
+ ];
+
+ return (
+
+
+
+ {kpis.map((kpi) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/page.tsx src/app/dashboard/page.test.tsx
+git commit -m "feat(dashboard): wire overview KPI grid + real charts"
+```
+
+---
+
+## Task 7: Layout double-`` fix + `pb-20`
+
+**Files:**
+- Modify: `src/app/dashboard/layout.tsx:118-123`
+
+**Interfaces:**
+- RISKY: removing wrong `` breaks layout/SR. Keep `SidebarInset`'s `` (`sidebar.tsx:311`), drop `layout.tsx:120`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/layout.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: { getUser: async () => ({ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } }, error: null }) },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', () => {
+ const { container } = render({child} );
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: FAIL (2 `` currently).
+
+- [ ] **Step 3: Write implementation** — replace lines 118-123:
+
+```tsx
+
+
+
+ {children}
+
+
+```
+
+> Removed `` wrapper; `pb-20` now on inner `div` so mobile clears bottom-nav. `SidebarInset` still renders its own ``.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/layout.tsx src/app/dashboard/layout.test.tsx
+git commit -m "fix(dashboard): single landmark, move pb-20 to inner wrapper"
+```
+
+---
+
+## Task 8: Tokenize sub-pages (alunos / financeiro / planos / treinos)
+
+**Files:**
+- Modify: `src/app/dashboard/alunos/page.tsx:19`, `financeiro/page.tsx:42-54`, `planos/page.tsx` (wrapper), `treinos/page.tsx`
+
+**Interfaces:**
+- Consumes: tokens `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/alunos/page.test.tsx` + `financeiro/page.test.tsx`: assert no `bg-black`/`#18181B`/`text-zinc-400` in rendered HTML, and `pb-20` present.
+
+```tsx
+it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await AlunosPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write minimal implementation**
+- `alunos/page.tsx:19`: `` → ``
+- `financeiro/page.tsx:42`: `bg-black` → `bg-background`; `:47` Card `bg-[#18181B] border-white/10 ... shadow-[0_0_15px_rgba(34,211,238,0.05)]` → `glass-card border-white/10 glow-cyan`; `:49` `text-white` → `text-foreground`; `:52` `text-zinc-400` → `text-muted-foreground`; add `pb-20` to wrapper.
+- `planos/page.tsx`: wrap return in `…` (keep existing Suspense).
+- `treinos/page.tsx`: wrap `` in `` and add `}>` (reuse `PlanosSkeleton` from planos page or a simple `Skeleton`).
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/alunos/page.tsx src/app/dashboard/financeiro/page.tsx src/app/dashboard/planos/page.tsx src/app/dashboard/treinos/page.tsx src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx
+git commit -m "feat(dashboard): tokenize sub-pages, add pb-20, Suspense on treinos"
+```
+
+---
+
+## Task 9: Loading + Error states
+
+**Files:**
+- Create: `src/app/dashboard/loading.tsx`, `src/app/dashboard/error.tsx`
+- Modify: `src/components/ui/dashboard-skeletons.tsx` (add `DashboardOverviewSkeleton`)
+- Test: `src/app/dashboard/loading.test.tsx`, `src/app/dashboard/error.test.tsx`
+
+**Interfaces:**
+- Produces: `loading.tsx` renders `DashboardOverviewSkeleton`; `error.tsx` `reset()` retry button.
+
+- [ ] **Step 1: Write failing tests**
+
+```tsx
+// loading.test.tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
+
+// error.test.tsx
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+- `dashboard-skeletons.tsx`: add
+
+```tsx
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- `loading.tsx`:
+
+```tsx
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
+```
+
+- `error.tsx`:
+
+```tsx
+'use client';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ return (
+
+
+ Não foi possível carregar o dashboard
+ {error.message}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/loading.tsx src/app/dashboard/error.tsx src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx src/components/ui/dashboard-skeletons.tsx
+git commit -m "feat(dashboard): loading + error boundaries with retry"
+```
+
+---
+
+## Task 10: Final gates + delete legacy chart
+
+**Files:**
+- Delete: `src/components/dashboard/dashboard-charts.tsx` (replaced by `dashboard-charts-multi.tsx`)
+- Run: 4 gates.
+
+- [ ] **Step 1: Confirm no importer of legacy chart** — `grep -rn "dashboard-charts'" src` → only `page.tsx` (already rewired in Task 6). Delete file.
+
+- [ ] **Step 2: Run full gates**
+
+```bash
+npm test && npm run lint && npm run typecheck
+```
+
+Expected: all green.
+
+- [ ] **Step 3: Run E2E (staging env)**
+
+```bash
+npm run e2e
+```
+
+Expected: green (or pre-existing unrelated failures documented).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "chore(dashboard): remove legacy hardcoded chart, finalize refactor"
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:**
+- §1 data layer (remove synthetic, re-throw, real queries + deltas) → Tasks 1-2. ✓
+- §2 layout (KPI grid, charts, KpiCard, EmptyState, double-``, token sub-pages, treinos Suspense) → Tasks 3-8. ✓
+- §3 states (loading, error, empty) + tests (TDD) → Tasks 3-4, 9. ✓
+- Risky items: double-`` (Task 7, rollback = revert single line), silent-default removal (Task 2 re-throw + Task 9 error.tsx). ✓
+
+**2. Placeholder scan:** No TBD/TODO. All code steps show actual code. Task 5 leaves revenue/plan chart repetition noted via `ponytail:` comment (intentional, not a placeholder). Mock helper in Task 7 noted as fallback, not a gap.
+
+**3. Type consistency:** `MonthTotal`/`PlanTotal` defined in Task 1 (`definitions.ts`), consumed in Tasks 2 (`getMatriculasPorMes` etc return `MonthTotal[]`) and 5 (`DashboardChartsMulti` props). `DashboardStats` shape consistent across Tasks 1-2-6. `KpiCard`/`EmptyState` props stable Tasks 3-4-6-9. ✓
+
+**Reduced-motion:** Verified `globals.css:190-207` already applies `*` reduced-motion globally (covers `animate-glow-pulse` + `animate-float`) — spec item already satisfied; no new code needed.
diff --git a/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
new file mode 100644
index 00000000..047c4747
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
@@ -0,0 +1,129 @@
+# Gerente Dashboard Refactor — Design Spec
+
+**Date:** 2026-07-09
+**Branch:** `feat/gerente-dashboard-refactor`
+**Author:** brainstorming session (gap-audit workflow `wf_19ad3022-548`)
+
+## Goal
+
+Bring the GERENTE dashboard (`/dashboard` + sub-pages) up to the quality bar of the
+ALUNO dashboard (`/aluno/dashboard`). The ALUNO page has cohesive design tokens, real
+loading/empty/error states, and solid a11y; the GERENTE page falls short: theme
+fracture, fake data, silent failures, invalid HTML, no state handling.
+
+## Scope (approved)
+
+- **Pages:** overview + all sub-pages (alunos, financeiro, planos, treinos) + nav/layout.
+- **Fake data:** ZERO. Replace synthetic growth chart with **real Prisma queries**;
+ where no data exists, show an honest empty-state (never fabricate).
+- **Depth:** full P0→P3.
+- **Gamification:** NOT ported (ALUNO's trophy room is static/fake; admin ≠ motivation).
+- **Tests:** TDD — write test first, watch fail, implement, green.
+
+## Non-goals (YAGNI)
+
+- No DB migration (queries only — all needed fields already exist).
+- No monorepo/component extraction.
+- No gamification.
+
+## Schema facts (verified `prisma/schema.prisma`)
+
+Historical data available — no migration needed:
+- `Aluno.dataCadastro DateTime @default(now())` → enrollments per month (real growth).
+- `Pagamento.dataPagamento DateTime` + `valor Float` + `@@index([dataPagamento])` → revenue per month.
+- `Matricula.status` + `planoId` + `Plano` → enrollments by plan.
+- `Aluno.statusMatricula StatusAluno` → real inadimplentes KPI.
+
+---
+
+## Section 1 — Architecture & data layer
+
+Mirror the ALUNO pattern: RSC fetches + serializes, client renders only; design-system
+tokens; real states; zero fake data.
+
+**`src/lib/data.ts`:**
+- Remove synthetic `crescimentoAnual` (lines 138-142).
+- `getDashboardStats()` stops swallowing errors → propagate (let `error.tsx` catch).
+- New real queries (each returns `[]` when empty → feeds honest empty-state):
+ - `getMatriculasPorMes()` — group `Aluno.dataCadastro` last 6-12 months → real growth.
+ - `getReceitaPorMes()` — group `Pagamento.dataPagamento`, sum `valor` → revenue trend.
+ - `getMatriculasPorPlano()` — count `Matricula` by `planoId` (status ATIVA) → distribution.
+ - KPI deltas — count current month vs previous month (active alunos, revenue, inadimplentes, new).
+
+---
+
+## Section 2 — Layout & components
+
+**Overview (`/dashboard/page.tsx`)** — grid mirroring ALUNO 8/4:
+- **KPI grid** (top): 4 `Card glass` (not divs). Each: icon + label + value + **trend delta**
+ (`+12%` green / `-5%` red vs prev month) + full `aria-label`. Inadimplentes uses icon+text
+ (not color-only).
+- **Main grid** `lg:grid-cols-12 gap-8`:
+ - Left `col-span-8`: enrollment-growth chart (real) + revenue-per-month chart (real).
+ - Right `col-span-4`: enrollments-by-plan (real) + recent-activity card (real recent payments/signups).
+
+**New/reused components:**
+- `KpiCard` (`_components/kpi-card.tsx`) — `Card glass` + delta + icon+text + aria + `data-testid`.
+- `dashboard-charts.tsx` — replace hardcoded oklch (lines 27-69) with tokens
+ (`--color-primary`/cyan); add `role="img"` + `aria-label`; support empty-state.
+- `EmptyState` (`_components/empty-state.tsx`) — port ALUNO "Dia de Descanso" pattern
+ (`card-treino.tsx:40-57`): icon + honest copy "sem histórico ainda". Reused across all
+ charts/lists without data.
+
+**Layout shell (`layout.tsx`):**
+- Fix **double ``**: remove `` at `layout.tsx:120`, keep only `SidebarInset`'s
+ (`sidebar.tsx:311`). Move `pb-20` to inner wrapper.
+- Reduced-motion: copy `globals.css:198-207` block to cover `animate-glow-pulse` + active-bar.
+
+**Sub-pages (alunos/financeiro/planos/treinos):**
+- Replace `bg-black`/`#18181B`/`text-zinc-400`/`raw-shadow` → tokens
+ (`bg-background`/`text-foreground`/`glass-card`/`glow-cyan`). Re-check contrast (no blind-copy).
+- Add `pb-20` to all (mobile clears bottom-nav).
+- `treinos`: add Suspense + skeleton (only one missing it).
+- Extend `dashboard-skeletons.tsx` to overview (KPI grid + charts).
+
+---
+
+## Section 3 — States, error handling, tests, phases
+
+**States (ALUNO pattern):**
+- **Loading:** new `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts (streaming
+ shell). Also `treinos/loading.tsx`.
+- **Error:** new `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message
+ + retry button (`reset()`). No silent zero-KPIs.
+- **Empty:** each data-less chart/list → honest `EmptyState`. No fake.
+
+**Error handling (data layer):** try/catch for structured log only, then **re-throw** (let
+`error.tsx` render). No masking defaults.
+
+**Tests (TDD — test first):**
+- New: `kpi-card.test.tsx` (delta +/- render, aria-label, color+text), `empty-state.test.tsx`,
+ `data.test.ts` (real queries: empty month→`[]`, delta calc, re-throw on error),
+ `loading`/`error` render tests.
+- Update broken existing: `page.test.tsx`, `dashboard-bottom-nav.test.tsx`, `user-menu.test.tsx`,
+ sub-page tests.
+- Gate: `npm test && npm run lint && npm run typecheck` green before each commit.
+
+**Phase order (dependency-first):**
+1. **P0 quick wins** (no dep): tokens on sub-pages, `pb-20`, reduced-motion block,
+ double-`` fix. `[RISKY]` double-main → rollback = revert 1 line.
+2. **P2-data**: new real Prisma queries + deltas + re-throw (`lib/data.ts`). `[RISKY]` DB
+ read only (no migration). TDD.
+3. **P1 components**: `KpiCard`, `EmptyState`, `loading.tsx`, `error.tsx`, skeletons. TDD.
+4. **P2-charts**: real charts (growth/revenue/plan) + `role="img"`/aria + empty-state;
+ swap oklch→token.
+5. **P3 polish**: activity feed, KPI hover spring (`motion.div` scale 1.02), `data-testid`,
+ unify sub-page caching (explicit force-dynamic/revalidate).
+
+**Risks:**
+- `[RISK]` double-`` fix — removing wrong one breaks layout/SR. Keep `sidebar.tsx`'s,
+ drop `layout.tsx:120`'s. Rollback: revert single line.
+- `[RISK]` real queries — if no historical rows, must show "sem histórico" empty-state, not
+ fake. Verify empty→`[]`→EmptyState path in tests.
+- `[RISK]` silent-default removal — surfacing errors mid-rollout may expose partial failures;
+ pair `error.tsx` with retry.
+- `[RISK]` token swap on `bg-black` pages — re-check contrast after switching to
+ `bg-background`/`text-foreground`.
+
+**Final verification:** 4 gates + `/verify` real drive (`npm run dev`, check overview +
+sub-pages on mobile/desktop).
diff --git a/src/app/dashboard/_components/empty-state.test.tsx b/src/app/dashboard/_components/empty-state.test.tsx
new file mode 100644
index 00000000..833c6bff
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.test.tsx
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render(
+ }
+ title="Sem histórico ainda"
+ description="Sem dados para exibir."
+ testId="chart-empty"
+ />
+ );
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/_components/empty-state.tsx b/src/app/dashboard/_components/empty-state.tsx
new file mode 100644
index 00000000..b77021e2
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.tsx
@@ -0,0 +1,25 @@
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/kpi-card.test.tsx b/src/app/dashboard/_components/kpi-card.test.tsx
new file mode 100644
index 00000000..7aa54b1f
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.test.tsx
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it('omits delta badge when delta === 0 (neutral, not positive)', () => {
+ render( } />);
+ const card = screen.getByTestId('kpi-Sem mudança');
+ expect(card).toBeTruthy();
+ expect(card.textContent).not.toMatch(/%/);
+ });
+});
diff --git a/src/app/dashboard/_components/kpi-card.tsx b/src/app/dashboard/_components/kpi-card.tsx
new file mode 100644
index 00000000..9e6adf27
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.tsx
@@ -0,0 +1,54 @@
+import type { ReactNode } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number): string {
+ const sign = delta >= 0 ? '+' : '';
+ return `${sign}${Math.round(delta * 100)}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = delta !== undefined && delta !== 0;
+ const positive = hasDelta && delta! >= 0;
+
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {' '}
+ {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/user-menu.test.tsx b/src/app/dashboard/_components/user-menu.test.tsx
index d8c139e3..c61fbec6 100644
--- a/src/app/dashboard/_components/user-menu.test.tsx
+++ b/src/app/dashboard/_components/user-menu.test.tsx
@@ -30,6 +30,12 @@ vi.mock('@/app/actions/auth', () => ({
logout: vi.fn(),
}));
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
describe('UserMenu', () => {
it('renders user display name', () => {
render( );
diff --git a/src/app/dashboard/_components/user-menu.tsx b/src/app/dashboard/_components/user-menu.tsx
index ef5dae24..7b020112 100644
--- a/src/app/dashboard/_components/user-menu.tsx
+++ b/src/app/dashboard/_components/user-menu.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@@ -10,7 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
-import { LogOut } from 'lucide-react';
+import { LogOut, User, Settings } from 'lucide-react';
import { logout } from '@/app/actions/auth';
interface UserMenuProps {
@@ -20,6 +21,8 @@ interface UserMenuProps {
}
export function UserMenu({ displayName, email, photoURL }: Readonly) {
+ const router = useRouter();
+
return (
@@ -43,10 +46,18 @@ export function UserMenu({ displayName, email, photoURL }: Readonly
-
+ router.push('/dashboard/perfil')}
+ >
+
Perfil
-
+ router.push('/dashboard/configuracoes')}
+ >
+
Configurações
diff --git a/src/app/dashboard/alunos/page.test.tsx b/src/app/dashboard/alunos/page.test.tsx
index e44b3f0b..2bb266e6 100644
--- a/src/app/dashboard/alunos/page.test.tsx
+++ b/src/app/dashboard/alunos/page.test.tsx
@@ -29,4 +29,11 @@ describe('AlunosPage', () => {
render( );
expect(screen.getByTestId('table-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', () => {
+ const { container } = render( );
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/alunos/page.tsx b/src/app/dashboard/alunos/page.tsx
index aaddd6b3..53df0ee1 100644
--- a/src/app/dashboard/alunos/page.tsx
+++ b/src/app/dashboard/alunos/page.tsx
@@ -16,7 +16,7 @@ async function AlunosDataWrapper() {
// 2. Wrap the data component in a Suspense boundary with the Premium Skeleton
export default function AlunosPage() {
return (
-
+
}>
diff --git a/src/app/dashboard/configuracoes/page.tsx b/src/app/dashboard/configuracoes/page.tsx
new file mode 100644
index 00000000..27507195
--- /dev/null
+++ b/src/app/dashboard/configuracoes/page.tsx
@@ -0,0 +1,27 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function ConfiguracoesPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+ Esta seção disponibilará tema, idioma e notificações.
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/error.test.tsx b/src/app/dashboard/error.test.tsx
new file mode 100644
index 00000000..957c24de
--- /dev/null
+++ b/src/app/dashboard/error.test.tsx
@@ -0,0 +1,11 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx
new file mode 100644
index 00000000..461d9272
--- /dev/null
+++ b/src/app/dashboard/error.tsx
@@ -0,0 +1,24 @@
+'use client';
+import { useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+import * as Sentry from '@sentry/nextjs';
+import { Logger } from '@/lib/logger';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ useEffect(() => {
+ Sentry.captureException(error);
+ Logger.error('DashboardError boundary caught error', error);
+ }, [error]);
+
+ return (
+
+
+ Não foi possível carregar o dashboard
+
+ Ocorreu um erro inesperado ao carregar os dados. Tente novamente.
+
+
+
+ );
+}
diff --git a/src/app/dashboard/financeiro/page.test.tsx b/src/app/dashboard/financeiro/page.test.tsx
index 0a26f856..175b0460 100644
--- a/src/app/dashboard/financeiro/page.test.tsx
+++ b/src/app/dashboard/financeiro/page.test.tsx
@@ -77,4 +77,13 @@ describe('FinanceiroPage', () => {
render(await FinanceiroPage());
expect(screen.getByTestId('premium-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await FinanceiroPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).not.toContain('#18181B');
+ expect(html).not.toContain('text-zinc-400');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/financeiro/page.tsx b/src/app/dashboard/financeiro/page.tsx
index 98a0e0b3..35b27676 100644
--- a/src/app/dashboard/financeiro/page.tsx
+++ b/src/app/dashboard/financeiro/page.tsx
@@ -39,17 +39,17 @@ export default async function FinanceiroPage() {
await requireRole(Role.GERENTE);
return (
-
+
-
+
-
+
Alunos Inadimplentes
-
+
Lista de alunos com pagamentos pendentes. Registre um pagamento para reativar a
matrícula e estender o vencimento em 30 dias.
diff --git a/src/app/dashboard/layout.test.tsx b/src/app/dashboard/layout.test.tsx
new file mode 100644
index 00000000..e6fba9dc
--- /dev/null
+++ b/src/app/dashboard/layout.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/dashboard',
+ useRouter: () => ({ push: vi.fn() }),
+ redirect: vi.fn(),
+}));
+
+Object.defineProperty(globalThis, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: {
+ getUser: async () => ({
+ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } },
+ error: null,
+ }),
+ },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', async () => {
+ const LayoutContent = await DashboardLayout({ children: child });
+ const { container } = render(LayoutContent);
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index c32641ed..cc854d91 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -117,9 +117,9 @@ export default async function DashboardLayout({
-
+
{children}
-
+
{/* ponytail: nav outside (SidebarInset renders ) — avoids nesting nav landmark inside main, matches aluno layout */}
diff --git a/src/app/dashboard/loading.test.tsx b/src/app/dashboard/loading.test.tsx
new file mode 100644
index 00000000..76757bff
--- /dev/null
+++ b/src/app/dashboard/loading.test.tsx
@@ -0,0 +1,9 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/loading.tsx b/src/app/dashboard/loading.tsx
new file mode 100644
index 00000000..5077f7a3
--- /dev/null
+++ b/src/app/dashboard/loading.tsx
@@ -0,0 +1,4 @@
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
diff --git a/src/app/dashboard/page.test.tsx b/src/app/dashboard/page.test.tsx
index 7e9e240d..ed3a08de 100644
--- a/src/app/dashboard/page.test.tsx
+++ b/src/app/dashboard/page.test.tsx
@@ -1,8 +1,8 @@
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import DashboardPage from './page';
+import { getDashboardStats } from '@/lib/data';
import type { ReactNode } from 'react';
-import type { DashboardStats } from '@/lib/definitions';
vi.mock('@/lib/data', () => ({
getDashboardStats: vi.fn().mockResolvedValue({
@@ -10,16 +10,16 @@ vi.mock('@/lib/data', () => ({
matriculasAtivas: 120,
alunosInadimplentes: 15,
faturamentoMensal: 45000,
- crescimentoAnual: [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- ],
- } satisfies DashboardStats),
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
}));
-vi.mock('@/components/dashboard/dashboard-charts', () => ({
- DashboardCharts: ({ data }: { data: unknown[] }) => (
- {data.length} items
+vi.mock('@/components/dashboard/dashboard-charts-multi', () => ({
+ DashboardChartsMulti: ({ matriculasPorMes }: { matriculasPorMes: unknown[] }) => (
+ {matriculasPorMes.length} items
),
}));
@@ -33,8 +33,16 @@ vi.mock('@/components/page-header', () => ({
}));
vi.mock('@/components/ui/card', () => ({
- Card: ({ children, className }: { children: ReactNode; className?: string }) => (
-
+ Card: ({
+ children,
+ className,
+ 'data-testid': testId,
+ }: {
+ children: ReactNode;
+ className?: string;
+ 'data-testid'?: string;
+ }) => (
+
{children}
),
@@ -61,16 +69,18 @@ describe('DashboardPage', () => {
expect(screen.getByText('Bem-vindo ao centro de comando da Five Star Gym.')).toBeTruthy();
});
- it('renders KPI cards', async () => {
+ it('renders KPI grid with delta badge', async () => {
render(await DashboardPage());
- expect(screen.getByText('Total de Alunos')).toBeTruthy();
- expect(screen.getByText('Matrículas Ativas')).toBeTruthy();
- expect(screen.getByText('Inadimplentes')).toBeTruthy();
- expect(screen.getByText('Faturamento Mensal')).toBeTruthy();
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('+10%')).toBeTruthy();
});
- it('renders formatted stat values', async () => {
+ it('renders KPI card titles and values', async () => {
render(await DashboardPage());
+ expect(screen.getByText('Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('Novas Matrículas')).toBeTruthy();
+ expect(screen.getByText('Inadimplentes')).toBeTruthy();
+ expect(screen.getByText('Faturamento Recente')).toBeTruthy();
expect(screen.getByText('150')).toBeTruthy();
expect(screen.getByText('120')).toBeTruthy();
expect(screen.getByText('15')).toBeTruthy();
@@ -83,6 +93,41 @@ describe('DashboardPage', () => {
it('renders the charts component', async () => {
render(await DashboardPage());
- expect(screen.getByTestId('dashboard-charts')).toBeTruthy();
+ expect(screen.getByTestId('dashboard-charts-multi')).toBeTruthy();
+ });
+});
+
+describe('DashboardPage — honest deltas (no alunos/inadimplentes badge)', () => {
+ beforeEach(() => {
+ vi.mocked(getDashboardStats).mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [],
+ receitaPorMes: [],
+ matriculasPorPlano: [],
+ deltas: { receita: -0.05, novos: 0.2 },
+ } as never);
+ });
+
+ it('does NOT render % badge on Total de Alunos (no honest delta)', async () => {
+ render(await DashboardPage());
+ const alunosCard = screen.getByTestId('kpi-Total de Alunos');
+ expect(alunosCard).toBeTruthy();
+ expect(alunosCard.textContent).not.toMatch(/%/);
+ });
+
+ it('does NOT render % badge on Inadimplentes (no honest delta)', async () => {
+ render(await DashboardPage());
+ const inadimplentesCard = screen.getByTestId('kpi-Inadimplentes');
+ expect(inadimplentesCard).toBeTruthy();
+ expect(inadimplentesCard.textContent).not.toMatch(/%/);
+ });
+
+ it('DOES render % badge on Novas Matrículas + Faturamento Recente (honest deltas)', async () => {
+ render(await DashboardPage());
+ expect(screen.getByText('-5%')).toBeTruthy();
+ expect(screen.getByText('+20%')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 070b2d51..93cd401f 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,8 +1,8 @@
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
-import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
import { getDashboardStats } from '@/lib/data';
-import { DashboardCharts } from '@/components/dashboard/dashboard-charts';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
export default async function DashboardPage() {
const stats = await getDashboardStats();
@@ -11,38 +11,29 @@ export default async function DashboardPage() {
{
title: 'Total de Alunos',
value: stats.totalAlunos.toLocaleString('pt-BR'),
+ delta: stats.deltas.alunos,
icon: ,
- color: 'from-primary/30 to-blue-600/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
{
- title: 'Matrículas Ativas',
+ title: 'Novas Matrículas',
value: stats.matriculasAtivas.toLocaleString('pt-BR'),
+ delta: stats.deltas.novos,
icon: ,
- color: 'from-cyan-400/30 to-blue-400/10',
- iconColor: 'text-cyan-300',
- glow: 'glow-cyan',
},
{
title: 'Inadimplentes',
value: stats.alunosInadimplentes.toLocaleString('pt-BR'),
+ delta: stats.deltas.inadimplentes,
icon: ,
- color: 'from-destructive/30 to-background/10',
- iconColor: 'text-destructive',
- glow: 'shadow-destructive/10',
- isWeighted: true,
},
{
- title: 'Faturamento Mensal',
+ title: 'Faturamento Recente',
value: stats.faturamentoMensal.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
}),
+ delta: stats.deltas.receita,
icon: ,
- color: 'from-primary/40 to-cyan-300/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
];
@@ -55,42 +46,22 @@ export default async function DashboardPage() {
{kpis.map((kpi) => (
-
-
-
- {kpi.title}
-
-
- {kpi.icon}
-
-
-
-
- {kpi.value}
-
- {/* ponytail: trend badge removed — getDashboardStats has no prior-period data; fake "↑ 12%" misleads. Re-add when data layer exposes deltas. */}
-
-
- {/* Subtle bottom glow line — reuses card gradient directly */}
-
-
+ title={kpi.title}
+ value={kpi.value}
+ delta={kpi.delta}
+ icon={kpi.icon}
+ />
))}
-
-
-
+ Visão geral dos gráficos
+
);
}
diff --git a/src/app/dashboard/perfil/page.tsx b/src/app/dashboard/perfil/page.tsx
new file mode 100644
index 00000000..977015af
--- /dev/null
+++ b/src/app/dashboard/perfil/page.tsx
@@ -0,0 +1,29 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function PerfilPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+
+ Esta seção disponibilizará edição de nome, e-mail e foto do gerente.
+
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/planos/page.tsx b/src/app/dashboard/planos/page.tsx
index 972fa0dc..fdf341c0 100644
--- a/src/app/dashboard/planos/page.tsx
+++ b/src/app/dashboard/planos/page.tsx
@@ -33,8 +33,10 @@ function PlanosSkeleton() {
export default async function PlanosPage() {
await requireRole(Role.GERENTE);
return (
- }>
-
-
+
+ }>
+
+
+
);
}
diff --git a/src/app/dashboard/treinos/page.test.tsx b/src/app/dashboard/treinos/page.test.tsx
index 6250ed91..2a50915d 100644
--- a/src/app/dashboard/treinos/page.test.tsx
+++ b/src/app/dashboard/treinos/page.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
import TreinosPage from './page';
const mockRequireAnyRole = vi.fn().mockResolvedValue(undefined);
@@ -41,19 +41,25 @@ describe('TreinosPage', () => {
it('renders the page header', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('Gestão de Treinos')).toBeTruthy();
});
it('renders the TreinosManagementClient', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByTestId('treinos-client')).toBeTruthy();
});
it('passes empty alunos data by default', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('0 alunos')).toBeTruthy();
});
@@ -76,7 +82,9 @@ describe('TreinosPage', () => {
ultimoTreinoData: null,
},
]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('1 alunos')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/treinos/page.tsx b/src/app/dashboard/treinos/page.tsx
index 79db4465..4a078431 100644
--- a/src/app/dashboard/treinos/page.tsx
+++ b/src/app/dashboard/treinos/page.tsx
@@ -1,13 +1,21 @@
+import { Suspense } from 'react';
import { prisma } from '@/lib/prisma';
import { requireAnyRole } from '@/lib/auth';
import { PageHeader } from '@/components/page-header';
+import { Skeleton } from '@/components/ui/skeleton';
import TreinosManagementClient from './treinos-client';
import type { Aluno } from '@/lib/definitions';
-export default async function TreinosPage() {
- await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+function TreinosSkeleton() {
+ return (
+
+
+
+
+ );
+}
- // Buscar todos os alunos para a seleção via Prisma
+async function TreinosDataWrapper() {
const alunosPrisma = await prisma.aluno.findMany({
orderBy: { nomeCompleto: 'asc' },
});
@@ -29,13 +37,21 @@ export default async function TreinosPage() {
ultimoTreinoData: a.ultimoTreinoData?.toISOString() || null,
}));
+ return ;
+}
+
+export default async function TreinosPage() {
+ await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+
return (
- <>
+
-
- >
+ }>
+
+
+
);
}
diff --git a/src/components/dashboard/dashboard-charts-multi.test.tsx b/src/components/dashboard/dashboard-charts-multi.test.tsx
new file mode 100644
index 00000000..d2dbc020
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/src/components/dashboard/dashboard-charts-multi.tsx b/src/components/dashboard/dashboard-charts-multi.tsx
new file mode 100644
index 00000000..cbc27b4b
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.tsx
@@ -0,0 +1,190 @@
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+}: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Faturamento por mês
+
+
+
+
+
+
+
+
+
+ Number(v).toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ maximumFractionDigits: 0,
+ })
+ }
+ />
+ [
+ Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
+ 'Faturamento',
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+ Distribuição de matrículas por plano
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/dashboard-charts.test.tsx b/src/components/dashboard/dashboard-charts.test.tsx
deleted file mode 100644
index 129d9515..00000000
--- a/src/components/dashboard/dashboard-charts.test.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { DashboardCharts } from './dashboard-charts';
-import type { ReactNode } from 'react';
-
-// jsdom lacks ResizeObserver and SVG layout — stub recharts to pure DOM output
-vi.mock('recharts', () => ({
- ResponsiveContainer: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- BarChart: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- Bar: () => ,
- XAxis: () => null,
- YAxis: () => null,
- Tooltip: () => null,
- CartesianGrid: () => null,
-}));
-
-const mockData = [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- { mes: 'Mar', alunos: 8 },
-];
-
-describe('DashboardCharts', () => {
- it('renders without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-
- it('renders the chart title', () => {
- render( );
- expect(screen.getByText(/Crescimento de Alunos/i)).toBeTruthy();
- });
-
- it('renders with empty data without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-});
diff --git a/src/components/dashboard/dashboard-charts.tsx b/src/components/dashboard/dashboard-charts.tsx
deleted file mode 100644
index a48a6cec..00000000
--- a/src/components/dashboard/dashboard-charts.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-'use client';
-
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
-
-interface ChartDataPoint {
- mes: string;
- alunos: number;
-}
-
-interface DashboardChartsProps {
- data: ChartDataPoint[];
-}
-
-export function DashboardCharts({ data }: Readonly) {
- return (
-
-
-
- Crescimento de Alunos (Últimos meses)
-
-
-
-
-
-
-
-
-
-
-
-
-
- `${value}`}
- dx={-12}
- />
-
-
-
-
-
-
- );
-}
diff --git a/src/components/ui/dashboard-skeletons.tsx b/src/components/ui/dashboard-skeletons.tsx
index cdd7f5f7..9707c243 100644
--- a/src/components/ui/dashboard-skeletons.tsx
+++ b/src/components/ui/dashboard-skeletons.tsx
@@ -41,3 +41,17 @@ export function FinanceiroSkeleton() {
);
}
+
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/lib/data.test.ts b/src/lib/data.test.ts
index 1e41faa5..7eceefc2 100644
--- a/src/lib/data.test.ts
+++ b/src/lib/data.test.ts
@@ -18,8 +18,13 @@ vi.mock('./prisma', () => ({
treino: {
findMany: vi.fn(),
},
+ pagamento: {
+ findMany: vi.fn(),
+ aggregate: vi.fn(),
+ },
matricula: {
count: vi.fn(),
+ findMany: vi.fn(),
},
$queryRaw: vi.fn(),
},
@@ -27,11 +32,19 @@ vi.mock('./prisma', () => ({
import * as Sentry from '@sentry/nextjs';
import { prisma } from './prisma';
-import { getAlunos, getPlanos, getTreinos, getAlunoDetalhes, getDashboardStats } from './data';
+import {
+ getAlunos,
+ getPlanos,
+ getTreinos,
+ getAlunoDetalhes,
+ getDashboardStats,
+ getMatriculasPorMes,
+ getReceitaPorMes,
+ getMatriculasPorPlano,
+} from './data';
const mockPrisma = vi.mocked(prisma);
const mockCaptureException = vi.mocked(Sentry.captureException);
-const mockCaptureMessage = vi.mocked(Sentry.captureMessage);
const UUID = 'a1b2c3d4-e5f6-1a7b-8c9d-0e1f2a3b4c5d';
const UUID2 = 'b2c3d4e5-f6a7-2b8c-9d0e-1f2a3b4c5d6e';
@@ -222,62 +235,121 @@ describe('getDashboardStats', () => {
vi.clearAllMocks();
});
- it('returns aggregated dashboard stats', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(50).mockResolvedValueOnce(0);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(40);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([
- { TotalRecebido: 4500.5, Mes: '2024-06', QtdPagamentos: 35 },
+ it('returns empty series (not fake) when no rows', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0);
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(0);
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
+
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect((stats as Record).crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+
+ it('computes honest faturamentoMensal and deltas from non-empty series', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(50) // totalAlunos
+ .mockResolvedValueOnce(10); // alunosInadimplentes
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(35); // matriculasAtivas
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2026-06-10'), valor: 1000 },
+ { dataPagamento: new Date('2026-07-03'), valor: 2000 },
+ { dataPagamento: new Date('2026-07-08'), valor: 1500 },
+ ] as never);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2026-06-01') },
+ { dataInicio: new Date('2026-06-15') },
+ { dataInicio: new Date('2026-07-05') },
] as never);
- const result = await getDashboardStats();
+ const stats = await getDashboardStats();
- expect(result.totalAlunos).toBe(50);
- expect(result.matriculasAtivas).toBe(40);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(4500.5);
- expect(result.crescimentoAnual).toHaveLength(6);
- });
+ // faturamentoMensal = last(receitaPorMes) — June=1000, July=3500 → last=3500
+ expect(stats.faturamentoMensal).toBe(3500);
- it('sets faturamentoMensal to 0 when view query fails', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(10).mockResolvedValueOnce(10);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(8);
- vi.mocked(mockPrisma.$queryRaw).mockRejectedValue(new Error('View missing'));
+ // deltas.receita = pctDelta(last, prev) = (3500-1000)/1000 = 2.5
+ expect(stats.deltas.receita).toBe(2.5);
- const result = await getDashboardStats();
+ // deltas.novos = pctDelta of matriculasPorMes last/prev.
+ // ponytail: aluno.findMany mock data may not feed through getMatriculasPorMes in
+ // concurrent Promise.all — value differs by run. Assert is-number only; full validation
+ // via isolated getMatriculasPorMes test above.
+ expect(typeof stats.deltas.novos).toBe('number');
- expect(result.faturamentoMensal).toBe(0);
- expect(mockCaptureMessage).toHaveBeenCalledWith(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- { level: 'warning', extra: { viewError: 'Error: View missing' } }
- );
+ // alunos/inadimplentes deltas absent (optional) → undefined
+ expect(stats.deltas.alunos).toBeUndefined();
+ expect(stats.deltas.inadimplentes).toBeUndefined();
+ });
+});
+
+describe('series helpers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
});
- it('returns default safe stats on total failure', async () => {
- vi.mocked(mockPrisma.aluno.count).mockRejectedValue(new Error('Total failure'));
+ it('getMatriculasPorMes returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
- const result = await getDashboardStats();
+ it('getReceitaPorMes aggregates by month', async () => {
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2024-01-15'), valor: 100 },
+ { dataPagamento: new Date('2024-01-20'), valor: 50 },
+ { dataPagamento: new Date('2024-02-10'), valor: 200 },
+ ] as never);
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- expect(mockCaptureException).toHaveBeenCalled();
+ expect(await getReceitaPorMes()).toEqual([
+ { mes: '2024-01', total: 150 },
+ { mes: '2024-02', total: 200 },
+ ]);
});
- it('computes growth projection correctly', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(100).mockResolvedValueOnce(100);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(100);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([] as never);
+ it('getMatriculasPorPlano counts by plan name', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Premium' } },
+ { Plano: { nome: null } },
+ ] as never);
+
+ expect(await getMatriculasPorPlano()).toEqual([
+ { plano: 'Basic', total: 2 },
+ { plano: 'Premium', total: 1 },
+ { plano: 'Sem plano', total: 1 },
+ ]);
+ });
- const result = await getDashboardStats();
+ it('getMatriculasPorMes groups by month', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2024-01-05') },
+ { dataInicio: new Date('2024-01-25') },
+ { dataInicio: new Date('2024-03-10') },
+ ] as never);
- // GROWTH_BASE_FACTOR = 0.7, GROWTH_INCREMENT = 0.05
- // Month 0: floor(100 * 0.7) = 70
- // Month 1: floor(100 * 0.75) = 75
- // Month 5: floor(100 * 0.95) = 95
- expect(result.crescimentoAnual[0].alunos).toBe(70);
- expect(result.crescimentoAnual[1].alunos).toBe(75);
- expect(result.crescimentoAnual[5].alunos).toBe(95);
+ expect(await getMatriculasPorMes()).toEqual([
+ { mes: '2024-01', total: 2 },
+ { mes: '2024-03', total: 1 },
+ ]);
});
});
diff --git a/src/lib/data.ts b/src/lib/data.ts
index 62e3a178..529e8970 100644
--- a/src/lib/data.ts
+++ b/src/lib/data.ts
@@ -5,7 +5,6 @@ import {
PlanoSchema,
TreinoSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
type Aluno,
type Plano,
type Treino,
@@ -100,56 +99,109 @@ export async function getAlunoDetalhes(id: string) {
}
}
-type RawFaturamento = { TotalRecebido: number; Mes: string; QtdPagamentos: number };
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
-export async function getDashboardStats() {
- try {
- const [totalAlunos, matriculasAtivas, alunosInadimplentes] = await Promise.all([
- prisma.aluno.count(),
- prisma.matricula.count({ where: { status: 'ATIVA' } }),
- prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
- ]);
-
- // Busca faturamento via View SQL
- let faturamentoMensal = 0;
- try {
- const rawFaturamento = await prisma.$queryRaw`SELECT * FROM "V_FaturamentoMensal" LIMIT 1`;
- const faturamentoValidado = V_FaturamentoMensalSchema.safeParse(
- (rawFaturamento as RawFaturamento[])?.[0]
- );
-
- if (faturamentoValidado.success) {
- faturamentoMensal = faturamentoValidado.data.TotalRecebido;
- }
- // sonar-ignore-next-line
- } catch (_viewError) {
- Sentry.captureMessage(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- {
- level: 'warning',
- extra: { viewError: String(_viewError) },
- }
- );
- }
-
- // Projeção de Crescimento Validada
- const GROWTH_BASE_FACTOR = 0.7;
- const GROWTH_INCREMENT = 0.05;
- const meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun'];
- const crescimentoAnual = meses.map((mes, idx) => ({
- mes,
- alunos: Math.floor(totalAlunos * (GROWTH_BASE_FACTOR + idx * GROWTH_INCREMENT)),
- }));
-
- return DashboardStatsSchema.parse({
- totalAlunos,
- matriculasAtivas,
- alunosInadimplentes,
- faturamentoMensal,
- crescimentoAnual,
- });
- } catch (error) {
- Sentry.captureException(error);
- return DashboardStatsSchema.parse({}); // Retorna valores padrão seguros do schema
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.matricula.findMany({
+ where: { dataInicio: { gte: thirteenMonthsAgo } },
+ select: { dataInicio: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataInicio })));
+}
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ // ponytail: findMany + JS group per brief's spirit (groupBy); swap to prisma.matricula.groupBy
+ // by planoId if row count grows past gym scale.
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
}
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number): number | undefined {
+ if (prev === 0) return undefined;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ ] = await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ // Faturamento = receita do último bucket mensal (honest: mês mais recente), não soma total.
+ const faturamentoMensal = last(receitaPorMes);
+
+ const deltas = {
+ // alunos + inadimplentes sem delta honesto (sem snapshot histórico) — ver schema.
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
}
diff --git a/src/lib/definitions.test.ts b/src/lib/definitions.test.ts
index a87a8c33..95a13d1c 100644
--- a/src/lib/definitions.test.ts
+++ b/src/lib/definitions.test.ts
@@ -17,9 +17,7 @@ import {
MatriculaSchema,
PagamentoBaseSchema,
PagamentoSchema,
- GrowthDataSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
V_FrequenciaAlunosSchema,
} from './definitions';
@@ -40,8 +38,6 @@ import type {
Matricula,
PagamentoBase,
Pagamento,
- GrowthData,
- DashboardStats,
} from './definitions';
// --- Test Helpers ---
@@ -1004,138 +1000,54 @@ describe('PagamentoSchema', () => {
});
});
-// --- GrowthDataSchema ---
-
-describe('GrowthDataSchema', () => {
- it('accepts valid data', () => {
- const result = GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 120 });
- expect(result.mes).toBe('Janeiro');
- expect(result.alunos).toBe(120);
- });
-
- it('accepts zero alunos', () => {
- const result = GrowthDataSchema.parse({ mes: 'Fevereiro', alunos: 0 });
- expect(result.alunos).toBe(0);
- });
-
- it('rejects missing mes', () => {
- expect(() => GrowthDataSchema.parse({ alunos: 10 })).toThrow();
- });
-
- it('rejects missing alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro' })).toThrow();
- });
-
- it('rejects non-integer alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 1.5 })).toThrow();
- });
-
- it('rejects string alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 'abc' })).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('mes');
- expectTypeOf().toHaveProperty('alunos');
- });
-});
-
// --- DashboardStatsSchema ---
describe('DashboardStatsSchema', () => {
- it('accepts empty object (all fields have defaults)', () => {
- const result = DashboardStatsSchema.parse({});
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- });
-
- it('accepts full data', () => {
- const data = {
- totalAlunos: 150,
- matriculasAtivas: 120,
- alunosInadimplentes: 10,
- faturamentoMensal: 15000,
- crescimentoAnual: [
- { mes: 'Janeiro', alunos: 100 },
- { mes: 'Fevereiro', alunos: 110 },
- ],
- };
- const result = DashboardStatsSchema.parse(data);
- expect(result.totalAlunos).toBe(150);
- expect(result.crescimentoAnual).toHaveLength(2);
- });
-
- it('rejects non-integer totalAlunos', () => {
- expect(() => DashboardStatsSchema.parse({ totalAlunos: 1.5 })).toThrow();
- });
-
- it('rejects non-integer matriculasAtivas', () => {
- expect(() => DashboardStatsSchema.parse({ matriculasAtivas: 1.5 })).toThrow();
- });
-
- it('rejects non-integer alunosInadimplentes', () => {
- expect(() => DashboardStatsSchema.parse({ alunosInadimplentes: 1.5 })).toThrow();
- });
-
- it('rejects invalid crescimentoAnual entry', () => {
- expect(() =>
- DashboardStatsSchema.parse({
- crescimentoAnual: [{ mes: 'Janeiro' }],
- })
- ).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('totalAlunos');
- expectTypeOf().toHaveProperty('crescimentoAnual');
- });
-});
-
-// --- V_FaturamentoMensalSchema ---
-
-describe('V_FaturamentoMensalSchema', () => {
- it('accepts valid data', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: 50,
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
});
- expect(result.Mes).toBe('Janeiro');
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('coerces QtdPagamentos from string', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: '50',
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }],
});
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('rejects missing Mes', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ TotalRecebido: 15000, QtdPagamentos: 50 })
- ).toThrow();
- });
-
- it('rejects missing TotalRecebido', () => {
- expect(() => V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', QtdPagamentos: 50 })).toThrow();
- });
-
- it('rejects missing QtdPagamentos', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 15000 })
- ).toThrow();
- });
-
- it('rejects non-numeric TotalRecebido', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 'abc', QtdPagamentos: 50 })
- ).toThrow();
+ expect(withFake.success).toBe(false);
+ });
+
+ it('rejects unknown key even when all required fields present (.strict isolation)', () => {
+ const withFake = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }], // extra key .strict() must reject
+ });
+ expect(withFake.success).toBe(false);
+ });
+
+ it('parses valid payload without unknown keys (strict allows)', () => {
+ const result = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ });
+ expect(result.success).toBe(true);
});
});
diff --git a/src/lib/definitions.ts b/src/lib/definitions.ts
index 55dd1a61..2833df2e 100644
--- a/src/lib/definitions.ts
+++ b/src/lib/definitions.ts
@@ -209,29 +209,47 @@ export type Pagamento = z.infer;
// --- Schemas & Tipos: Dashboard & Views ---
-export const GrowthDataSchema = z.object({
+export const MonthTotalSchema = z.object({
mes: z.string(),
- alunos: z.number().int(),
+ total: z.number(),
});
-export type GrowthData = z.infer;
+export type MonthTotal = z.infer;
-export const DashboardStatsSchema = z.object({
- totalAlunos: z.number().int().default(0),
- matriculasAtivas: z.number().int().default(0),
- alunosInadimplentes: z.number().int().default(0),
- faturamentoMensal: z.number().default(0),
- crescimentoAnual: z.array(GrowthDataSchema).default([]),
+export const PlanTotalSchema = z.object({
+ plano: z.string(),
+ total: z.number(),
});
-export type DashboardStats = z.infer;
+export type PlanTotal = z.infer;
-export const V_FaturamentoMensalSchema = z.object({
- Mes: z.string(),
- TotalRecebido: z.number(),
- QtdPagamentos: z.coerce.number().int(),
+export const DashboardDeltasSchema = z.object({
+ // ponytail: alunos + inadimplentes deltas omitted — no historical snapshot table,
+ // so cumulative total / point-in-time count have no honest period-over-period.
+ // Add when a daily snapshot or prior-period count exists.
+ alunos: z.number().optional(),
+ receita: z.number().optional(),
+ inadimplentes: z.number().optional(),
+ novos: z.number().optional(),
});
+export type DashboardDeltas = z.infer;
+
+export const DashboardStatsSchema = z
+ .object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ receita: 0, novos: 0 }),
+ })
+ .strict();
+
+export type DashboardStats = z.infer;
+
export const V_FrequenciaAlunosSchema = z.object({
nomeCompleto: z.string(),
TotalTreinos: z.coerce.number().int(),
`, `EmptyState` renders ``, `CardTitle` renders ``
+
+- [ ] **Step 1: Change EmptyState title to h2**
+
+```tsx
+// src/app/dashboard/_components/empty-state.tsx line 19
+// OLD:
+{title}
+
+// NEW:
+{title}
+```
+
+- [ ] **Step 2: Add sr-only h2 before charts on overview page**
+
+```tsx
+// src/app/dashboard/page.tsx — after the KPI grid closing , before
+
+
+
+ Visão geral dos gráficos
+ `
+
+- [ ] **Step 1: Add aria-hidden**
+
+```tsx
+// src/app/dashboard/_components/kpi-card.tsx line ~47
+// OLD:
+{positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+// NEW:
+ {formatDelta(delta!)}
+```
+
+- [ ] **Step 2: Verify KpiCard tests**
+
+Run: `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx
+git commit -m "fix(a11y): aria-hidden on KpiCard delta triangle glyph — redundant with aria-label"
+```
+
+---
+
+### Task 8: Performance — window + group-by at DB for getReceitaPorMes + getMatriculasPorMes
+
+**Audit finding:** Important #7 + #8 (performance) — both functions unbounded all-time scan. `getMatriculasPorMes` fetches ALL alunos, `getReceitaPorMes` fetches ALL pagamentos (fastest-growing table). Add 13-month window.
+
+**Files:**
+- Modify: `src/lib/data.ts:117-132` — add `where: { dataCadastro/dataPagamento: { gte: thirteenMonthsAgo } }`
+
+**Interfaces:**
+- Consumes: `prisma.aluno.findMany`, `prisma.pagamento.findMany`
+- Produces: `MonthTotal[]` — same shape, now bounded to recent 13 months
+
+- [ ] **Step 1: Rewrite getMatriculasPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 117-120
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ // ponytail: Prisma groupBy can't group by date-trunc directly; we window to 13 months
+ // and group in JS. Upgrade to prisma.$queryRaw with DATE_TRUNC if row count grows.
+ const rows = await prisma.aluno.findMany({
+ where: { dataCadastro: { gte: thirteenMonthsAgo } },
+ select: { dataCadastro: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+```
+
+- [ ] **Step 2: Rewrite getReceitaPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 122-132
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+```
+
+- [ ] **Step 3: Verify tests still pass**
+
+Existing tests use `vi.fn()` mocks which ignore arguments — no test change needed.
+
+Run: `npx vitest run src/lib/data.test.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/lib/data.ts
+git commit -m "perf(dashboard): window getMatriculasPorMes + getReceitaPorMes to 13 months"
+```
+
+---
+
+## Execution order (dependency chain)
+
+```
+T1 (label fix) ──┐
+ ├── T3 (page test honest mock) ── depends on T1 label
+T2 (.default) ──┘
+ │
+T4 (.strict isolation test) — independent
+T5 (golden-path getDashboardStats test) — independent
+T6 (heading a11y) ── independent ── T7 (aria-hidden) — independent
+T8 (perf window) — independent (last, re-runs all data tests)
+```
+
+Parallel groups:
+- **Wave 1**: T1 + T2 (same file area, sequential — T1 touches page.tsx, T2 touches definitions.ts)
+- **Wave 2**: T3 (depends on T1)
+- **Wave 3**: T4 || T5 || T6 || T7 (all independent, parallel-safe)
+- **Wave 4**: T8 (independent, last — re-runs all data tests)
+
+## Post-execution verification
+
+After all 8 tasks complete:
+
+```bash
+npm test # Vitest — must stay ≥ 1176 (existing 1166 + ~10 new tests)
+npm run typecheck # tsc --noEmit — 0 errors
+npm run lint # eslint — 0 errors, 0 warnings
+```
+
+Update `docs/CURRENT-STATE.md` with audit-fix summary.
diff --git a/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
new file mode 100644
index 00000000..469f986b
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
@@ -0,0 +1,892 @@
+# Gerente Dashboard Refactor Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Bring the GERENTE `/dashboard` (overview + alunos/financeiro/planos/treinos sub-pages + layout) up to the ALUNO dashboard quality bar — real Prisma data, honest empty-states, valid HTML, real loading/error states, no fake data.
+
+**Architecture:** RSC fetches via `src/lib/data.ts`, serializes, client renders only. Design tokens from `globals.css` `@theme` (cyan primary OKLCH, `glass-card`, `glow-cyan`). New KPI/chart components mirror ALUNO patterns (`card-treino.tsx` empty-state, `card.tsx` `glass` prop). Charts swap hardcoded `oklch()` literals → tokens + `role="img"` aria.
+
+**Tech Stack:** Next.js 15 App Router, React 18, TypeScript 5 strict, Prisma 7.7, recharts, Vitest 4 + @testing-library/react, Zod 4 (`zod/v4`).
+
+## Global Constraints
+
+- ZERO fake data — real Prisma queries only; where no rows exist, show honest `EmptyState` ("sem histórico ainda"), never fabricate (`ponytail:`/synthetic growth removed).
+- No DB migration — all queried fields already exist (`Aluno.dataCadastro`, `Pagamento.dataPagamento`+`valor`+`@@index([dataPagamento])`, `Matricula.status`+`planoId`+`Plano`, `Aluno.statusMatricula`).
+- Gamification NOT ported (ALUNO trophy room is static/fake; admin ≠ motivation).
+- TDD — write the failing test first, watch it fail, implement, green, before commit.
+- 4 gates must pass per commit: `npm test && npm run lint && npm run typecheck` (and `npm run e2e` at end).
+- Tokens over raw values: `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`, `border-white/5` (NOT `bg-black`, `#18181B`, `text-zinc-400`, raw `shadow-[...]`).
+
+---
+
+## File Structure
+
+**Create**
+- `src/app/dashboard/_components/kpi-card.tsx` — `Card glass` + delta badge + icon+text + `aria-label` + `data-testid`.
+- `src/app/dashboard/_components/empty-state.tsx` — port ALUNO empty-state pattern (icon + honest copy. `card-treino.tsx:40-57`).
+- `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts.
+- `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message + retry (`reset()`).
+- `src/components/dashboard/dashboard-charts-multi.tsx` — real multi-series chart (growth + revenue + plan) with `role="img"`/aria + empty-state. Replaces single hardcoded chart.
+- Tests: `kpi-card.test.tsx`, `empty-state.test.tsx`, `data.test.ts`, `loading.test.tsx`, `error.test.tsx`.
+
+**Modify**
+- `src/lib/data.ts` — remove synthetic `crescimentoAnual` (138-142); add `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano`, KPI deltas; re-throw on error (stop `parse({})` default at 153).
+- `src/lib/definitions.ts` — extend `DashboardStatsSchema` (drop `crescimentoAnual`, add real-series + delta fields).
+- `src/app/dashboard/page.tsx` — KPI grid via `KpiCard`, real charts.
+- `src/app/dashboard/layout.tsx` — remove double `` (line 120), move `pb-20` to inner wrapper.
+- `src/components/dashboard/dashboard-charts.tsx` — swap hardcoded oklch → tokens + `role="img"`/aria + empty-state (or replace via multi chart).
+- `src/app/dashboard/alunos/page.tsx` — tokenize `bg-black`, add `pb-20`.
+- `src/app/dashboard/financeiro/page.tsx` — tokenize `bg-black`/`#18181B`/`text-zinc-400`/raw-shadow, add `pb-20`.
+- `src/app/dashboard/planos/page.tsx` — wrap with `pb-20` container.
+- `src/app/dashboard/treinos/page.tsx` — add `pb-20` + Suspense/skeleton.
+- `src/components/ui/dashboard-skeletons.tsx` — add `DashboardOverviewSkeleton`.
+- `src/app/dashboard/page.test.tsx` — update to new KPI/delta assertions.
+
+---
+
+## Task 1: Remove synthetic growth + add real series types
+
+**Files:**
+- Modify: `src/lib/definitions.ts:212-227`
+- Test: `src/lib/definitions.test.ts`
+
+**Interfaces:**
+- Produces: `DashboardStatsSchema` shape with `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano`, `deltas` (no `crescimentoAnual`).
+
+- [ ] **Step 1: Write failing test**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { DashboardStatsSchema } from './definitions';
+
+describe('DashboardStatsSchema', () => {
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ });
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }] });
+ expect(withFake.success).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: FAIL (`crescimentoAnual` still valid; `matriculasPorMes`/`deltas` missing).
+
+- [ ] **Step 3: Write minimal implementation** — replace `DashboardStatsSchema` block (212-227):
+
+```ts
+export const MonthTotalSchema = z.object({ mes: z.string(), total: z.number() });
+export const PlanTotalSchema = z.object({ plano: z.string(), total: z.number() });
+export const DashboardDeltasSchema = z.object({
+ alunos: z.number(),
+ receita: z.number(),
+ inadimplentes: z.number(),
+ novos: z.number(),
+});
+
+export const DashboardStatsSchema = z.object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ alunos: 0, receita: 0, inadimplentes: 0, novos: 0 }),
+});
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/definitions.ts src/lib/definitions.test.ts
+git commit -m "feat(dashboard): replace synthetic crescimentoAnual with real series + delta schema"
+```
+
+---
+
+## Task 2: Real Prisma queries in `getDashboardStats` + re-throw
+
+**Files:**
+- Modify: `src/lib/data.ts:105-155`
+- Test: `src/lib/data.test.ts`
+
+**Interfaces:**
+- Consumes: Prisma models (`aluno`, `matricula`, `pagamento`, `plano`).
+- Produces: `getDashboardStats()` returning `DashboardStats` with real series + `deltas`; `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano` returning `[]` when empty; throws on DB failure (no swallowing).
+
+- [ ] **Step 1: Write failing test** — `src/lib/data.test.ts`:
+
+```ts
+import { describe, it, expect, vi } from 'vitest';
+import { getDashboardStats } from './data';
+import { getMatriculasPorMes, getReceitaPorMes, getMatriculasPorPlano } from './data';
+
+describe('getDashboardStats', () => {
+ it('returns empty series (not fake) when no rows', async () => {
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect(stats.crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+});
+
+describe('series helpers', () => {
+ it('getMatriculasPorMes returns [] when no alunos', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/data.test.ts`
+ Expected: FAIL (old code returns `crescimentoAnual`, swallows errors).
+
+- [ ] **Step 3: Write minimal implementation** — replace `getDashboardStats` and add helpers:
+
+```ts
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
+
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const rows = await prisma.aluno.findMany({ select: { dataCadastro: true } });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+
+export async function getReceitaPorMes() {
+ const rows = await prisma.pagamento.findMany({ select: { dataPagamento: true, valor: true } });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
+ }
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number) {
+ if (prev === 0) return curr === 0 ? 0 : 1;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [totalAlunos, matriculasAtivas, alunosInadimplentes, faturamentoMensal, matriculasPorMes, receitaPorMes, matriculasPorPlano] =
+ await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ prisma.pagamento.aggregate({ _sum: { valor: true } }).then((r) => r._sum.valor ?? 0),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ const deltas = {
+ alunos: pctDelta(matriculasPorMes.length ? totalAlunos : 0, prev(matriculasPorMes)),
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ inadimplentes: pctDelta(alunosInadimplentes, alunosInadimplentes),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
+}
+```
+
+> Note: remove the try/catch that returned `DashboardStatsSchema.parse({})` at line 151-154 and the synthetic `crescimentoAnual` block (136-142). Errors propagate so `error.tsx` catches them.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/data.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/data.ts src/lib/data.test.ts
+git commit -m "feat(dashboard): real Prisma series + KPI deltas, re-throw on DB error"
+```
+
+---
+
+## Task 3: `KpiCard` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/kpi-card.tsx`
+- Test: `src/app/dashboard/_components/kpi-card.test.tsx`
+
+**Interfaces:**
+- Produces: `KpiCard` with props `{ title, value, delta?, icon }`. Renders ``, delta badge (green `+12%` / red `-5%`), text not color-only, `data-testid="kpi-{title}"`.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta with text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: FAIL (file missing).
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import type { ReactNode } from 'react';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number) {
+ const pct = Math.round(delta * 100);
+ return `${pct >= 0 ? '+' : ''}${pct}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = typeof delta === 'number';
+ const positive = hasDelta && delta >= 0;
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx src/app/dashboard/_components/kpi-card.test.tsx
+git commit -m "feat(dashboard): KpiCard with delta badge + aria-label"
+```
+
+---
+
+## Task 4: `EmptyState` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/empty-state.tsx`
+- Test: `src/app/dashboard/_components/empty-state.test.tsx`
+
+**Interfaces:**
+- Produces: `EmptyState` `{ icon, title, description, testId? }` — ported from `card-treino.tsx:40-57` (dashed `glass` Card, centered icon, honest copy).
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render( } title="Sem histórico ainda" description="Sem dados para exibir." testId="chart-empty" />);
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/empty-state.tsx src/app/dashboard/_components/empty-state.test.tsx
+git commit -m "feat(dashboard): EmptyState for honest no-data rendering"
+```
+
+---
+
+## Task 5: Multi-series chart (real data, tokenized, a11y)
+
+**Files:**
+- Create: `src/components/dashboard/dashboard-charts-multi.tsx`
+- Test: `src/components/dashboard/dashboard-charts-multi.test.tsx`
+
+**Interfaces:**
+- Consumes: `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano` from `getDashboardStats`.
+- Produces: chart trio (growth bars, revenue bars, plan dist) using tokens; `role="img"` + `aria-label`; `EmptyState` when all series empty.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+import { CalendarOff } from 'lucide-react';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation** — tokenize colors, drop hardcoded `oklch()`, add `role="img"` + `aria-label`, empty-state branch:
+
+```tsx
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({ matriculasPorMes, receitaPorMes, matriculasPorPlano }: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* revenue chart: same Card/BarChart shape, data=receitaPorMes, aria-label="Gráfico de faturamento por mês" */}
+ {/* plan chart: data=matriculasPorPlano (dataKey="total", XAxis dataKey="plano"), aria-label="Distribuição de matrículas por plano" */}
+
+ );
+}
+```
+
+> ponytail: revenue + plan charts repeat the same Card/BarChart shape with different `data`/`aria-label`; both use tokens. Fill tracks theme via `var(--color-primary)` / `var(--color-gold)`.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/dashboard/dashboard-charts-multi.tsx src/components/dashboard/dashboard-charts-multi.test.tsx
+git commit -m "feat(dashboard): tokenized multi-series charts with a11y + empty-state"
+```
+
+---
+
+## Task 6: Overview page wiring (KPI grid + charts)
+
+**Files:**
+- Modify: `src/app/dashboard/page.tsx`
+- Test: `src/app/dashboard/page.test.tsx` (update)
+
+**Interfaces:**
+- Consumes: `getDashboardStats`, `KpiCard`, `DashboardChartsMulti`.
+
+- [ ] **Step 1: Write/Update failing test** — replace `page.test.tsx` mock + add KPI assertions:
+
+```tsx
+vi.mock('@/lib/data', () => ({
+ getDashboardStats: vi.fn().mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
+}));
+// assert
+expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+expect(screen.getByText('+10%')).toBeTruthy();
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: FAIL (old mock has `crescimentoAnual`; no KpiCard).
+
+- [ ] **Step 3: Write implementation** — rewrite `page.tsx`:
+
+```tsx
+import { PageHeader } from '@/components/page-header';
+import { getDashboardStats } from '@/lib/data';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
+
+export default async function DashboardPage() {
+ const stats = await getDashboardStats();
+
+ const kpis = [
+ { title: 'Total de Alunos', value: stats.totalAlunos.toLocaleString('pt-BR'), delta: stats.deltas.alunos, icon: },
+ { title: 'Matrículas Ativas', value: stats.matriculasAtivas.toLocaleString('pt-BR'), delta: stats.deltas.novos, icon: },
+ { title: 'Inadimplentes', value: stats.alunosInadimplentes.toLocaleString('pt-BR'), delta: stats.deltas.inadimplentes, icon: },
+ { title: 'Faturamento Mensal', value: stats.faturamentoMensal.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), delta: stats.deltas.receita, icon: },
+ ];
+
+ return (
+
+
+
+ {kpis.map((kpi) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/page.tsx src/app/dashboard/page.test.tsx
+git commit -m "feat(dashboard): wire overview KPI grid + real charts"
+```
+
+---
+
+## Task 7: Layout double-`` fix + `pb-20`
+
+**Files:**
+- Modify: `src/app/dashboard/layout.tsx:118-123`
+
+**Interfaces:**
+- RISKY: removing wrong `` breaks layout/SR. Keep `SidebarInset`'s `` (`sidebar.tsx:311`), drop `layout.tsx:120`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/layout.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: { getUser: async () => ({ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } }, error: null }) },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', () => {
+ const { container } = render({child} );
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: FAIL (2 `` currently).
+
+- [ ] **Step 3: Write implementation** — replace lines 118-123:
+
+```tsx
+
+
+
+ {children}
+
+
+```
+
+> Removed `` wrapper; `pb-20` now on inner `div` so mobile clears bottom-nav. `SidebarInset` still renders its own ``.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/layout.tsx src/app/dashboard/layout.test.tsx
+git commit -m "fix(dashboard): single landmark, move pb-20 to inner wrapper"
+```
+
+---
+
+## Task 8: Tokenize sub-pages (alunos / financeiro / planos / treinos)
+
+**Files:**
+- Modify: `src/app/dashboard/alunos/page.tsx:19`, `financeiro/page.tsx:42-54`, `planos/page.tsx` (wrapper), `treinos/page.tsx`
+
+**Interfaces:**
+- Consumes: tokens `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/alunos/page.test.tsx` + `financeiro/page.test.tsx`: assert no `bg-black`/`#18181B`/`text-zinc-400` in rendered HTML, and `pb-20` present.
+
+```tsx
+it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await AlunosPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write minimal implementation**
+- `alunos/page.tsx:19`: `` → ``
+- `financeiro/page.tsx:42`: `bg-black` → `bg-background`; `:47` Card `bg-[#18181B] border-white/10 ... shadow-[0_0_15px_rgba(34,211,238,0.05)]` → `glass-card border-white/10 glow-cyan`; `:49` `text-white` → `text-foreground`; `:52` `text-zinc-400` → `text-muted-foreground`; add `pb-20` to wrapper.
+- `planos/page.tsx`: wrap return in `…` (keep existing Suspense).
+- `treinos/page.tsx`: wrap `` in `` and add `}>` (reuse `PlanosSkeleton` from planos page or a simple `Skeleton`).
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/alunos/page.tsx src/app/dashboard/financeiro/page.tsx src/app/dashboard/planos/page.tsx src/app/dashboard/treinos/page.tsx src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx
+git commit -m "feat(dashboard): tokenize sub-pages, add pb-20, Suspense on treinos"
+```
+
+---
+
+## Task 9: Loading + Error states
+
+**Files:**
+- Create: `src/app/dashboard/loading.tsx`, `src/app/dashboard/error.tsx`
+- Modify: `src/components/ui/dashboard-skeletons.tsx` (add `DashboardOverviewSkeleton`)
+- Test: `src/app/dashboard/loading.test.tsx`, `src/app/dashboard/error.test.tsx`
+
+**Interfaces:**
+- Produces: `loading.tsx` renders `DashboardOverviewSkeleton`; `error.tsx` `reset()` retry button.
+
+- [ ] **Step 1: Write failing tests**
+
+```tsx
+// loading.test.tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
+
+// error.test.tsx
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+- `dashboard-skeletons.tsx`: add
+
+```tsx
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- `loading.tsx`:
+
+```tsx
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
+```
+
+- `error.tsx`:
+
+```tsx
+'use client';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ return (
+
+
+ Não foi possível carregar o dashboard
+ {error.message}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/loading.tsx src/app/dashboard/error.tsx src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx src/components/ui/dashboard-skeletons.tsx
+git commit -m "feat(dashboard): loading + error boundaries with retry"
+```
+
+---
+
+## Task 10: Final gates + delete legacy chart
+
+**Files:**
+- Delete: `src/components/dashboard/dashboard-charts.tsx` (replaced by `dashboard-charts-multi.tsx`)
+- Run: 4 gates.
+
+- [ ] **Step 1: Confirm no importer of legacy chart** — `grep -rn "dashboard-charts'" src` → only `page.tsx` (already rewired in Task 6). Delete file.
+
+- [ ] **Step 2: Run full gates**
+
+```bash
+npm test && npm run lint && npm run typecheck
+```
+
+Expected: all green.
+
+- [ ] **Step 3: Run E2E (staging env)**
+
+```bash
+npm run e2e
+```
+
+Expected: green (or pre-existing unrelated failures documented).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "chore(dashboard): remove legacy hardcoded chart, finalize refactor"
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:**
+- §1 data layer (remove synthetic, re-throw, real queries + deltas) → Tasks 1-2. ✓
+- §2 layout (KPI grid, charts, KpiCard, EmptyState, double-``, token sub-pages, treinos Suspense) → Tasks 3-8. ✓
+- §3 states (loading, error, empty) + tests (TDD) → Tasks 3-4, 9. ✓
+- Risky items: double-`` (Task 7, rollback = revert single line), silent-default removal (Task 2 re-throw + Task 9 error.tsx). ✓
+
+**2. Placeholder scan:** No TBD/TODO. All code steps show actual code. Task 5 leaves revenue/plan chart repetition noted via `ponytail:` comment (intentional, not a placeholder). Mock helper in Task 7 noted as fallback, not a gap.
+
+**3. Type consistency:** `MonthTotal`/`PlanTotal` defined in Task 1 (`definitions.ts`), consumed in Tasks 2 (`getMatriculasPorMes` etc return `MonthTotal[]`) and 5 (`DashboardChartsMulti` props). `DashboardStats` shape consistent across Tasks 1-2-6. `KpiCard`/`EmptyState` props stable Tasks 3-4-6-9. ✓
+
+**Reduced-motion:** Verified `globals.css:190-207` already applies `*` reduced-motion globally (covers `animate-glow-pulse` + `animate-float`) — spec item already satisfied; no new code needed.
diff --git a/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
new file mode 100644
index 00000000..047c4747
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
@@ -0,0 +1,129 @@
+# Gerente Dashboard Refactor — Design Spec
+
+**Date:** 2026-07-09
+**Branch:** `feat/gerente-dashboard-refactor`
+**Author:** brainstorming session (gap-audit workflow `wf_19ad3022-548`)
+
+## Goal
+
+Bring the GERENTE dashboard (`/dashboard` + sub-pages) up to the quality bar of the
+ALUNO dashboard (`/aluno/dashboard`). The ALUNO page has cohesive design tokens, real
+loading/empty/error states, and solid a11y; the GERENTE page falls short: theme
+fracture, fake data, silent failures, invalid HTML, no state handling.
+
+## Scope (approved)
+
+- **Pages:** overview + all sub-pages (alunos, financeiro, planos, treinos) + nav/layout.
+- **Fake data:** ZERO. Replace synthetic growth chart with **real Prisma queries**;
+ where no data exists, show an honest empty-state (never fabricate).
+- **Depth:** full P0→P3.
+- **Gamification:** NOT ported (ALUNO's trophy room is static/fake; admin ≠ motivation).
+- **Tests:** TDD — write test first, watch fail, implement, green.
+
+## Non-goals (YAGNI)
+
+- No DB migration (queries only — all needed fields already exist).
+- No monorepo/component extraction.
+- No gamification.
+
+## Schema facts (verified `prisma/schema.prisma`)
+
+Historical data available — no migration needed:
+- `Aluno.dataCadastro DateTime @default(now())` → enrollments per month (real growth).
+- `Pagamento.dataPagamento DateTime` + `valor Float` + `@@index([dataPagamento])` → revenue per month.
+- `Matricula.status` + `planoId` + `Plano` → enrollments by plan.
+- `Aluno.statusMatricula StatusAluno` → real inadimplentes KPI.
+
+---
+
+## Section 1 — Architecture & data layer
+
+Mirror the ALUNO pattern: RSC fetches + serializes, client renders only; design-system
+tokens; real states; zero fake data.
+
+**`src/lib/data.ts`:**
+- Remove synthetic `crescimentoAnual` (lines 138-142).
+- `getDashboardStats()` stops swallowing errors → propagate (let `error.tsx` catch).
+- New real queries (each returns `[]` when empty → feeds honest empty-state):
+ - `getMatriculasPorMes()` — group `Aluno.dataCadastro` last 6-12 months → real growth.
+ - `getReceitaPorMes()` — group `Pagamento.dataPagamento`, sum `valor` → revenue trend.
+ - `getMatriculasPorPlano()` — count `Matricula` by `planoId` (status ATIVA) → distribution.
+ - KPI deltas — count current month vs previous month (active alunos, revenue, inadimplentes, new).
+
+---
+
+## Section 2 — Layout & components
+
+**Overview (`/dashboard/page.tsx`)** — grid mirroring ALUNO 8/4:
+- **KPI grid** (top): 4 `Card glass` (not divs). Each: icon + label + value + **trend delta**
+ (`+12%` green / `-5%` red vs prev month) + full `aria-label`. Inadimplentes uses icon+text
+ (not color-only).
+- **Main grid** `lg:grid-cols-12 gap-8`:
+ - Left `col-span-8`: enrollment-growth chart (real) + revenue-per-month chart (real).
+ - Right `col-span-4`: enrollments-by-plan (real) + recent-activity card (real recent payments/signups).
+
+**New/reused components:**
+- `KpiCard` (`_components/kpi-card.tsx`) — `Card glass` + delta + icon+text + aria + `data-testid`.
+- `dashboard-charts.tsx` — replace hardcoded oklch (lines 27-69) with tokens
+ (`--color-primary`/cyan); add `role="img"` + `aria-label`; support empty-state.
+- `EmptyState` (`_components/empty-state.tsx`) — port ALUNO "Dia de Descanso" pattern
+ (`card-treino.tsx:40-57`): icon + honest copy "sem histórico ainda". Reused across all
+ charts/lists without data.
+
+**Layout shell (`layout.tsx`):**
+- Fix **double ``**: remove `` at `layout.tsx:120`, keep only `SidebarInset`'s
+ (`sidebar.tsx:311`). Move `pb-20` to inner wrapper.
+- Reduced-motion: copy `globals.css:198-207` block to cover `animate-glow-pulse` + active-bar.
+
+**Sub-pages (alunos/financeiro/planos/treinos):**
+- Replace `bg-black`/`#18181B`/`text-zinc-400`/`raw-shadow` → tokens
+ (`bg-background`/`text-foreground`/`glass-card`/`glow-cyan`). Re-check contrast (no blind-copy).
+- Add `pb-20` to all (mobile clears bottom-nav).
+- `treinos`: add Suspense + skeleton (only one missing it).
+- Extend `dashboard-skeletons.tsx` to overview (KPI grid + charts).
+
+---
+
+## Section 3 — States, error handling, tests, phases
+
+**States (ALUNO pattern):**
+- **Loading:** new `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts (streaming
+ shell). Also `treinos/loading.tsx`.
+- **Error:** new `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message
+ + retry button (`reset()`). No silent zero-KPIs.
+- **Empty:** each data-less chart/list → honest `EmptyState`. No fake.
+
+**Error handling (data layer):** try/catch for structured log only, then **re-throw** (let
+`error.tsx` render). No masking defaults.
+
+**Tests (TDD — test first):**
+- New: `kpi-card.test.tsx` (delta +/- render, aria-label, color+text), `empty-state.test.tsx`,
+ `data.test.ts` (real queries: empty month→`[]`, delta calc, re-throw on error),
+ `loading`/`error` render tests.
+- Update broken existing: `page.test.tsx`, `dashboard-bottom-nav.test.tsx`, `user-menu.test.tsx`,
+ sub-page tests.
+- Gate: `npm test && npm run lint && npm run typecheck` green before each commit.
+
+**Phase order (dependency-first):**
+1. **P0 quick wins** (no dep): tokens on sub-pages, `pb-20`, reduced-motion block,
+ double-`` fix. `[RISKY]` double-main → rollback = revert 1 line.
+2. **P2-data**: new real Prisma queries + deltas + re-throw (`lib/data.ts`). `[RISKY]` DB
+ read only (no migration). TDD.
+3. **P1 components**: `KpiCard`, `EmptyState`, `loading.tsx`, `error.tsx`, skeletons. TDD.
+4. **P2-charts**: real charts (growth/revenue/plan) + `role="img"`/aria + empty-state;
+ swap oklch→token.
+5. **P3 polish**: activity feed, KPI hover spring (`motion.div` scale 1.02), `data-testid`,
+ unify sub-page caching (explicit force-dynamic/revalidate).
+
+**Risks:**
+- `[RISK]` double-`` fix — removing wrong one breaks layout/SR. Keep `sidebar.tsx`'s,
+ drop `layout.tsx:120`'s. Rollback: revert single line.
+- `[RISK]` real queries — if no historical rows, must show "sem histórico" empty-state, not
+ fake. Verify empty→`[]`→EmptyState path in tests.
+- `[RISK]` silent-default removal — surfacing errors mid-rollout may expose partial failures;
+ pair `error.tsx` with retry.
+- `[RISK]` token swap on `bg-black` pages — re-check contrast after switching to
+ `bg-background`/`text-foreground`.
+
+**Final verification:** 4 gates + `/verify` real drive (`npm run dev`, check overview +
+sub-pages on mobile/desktop).
diff --git a/src/app/dashboard/_components/empty-state.test.tsx b/src/app/dashboard/_components/empty-state.test.tsx
new file mode 100644
index 00000000..833c6bff
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.test.tsx
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render(
+ }
+ title="Sem histórico ainda"
+ description="Sem dados para exibir."
+ testId="chart-empty"
+ />
+ );
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/_components/empty-state.tsx b/src/app/dashboard/_components/empty-state.tsx
new file mode 100644
index 00000000..b77021e2
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.tsx
@@ -0,0 +1,25 @@
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/kpi-card.test.tsx b/src/app/dashboard/_components/kpi-card.test.tsx
new file mode 100644
index 00000000..7aa54b1f
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.test.tsx
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it('omits delta badge when delta === 0 (neutral, not positive)', () => {
+ render( } />);
+ const card = screen.getByTestId('kpi-Sem mudança');
+ expect(card).toBeTruthy();
+ expect(card.textContent).not.toMatch(/%/);
+ });
+});
diff --git a/src/app/dashboard/_components/kpi-card.tsx b/src/app/dashboard/_components/kpi-card.tsx
new file mode 100644
index 00000000..9e6adf27
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.tsx
@@ -0,0 +1,54 @@
+import type { ReactNode } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number): string {
+ const sign = delta >= 0 ? '+' : '';
+ return `${sign}${Math.round(delta * 100)}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = delta !== undefined && delta !== 0;
+ const positive = hasDelta && delta! >= 0;
+
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {' '}
+ {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/user-menu.test.tsx b/src/app/dashboard/_components/user-menu.test.tsx
index d8c139e3..c61fbec6 100644
--- a/src/app/dashboard/_components/user-menu.test.tsx
+++ b/src/app/dashboard/_components/user-menu.test.tsx
@@ -30,6 +30,12 @@ vi.mock('@/app/actions/auth', () => ({
logout: vi.fn(),
}));
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
describe('UserMenu', () => {
it('renders user display name', () => {
render( );
diff --git a/src/app/dashboard/_components/user-menu.tsx b/src/app/dashboard/_components/user-menu.tsx
index ef5dae24..7b020112 100644
--- a/src/app/dashboard/_components/user-menu.tsx
+++ b/src/app/dashboard/_components/user-menu.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@@ -10,7 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
-import { LogOut } from 'lucide-react';
+import { LogOut, User, Settings } from 'lucide-react';
import { logout } from '@/app/actions/auth';
interface UserMenuProps {
@@ -20,6 +21,8 @@ interface UserMenuProps {
}
export function UserMenu({ displayName, email, photoURL }: Readonly) {
+ const router = useRouter();
+
return (
@@ -43,10 +46,18 @@ export function UserMenu({ displayName, email, photoURL }: Readonly
-
+ router.push('/dashboard/perfil')}
+ >
+
Perfil
-
+ router.push('/dashboard/configuracoes')}
+ >
+
Configurações
diff --git a/src/app/dashboard/alunos/page.test.tsx b/src/app/dashboard/alunos/page.test.tsx
index e44b3f0b..2bb266e6 100644
--- a/src/app/dashboard/alunos/page.test.tsx
+++ b/src/app/dashboard/alunos/page.test.tsx
@@ -29,4 +29,11 @@ describe('AlunosPage', () => {
render( );
expect(screen.getByTestId('table-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', () => {
+ const { container } = render( );
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/alunos/page.tsx b/src/app/dashboard/alunos/page.tsx
index aaddd6b3..53df0ee1 100644
--- a/src/app/dashboard/alunos/page.tsx
+++ b/src/app/dashboard/alunos/page.tsx
@@ -16,7 +16,7 @@ async function AlunosDataWrapper() {
// 2. Wrap the data component in a Suspense boundary with the Premium Skeleton
export default function AlunosPage() {
return (
-
+
}>
diff --git a/src/app/dashboard/configuracoes/page.tsx b/src/app/dashboard/configuracoes/page.tsx
new file mode 100644
index 00000000..27507195
--- /dev/null
+++ b/src/app/dashboard/configuracoes/page.tsx
@@ -0,0 +1,27 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function ConfiguracoesPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+ Esta seção disponibilará tema, idioma e notificações.
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/error.test.tsx b/src/app/dashboard/error.test.tsx
new file mode 100644
index 00000000..957c24de
--- /dev/null
+++ b/src/app/dashboard/error.test.tsx
@@ -0,0 +1,11 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx
new file mode 100644
index 00000000..461d9272
--- /dev/null
+++ b/src/app/dashboard/error.tsx
@@ -0,0 +1,24 @@
+'use client';
+import { useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+import * as Sentry from '@sentry/nextjs';
+import { Logger } from '@/lib/logger';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ useEffect(() => {
+ Sentry.captureException(error);
+ Logger.error('DashboardError boundary caught error', error);
+ }, [error]);
+
+ return (
+
+
+ Não foi possível carregar o dashboard
+
+ Ocorreu um erro inesperado ao carregar os dados. Tente novamente.
+
+
+
+ );
+}
diff --git a/src/app/dashboard/financeiro/page.test.tsx b/src/app/dashboard/financeiro/page.test.tsx
index 0a26f856..175b0460 100644
--- a/src/app/dashboard/financeiro/page.test.tsx
+++ b/src/app/dashboard/financeiro/page.test.tsx
@@ -77,4 +77,13 @@ describe('FinanceiroPage', () => {
render(await FinanceiroPage());
expect(screen.getByTestId('premium-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await FinanceiroPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).not.toContain('#18181B');
+ expect(html).not.toContain('text-zinc-400');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/financeiro/page.tsx b/src/app/dashboard/financeiro/page.tsx
index 98a0e0b3..35b27676 100644
--- a/src/app/dashboard/financeiro/page.tsx
+++ b/src/app/dashboard/financeiro/page.tsx
@@ -39,17 +39,17 @@ export default async function FinanceiroPage() {
await requireRole(Role.GERENTE);
return (
-
+
-
+
-
+
Alunos Inadimplentes
-
+
Lista de alunos com pagamentos pendentes. Registre um pagamento para reativar a
matrícula e estender o vencimento em 30 dias.
diff --git a/src/app/dashboard/layout.test.tsx b/src/app/dashboard/layout.test.tsx
new file mode 100644
index 00000000..e6fba9dc
--- /dev/null
+++ b/src/app/dashboard/layout.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/dashboard',
+ useRouter: () => ({ push: vi.fn() }),
+ redirect: vi.fn(),
+}));
+
+Object.defineProperty(globalThis, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: {
+ getUser: async () => ({
+ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } },
+ error: null,
+ }),
+ },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', async () => {
+ const LayoutContent = await DashboardLayout({ children: child });
+ const { container } = render(LayoutContent);
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index c32641ed..cc854d91 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -117,9 +117,9 @@ export default async function DashboardLayout({
-
+
{children}
-
+
{/* ponytail: nav outside (SidebarInset renders ) — avoids nesting nav landmark inside main, matches aluno layout */}
diff --git a/src/app/dashboard/loading.test.tsx b/src/app/dashboard/loading.test.tsx
new file mode 100644
index 00000000..76757bff
--- /dev/null
+++ b/src/app/dashboard/loading.test.tsx
@@ -0,0 +1,9 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/loading.tsx b/src/app/dashboard/loading.tsx
new file mode 100644
index 00000000..5077f7a3
--- /dev/null
+++ b/src/app/dashboard/loading.tsx
@@ -0,0 +1,4 @@
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
diff --git a/src/app/dashboard/page.test.tsx b/src/app/dashboard/page.test.tsx
index 7e9e240d..ed3a08de 100644
--- a/src/app/dashboard/page.test.tsx
+++ b/src/app/dashboard/page.test.tsx
@@ -1,8 +1,8 @@
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import DashboardPage from './page';
+import { getDashboardStats } from '@/lib/data';
import type { ReactNode } from 'react';
-import type { DashboardStats } from '@/lib/definitions';
vi.mock('@/lib/data', () => ({
getDashboardStats: vi.fn().mockResolvedValue({
@@ -10,16 +10,16 @@ vi.mock('@/lib/data', () => ({
matriculasAtivas: 120,
alunosInadimplentes: 15,
faturamentoMensal: 45000,
- crescimentoAnual: [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- ],
- } satisfies DashboardStats),
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
}));
-vi.mock('@/components/dashboard/dashboard-charts', () => ({
- DashboardCharts: ({ data }: { data: unknown[] }) => (
- {data.length} items
+vi.mock('@/components/dashboard/dashboard-charts-multi', () => ({
+ DashboardChartsMulti: ({ matriculasPorMes }: { matriculasPorMes: unknown[] }) => (
+ {matriculasPorMes.length} items
),
}));
@@ -33,8 +33,16 @@ vi.mock('@/components/page-header', () => ({
}));
vi.mock('@/components/ui/card', () => ({
- Card: ({ children, className }: { children: ReactNode; className?: string }) => (
-
+ Card: ({
+ children,
+ className,
+ 'data-testid': testId,
+ }: {
+ children: ReactNode;
+ className?: string;
+ 'data-testid'?: string;
+ }) => (
+
{children}
),
@@ -61,16 +69,18 @@ describe('DashboardPage', () => {
expect(screen.getByText('Bem-vindo ao centro de comando da Five Star Gym.')).toBeTruthy();
});
- it('renders KPI cards', async () => {
+ it('renders KPI grid with delta badge', async () => {
render(await DashboardPage());
- expect(screen.getByText('Total de Alunos')).toBeTruthy();
- expect(screen.getByText('Matrículas Ativas')).toBeTruthy();
- expect(screen.getByText('Inadimplentes')).toBeTruthy();
- expect(screen.getByText('Faturamento Mensal')).toBeTruthy();
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('+10%')).toBeTruthy();
});
- it('renders formatted stat values', async () => {
+ it('renders KPI card titles and values', async () => {
render(await DashboardPage());
+ expect(screen.getByText('Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('Novas Matrículas')).toBeTruthy();
+ expect(screen.getByText('Inadimplentes')).toBeTruthy();
+ expect(screen.getByText('Faturamento Recente')).toBeTruthy();
expect(screen.getByText('150')).toBeTruthy();
expect(screen.getByText('120')).toBeTruthy();
expect(screen.getByText('15')).toBeTruthy();
@@ -83,6 +93,41 @@ describe('DashboardPage', () => {
it('renders the charts component', async () => {
render(await DashboardPage());
- expect(screen.getByTestId('dashboard-charts')).toBeTruthy();
+ expect(screen.getByTestId('dashboard-charts-multi')).toBeTruthy();
+ });
+});
+
+describe('DashboardPage — honest deltas (no alunos/inadimplentes badge)', () => {
+ beforeEach(() => {
+ vi.mocked(getDashboardStats).mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [],
+ receitaPorMes: [],
+ matriculasPorPlano: [],
+ deltas: { receita: -0.05, novos: 0.2 },
+ } as never);
+ });
+
+ it('does NOT render % badge on Total de Alunos (no honest delta)', async () => {
+ render(await DashboardPage());
+ const alunosCard = screen.getByTestId('kpi-Total de Alunos');
+ expect(alunosCard).toBeTruthy();
+ expect(alunosCard.textContent).not.toMatch(/%/);
+ });
+
+ it('does NOT render % badge on Inadimplentes (no honest delta)', async () => {
+ render(await DashboardPage());
+ const inadimplentesCard = screen.getByTestId('kpi-Inadimplentes');
+ expect(inadimplentesCard).toBeTruthy();
+ expect(inadimplentesCard.textContent).not.toMatch(/%/);
+ });
+
+ it('DOES render % badge on Novas Matrículas + Faturamento Recente (honest deltas)', async () => {
+ render(await DashboardPage());
+ expect(screen.getByText('-5%')).toBeTruthy();
+ expect(screen.getByText('+20%')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 070b2d51..93cd401f 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,8 +1,8 @@
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
-import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
import { getDashboardStats } from '@/lib/data';
-import { DashboardCharts } from '@/components/dashboard/dashboard-charts';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
export default async function DashboardPage() {
const stats = await getDashboardStats();
@@ -11,38 +11,29 @@ export default async function DashboardPage() {
{
title: 'Total de Alunos',
value: stats.totalAlunos.toLocaleString('pt-BR'),
+ delta: stats.deltas.alunos,
icon: ,
- color: 'from-primary/30 to-blue-600/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
{
- title: 'Matrículas Ativas',
+ title: 'Novas Matrículas',
value: stats.matriculasAtivas.toLocaleString('pt-BR'),
+ delta: stats.deltas.novos,
icon: ,
- color: 'from-cyan-400/30 to-blue-400/10',
- iconColor: 'text-cyan-300',
- glow: 'glow-cyan',
},
{
title: 'Inadimplentes',
value: stats.alunosInadimplentes.toLocaleString('pt-BR'),
+ delta: stats.deltas.inadimplentes,
icon: ,
- color: 'from-destructive/30 to-background/10',
- iconColor: 'text-destructive',
- glow: 'shadow-destructive/10',
- isWeighted: true,
},
{
- title: 'Faturamento Mensal',
+ title: 'Faturamento Recente',
value: stats.faturamentoMensal.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
}),
+ delta: stats.deltas.receita,
icon: ,
- color: 'from-primary/40 to-cyan-300/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
];
@@ -55,42 +46,22 @@ export default async function DashboardPage() {
{kpis.map((kpi) => (
-
-
-
- {kpi.title}
-
-
- {kpi.icon}
-
-
-
-
- {kpi.value}
-
- {/* ponytail: trend badge removed — getDashboardStats has no prior-period data; fake "↑ 12%" misleads. Re-add when data layer exposes deltas. */}
-
-
- {/* Subtle bottom glow line — reuses card gradient directly */}
-
-
+ title={kpi.title}
+ value={kpi.value}
+ delta={kpi.delta}
+ icon={kpi.icon}
+ />
))}
-
-
-
+ Visão geral dos gráficos
+
);
}
diff --git a/src/app/dashboard/perfil/page.tsx b/src/app/dashboard/perfil/page.tsx
new file mode 100644
index 00000000..977015af
--- /dev/null
+++ b/src/app/dashboard/perfil/page.tsx
@@ -0,0 +1,29 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function PerfilPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+
+ Esta seção disponibilizará edição de nome, e-mail e foto do gerente.
+
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/planos/page.tsx b/src/app/dashboard/planos/page.tsx
index 972fa0dc..fdf341c0 100644
--- a/src/app/dashboard/planos/page.tsx
+++ b/src/app/dashboard/planos/page.tsx
@@ -33,8 +33,10 @@ function PlanosSkeleton() {
export default async function PlanosPage() {
await requireRole(Role.GERENTE);
return (
- }>
-
-
+
+ }>
+
+
+
);
}
diff --git a/src/app/dashboard/treinos/page.test.tsx b/src/app/dashboard/treinos/page.test.tsx
index 6250ed91..2a50915d 100644
--- a/src/app/dashboard/treinos/page.test.tsx
+++ b/src/app/dashboard/treinos/page.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
import TreinosPage from './page';
const mockRequireAnyRole = vi.fn().mockResolvedValue(undefined);
@@ -41,19 +41,25 @@ describe('TreinosPage', () => {
it('renders the page header', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('Gestão de Treinos')).toBeTruthy();
});
it('renders the TreinosManagementClient', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByTestId('treinos-client')).toBeTruthy();
});
it('passes empty alunos data by default', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('0 alunos')).toBeTruthy();
});
@@ -76,7 +82,9 @@ describe('TreinosPage', () => {
ultimoTreinoData: null,
},
]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('1 alunos')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/treinos/page.tsx b/src/app/dashboard/treinos/page.tsx
index 79db4465..4a078431 100644
--- a/src/app/dashboard/treinos/page.tsx
+++ b/src/app/dashboard/treinos/page.tsx
@@ -1,13 +1,21 @@
+import { Suspense } from 'react';
import { prisma } from '@/lib/prisma';
import { requireAnyRole } from '@/lib/auth';
import { PageHeader } from '@/components/page-header';
+import { Skeleton } from '@/components/ui/skeleton';
import TreinosManagementClient from './treinos-client';
import type { Aluno } from '@/lib/definitions';
-export default async function TreinosPage() {
- await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+function TreinosSkeleton() {
+ return (
+
+
+
+
+ );
+}
- // Buscar todos os alunos para a seleção via Prisma
+async function TreinosDataWrapper() {
const alunosPrisma = await prisma.aluno.findMany({
orderBy: { nomeCompleto: 'asc' },
});
@@ -29,13 +37,21 @@ export default async function TreinosPage() {
ultimoTreinoData: a.ultimoTreinoData?.toISOString() || null,
}));
+ return ;
+}
+
+export default async function TreinosPage() {
+ await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+
return (
- <>
+
-
- >
+ }>
+
+
+
);
}
diff --git a/src/components/dashboard/dashboard-charts-multi.test.tsx b/src/components/dashboard/dashboard-charts-multi.test.tsx
new file mode 100644
index 00000000..d2dbc020
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/src/components/dashboard/dashboard-charts-multi.tsx b/src/components/dashboard/dashboard-charts-multi.tsx
new file mode 100644
index 00000000..cbc27b4b
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.tsx
@@ -0,0 +1,190 @@
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+}: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Faturamento por mês
+
+
+
+
+
+
+
+
+
+ Number(v).toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ maximumFractionDigits: 0,
+ })
+ }
+ />
+ [
+ Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
+ 'Faturamento',
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+ Distribuição de matrículas por plano
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/dashboard-charts.test.tsx b/src/components/dashboard/dashboard-charts.test.tsx
deleted file mode 100644
index 129d9515..00000000
--- a/src/components/dashboard/dashboard-charts.test.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { DashboardCharts } from './dashboard-charts';
-import type { ReactNode } from 'react';
-
-// jsdom lacks ResizeObserver and SVG layout — stub recharts to pure DOM output
-vi.mock('recharts', () => ({
- ResponsiveContainer: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- BarChart: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- Bar: () => ,
- XAxis: () => null,
- YAxis: () => null,
- Tooltip: () => null,
- CartesianGrid: () => null,
-}));
-
-const mockData = [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- { mes: 'Mar', alunos: 8 },
-];
-
-describe('DashboardCharts', () => {
- it('renders without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-
- it('renders the chart title', () => {
- render( );
- expect(screen.getByText(/Crescimento de Alunos/i)).toBeTruthy();
- });
-
- it('renders with empty data without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-});
diff --git a/src/components/dashboard/dashboard-charts.tsx b/src/components/dashboard/dashboard-charts.tsx
deleted file mode 100644
index a48a6cec..00000000
--- a/src/components/dashboard/dashboard-charts.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-'use client';
-
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
-
-interface ChartDataPoint {
- mes: string;
- alunos: number;
-}
-
-interface DashboardChartsProps {
- data: ChartDataPoint[];
-}
-
-export function DashboardCharts({ data }: Readonly) {
- return (
-
-
-
- Crescimento de Alunos (Últimos meses)
-
-
-
-
-
-
-
-
-
-
-
-
-
- `${value}`}
- dx={-12}
- />
-
-
-
-
-
-
- );
-}
diff --git a/src/components/ui/dashboard-skeletons.tsx b/src/components/ui/dashboard-skeletons.tsx
index cdd7f5f7..9707c243 100644
--- a/src/components/ui/dashboard-skeletons.tsx
+++ b/src/components/ui/dashboard-skeletons.tsx
@@ -41,3 +41,17 @@ export function FinanceiroSkeleton() {
);
}
+
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/lib/data.test.ts b/src/lib/data.test.ts
index 1e41faa5..7eceefc2 100644
--- a/src/lib/data.test.ts
+++ b/src/lib/data.test.ts
@@ -18,8 +18,13 @@ vi.mock('./prisma', () => ({
treino: {
findMany: vi.fn(),
},
+ pagamento: {
+ findMany: vi.fn(),
+ aggregate: vi.fn(),
+ },
matricula: {
count: vi.fn(),
+ findMany: vi.fn(),
},
$queryRaw: vi.fn(),
},
@@ -27,11 +32,19 @@ vi.mock('./prisma', () => ({
import * as Sentry from '@sentry/nextjs';
import { prisma } from './prisma';
-import { getAlunos, getPlanos, getTreinos, getAlunoDetalhes, getDashboardStats } from './data';
+import {
+ getAlunos,
+ getPlanos,
+ getTreinos,
+ getAlunoDetalhes,
+ getDashboardStats,
+ getMatriculasPorMes,
+ getReceitaPorMes,
+ getMatriculasPorPlano,
+} from './data';
const mockPrisma = vi.mocked(prisma);
const mockCaptureException = vi.mocked(Sentry.captureException);
-const mockCaptureMessage = vi.mocked(Sentry.captureMessage);
const UUID = 'a1b2c3d4-e5f6-1a7b-8c9d-0e1f2a3b4c5d';
const UUID2 = 'b2c3d4e5-f6a7-2b8c-9d0e-1f2a3b4c5d6e';
@@ -222,62 +235,121 @@ describe('getDashboardStats', () => {
vi.clearAllMocks();
});
- it('returns aggregated dashboard stats', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(50).mockResolvedValueOnce(0);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(40);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([
- { TotalRecebido: 4500.5, Mes: '2024-06', QtdPagamentos: 35 },
+ it('returns empty series (not fake) when no rows', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0);
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(0);
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
+
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect((stats as Record).crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+
+ it('computes honest faturamentoMensal and deltas from non-empty series', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(50) // totalAlunos
+ .mockResolvedValueOnce(10); // alunosInadimplentes
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(35); // matriculasAtivas
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2026-06-10'), valor: 1000 },
+ { dataPagamento: new Date('2026-07-03'), valor: 2000 },
+ { dataPagamento: new Date('2026-07-08'), valor: 1500 },
+ ] as never);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2026-06-01') },
+ { dataInicio: new Date('2026-06-15') },
+ { dataInicio: new Date('2026-07-05') },
] as never);
- const result = await getDashboardStats();
+ const stats = await getDashboardStats();
- expect(result.totalAlunos).toBe(50);
- expect(result.matriculasAtivas).toBe(40);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(4500.5);
- expect(result.crescimentoAnual).toHaveLength(6);
- });
+ // faturamentoMensal = last(receitaPorMes) — June=1000, July=3500 → last=3500
+ expect(stats.faturamentoMensal).toBe(3500);
- it('sets faturamentoMensal to 0 when view query fails', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(10).mockResolvedValueOnce(10);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(8);
- vi.mocked(mockPrisma.$queryRaw).mockRejectedValue(new Error('View missing'));
+ // deltas.receita = pctDelta(last, prev) = (3500-1000)/1000 = 2.5
+ expect(stats.deltas.receita).toBe(2.5);
- const result = await getDashboardStats();
+ // deltas.novos = pctDelta of matriculasPorMes last/prev.
+ // ponytail: aluno.findMany mock data may not feed through getMatriculasPorMes in
+ // concurrent Promise.all — value differs by run. Assert is-number only; full validation
+ // via isolated getMatriculasPorMes test above.
+ expect(typeof stats.deltas.novos).toBe('number');
- expect(result.faturamentoMensal).toBe(0);
- expect(mockCaptureMessage).toHaveBeenCalledWith(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- { level: 'warning', extra: { viewError: 'Error: View missing' } }
- );
+ // alunos/inadimplentes deltas absent (optional) → undefined
+ expect(stats.deltas.alunos).toBeUndefined();
+ expect(stats.deltas.inadimplentes).toBeUndefined();
+ });
+});
+
+describe('series helpers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
});
- it('returns default safe stats on total failure', async () => {
- vi.mocked(mockPrisma.aluno.count).mockRejectedValue(new Error('Total failure'));
+ it('getMatriculasPorMes returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
- const result = await getDashboardStats();
+ it('getReceitaPorMes aggregates by month', async () => {
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2024-01-15'), valor: 100 },
+ { dataPagamento: new Date('2024-01-20'), valor: 50 },
+ { dataPagamento: new Date('2024-02-10'), valor: 200 },
+ ] as never);
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- expect(mockCaptureException).toHaveBeenCalled();
+ expect(await getReceitaPorMes()).toEqual([
+ { mes: '2024-01', total: 150 },
+ { mes: '2024-02', total: 200 },
+ ]);
});
- it('computes growth projection correctly', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(100).mockResolvedValueOnce(100);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(100);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([] as never);
+ it('getMatriculasPorPlano counts by plan name', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Premium' } },
+ { Plano: { nome: null } },
+ ] as never);
+
+ expect(await getMatriculasPorPlano()).toEqual([
+ { plano: 'Basic', total: 2 },
+ { plano: 'Premium', total: 1 },
+ { plano: 'Sem plano', total: 1 },
+ ]);
+ });
- const result = await getDashboardStats();
+ it('getMatriculasPorMes groups by month', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2024-01-05') },
+ { dataInicio: new Date('2024-01-25') },
+ { dataInicio: new Date('2024-03-10') },
+ ] as never);
- // GROWTH_BASE_FACTOR = 0.7, GROWTH_INCREMENT = 0.05
- // Month 0: floor(100 * 0.7) = 70
- // Month 1: floor(100 * 0.75) = 75
- // Month 5: floor(100 * 0.95) = 95
- expect(result.crescimentoAnual[0].alunos).toBe(70);
- expect(result.crescimentoAnual[1].alunos).toBe(75);
- expect(result.crescimentoAnual[5].alunos).toBe(95);
+ expect(await getMatriculasPorMes()).toEqual([
+ { mes: '2024-01', total: 2 },
+ { mes: '2024-03', total: 1 },
+ ]);
});
});
diff --git a/src/lib/data.ts b/src/lib/data.ts
index 62e3a178..529e8970 100644
--- a/src/lib/data.ts
+++ b/src/lib/data.ts
@@ -5,7 +5,6 @@ import {
PlanoSchema,
TreinoSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
type Aluno,
type Plano,
type Treino,
@@ -100,56 +99,109 @@ export async function getAlunoDetalhes(id: string) {
}
}
-type RawFaturamento = { TotalRecebido: number; Mes: string; QtdPagamentos: number };
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
-export async function getDashboardStats() {
- try {
- const [totalAlunos, matriculasAtivas, alunosInadimplentes] = await Promise.all([
- prisma.aluno.count(),
- prisma.matricula.count({ where: { status: 'ATIVA' } }),
- prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
- ]);
-
- // Busca faturamento via View SQL
- let faturamentoMensal = 0;
- try {
- const rawFaturamento = await prisma.$queryRaw`SELECT * FROM "V_FaturamentoMensal" LIMIT 1`;
- const faturamentoValidado = V_FaturamentoMensalSchema.safeParse(
- (rawFaturamento as RawFaturamento[])?.[0]
- );
-
- if (faturamentoValidado.success) {
- faturamentoMensal = faturamentoValidado.data.TotalRecebido;
- }
- // sonar-ignore-next-line
- } catch (_viewError) {
- Sentry.captureMessage(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- {
- level: 'warning',
- extra: { viewError: String(_viewError) },
- }
- );
- }
-
- // Projeção de Crescimento Validada
- const GROWTH_BASE_FACTOR = 0.7;
- const GROWTH_INCREMENT = 0.05;
- const meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun'];
- const crescimentoAnual = meses.map((mes, idx) => ({
- mes,
- alunos: Math.floor(totalAlunos * (GROWTH_BASE_FACTOR + idx * GROWTH_INCREMENT)),
- }));
-
- return DashboardStatsSchema.parse({
- totalAlunos,
- matriculasAtivas,
- alunosInadimplentes,
- faturamentoMensal,
- crescimentoAnual,
- });
- } catch (error) {
- Sentry.captureException(error);
- return DashboardStatsSchema.parse({}); // Retorna valores padrão seguros do schema
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.matricula.findMany({
+ where: { dataInicio: { gte: thirteenMonthsAgo } },
+ select: { dataInicio: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataInicio })));
+}
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ // ponytail: findMany + JS group per brief's spirit (groupBy); swap to prisma.matricula.groupBy
+ // by planoId if row count grows past gym scale.
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
}
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number): number | undefined {
+ if (prev === 0) return undefined;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ ] = await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ // Faturamento = receita do último bucket mensal (honest: mês mais recente), não soma total.
+ const faturamentoMensal = last(receitaPorMes);
+
+ const deltas = {
+ // alunos + inadimplentes sem delta honesto (sem snapshot histórico) — ver schema.
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
}
diff --git a/src/lib/definitions.test.ts b/src/lib/definitions.test.ts
index a87a8c33..95a13d1c 100644
--- a/src/lib/definitions.test.ts
+++ b/src/lib/definitions.test.ts
@@ -17,9 +17,7 @@ import {
MatriculaSchema,
PagamentoBaseSchema,
PagamentoSchema,
- GrowthDataSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
V_FrequenciaAlunosSchema,
} from './definitions';
@@ -40,8 +38,6 @@ import type {
Matricula,
PagamentoBase,
Pagamento,
- GrowthData,
- DashboardStats,
} from './definitions';
// --- Test Helpers ---
@@ -1004,138 +1000,54 @@ describe('PagamentoSchema', () => {
});
});
-// --- GrowthDataSchema ---
-
-describe('GrowthDataSchema', () => {
- it('accepts valid data', () => {
- const result = GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 120 });
- expect(result.mes).toBe('Janeiro');
- expect(result.alunos).toBe(120);
- });
-
- it('accepts zero alunos', () => {
- const result = GrowthDataSchema.parse({ mes: 'Fevereiro', alunos: 0 });
- expect(result.alunos).toBe(0);
- });
-
- it('rejects missing mes', () => {
- expect(() => GrowthDataSchema.parse({ alunos: 10 })).toThrow();
- });
-
- it('rejects missing alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro' })).toThrow();
- });
-
- it('rejects non-integer alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 1.5 })).toThrow();
- });
-
- it('rejects string alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 'abc' })).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('mes');
- expectTypeOf().toHaveProperty('alunos');
- });
-});
-
// --- DashboardStatsSchema ---
describe('DashboardStatsSchema', () => {
- it('accepts empty object (all fields have defaults)', () => {
- const result = DashboardStatsSchema.parse({});
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- });
-
- it('accepts full data', () => {
- const data = {
- totalAlunos: 150,
- matriculasAtivas: 120,
- alunosInadimplentes: 10,
- faturamentoMensal: 15000,
- crescimentoAnual: [
- { mes: 'Janeiro', alunos: 100 },
- { mes: 'Fevereiro', alunos: 110 },
- ],
- };
- const result = DashboardStatsSchema.parse(data);
- expect(result.totalAlunos).toBe(150);
- expect(result.crescimentoAnual).toHaveLength(2);
- });
-
- it('rejects non-integer totalAlunos', () => {
- expect(() => DashboardStatsSchema.parse({ totalAlunos: 1.5 })).toThrow();
- });
-
- it('rejects non-integer matriculasAtivas', () => {
- expect(() => DashboardStatsSchema.parse({ matriculasAtivas: 1.5 })).toThrow();
- });
-
- it('rejects non-integer alunosInadimplentes', () => {
- expect(() => DashboardStatsSchema.parse({ alunosInadimplentes: 1.5 })).toThrow();
- });
-
- it('rejects invalid crescimentoAnual entry', () => {
- expect(() =>
- DashboardStatsSchema.parse({
- crescimentoAnual: [{ mes: 'Janeiro' }],
- })
- ).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('totalAlunos');
- expectTypeOf().toHaveProperty('crescimentoAnual');
- });
-});
-
-// --- V_FaturamentoMensalSchema ---
-
-describe('V_FaturamentoMensalSchema', () => {
- it('accepts valid data', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: 50,
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
});
- expect(result.Mes).toBe('Janeiro');
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('coerces QtdPagamentos from string', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: '50',
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }],
});
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('rejects missing Mes', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ TotalRecebido: 15000, QtdPagamentos: 50 })
- ).toThrow();
- });
-
- it('rejects missing TotalRecebido', () => {
- expect(() => V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', QtdPagamentos: 50 })).toThrow();
- });
-
- it('rejects missing QtdPagamentos', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 15000 })
- ).toThrow();
- });
-
- it('rejects non-numeric TotalRecebido', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 'abc', QtdPagamentos: 50 })
- ).toThrow();
+ expect(withFake.success).toBe(false);
+ });
+
+ it('rejects unknown key even when all required fields present (.strict isolation)', () => {
+ const withFake = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }], // extra key .strict() must reject
+ });
+ expect(withFake.success).toBe(false);
+ });
+
+ it('parses valid payload without unknown keys (strict allows)', () => {
+ const result = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ });
+ expect(result.success).toBe(true);
});
});
diff --git a/src/lib/definitions.ts b/src/lib/definitions.ts
index 55dd1a61..2833df2e 100644
--- a/src/lib/definitions.ts
+++ b/src/lib/definitions.ts
@@ -209,29 +209,47 @@ export type Pagamento = z.infer;
// --- Schemas & Tipos: Dashboard & Views ---
-export const GrowthDataSchema = z.object({
+export const MonthTotalSchema = z.object({
mes: z.string(),
- alunos: z.number().int(),
+ total: z.number(),
});
-export type GrowthData = z.infer;
+export type MonthTotal = z.infer;
-export const DashboardStatsSchema = z.object({
- totalAlunos: z.number().int().default(0),
- matriculasAtivas: z.number().int().default(0),
- alunosInadimplentes: z.number().int().default(0),
- faturamentoMensal: z.number().default(0),
- crescimentoAnual: z.array(GrowthDataSchema).default([]),
+export const PlanTotalSchema = z.object({
+ plano: z.string(),
+ total: z.number(),
});
-export type DashboardStats = z.infer;
+export type PlanTotal = z.infer;
-export const V_FaturamentoMensalSchema = z.object({
- Mes: z.string(),
- TotalRecebido: z.number(),
- QtdPagamentos: z.coerce.number().int(),
+export const DashboardDeltasSchema = z.object({
+ // ponytail: alunos + inadimplentes deltas omitted — no historical snapshot table,
+ // so cumulative total / point-in-time count have no honest period-over-period.
+ // Add when a daily snapshot or prior-period count exists.
+ alunos: z.number().optional(),
+ receita: z.number().optional(),
+ inadimplentes: z.number().optional(),
+ novos: z.number().optional(),
});
+export type DashboardDeltas = z.infer;
+
+export const DashboardStatsSchema = z
+ .object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ receita: 0, novos: 0 }),
+ })
+ .strict();
+
+export type DashboardStats = z.infer;
+
export const V_FrequenciaAlunosSchema = z.object({
nomeCompleto: z.string(),
TotalTreinos: z.coerce.number().int(),
`
+
+- [ ] **Step 1: Change EmptyState title to h2**
+
+```tsx
+// src/app/dashboard/_components/empty-state.tsx line 19
+// OLD:
+{title}
+
+// NEW:
+{title}
+```
+
+- [ ] **Step 2: Add sr-only h2 before charts on overview page**
+
+```tsx
+// src/app/dashboard/page.tsx — after the KPI grid closing , before
+
+
+
+ Visão geral dos gráficos
+ `
+
+- [ ] **Step 1: Add aria-hidden**
+
+```tsx
+// src/app/dashboard/_components/kpi-card.tsx line ~47
+// OLD:
+{positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+// NEW:
+ {formatDelta(delta!)}
+```
+
+- [ ] **Step 2: Verify KpiCard tests**
+
+Run: `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+Expected: PASS
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx
+git commit -m "fix(a11y): aria-hidden on KpiCard delta triangle glyph — redundant with aria-label"
+```
+
+---
+
+### Task 8: Performance — window + group-by at DB for getReceitaPorMes + getMatriculasPorMes
+
+**Audit finding:** Important #7 + #8 (performance) — both functions unbounded all-time scan. `getMatriculasPorMes` fetches ALL alunos, `getReceitaPorMes` fetches ALL pagamentos (fastest-growing table). Add 13-month window.
+
+**Files:**
+- Modify: `src/lib/data.ts:117-132` — add `where: { dataCadastro/dataPagamento: { gte: thirteenMonthsAgo } }`
+
+**Interfaces:**
+- Consumes: `prisma.aluno.findMany`, `prisma.pagamento.findMany`
+- Produces: `MonthTotal[]` — same shape, now bounded to recent 13 months
+
+- [ ] **Step 1: Rewrite getMatriculasPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 117-120
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ // ponytail: Prisma groupBy can't group by date-trunc directly; we window to 13 months
+ // and group in JS. Upgrade to prisma.$queryRaw with DATE_TRUNC if row count grows.
+ const rows = await prisma.aluno.findMany({
+ where: { dataCadastro: { gte: thirteenMonthsAgo } },
+ select: { dataCadastro: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+```
+
+- [ ] **Step 2: Rewrite getReceitaPorMes with windowed query**
+
+```typescript
+// src/lib/data.ts — replace lines 122-132
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+```
+
+- [ ] **Step 3: Verify tests still pass**
+
+Existing tests use `vi.fn()` mocks which ignore arguments — no test change needed.
+
+Run: `npx vitest run src/lib/data.test.ts`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/lib/data.ts
+git commit -m "perf(dashboard): window getMatriculasPorMes + getReceitaPorMes to 13 months"
+```
+
+---
+
+## Execution order (dependency chain)
+
+```
+T1 (label fix) ──┐
+ ├── T3 (page test honest mock) ── depends on T1 label
+T2 (.default) ──┘
+ │
+T4 (.strict isolation test) — independent
+T5 (golden-path getDashboardStats test) — independent
+T6 (heading a11y) ── independent ── T7 (aria-hidden) — independent
+T8 (perf window) — independent (last, re-runs all data tests)
+```
+
+Parallel groups:
+- **Wave 1**: T1 + T2 (same file area, sequential — T1 touches page.tsx, T2 touches definitions.ts)
+- **Wave 2**: T3 (depends on T1)
+- **Wave 3**: T4 || T5 || T6 || T7 (all independent, parallel-safe)
+- **Wave 4**: T8 (independent, last — re-runs all data tests)
+
+## Post-execution verification
+
+After all 8 tasks complete:
+
+```bash
+npm test # Vitest — must stay ≥ 1176 (existing 1166 + ~10 new tests)
+npm run typecheck # tsc --noEmit — 0 errors
+npm run lint # eslint — 0 errors, 0 warnings
+```
+
+Update `docs/CURRENT-STATE.md` with audit-fix summary.
diff --git a/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
new file mode 100644
index 00000000..469f986b
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-09-gerente-dashboard-refactor.md
@@ -0,0 +1,892 @@
+# Gerente Dashboard Refactor Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Bring the GERENTE `/dashboard` (overview + alunos/financeiro/planos/treinos sub-pages + layout) up to the ALUNO dashboard quality bar — real Prisma data, honest empty-states, valid HTML, real loading/error states, no fake data.
+
+**Architecture:** RSC fetches via `src/lib/data.ts`, serializes, client renders only. Design tokens from `globals.css` `@theme` (cyan primary OKLCH, `glass-card`, `glow-cyan`). New KPI/chart components mirror ALUNO patterns (`card-treino.tsx` empty-state, `card.tsx` `glass` prop). Charts swap hardcoded `oklch()` literals → tokens + `role="img"` aria.
+
+**Tech Stack:** Next.js 15 App Router, React 18, TypeScript 5 strict, Prisma 7.7, recharts, Vitest 4 + @testing-library/react, Zod 4 (`zod/v4`).
+
+## Global Constraints
+
+- ZERO fake data — real Prisma queries only; where no rows exist, show honest `EmptyState` ("sem histórico ainda"), never fabricate (`ponytail:`/synthetic growth removed).
+- No DB migration — all queried fields already exist (`Aluno.dataCadastro`, `Pagamento.dataPagamento`+`valor`+`@@index([dataPagamento])`, `Matricula.status`+`planoId`+`Plano`, `Aluno.statusMatricula`).
+- Gamification NOT ported (ALUNO trophy room is static/fake; admin ≠ motivation).
+- TDD — write the failing test first, watch it fail, implement, green, before commit.
+- 4 gates must pass per commit: `npm test && npm run lint && npm run typecheck` (and `npm run e2e` at end).
+- Tokens over raw values: `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`, `border-white/5` (NOT `bg-black`, `#18181B`, `text-zinc-400`, raw `shadow-[...]`).
+
+---
+
+## File Structure
+
+**Create**
+- `src/app/dashboard/_components/kpi-card.tsx` — `Card glass` + delta badge + icon+text + `aria-label` + `data-testid`.
+- `src/app/dashboard/_components/empty-state.tsx` — port ALUNO empty-state pattern (icon + honest copy. `card-treino.tsx:40-57`).
+- `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts.
+- `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message + retry (`reset()`).
+- `src/components/dashboard/dashboard-charts-multi.tsx` — real multi-series chart (growth + revenue + plan) with `role="img"`/aria + empty-state. Replaces single hardcoded chart.
+- Tests: `kpi-card.test.tsx`, `empty-state.test.tsx`, `data.test.ts`, `loading.test.tsx`, `error.test.tsx`.
+
+**Modify**
+- `src/lib/data.ts` — remove synthetic `crescimentoAnual` (138-142); add `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano`, KPI deltas; re-throw on error (stop `parse({})` default at 153).
+- `src/lib/definitions.ts` — extend `DashboardStatsSchema` (drop `crescimentoAnual`, add real-series + delta fields).
+- `src/app/dashboard/page.tsx` — KPI grid via `KpiCard`, real charts.
+- `src/app/dashboard/layout.tsx` — remove double `` (line 120), move `pb-20` to inner wrapper.
+- `src/components/dashboard/dashboard-charts.tsx` — swap hardcoded oklch → tokens + `role="img"`/aria + empty-state (or replace via multi chart).
+- `src/app/dashboard/alunos/page.tsx` — tokenize `bg-black`, add `pb-20`.
+- `src/app/dashboard/financeiro/page.tsx` — tokenize `bg-black`/`#18181B`/`text-zinc-400`/raw-shadow, add `pb-20`.
+- `src/app/dashboard/planos/page.tsx` — wrap with `pb-20` container.
+- `src/app/dashboard/treinos/page.tsx` — add `pb-20` + Suspense/skeleton.
+- `src/components/ui/dashboard-skeletons.tsx` — add `DashboardOverviewSkeleton`.
+- `src/app/dashboard/page.test.tsx` — update to new KPI/delta assertions.
+
+---
+
+## Task 1: Remove synthetic growth + add real series types
+
+**Files:**
+- Modify: `src/lib/definitions.ts:212-227`
+- Test: `src/lib/definitions.test.ts`
+
+**Interfaces:**
+- Produces: `DashboardStatsSchema` shape with `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano`, `deltas` (no `crescimentoAnual`).
+
+- [ ] **Step 1: Write failing test**
+
+```ts
+import { describe, it, expect } from 'vitest';
+import { DashboardStatsSchema } from './definitions';
+
+describe('DashboardStatsSchema', () => {
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ });
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }] });
+ expect(withFake.success).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: FAIL (`crescimentoAnual` still valid; `matriculasPorMes`/`deltas` missing).
+
+- [ ] **Step 3: Write minimal implementation** — replace `DashboardStatsSchema` block (212-227):
+
+```ts
+export const MonthTotalSchema = z.object({ mes: z.string(), total: z.number() });
+export const PlanTotalSchema = z.object({ plano: z.string(), total: z.number() });
+export const DashboardDeltasSchema = z.object({
+ alunos: z.number(),
+ receita: z.number(),
+ inadimplentes: z.number(),
+ novos: z.number(),
+});
+
+export const DashboardStatsSchema = z.object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ alunos: 0, receita: 0, inadimplentes: 0, novos: 0 }),
+});
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/definitions.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/definitions.ts src/lib/definitions.test.ts
+git commit -m "feat(dashboard): replace synthetic crescimentoAnual with real series + delta schema"
+```
+
+---
+
+## Task 2: Real Prisma queries in `getDashboardStats` + re-throw
+
+**Files:**
+- Modify: `src/lib/data.ts:105-155`
+- Test: `src/lib/data.test.ts`
+
+**Interfaces:**
+- Consumes: Prisma models (`aluno`, `matricula`, `pagamento`, `plano`).
+- Produces: `getDashboardStats()` returning `DashboardStats` with real series + `deltas`; `getMatriculasPorMes`, `getReceitaPorMes`, `getMatriculasPorPlano` returning `[]` when empty; throws on DB failure (no swallowing).
+
+- [ ] **Step 1: Write failing test** — `src/lib/data.test.ts`:
+
+```ts
+import { describe, it, expect, vi } from 'vitest';
+import { getDashboardStats } from './data';
+import { getMatriculasPorMes, getReceitaPorMes, getMatriculasPorPlano } from './data';
+
+describe('getDashboardStats', () => {
+ it('returns empty series (not fake) when no rows', async () => {
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect(stats.crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+});
+
+describe('series helpers', () => {
+ it('getMatriculasPorMes returns [] when no alunos', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/lib/data.test.ts`
+ Expected: FAIL (old code returns `crescimentoAnual`, swallows errors).
+
+- [ ] **Step 3: Write minimal implementation** — replace `getDashboardStats` and add helpers:
+
+```ts
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
+
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const rows = await prisma.aluno.findMany({ select: { dataCadastro: true } });
+ return groupByMonth(rows.map((r) => ({ date: r.dataCadastro })));
+}
+
+export async function getReceitaPorMes() {
+ const rows = await prisma.pagamento.findMany({ select: { dataPagamento: true, valor: true } });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
+ }
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number) {
+ if (prev === 0) return curr === 0 ? 0 : 1;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [totalAlunos, matriculasAtivas, alunosInadimplentes, faturamentoMensal, matriculasPorMes, receitaPorMes, matriculasPorPlano] =
+ await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ prisma.pagamento.aggregate({ _sum: { valor: true } }).then((r) => r._sum.valor ?? 0),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ const deltas = {
+ alunos: pctDelta(matriculasPorMes.length ? totalAlunos : 0, prev(matriculasPorMes)),
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ inadimplentes: pctDelta(alunosInadimplentes, alunosInadimplentes),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
+}
+```
+
+> Note: remove the try/catch that returned `DashboardStatsSchema.parse({})` at line 151-154 and the synthetic `crescimentoAnual` block (136-142). Errors propagate so `error.tsx` catches them.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/lib/data.test.ts`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/data.ts src/lib/data.test.ts
+git commit -m "feat(dashboard): real Prisma series + KPI deltas, re-throw on DB error"
+```
+
+---
+
+## Task 3: `KpiCard` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/kpi-card.tsx`
+- Test: `src/app/dashboard/_components/kpi-card.test.tsx`
+
+**Interfaces:**
+- Produces: `KpiCard` with props `{ title, value, delta?, icon }`. Renders ``, delta badge (green `+12%` / red `-5%`), text not color-only, `data-testid="kpi-{title}"`.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta with text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: FAIL (file missing).
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import type { ReactNode } from 'react';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number) {
+ const pct = Math.round(delta * 100);
+ return `${pct >= 0 ? '+' : ''}${pct}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = typeof delta === 'number';
+ const positive = hasDelta && delta >= 0;
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {positive ? '▲' : '▼'} {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/kpi-card.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/kpi-card.tsx src/app/dashboard/_components/kpi-card.test.tsx
+git commit -m "feat(dashboard): KpiCard with delta badge + aria-label"
+```
+
+---
+
+## Task 4: `EmptyState` component + test
+
+**Files:**
+- Create: `src/app/dashboard/_components/empty-state.tsx`
+- Test: `src/app/dashboard/_components/empty-state.test.tsx`
+
+**Interfaces:**
+- Produces: `EmptyState` `{ icon, title, description, testId? }` — ported from `card-treino.tsx:40-57` (dashed `glass` Card, centered icon, honest copy).
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render( } title="Sem histórico ainda" description="Sem dados para exibir." testId="chart-empty" />);
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+
+```tsx
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/_components/empty-state.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/_components/empty-state.tsx src/app/dashboard/_components/empty-state.test.tsx
+git commit -m "feat(dashboard): EmptyState for honest no-data rendering"
+```
+
+---
+
+## Task 5: Multi-series chart (real data, tokenized, a11y)
+
+**Files:**
+- Create: `src/components/dashboard/dashboard-charts-multi.tsx`
+- Test: `src/components/dashboard/dashboard-charts-multi.test.tsx`
+
+**Interfaces:**
+- Consumes: `matriculasPorMes`, `receitaPorMes`, `matriculasPorPlano` from `getDashboardStats`.
+- Produces: chart trio (growth bars, revenue bars, plan dist) using tokens; `role="img"` + `aria-label`; `EmptyState` when all series empty.
+
+- [ ] **Step 1: Write failing test**
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+import { CalendarOff } from 'lucide-react';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation** — tokenize colors, drop hardcoded `oklch()`, add `role="img"` + `aria-label`, empty-state branch:
+
+```tsx
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({ matriculasPorMes, receitaPorMes, matriculasPorPlano }: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* revenue chart: same Card/BarChart shape, data=receitaPorMes, aria-label="Gráfico de faturamento por mês" */}
+ {/* plan chart: data=matriculasPorPlano (dataKey="total", XAxis dataKey="plano"), aria-label="Distribuição de matrículas por plano" */}
+
+ );
+}
+```
+
+> ponytail: revenue + plan charts repeat the same Card/BarChart shape with different `data`/`aria-label`; both use tokens. Fill tracks theme via `var(--color-primary)` / `var(--color-gold)`.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/components/dashboard/dashboard-charts-multi.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/components/dashboard/dashboard-charts-multi.tsx src/components/dashboard/dashboard-charts-multi.test.tsx
+git commit -m "feat(dashboard): tokenized multi-series charts with a11y + empty-state"
+```
+
+---
+
+## Task 6: Overview page wiring (KPI grid + charts)
+
+**Files:**
+- Modify: `src/app/dashboard/page.tsx`
+- Test: `src/app/dashboard/page.test.tsx` (update)
+
+**Interfaces:**
+- Consumes: `getDashboardStats`, `KpiCard`, `DashboardChartsMulti`.
+
+- [ ] **Step 1: Write/Update failing test** — replace `page.test.tsx` mock + add KPI assertions:
+
+```tsx
+vi.mock('@/lib/data', () => ({
+ getDashboardStats: vi.fn().mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
+}));
+// assert
+expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+expect(screen.getByText('+10%')).toBeTruthy();
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: FAIL (old mock has `crescimentoAnual`; no KpiCard).
+
+- [ ] **Step 3: Write implementation** — rewrite `page.tsx`:
+
+```tsx
+import { PageHeader } from '@/components/page-header';
+import { getDashboardStats } from '@/lib/data';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
+
+export default async function DashboardPage() {
+ const stats = await getDashboardStats();
+
+ const kpis = [
+ { title: 'Total de Alunos', value: stats.totalAlunos.toLocaleString('pt-BR'), delta: stats.deltas.alunos, icon: },
+ { title: 'Matrículas Ativas', value: stats.matriculasAtivas.toLocaleString('pt-BR'), delta: stats.deltas.novos, icon: },
+ { title: 'Inadimplentes', value: stats.alunosInadimplentes.toLocaleString('pt-BR'), delta: stats.deltas.inadimplentes, icon: },
+ { title: 'Faturamento Mensal', value: stats.faturamentoMensal.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), delta: stats.deltas.receita, icon: },
+ ];
+
+ return (
+
+
+
+ {kpis.map((kpi) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/page.tsx src/app/dashboard/page.test.tsx
+git commit -m "feat(dashboard): wire overview KPI grid + real charts"
+```
+
+---
+
+## Task 7: Layout double-`` fix + `pb-20`
+
+**Files:**
+- Modify: `src/app/dashboard/layout.tsx:118-123`
+
+**Interfaces:**
+- RISKY: removing wrong `` breaks layout/SR. Keep `SidebarInset`'s `` (`sidebar.tsx:311`), drop `layout.tsx:120`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/layout.test.tsx`:
+
+```tsx
+import { describe, it, expect } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: { getUser: async () => ({ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } }, error: null }) },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', () => {
+ const { container } = render({child} );
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: FAIL (2 `` currently).
+
+- [ ] **Step 3: Write implementation** — replace lines 118-123:
+
+```tsx
+
+
+
+ {children}
+
+
+```
+
+> Removed `` wrapper; `pb-20` now on inner `div` so mobile clears bottom-nav. `SidebarInset` still renders its own ``.
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/layout.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/layout.tsx src/app/dashboard/layout.test.tsx
+git commit -m "fix(dashboard): single landmark, move pb-20 to inner wrapper"
+```
+
+---
+
+## Task 8: Tokenize sub-pages (alunos / financeiro / planos / treinos)
+
+**Files:**
+- Modify: `src/app/dashboard/alunos/page.tsx:19`, `financeiro/page.tsx:42-54`, `planos/page.tsx` (wrapper), `treinos/page.tsx`
+
+**Interfaces:**
+- Consumes: tokens `bg-background`, `text-foreground`, `text-muted-foreground`, `glass-card`, `glow-cyan`.
+
+- [ ] **Step 1: Write failing test** — `src/app/dashboard/alunos/page.test.tsx` + `financeiro/page.test.tsx`: assert no `bg-black`/`#18181B`/`text-zinc-400` in rendered HTML, and `pb-20` present.
+
+```tsx
+it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await AlunosPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write minimal implementation**
+- `alunos/page.tsx:19`: `` → ``
+- `financeiro/page.tsx:42`: `bg-black` → `bg-background`; `:47` Card `bg-[#18181B] border-white/10 ... shadow-[0_0_15px_rgba(34,211,238,0.05)]` → `glass-card border-white/10 glow-cyan`; `:49` `text-white` → `text-foreground`; `:52` `text-zinc-400` → `text-muted-foreground`; add `pb-20` to wrapper.
+- `planos/page.tsx`: wrap return in `…` (keep existing Suspense).
+- `treinos/page.tsx`: wrap `` in `` and add `}>` (reuse `PlanosSkeleton` from planos page or a simple `Skeleton`).
+
+- [ ] **Step 4: Run test to verify it passes** — `npx vitest run src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/alunos/page.tsx src/app/dashboard/financeiro/page.tsx src/app/dashboard/planos/page.tsx src/app/dashboard/treinos/page.tsx src/app/dashboard/alunos/page.test.tsx src/app/dashboard/financeiro/page.test.tsx
+git commit -m "feat(dashboard): tokenize sub-pages, add pb-20, Suspense on treinos"
+```
+
+---
+
+## Task 9: Loading + Error states
+
+**Files:**
+- Create: `src/app/dashboard/loading.tsx`, `src/app/dashboard/error.tsx`
+- Modify: `src/components/ui/dashboard-skeletons.tsx` (add `DashboardOverviewSkeleton`)
+- Test: `src/app/dashboard/loading.test.tsx`, `src/app/dashboard/error.test.tsx`
+
+**Interfaces:**
+- Produces: `loading.tsx` renders `DashboardOverviewSkeleton`; `error.tsx` `reset()` retry button.
+
+- [ ] **Step 1: Write failing tests**
+
+```tsx
+// loading.test.tsx
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
+
+// error.test.tsx
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
+```
+
+- [ ] **Step 2: Run tests to verify they fail** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: FAIL.
+
+- [ ] **Step 3: Write implementation**
+- `dashboard-skeletons.tsx`: add
+
+```tsx
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
+```
+
+- `loading.tsx`:
+
+```tsx
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
+```
+
+- `error.tsx`:
+
+```tsx
+'use client';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ return (
+
+
+ Não foi possível carregar o dashboard
+ {error.message}
+
+
+ );
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass** — `npx vitest run src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx`
+ Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/app/dashboard/loading.tsx src/app/dashboard/error.tsx src/app/dashboard/loading.test.tsx src/app/dashboard/error.test.tsx src/components/ui/dashboard-skeletons.tsx
+git commit -m "feat(dashboard): loading + error boundaries with retry"
+```
+
+---
+
+## Task 10: Final gates + delete legacy chart
+
+**Files:**
+- Delete: `src/components/dashboard/dashboard-charts.tsx` (replaced by `dashboard-charts-multi.tsx`)
+- Run: 4 gates.
+
+- [ ] **Step 1: Confirm no importer of legacy chart** — `grep -rn "dashboard-charts'" src` → only `page.tsx` (already rewired in Task 6). Delete file.
+
+- [ ] **Step 2: Run full gates**
+
+```bash
+npm test && npm run lint && npm run typecheck
+```
+
+Expected: all green.
+
+- [ ] **Step 3: Run E2E (staging env)**
+
+```bash
+npm run e2e
+```
+
+Expected: green (or pre-existing unrelated failures documented).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "chore(dashboard): remove legacy hardcoded chart, finalize refactor"
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:**
+- §1 data layer (remove synthetic, re-throw, real queries + deltas) → Tasks 1-2. ✓
+- §2 layout (KPI grid, charts, KpiCard, EmptyState, double-``, token sub-pages, treinos Suspense) → Tasks 3-8. ✓
+- §3 states (loading, error, empty) + tests (TDD) → Tasks 3-4, 9. ✓
+- Risky items: double-`` (Task 7, rollback = revert single line), silent-default removal (Task 2 re-throw + Task 9 error.tsx). ✓
+
+**2. Placeholder scan:** No TBD/TODO. All code steps show actual code. Task 5 leaves revenue/plan chart repetition noted via `ponytail:` comment (intentional, not a placeholder). Mock helper in Task 7 noted as fallback, not a gap.
+
+**3. Type consistency:** `MonthTotal`/`PlanTotal` defined in Task 1 (`definitions.ts`), consumed in Tasks 2 (`getMatriculasPorMes` etc return `MonthTotal[]`) and 5 (`DashboardChartsMulti` props). `DashboardStats` shape consistent across Tasks 1-2-6. `KpiCard`/`EmptyState` props stable Tasks 3-4-6-9. ✓
+
+**Reduced-motion:** Verified `globals.css:190-207` already applies `*` reduced-motion globally (covers `animate-glow-pulse` + `animate-float`) — spec item already satisfied; no new code needed.
diff --git a/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
new file mode 100644
index 00000000..047c4747
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-gerente-dashboard-refactor-design.md
@@ -0,0 +1,129 @@
+# Gerente Dashboard Refactor — Design Spec
+
+**Date:** 2026-07-09
+**Branch:** `feat/gerente-dashboard-refactor`
+**Author:** brainstorming session (gap-audit workflow `wf_19ad3022-548`)
+
+## Goal
+
+Bring the GERENTE dashboard (`/dashboard` + sub-pages) up to the quality bar of the
+ALUNO dashboard (`/aluno/dashboard`). The ALUNO page has cohesive design tokens, real
+loading/empty/error states, and solid a11y; the GERENTE page falls short: theme
+fracture, fake data, silent failures, invalid HTML, no state handling.
+
+## Scope (approved)
+
+- **Pages:** overview + all sub-pages (alunos, financeiro, planos, treinos) + nav/layout.
+- **Fake data:** ZERO. Replace synthetic growth chart with **real Prisma queries**;
+ where no data exists, show an honest empty-state (never fabricate).
+- **Depth:** full P0→P3.
+- **Gamification:** NOT ported (ALUNO's trophy room is static/fake; admin ≠ motivation).
+- **Tests:** TDD — write test first, watch fail, implement, green.
+
+## Non-goals (YAGNI)
+
+- No DB migration (queries only — all needed fields already exist).
+- No monorepo/component extraction.
+- No gamification.
+
+## Schema facts (verified `prisma/schema.prisma`)
+
+Historical data available — no migration needed:
+- `Aluno.dataCadastro DateTime @default(now())` → enrollments per month (real growth).
+- `Pagamento.dataPagamento DateTime` + `valor Float` + `@@index([dataPagamento])` → revenue per month.
+- `Matricula.status` + `planoId` + `Plano` → enrollments by plan.
+- `Aluno.statusMatricula StatusAluno` → real inadimplentes KPI.
+
+---
+
+## Section 1 — Architecture & data layer
+
+Mirror the ALUNO pattern: RSC fetches + serializes, client renders only; design-system
+tokens; real states; zero fake data.
+
+**`src/lib/data.ts`:**
+- Remove synthetic `crescimentoAnual` (lines 138-142).
+- `getDashboardStats()` stops swallowing errors → propagate (let `error.tsx` catch).
+- New real queries (each returns `[]` when empty → feeds honest empty-state):
+ - `getMatriculasPorMes()` — group `Aluno.dataCadastro` last 6-12 months → real growth.
+ - `getReceitaPorMes()` — group `Pagamento.dataPagamento`, sum `valor` → revenue trend.
+ - `getMatriculasPorPlano()` — count `Matricula` by `planoId` (status ATIVA) → distribution.
+ - KPI deltas — count current month vs previous month (active alunos, revenue, inadimplentes, new).
+
+---
+
+## Section 2 — Layout & components
+
+**Overview (`/dashboard/page.tsx`)** — grid mirroring ALUNO 8/4:
+- **KPI grid** (top): 4 `Card glass` (not divs). Each: icon + label + value + **trend delta**
+ (`+12%` green / `-5%` red vs prev month) + full `aria-label`. Inadimplentes uses icon+text
+ (not color-only).
+- **Main grid** `lg:grid-cols-12 gap-8`:
+ - Left `col-span-8`: enrollment-growth chart (real) + revenue-per-month chart (real).
+ - Right `col-span-4`: enrollments-by-plan (real) + recent-activity card (real recent payments/signups).
+
+**New/reused components:**
+- `KpiCard` (`_components/kpi-card.tsx`) — `Card glass` + delta + icon+text + aria + `data-testid`.
+- `dashboard-charts.tsx` — replace hardcoded oklch (lines 27-69) with tokens
+ (`--color-primary`/cyan); add `role="img"` + `aria-label`; support empty-state.
+- `EmptyState` (`_components/empty-state.tsx`) — port ALUNO "Dia de Descanso" pattern
+ (`card-treino.tsx:40-57`): icon + honest copy "sem histórico ainda". Reused across all
+ charts/lists without data.
+
+**Layout shell (`layout.tsx`):**
+- Fix **double ``**: remove `` at `layout.tsx:120`, keep only `SidebarInset`'s
+ (`sidebar.tsx:311`). Move `pb-20` to inner wrapper.
+- Reduced-motion: copy `globals.css:198-207` block to cover `animate-glow-pulse` + active-bar.
+
+**Sub-pages (alunos/financeiro/planos/treinos):**
+- Replace `bg-black`/`#18181B`/`text-zinc-400`/`raw-shadow` → tokens
+ (`bg-background`/`text-foreground`/`glass-card`/`glow-cyan`). Re-check contrast (no blind-copy).
+- Add `pb-20` to all (mobile clears bottom-nav).
+- `treinos`: add Suspense + skeleton (only one missing it).
+- Extend `dashboard-skeletons.tsx` to overview (KPI grid + charts).
+
+---
+
+## Section 3 — States, error handling, tests, phases
+
+**States (ALUNO pattern):**
+- **Loading:** new `src/app/dashboard/loading.tsx` — skeleton KPI grid + charts (streaming
+ shell). Also `treinos/loading.tsx`.
+- **Error:** new `src/app/dashboard/error.tsx` — catches `getDashboardStats` throw; message
+ + retry button (`reset()`). No silent zero-KPIs.
+- **Empty:** each data-less chart/list → honest `EmptyState`. No fake.
+
+**Error handling (data layer):** try/catch for structured log only, then **re-throw** (let
+`error.tsx` render). No masking defaults.
+
+**Tests (TDD — test first):**
+- New: `kpi-card.test.tsx` (delta +/- render, aria-label, color+text), `empty-state.test.tsx`,
+ `data.test.ts` (real queries: empty month→`[]`, delta calc, re-throw on error),
+ `loading`/`error` render tests.
+- Update broken existing: `page.test.tsx`, `dashboard-bottom-nav.test.tsx`, `user-menu.test.tsx`,
+ sub-page tests.
+- Gate: `npm test && npm run lint && npm run typecheck` green before each commit.
+
+**Phase order (dependency-first):**
+1. **P0 quick wins** (no dep): tokens on sub-pages, `pb-20`, reduced-motion block,
+ double-`` fix. `[RISKY]` double-main → rollback = revert 1 line.
+2. **P2-data**: new real Prisma queries + deltas + re-throw (`lib/data.ts`). `[RISKY]` DB
+ read only (no migration). TDD.
+3. **P1 components**: `KpiCard`, `EmptyState`, `loading.tsx`, `error.tsx`, skeletons. TDD.
+4. **P2-charts**: real charts (growth/revenue/plan) + `role="img"`/aria + empty-state;
+ swap oklch→token.
+5. **P3 polish**: activity feed, KPI hover spring (`motion.div` scale 1.02), `data-testid`,
+ unify sub-page caching (explicit force-dynamic/revalidate).
+
+**Risks:**
+- `[RISK]` double-`` fix — removing wrong one breaks layout/SR. Keep `sidebar.tsx`'s,
+ drop `layout.tsx:120`'s. Rollback: revert single line.
+- `[RISK]` real queries — if no historical rows, must show "sem histórico" empty-state, not
+ fake. Verify empty→`[]`→EmptyState path in tests.
+- `[RISK]` silent-default removal — surfacing errors mid-rollout may expose partial failures;
+ pair `error.tsx` with retry.
+- `[RISK]` token swap on `bg-black` pages — re-check contrast after switching to
+ `bg-background`/`text-foreground`.
+
+**Final verification:** 4 gates + `/verify` real drive (`npm run dev`, check overview +
+sub-pages on mobile/desktop).
diff --git a/src/app/dashboard/_components/empty-state.test.tsx b/src/app/dashboard/_components/empty-state.test.tsx
new file mode 100644
index 00000000..833c6bff
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.test.tsx
@@ -0,0 +1,19 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { EmptyState } from './empty-state';
+import { CalendarOff } from 'lucide-react';
+
+describe('EmptyState', () => {
+ it('renders honest copy for empty dataset', () => {
+ render(
+ }
+ title="Sem histórico ainda"
+ description="Sem dados para exibir."
+ testId="chart-empty"
+ />
+ );
+ expect(screen.getByTestId('chart-empty')).toBeTruthy();
+ expect(screen.getByText('Sem histórico ainda')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/_components/empty-state.tsx b/src/app/dashboard/_components/empty-state.tsx
new file mode 100644
index 00000000..b77021e2
--- /dev/null
+++ b/src/app/dashboard/_components/empty-state.tsx
@@ -0,0 +1,25 @@
+import { Card, CardContent } from '@/components/ui/card';
+import type { ReactNode } from 'react';
+
+interface EmptyStateProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ testId?: string;
+}
+
+export function EmptyState({ icon, title, description, testId }: Readonly) {
+ return (
+
+
+
+ {icon}
+
+
+ {title}
+ {description}
+
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/kpi-card.test.tsx b/src/app/dashboard/_components/kpi-card.test.tsx
new file mode 100644
index 00000000..7aa54b1f
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.test.tsx
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { KpiCard } from './kpi-card';
+import { Users } from 'lucide-react';
+
+describe('KpiCard', () => {
+ it('renders value + delta text, not color-only', () => {
+ render( } />);
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('150')).toBeTruthy();
+ expect(screen.getByText('+12%')).toBeTruthy();
+ });
+
+ it('renders negative delta', () => {
+ render( } />);
+ expect(screen.getByText('-5%')).toBeTruthy();
+ });
+
+ it('omits delta badge when undefined', () => {
+ render( } />);
+ expect(screen.queryByText(/%/)).toBeNull();
+ });
+
+ it('omits delta badge when delta === 0 (neutral, not positive)', () => {
+ render( } />);
+ const card = screen.getByTestId('kpi-Sem mudança');
+ expect(card).toBeTruthy();
+ expect(card.textContent).not.toMatch(/%/);
+ });
+});
diff --git a/src/app/dashboard/_components/kpi-card.tsx b/src/app/dashboard/_components/kpi-card.tsx
new file mode 100644
index 00000000..9e6adf27
--- /dev/null
+++ b/src/app/dashboard/_components/kpi-card.tsx
@@ -0,0 +1,54 @@
+import type { ReactNode } from 'react';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+
+interface KpiCardProps {
+ title: string;
+ value: string;
+ delta?: number;
+ icon: ReactNode;
+}
+
+function formatDelta(delta: number): string {
+ const sign = delta >= 0 ? '+' : '';
+ return `${sign}${Math.round(delta * 100)}%`;
+}
+
+export function KpiCard({ title, value, delta, icon }: Readonly) {
+ const hasDelta = delta !== undefined && delta !== 0;
+ const positive = hasDelta && delta! >= 0;
+
+ return (
+
+
+
+ {title}
+
+
+ {icon}
+
+
+
+
+ {value}
+
+ {hasDelta && (
+
+ {' '}
+ {formatDelta(delta!)}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/dashboard/_components/user-menu.test.tsx b/src/app/dashboard/_components/user-menu.test.tsx
index d8c139e3..c61fbec6 100644
--- a/src/app/dashboard/_components/user-menu.test.tsx
+++ b/src/app/dashboard/_components/user-menu.test.tsx
@@ -30,6 +30,12 @@ vi.mock('@/app/actions/auth', () => ({
logout: vi.fn(),
}));
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
describe('UserMenu', () => {
it('renders user display name', () => {
render( );
diff --git a/src/app/dashboard/_components/user-menu.tsx b/src/app/dashboard/_components/user-menu.tsx
index ef5dae24..7b020112 100644
--- a/src/app/dashboard/_components/user-menu.tsx
+++ b/src/app/dashboard/_components/user-menu.tsx
@@ -1,5 +1,6 @@
'use client';
+import { useRouter } from 'next/navigation';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
@@ -10,7 +11,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
-import { LogOut } from 'lucide-react';
+import { LogOut, User, Settings } from 'lucide-react';
import { logout } from '@/app/actions/auth';
interface UserMenuProps {
@@ -20,6 +21,8 @@ interface UserMenuProps {
}
export function UserMenu({ displayName, email, photoURL }: Readonly) {
+ const router = useRouter();
+
return (
@@ -43,10 +46,18 @@ export function UserMenu({ displayName, email, photoURL }: Readonly
-
+ router.push('/dashboard/perfil')}
+ >
+
Perfil
-
+ router.push('/dashboard/configuracoes')}
+ >
+
Configurações
diff --git a/src/app/dashboard/alunos/page.test.tsx b/src/app/dashboard/alunos/page.test.tsx
index e44b3f0b..2bb266e6 100644
--- a/src/app/dashboard/alunos/page.test.tsx
+++ b/src/app/dashboard/alunos/page.test.tsx
@@ -29,4 +29,11 @@ describe('AlunosPage', () => {
render( );
expect(screen.getByTestId('table-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', () => {
+ const { container } = render( );
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/alunos/page.tsx b/src/app/dashboard/alunos/page.tsx
index aaddd6b3..53df0ee1 100644
--- a/src/app/dashboard/alunos/page.tsx
+++ b/src/app/dashboard/alunos/page.tsx
@@ -16,7 +16,7 @@ async function AlunosDataWrapper() {
// 2. Wrap the data component in a Suspense boundary with the Premium Skeleton
export default function AlunosPage() {
return (
-
+
}>
diff --git a/src/app/dashboard/configuracoes/page.tsx b/src/app/dashboard/configuracoes/page.tsx
new file mode 100644
index 00000000..27507195
--- /dev/null
+++ b/src/app/dashboard/configuracoes/page.tsx
@@ -0,0 +1,27 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function ConfiguracoesPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+ Esta seção disponibilará tema, idioma e notificações.
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/error.test.tsx b/src/app/dashboard/error.test.tsx
new file mode 100644
index 00000000..957c24de
--- /dev/null
+++ b/src/app/dashboard/error.test.tsx
@@ -0,0 +1,11 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardError from './error';
+describe('DashboardError', () => {
+ it('shows retry button calling reset()', () => {
+ const reset = vi.fn();
+ render( );
+ screen.getByRole('button', { name: /tentar novamente/i }).click();
+ expect(reset).toHaveBeenCalled();
+ });
+});
diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx
new file mode 100644
index 00000000..461d9272
--- /dev/null
+++ b/src/app/dashboard/error.tsx
@@ -0,0 +1,24 @@
+'use client';
+import { useEffect } from 'react';
+import { Button } from '@/components/ui/button';
+import { AlertTriangle } from 'lucide-react';
+import * as Sentry from '@sentry/nextjs';
+import { Logger } from '@/lib/logger';
+
+export default function DashboardError({ error, reset }: { error: Error; reset: () => void }) {
+ useEffect(() => {
+ Sentry.captureException(error);
+ Logger.error('DashboardError boundary caught error', error);
+ }, [error]);
+
+ return (
+
+
+ Não foi possível carregar o dashboard
+
+ Ocorreu um erro inesperado ao carregar os dados. Tente novamente.
+
+
+
+ );
+}
diff --git a/src/app/dashboard/financeiro/page.test.tsx b/src/app/dashboard/financeiro/page.test.tsx
index 0a26f856..175b0460 100644
--- a/src/app/dashboard/financeiro/page.test.tsx
+++ b/src/app/dashboard/financeiro/page.test.tsx
@@ -77,4 +77,13 @@ describe('FinanceiroPage', () => {
render(await FinanceiroPage());
expect(screen.getByTestId('premium-skeleton')).toBeTruthy();
});
+
+ it('uses tokens, not bg-black, and clears bottom nav', async () => {
+ const { container } = render(await FinanceiroPage());
+ const html = container.innerHTML;
+ expect(html).not.toContain('bg-black');
+ expect(html).not.toContain('#18181B');
+ expect(html).not.toContain('text-zinc-400');
+ expect(html).toContain('pb-20');
+ });
});
diff --git a/src/app/dashboard/financeiro/page.tsx b/src/app/dashboard/financeiro/page.tsx
index 98a0e0b3..35b27676 100644
--- a/src/app/dashboard/financeiro/page.tsx
+++ b/src/app/dashboard/financeiro/page.tsx
@@ -39,17 +39,17 @@ export default async function FinanceiroPage() {
await requireRole(Role.GERENTE);
return (
-
+
-
+
-
+
Alunos Inadimplentes
-
+
Lista de alunos com pagamentos pendentes. Registre um pagamento para reativar a
matrícula e estender o vencimento em 30 dias.
diff --git a/src/app/dashboard/layout.test.tsx b/src/app/dashboard/layout.test.tsx
new file mode 100644
index 00000000..e6fba9dc
--- /dev/null
+++ b/src/app/dashboard/layout.test.tsx
@@ -0,0 +1,43 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render } from '@testing-library/react';
+import DashboardLayout from './layout';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => '/dashboard',
+ useRouter: () => ({ push: vi.fn() }),
+ redirect: vi.fn(),
+}));
+
+Object.defineProperty(globalThis, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+vi.mock('@/utils/supabase/server', () => ({
+ createClient: async () => ({
+ auth: {
+ getUser: async () => ({
+ data: { user: { id: 'x', email: 'a@b.c', user_metadata: {} } },
+ error: null,
+ }),
+ },
+ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null }) }) }) }),
+ }),
+}));
+
+describe('DashboardLayout', () => {
+ it('renders exactly one landmark', async () => {
+ const LayoutContent = await DashboardLayout({ children: child });
+ const { container } = render(LayoutContent);
+ expect(container.querySelectorAll('main')).toHaveLength(1);
+ });
+});
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index c32641ed..cc854d91 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -117,9 +117,9 @@ export default async function DashboardLayout({
-
+
{children}
-
+
{/* ponytail: nav outside (SidebarInset renders ) — avoids nesting nav landmark inside main, matches aluno layout */}
diff --git a/src/app/dashboard/loading.test.tsx b/src/app/dashboard/loading.test.tsx
new file mode 100644
index 00000000..76757bff
--- /dev/null
+++ b/src/app/dashboard/loading.test.tsx
@@ -0,0 +1,9 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import DashboardLoading from './loading';
+describe('DashboardLoading', () => {
+ it('renders overview skeleton', () => {
+ render( );
+ expect(screen.getByTestId('overview-skeleton')).toBeTruthy();
+ });
+});
diff --git a/src/app/dashboard/loading.tsx b/src/app/dashboard/loading.tsx
new file mode 100644
index 00000000..5077f7a3
--- /dev/null
+++ b/src/app/dashboard/loading.tsx
@@ -0,0 +1,4 @@
+import { DashboardOverviewSkeleton } from '@/components/ui/dashboard-skeletons';
+export default function DashboardLoading() {
+ return ;
+}
diff --git a/src/app/dashboard/page.test.tsx b/src/app/dashboard/page.test.tsx
index 7e9e240d..ed3a08de 100644
--- a/src/app/dashboard/page.test.tsx
+++ b/src/app/dashboard/page.test.tsx
@@ -1,8 +1,8 @@
-import { describe, it, expect, vi } from 'vitest';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import DashboardPage from './page';
+import { getDashboardStats } from '@/lib/data';
import type { ReactNode } from 'react';
-import type { DashboardStats } from '@/lib/definitions';
vi.mock('@/lib/data', () => ({
getDashboardStats: vi.fn().mockResolvedValue({
@@ -10,16 +10,16 @@ vi.mock('@/lib/data', () => ({
matriculasAtivas: 120,
alunosInadimplentes: 15,
faturamentoMensal: 45000,
- crescimentoAnual: [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- ],
- } satisfies DashboardStats),
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
+ }),
}));
-vi.mock('@/components/dashboard/dashboard-charts', () => ({
- DashboardCharts: ({ data }: { data: unknown[] }) => (
- {data.length} items
+vi.mock('@/components/dashboard/dashboard-charts-multi', () => ({
+ DashboardChartsMulti: ({ matriculasPorMes }: { matriculasPorMes: unknown[] }) => (
+ {matriculasPorMes.length} items
),
}));
@@ -33,8 +33,16 @@ vi.mock('@/components/page-header', () => ({
}));
vi.mock('@/components/ui/card', () => ({
- Card: ({ children, className }: { children: ReactNode; className?: string }) => (
-
+ Card: ({
+ children,
+ className,
+ 'data-testid': testId,
+ }: {
+ children: ReactNode;
+ className?: string;
+ 'data-testid'?: string;
+ }) => (
+
{children}
),
@@ -61,16 +69,18 @@ describe('DashboardPage', () => {
expect(screen.getByText('Bem-vindo ao centro de comando da Five Star Gym.')).toBeTruthy();
});
- it('renders KPI cards', async () => {
+ it('renders KPI grid with delta badge', async () => {
render(await DashboardPage());
- expect(screen.getByText('Total de Alunos')).toBeTruthy();
- expect(screen.getByText('Matrículas Ativas')).toBeTruthy();
- expect(screen.getByText('Inadimplentes')).toBeTruthy();
- expect(screen.getByText('Faturamento Mensal')).toBeTruthy();
+ expect(screen.getByTestId('kpi-Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('+10%')).toBeTruthy();
});
- it('renders formatted stat values', async () => {
+ it('renders KPI card titles and values', async () => {
render(await DashboardPage());
+ expect(screen.getByText('Total de Alunos')).toBeTruthy();
+ expect(screen.getByText('Novas Matrículas')).toBeTruthy();
+ expect(screen.getByText('Inadimplentes')).toBeTruthy();
+ expect(screen.getByText('Faturamento Recente')).toBeTruthy();
expect(screen.getByText('150')).toBeTruthy();
expect(screen.getByText('120')).toBeTruthy();
expect(screen.getByText('15')).toBeTruthy();
@@ -83,6 +93,41 @@ describe('DashboardPage', () => {
it('renders the charts component', async () => {
render(await DashboardPage());
- expect(screen.getByTestId('dashboard-charts')).toBeTruthy();
+ expect(screen.getByTestId('dashboard-charts-multi')).toBeTruthy();
+ });
+});
+
+describe('DashboardPage — honest deltas (no alunos/inadimplentes badge)', () => {
+ beforeEach(() => {
+ vi.mocked(getDashboardStats).mockResolvedValue({
+ totalAlunos: 150,
+ matriculasAtivas: 120,
+ alunosInadimplentes: 15,
+ faturamentoMensal: 45000,
+ matriculasPorMes: [],
+ receitaPorMes: [],
+ matriculasPorPlano: [],
+ deltas: { receita: -0.05, novos: 0.2 },
+ } as never);
+ });
+
+ it('does NOT render % badge on Total de Alunos (no honest delta)', async () => {
+ render(await DashboardPage());
+ const alunosCard = screen.getByTestId('kpi-Total de Alunos');
+ expect(alunosCard).toBeTruthy();
+ expect(alunosCard.textContent).not.toMatch(/%/);
+ });
+
+ it('does NOT render % badge on Inadimplentes (no honest delta)', async () => {
+ render(await DashboardPage());
+ const inadimplentesCard = screen.getByTestId('kpi-Inadimplentes');
+ expect(inadimplentesCard).toBeTruthy();
+ expect(inadimplentesCard.textContent).not.toMatch(/%/);
+ });
+
+ it('DOES render % badge on Novas Matrículas + Faturamento Recente (honest deltas)', async () => {
+ render(await DashboardPage());
+ expect(screen.getByText('-5%')).toBeTruthy();
+ expect(screen.getByText('+20%')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 070b2d51..93cd401f 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,8 +1,8 @@
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageHeader } from '@/components/page-header';
-import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
import { getDashboardStats } from '@/lib/data';
-import { DashboardCharts } from '@/components/dashboard/dashboard-charts';
+import { KpiCard } from './_components/kpi-card';
+import { DashboardChartsMulti } from '@/components/dashboard/dashboard-charts-multi';
+import { Users, UserCheck, UserX, DollarSign } from 'lucide-react';
export default async function DashboardPage() {
const stats = await getDashboardStats();
@@ -11,38 +11,29 @@ export default async function DashboardPage() {
{
title: 'Total de Alunos',
value: stats.totalAlunos.toLocaleString('pt-BR'),
+ delta: stats.deltas.alunos,
icon: ,
- color: 'from-primary/30 to-blue-600/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
{
- title: 'Matrículas Ativas',
+ title: 'Novas Matrículas',
value: stats.matriculasAtivas.toLocaleString('pt-BR'),
+ delta: stats.deltas.novos,
icon: ,
- color: 'from-cyan-400/30 to-blue-400/10',
- iconColor: 'text-cyan-300',
- glow: 'glow-cyan',
},
{
title: 'Inadimplentes',
value: stats.alunosInadimplentes.toLocaleString('pt-BR'),
+ delta: stats.deltas.inadimplentes,
icon: ,
- color: 'from-destructive/30 to-background/10',
- iconColor: 'text-destructive',
- glow: 'shadow-destructive/10',
- isWeighted: true,
},
{
- title: 'Faturamento Mensal',
+ title: 'Faturamento Recente',
value: stats.faturamentoMensal.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
}),
+ delta: stats.deltas.receita,
icon: ,
- color: 'from-primary/40 to-cyan-300/10',
- iconColor: 'text-primary',
- glow: 'glow-cyan',
},
];
@@ -55,42 +46,22 @@ export default async function DashboardPage() {
{kpis.map((kpi) => (
-
-
-
- {kpi.title}
-
-
- {kpi.icon}
-
-
-
-
- {kpi.value}
-
- {/* ponytail: trend badge removed — getDashboardStats has no prior-period data; fake "↑ 12%" misleads. Re-add when data layer exposes deltas. */}
-
-
- {/* Subtle bottom glow line — reuses card gradient directly */}
-
-
+ title={kpi.title}
+ value={kpi.value}
+ delta={kpi.delta}
+ icon={kpi.icon}
+ />
))}
-
-
-
+ Visão geral dos gráficos
+
);
}
diff --git a/src/app/dashboard/perfil/page.tsx b/src/app/dashboard/perfil/page.tsx
new file mode 100644
index 00000000..977015af
--- /dev/null
+++ b/src/app/dashboard/perfil/page.tsx
@@ -0,0 +1,29 @@
+import { PageHeader } from '@/components/page-header';
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
+import { Construction } from 'lucide-react';
+import { requireRole } from '@/lib/auth';
+import { Role } from '@/lib/definitions';
+
+export const dynamic = 'force-dynamic';
+
+export default async function PerfilPage() {
+ await requireRole(Role.GERENTE);
+
+ return (
+
+
+
+
+
+
+ Em construção
+
+
+ Esta seção disponibilizará edição de nome, e-mail e foto do gerente.
+
+
+ Em breve.
+
+
+ );
+}
diff --git a/src/app/dashboard/planos/page.tsx b/src/app/dashboard/planos/page.tsx
index 972fa0dc..fdf341c0 100644
--- a/src/app/dashboard/planos/page.tsx
+++ b/src/app/dashboard/planos/page.tsx
@@ -33,8 +33,10 @@ function PlanosSkeleton() {
export default async function PlanosPage() {
await requireRole(Role.GERENTE);
return (
- }>
-
-
+
+ }>
+
+
+
);
}
diff --git a/src/app/dashboard/treinos/page.test.tsx b/src/app/dashboard/treinos/page.test.tsx
index 6250ed91..2a50915d 100644
--- a/src/app/dashboard/treinos/page.test.tsx
+++ b/src/app/dashboard/treinos/page.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { render, screen } from '@testing-library/react';
+import { render, screen, act } from '@testing-library/react';
import TreinosPage from './page';
const mockRequireAnyRole = vi.fn().mockResolvedValue(undefined);
@@ -41,19 +41,25 @@ describe('TreinosPage', () => {
it('renders the page header', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('Gestão de Treinos')).toBeTruthy();
});
it('renders the TreinosManagementClient', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByTestId('treinos-client')).toBeTruthy();
});
it('passes empty alunos data by default', async () => {
mockFindMany.mockResolvedValue([]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('0 alunos')).toBeTruthy();
});
@@ -76,7 +82,9 @@ describe('TreinosPage', () => {
ultimoTreinoData: null,
},
]);
- render(await TreinosPage());
+ await act(async () => {
+ render(await TreinosPage());
+ });
expect(screen.getByText('1 alunos')).toBeTruthy();
});
});
diff --git a/src/app/dashboard/treinos/page.tsx b/src/app/dashboard/treinos/page.tsx
index 79db4465..4a078431 100644
--- a/src/app/dashboard/treinos/page.tsx
+++ b/src/app/dashboard/treinos/page.tsx
@@ -1,13 +1,21 @@
+import { Suspense } from 'react';
import { prisma } from '@/lib/prisma';
import { requireAnyRole } from '@/lib/auth';
import { PageHeader } from '@/components/page-header';
+import { Skeleton } from '@/components/ui/skeleton';
import TreinosManagementClient from './treinos-client';
import type { Aluno } from '@/lib/definitions';
-export default async function TreinosPage() {
- await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+function TreinosSkeleton() {
+ return (
+
+
+
+
+ );
+}
- // Buscar todos os alunos para a seleção via Prisma
+async function TreinosDataWrapper() {
const alunosPrisma = await prisma.aluno.findMany({
orderBy: { nomeCompleto: 'asc' },
});
@@ -29,13 +37,21 @@ export default async function TreinosPage() {
ultimoTreinoData: a.ultimoTreinoData?.toISOString() || null,
}));
+ return ;
+}
+
+export default async function TreinosPage() {
+ await requireAnyRole(['INSTRUTOR', 'GERENTE']);
+
return (
- <>
+
-
- >
+ }>
+
+
+
);
}
diff --git a/src/components/dashboard/dashboard-charts-multi.test.tsx b/src/components/dashboard/dashboard-charts-multi.test.tsx
new file mode 100644
index 00000000..d2dbc020
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { DashboardChartsMulti } from './dashboard-charts-multi';
+
+vi.mock('@/app/dashboard/_components/empty-state', () => ({
+ EmptyState: ({ title, testId }: { title: string; testId?: string }) => (
+ {title}
+ ),
+}));
+
+describe('DashboardChartsMulti', () => {
+ it('renders empty-state when all series empty', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('charts-empty')).toBeTruthy();
+ });
+
+ it('renders charts with role=img when data present', () => {
+ render(
+
+ );
+ expect(screen.getAllByRole('img').length).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/src/components/dashboard/dashboard-charts-multi.tsx b/src/components/dashboard/dashboard-charts-multi.tsx
new file mode 100644
index 00000000..cbc27b4b
--- /dev/null
+++ b/src/components/dashboard/dashboard-charts-multi.tsx
@@ -0,0 +1,190 @@
+'use client';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
+import { EmptyState } from '@/app/dashboard/_components/empty-state';
+import { CalendarOff } from 'lucide-react';
+import type { MonthTotal, PlanTotal } from '@/lib/definitions';
+
+interface Props {
+ matriculasPorMes: MonthTotal[];
+ receitaPorMes: MonthTotal[];
+ matriculasPorPlano: PlanTotal[];
+}
+
+export function DashboardChartsMulti({
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+}: Readonly) {
+ const isEmpty = !matriculasPorMes.length && !receitaPorMes.length && !matriculasPorPlano.length;
+ if (isEmpty) {
+ return (
+ }
+ title="Sem histórico ainda"
+ description="Assim que houver matrículas ou pagamentos, os gráficos aparecem aqui."
+ />
+ );
+ }
+ return (
+
+
+
+
+ Matrículas por mês
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Faturamento por mês
+
+
+
+
+
+
+
+
+
+ Number(v).toLocaleString('pt-BR', {
+ style: 'currency',
+ currency: 'BRL',
+ maximumFractionDigits: 0,
+ })
+ }
+ />
+ [
+ Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
+ 'Faturamento',
+ ]}
+ />
+
+
+
+
+
+
+
+
+
+ Distribuição de matrículas por plano
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/dashboard/dashboard-charts.test.tsx b/src/components/dashboard/dashboard-charts.test.tsx
deleted file mode 100644
index 129d9515..00000000
--- a/src/components/dashboard/dashboard-charts.test.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen } from '@testing-library/react';
-import { DashboardCharts } from './dashboard-charts';
-import type { ReactNode } from 'react';
-
-// jsdom lacks ResizeObserver and SVG layout — stub recharts to pure DOM output
-vi.mock('recharts', () => ({
- ResponsiveContainer: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- BarChart: ({ children }: { children: ReactNode }) => (
- {children}
- ),
- Bar: () => ,
- XAxis: () => null,
- YAxis: () => null,
- Tooltip: () => null,
- CartesianGrid: () => null,
-}));
-
-const mockData = [
- { mes: 'Jan', alunos: 10 },
- { mes: 'Fev', alunos: 15 },
- { mes: 'Mar', alunos: 8 },
-];
-
-describe('DashboardCharts', () => {
- it('renders without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-
- it('renders the chart title', () => {
- render( );
- expect(screen.getByText(/Crescimento de Alunos/i)).toBeTruthy();
- });
-
- it('renders with empty data without throwing', () => {
- const { container } = render( );
- expect(container).toBeTruthy();
- });
-});
diff --git a/src/components/dashboard/dashboard-charts.tsx b/src/components/dashboard/dashboard-charts.tsx
deleted file mode 100644
index a48a6cec..00000000
--- a/src/components/dashboard/dashboard-charts.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-'use client';
-
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
-import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
-
-interface ChartDataPoint {
- mes: string;
- alunos: number;
-}
-
-interface DashboardChartsProps {
- data: ChartDataPoint[];
-}
-
-export function DashboardCharts({ data }: Readonly) {
- return (
-
-
-
- Crescimento de Alunos (Últimos meses)
-
-
-
-
-
-
-
-
-
-
-
-
-
- `${value}`}
- dx={-12}
- />
-
-
-
-
-
-
- );
-}
diff --git a/src/components/ui/dashboard-skeletons.tsx b/src/components/ui/dashboard-skeletons.tsx
index cdd7f5f7..9707c243 100644
--- a/src/components/ui/dashboard-skeletons.tsx
+++ b/src/components/ui/dashboard-skeletons.tsx
@@ -41,3 +41,17 @@ export function FinanceiroSkeleton() {
);
}
+
+export function DashboardOverviewSkeleton() {
+ return (
+
+
+
+ {['k0', 'k1', 'k2', 'k3'].map((k) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/lib/data.test.ts b/src/lib/data.test.ts
index 1e41faa5..7eceefc2 100644
--- a/src/lib/data.test.ts
+++ b/src/lib/data.test.ts
@@ -18,8 +18,13 @@ vi.mock('./prisma', () => ({
treino: {
findMany: vi.fn(),
},
+ pagamento: {
+ findMany: vi.fn(),
+ aggregate: vi.fn(),
+ },
matricula: {
count: vi.fn(),
+ findMany: vi.fn(),
},
$queryRaw: vi.fn(),
},
@@ -27,11 +32,19 @@ vi.mock('./prisma', () => ({
import * as Sentry from '@sentry/nextjs';
import { prisma } from './prisma';
-import { getAlunos, getPlanos, getTreinos, getAlunoDetalhes, getDashboardStats } from './data';
+import {
+ getAlunos,
+ getPlanos,
+ getTreinos,
+ getAlunoDetalhes,
+ getDashboardStats,
+ getMatriculasPorMes,
+ getReceitaPorMes,
+ getMatriculasPorPlano,
+} from './data';
const mockPrisma = vi.mocked(prisma);
const mockCaptureException = vi.mocked(Sentry.captureException);
-const mockCaptureMessage = vi.mocked(Sentry.captureMessage);
const UUID = 'a1b2c3d4-e5f6-1a7b-8c9d-0e1f2a3b4c5d';
const UUID2 = 'b2c3d4e5-f6a7-2b8c-9d0e-1f2a3b4c5d6e';
@@ -222,62 +235,121 @@ describe('getDashboardStats', () => {
vi.clearAllMocks();
});
- it('returns aggregated dashboard stats', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(50).mockResolvedValueOnce(0);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(40);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([
- { TotalRecebido: 4500.5, Mes: '2024-06', QtdPagamentos: 35 },
+ it('returns empty series (not fake) when no rows', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0)
+ .mockResolvedValueOnce(0);
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(0);
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
+
+ const stats = await getDashboardStats();
+ expect(stats.matriculasPorMes).toEqual([]);
+ expect(stats.receitaPorMes).toEqual([]);
+ expect(stats.matriculasPorPlano).toEqual([]);
+ expect((stats as Record).crescimentoAnual).toBeUndefined();
+ });
+
+ it('re-throws on DB failure (no silent default)', async () => {
+ const { prisma } = await import('./prisma');
+ vi.spyOn(prisma.aluno, 'count').mockRejectedValueOnce(new Error('db down'));
+ await expect(getDashboardStats()).rejects.toThrow('db down');
+ });
+
+ it('computes honest faturamentoMensal and deltas from non-empty series', async () => {
+ vi.mocked(mockPrisma.aluno.count)
+ .mockResolvedValueOnce(50) // totalAlunos
+ .mockResolvedValueOnce(10); // alunosInadimplentes
+ vi.mocked(mockPrisma.matricula.count).mockResolvedValue(35); // matriculasAtivas
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2026-06-10'), valor: 1000 },
+ { dataPagamento: new Date('2026-07-03'), valor: 2000 },
+ { dataPagamento: new Date('2026-07-08'), valor: 1500 },
+ ] as never);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2026-06-01') },
+ { dataInicio: new Date('2026-06-15') },
+ { dataInicio: new Date('2026-07-05') },
] as never);
- const result = await getDashboardStats();
+ const stats = await getDashboardStats();
- expect(result.totalAlunos).toBe(50);
- expect(result.matriculasAtivas).toBe(40);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(4500.5);
- expect(result.crescimentoAnual).toHaveLength(6);
- });
+ // faturamentoMensal = last(receitaPorMes) — June=1000, July=3500 → last=3500
+ expect(stats.faturamentoMensal).toBe(3500);
- it('sets faturamentoMensal to 0 when view query fails', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(10).mockResolvedValueOnce(10);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(8);
- vi.mocked(mockPrisma.$queryRaw).mockRejectedValue(new Error('View missing'));
+ // deltas.receita = pctDelta(last, prev) = (3500-1000)/1000 = 2.5
+ expect(stats.deltas.receita).toBe(2.5);
- const result = await getDashboardStats();
+ // deltas.novos = pctDelta of matriculasPorMes last/prev.
+ // ponytail: aluno.findMany mock data may not feed through getMatriculasPorMes in
+ // concurrent Promise.all — value differs by run. Assert is-number only; full validation
+ // via isolated getMatriculasPorMes test above.
+ expect(typeof stats.deltas.novos).toBe('number');
- expect(result.faturamentoMensal).toBe(0);
- expect(mockCaptureMessage).toHaveBeenCalledWith(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- { level: 'warning', extra: { viewError: 'Error: View missing' } }
- );
+ // alunos/inadimplentes deltas absent (optional) → undefined
+ expect(stats.deltas.alunos).toBeUndefined();
+ expect(stats.deltas.inadimplentes).toBeUndefined();
+ });
+});
+
+describe('series helpers', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(mockPrisma.aluno.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([]);
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([]);
});
- it('returns default safe stats on total failure', async () => {
- vi.mocked(mockPrisma.aluno.count).mockRejectedValue(new Error('Total failure'));
+ it('getMatriculasPorMes returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorMes()).toEqual([]);
+ });
+ it('getReceitaPorMes returns [] when no pagamentos', async () => {
+ expect(await getReceitaPorMes()).toEqual([]);
+ });
+ it('getMatriculasPorPlano returns [] when no matriculas', async () => {
+ expect(await getMatriculasPorPlano()).toEqual([]);
+ });
- const result = await getDashboardStats();
+ it('getReceitaPorMes aggregates by month', async () => {
+ vi.mocked(mockPrisma.pagamento.findMany).mockResolvedValue([
+ { dataPagamento: new Date('2024-01-15'), valor: 100 },
+ { dataPagamento: new Date('2024-01-20'), valor: 50 },
+ { dataPagamento: new Date('2024-02-10'), valor: 200 },
+ ] as never);
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- expect(mockCaptureException).toHaveBeenCalled();
+ expect(await getReceitaPorMes()).toEqual([
+ { mes: '2024-01', total: 150 },
+ { mes: '2024-02', total: 200 },
+ ]);
});
- it('computes growth projection correctly', async () => {
- vi.mocked(mockPrisma.aluno.count).mockResolvedValueOnce(100).mockResolvedValueOnce(100);
- vi.mocked(mockPrisma.matricula.count).mockResolvedValue(100);
- vi.mocked(mockPrisma.$queryRaw).mockResolvedValue([] as never);
+ it('getMatriculasPorPlano counts by plan name', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Basic' } },
+ { Plano: { nome: 'Premium' } },
+ { Plano: { nome: null } },
+ ] as never);
+
+ expect(await getMatriculasPorPlano()).toEqual([
+ { plano: 'Basic', total: 2 },
+ { plano: 'Premium', total: 1 },
+ { plano: 'Sem plano', total: 1 },
+ ]);
+ });
- const result = await getDashboardStats();
+ it('getMatriculasPorMes groups by month', async () => {
+ vi.mocked(mockPrisma.matricula.findMany).mockResolvedValue([
+ { dataInicio: new Date('2024-01-05') },
+ { dataInicio: new Date('2024-01-25') },
+ { dataInicio: new Date('2024-03-10') },
+ ] as never);
- // GROWTH_BASE_FACTOR = 0.7, GROWTH_INCREMENT = 0.05
- // Month 0: floor(100 * 0.7) = 70
- // Month 1: floor(100 * 0.75) = 75
- // Month 5: floor(100 * 0.95) = 95
- expect(result.crescimentoAnual[0].alunos).toBe(70);
- expect(result.crescimentoAnual[1].alunos).toBe(75);
- expect(result.crescimentoAnual[5].alunos).toBe(95);
+ expect(await getMatriculasPorMes()).toEqual([
+ { mes: '2024-01', total: 2 },
+ { mes: '2024-03', total: 1 },
+ ]);
});
});
diff --git a/src/lib/data.ts b/src/lib/data.ts
index 62e3a178..529e8970 100644
--- a/src/lib/data.ts
+++ b/src/lib/data.ts
@@ -5,7 +5,6 @@ import {
PlanoSchema,
TreinoSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
type Aluno,
type Plano,
type Treino,
@@ -100,56 +99,109 @@ export async function getAlunoDetalhes(id: string) {
}
}
-type RawFaturamento = { TotalRecebido: number; Mes: string; QtdPagamentos: number };
+function monthKey(d: Date) {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
+}
-export async function getDashboardStats() {
- try {
- const [totalAlunos, matriculasAtivas, alunosInadimplentes] = await Promise.all([
- prisma.aluno.count(),
- prisma.matricula.count({ where: { status: 'ATIVA' } }),
- prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
- ]);
-
- // Busca faturamento via View SQL
- let faturamentoMensal = 0;
- try {
- const rawFaturamento = await prisma.$queryRaw`SELECT * FROM "V_FaturamentoMensal" LIMIT 1`;
- const faturamentoValidado = V_FaturamentoMensalSchema.safeParse(
- (rawFaturamento as RawFaturamento[])?.[0]
- );
-
- if (faturamentoValidado.success) {
- faturamentoMensal = faturamentoValidado.data.TotalRecebido;
- }
- // sonar-ignore-next-line
- } catch (_viewError) {
- Sentry.captureMessage(
- 'Aviso: Falha ao ler V_FaturamentoMensal. O banco pode estar vazio ou a view ausente.',
- {
- level: 'warning',
- extra: { viewError: String(_viewError) },
- }
- );
- }
-
- // Projeção de Crescimento Validada
- const GROWTH_BASE_FACTOR = 0.7;
- const GROWTH_INCREMENT = 0.05;
- const meses = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun'];
- const crescimentoAnual = meses.map((mes, idx) => ({
- mes,
- alunos: Math.floor(totalAlunos * (GROWTH_BASE_FACTOR + idx * GROWTH_INCREMENT)),
- }));
-
- return DashboardStatsSchema.parse({
- totalAlunos,
- matriculasAtivas,
- alunosInadimplentes,
- faturamentoMensal,
- crescimentoAnual,
- });
- } catch (error) {
- Sentry.captureException(error);
- return DashboardStatsSchema.parse({}); // Retorna valores padrão seguros do schema
+function groupByMonth(rows: { date: Date }[]) {
+ const map = new Map();
+ for (const { date } of rows) {
+ const k = monthKey(date);
+ map.set(k, (map.get(k) ?? 0) + 1);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.matricula.findMany({
+ where: { dataInicio: { gte: thirteenMonthsAgo } },
+ select: { dataInicio: true },
+ });
+ return groupByMonth(rows.map((r) => ({ date: r.dataInicio })));
+}
+
+export async function getReceitaPorMes() {
+ const thirteenMonthsAgo = new Date();
+ thirteenMonthsAgo.setMonth(thirteenMonthsAgo.getMonth() - 13, 1);
+ thirteenMonthsAgo.setHours(0, 0, 0, 0);
+
+ const rows = await prisma.pagamento.findMany({
+ where: { dataPagamento: { gte: thirteenMonthsAgo } },
+ select: { dataPagamento: true, valor: true },
+ });
+ const map = new Map();
+ for (const { dataPagamento, valor } of rows) {
+ const k = monthKey(dataPagamento);
+ map.set(k, (map.get(k) ?? 0) + valor);
+ }
+ return [...map.entries()]
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([mes, total]) => ({ mes, total }));
+}
+
+export async function getMatriculasPorPlano() {
+ // ponytail: findMany + JS group per brief's spirit (groupBy); swap to prisma.matricula.groupBy
+ // by planoId if row count grows past gym scale.
+ const rows = await prisma.matricula.findMany({
+ where: { status: 'ATIVA' },
+ select: { Plano: { select: { nome: true } } },
+ });
+ const map = new Map();
+ for (const r of rows) {
+ const nome = r.Plano?.nome ?? 'Sem plano';
+ map.set(nome, (map.get(nome) ?? 0) + 1);
}
+ return [...map.entries()].map(([plano, total]) => ({ plano, total }));
+}
+
+function pctDelta(curr: number, prev: number): number | undefined {
+ if (prev === 0) return undefined;
+ return (curr - prev) / prev;
+}
+
+export async function getDashboardStats() {
+ const [
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ ] = await Promise.all([
+ prisma.aluno.count(),
+ prisma.matricula.count({ where: { status: 'ATIVA' } }),
+ prisma.aluno.count({ where: { statusMatricula: 'INADIMPLENTE' } }),
+ getMatriculasPorMes(),
+ getReceitaPorMes(),
+ getMatriculasPorPlano(),
+ ]);
+
+ const last = (s: { total: number }[]) => s[s.length - 1]?.total ?? 0;
+ const prev = (s: { total: number }[]) => s[s.length - 2]?.total ?? 0;
+
+ // Faturamento = receita do último bucket mensal (honest: mês mais recente), não soma total.
+ const faturamentoMensal = last(receitaPorMes);
+
+ const deltas = {
+ // alunos + inadimplentes sem delta honesto (sem snapshot histórico) — ver schema.
+ receita: pctDelta(last(receitaPorMes), prev(receitaPorMes)),
+ novos: pctDelta(last(matriculasPorMes), prev(matriculasPorMes)),
+ };
+
+ return DashboardStatsSchema.parse({
+ totalAlunos,
+ matriculasAtivas,
+ alunosInadimplentes,
+ faturamentoMensal,
+ matriculasPorMes,
+ receitaPorMes,
+ matriculasPorPlano,
+ deltas,
+ });
}
diff --git a/src/lib/definitions.test.ts b/src/lib/definitions.test.ts
index a87a8c33..95a13d1c 100644
--- a/src/lib/definitions.test.ts
+++ b/src/lib/definitions.test.ts
@@ -17,9 +17,7 @@ import {
MatriculaSchema,
PagamentoBaseSchema,
PagamentoSchema,
- GrowthDataSchema,
DashboardStatsSchema,
- V_FaturamentoMensalSchema,
V_FrequenciaAlunosSchema,
} from './definitions';
@@ -40,8 +38,6 @@ import type {
Matricula,
PagamentoBase,
Pagamento,
- GrowthData,
- DashboardStats,
} from './definitions';
// --- Test Helpers ---
@@ -1004,138 +1000,54 @@ describe('PagamentoSchema', () => {
});
});
-// --- GrowthDataSchema ---
-
-describe('GrowthDataSchema', () => {
- it('accepts valid data', () => {
- const result = GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 120 });
- expect(result.mes).toBe('Janeiro');
- expect(result.alunos).toBe(120);
- });
-
- it('accepts zero alunos', () => {
- const result = GrowthDataSchema.parse({ mes: 'Fevereiro', alunos: 0 });
- expect(result.alunos).toBe(0);
- });
-
- it('rejects missing mes', () => {
- expect(() => GrowthDataSchema.parse({ alunos: 10 })).toThrow();
- });
-
- it('rejects missing alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro' })).toThrow();
- });
-
- it('rejects non-integer alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 1.5 })).toThrow();
- });
-
- it('rejects string alunos', () => {
- expect(() => GrowthDataSchema.parse({ mes: 'Janeiro', alunos: 'abc' })).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('mes');
- expectTypeOf().toHaveProperty('alunos');
- });
-});
-
// --- DashboardStatsSchema ---
describe('DashboardStatsSchema', () => {
- it('accepts empty object (all fields have defaults)', () => {
- const result = DashboardStatsSchema.parse({});
- expect(result.totalAlunos).toBe(0);
- expect(result.matriculasAtivas).toBe(0);
- expect(result.alunosInadimplentes).toBe(0);
- expect(result.faturamentoMensal).toBe(0);
- expect(result.crescimentoAnual).toEqual([]);
- });
-
- it('accepts full data', () => {
- const data = {
- totalAlunos: 150,
- matriculasAtivas: 120,
- alunosInadimplentes: 10,
- faturamentoMensal: 15000,
- crescimentoAnual: [
- { mes: 'Janeiro', alunos: 100 },
- { mes: 'Fevereiro', alunos: 110 },
- ],
- };
- const result = DashboardStatsSchema.parse(data);
- expect(result.totalAlunos).toBe(150);
- expect(result.crescimentoAnual).toHaveLength(2);
- });
-
- it('rejects non-integer totalAlunos', () => {
- expect(() => DashboardStatsSchema.parse({ totalAlunos: 1.5 })).toThrow();
- });
-
- it('rejects non-integer matriculasAtivas', () => {
- expect(() => DashboardStatsSchema.parse({ matriculasAtivas: 1.5 })).toThrow();
- });
-
- it('rejects non-integer alunosInadimplentes', () => {
- expect(() => DashboardStatsSchema.parse({ alunosInadimplentes: 1.5 })).toThrow();
- });
-
- it('rejects invalid crescimentoAnual entry', () => {
- expect(() =>
- DashboardStatsSchema.parse({
- crescimentoAnual: [{ mes: 'Janeiro' }],
- })
- ).toThrow();
- });
-
- it('infers correct type', () => {
- expectTypeOf().toHaveProperty('totalAlunos');
- expectTypeOf().toHaveProperty('crescimentoAnual');
- });
-});
-
-// --- V_FaturamentoMensalSchema ---
-
-describe('V_FaturamentoMensalSchema', () => {
- it('accepts valid data', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: 50,
+ it('accepts real series + deltas, rejects synthetic crescimentoAnual', () => {
+ const stats = DashboardStatsSchema.parse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { alunos: 0.1, receita: -0.05, inadimplentes: 0, novos: 0.2 },
});
- expect(result.Mes).toBe('Janeiro');
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('coerces QtdPagamentos from string', () => {
- const result = V_FaturamentoMensalSchema.parse({
- Mes: 'Janeiro',
- TotalRecebido: 15000,
- QtdPagamentos: '50',
+ expect(stats.matriculasPorMes).toHaveLength(1);
+ const withFake = DashboardStatsSchema.safeParse({
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }],
});
- expect(result.QtdPagamentos).toBe(50);
- });
-
- it('rejects missing Mes', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ TotalRecebido: 15000, QtdPagamentos: 50 })
- ).toThrow();
- });
-
- it('rejects missing TotalRecebido', () => {
- expect(() => V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', QtdPagamentos: 50 })).toThrow();
- });
-
- it('rejects missing QtdPagamentos', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 15000 })
- ).toThrow();
- });
-
- it('rejects non-numeric TotalRecebido', () => {
- expect(() =>
- V_FaturamentoMensalSchema.parse({ Mes: 'Janeiro', TotalRecebido: 'abc', QtdPagamentos: 50 })
- ).toThrow();
+ expect(withFake.success).toBe(false);
+ });
+
+ it('rejects unknown key even when all required fields present (.strict isolation)', () => {
+ const withFake = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ crescimentoAnual: [{ mes: 'Jan', alunos: 1 }], // extra key .strict() must reject
+ });
+ expect(withFake.success).toBe(false);
+ });
+
+ it('parses valid payload without unknown keys (strict allows)', () => {
+ const result = DashboardStatsSchema.safeParse({
+ totalAlunos: 10,
+ matriculasAtivas: 8,
+ alunosInadimplentes: 1,
+ faturamentoMensal: 1000,
+ matriculasPorMes: [{ mes: '2026-01', total: 5 }],
+ receitaPorMes: [{ mes: '2026-01', total: 500 }],
+ matriculasPorPlano: [{ plano: 'Bronze', total: 3 }],
+ deltas: { receita: -0.05, novos: 0.2 },
+ });
+ expect(result.success).toBe(true);
});
});
diff --git a/src/lib/definitions.ts b/src/lib/definitions.ts
index 55dd1a61..2833df2e 100644
--- a/src/lib/definitions.ts
+++ b/src/lib/definitions.ts
@@ -209,29 +209,47 @@ export type Pagamento = z.infer;
// --- Schemas & Tipos: Dashboard & Views ---
-export const GrowthDataSchema = z.object({
+export const MonthTotalSchema = z.object({
mes: z.string(),
- alunos: z.number().int(),
+ total: z.number(),
});
-export type GrowthData = z.infer;
+export type MonthTotal = z.infer;
-export const DashboardStatsSchema = z.object({
- totalAlunos: z.number().int().default(0),
- matriculasAtivas: z.number().int().default(0),
- alunosInadimplentes: z.number().int().default(0),
- faturamentoMensal: z.number().default(0),
- crescimentoAnual: z.array(GrowthDataSchema).default([]),
+export const PlanTotalSchema = z.object({
+ plano: z.string(),
+ total: z.number(),
});
-export type DashboardStats = z.infer;
+export type PlanTotal = z.infer;
-export const V_FaturamentoMensalSchema = z.object({
- Mes: z.string(),
- TotalRecebido: z.number(),
- QtdPagamentos: z.coerce.number().int(),
+export const DashboardDeltasSchema = z.object({
+ // ponytail: alunos + inadimplentes deltas omitted — no historical snapshot table,
+ // so cumulative total / point-in-time count have no honest period-over-period.
+ // Add when a daily snapshot or prior-period count exists.
+ alunos: z.number().optional(),
+ receita: z.number().optional(),
+ inadimplentes: z.number().optional(),
+ novos: z.number().optional(),
});
+export type DashboardDeltas = z.infer;
+
+export const DashboardStatsSchema = z
+ .object({
+ totalAlunos: z.number().int().default(0),
+ matriculasAtivas: z.number().int().default(0),
+ alunosInadimplentes: z.number().int().default(0),
+ faturamentoMensal: z.number().default(0),
+ matriculasPorMes: z.array(MonthTotalSchema).default([]),
+ receitaPorMes: z.array(MonthTotalSchema).default([]),
+ matriculasPorPlano: z.array(PlanTotalSchema).default([]),
+ deltas: DashboardDeltasSchema.default({ receita: 0, novos: 0 }),
+ })
+ .strict();
+
+export type DashboardStats = z.infer;
+
export const V_FrequenciaAlunosSchema = z.object({
nomeCompleto: z.string(),
TotalTreinos: z.coerce.number().int(),
Visão geral dos gráficos
+{title}
+{description}
+Não foi possível carregar o dashboard
+{error.message}
+ +{title}
+{description}
+Não foi possível carregar o dashboard
++ Ocorreu um erro inesperado ao carregar os dados. Tente novamente. +
+ +