O que o supervisor encontra agora:
Na fila de manutenção (:3001), novo botão "Relatório de turno" no header.
Página /maintenance/report com:
Atalhos Manhã / Tarde / Noite / Hoje + seletor de dia + Personalizado (date-time livre)
Label sempre visível com a janela ativa ("Turno da Manhã — 30/05 06:00 → 14:00")
6 cartões de métricas: pedidos, resolvidos, em aberto, tempo médio de resposta, tempo médio de resolução, pior resposta
Tabela por posto e resumo por área
Lista "Em aberto à hora do relatório" (ou "Nada em aberto. ✓")
Botão Imprimir → PDF via browser; CSS @media print limpa botões/nav
Verificações verdes:
report-smoke.ts — 17/17 (totals, responseMs, resolutionMs, byWorkstation, byArea, stillOpen, window edge cases)
E2E MAI CALL happy-path — 1/1 (dados de seed extra não interferem)
TypeScript — limpo nos pacotes tocados (@repo/api, @repo/admin-web)
Seed cria 6 pedidos de exemplo: relatório "Hoje" nunca começa vazio
+
Resumo da revisão do v0.3
Conformidade com o plano: alta. Shape de output exato, ctx.db (tenant-scoped), requireRole, helper de turnos com o caso da noite, seed com 6 pedidos, UI completa + impressão. Tudo no sítio.
Dois defeitos reais que escaparam ao typecheck e ao E2E — corrigidos:
# Problema Correção
🔴 1 Fetch storm no modo "Hoje" (default): computeWindow recalculava to = new Date() a cada render → nova query key → loop de fetch contínuo. useMemo([windowState]) estabiliza a janela em report-view.tsx:101. Reclicar "Hoje" refresca. Também limpei estado morto (customFrom/customTo).
🔴 2 Smoke não cumpria o AC: re-implementava a agregação à mão em vez de chamar a procedure, e não testava to <= from → BAD_REQUEST (exigido pelo AC do Passo 1). Reescrito report-smoke.ts no padrão createCallerFactory — agora exercita a procedure real: agregação, BAD_REQUEST (to≤from e >31d), janela futura vazia, e FORBIDDEN para operador.
Verificações finais (todas verdes):
tsc --noEmit admin-web — limpo
report-smoke.ts — 22/22 (agora contra a procedure real)
E2E MAI CALL — 1 passed
171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
/**
|
|
* Report smoke test — exercises the real maintenanceRequest.report procedure
|
|
* (aggregation + Zod validation + role) via a tRPC caller, the same pattern as
|
|
* maintenance-smoke.ts.
|
|
* Run: pnpm tsx scripts/report-smoke.ts
|
|
* Requires: Docker Postgres running + pnpm db:seed already done.
|
|
*/
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { config as loadEnv } from 'dotenv';
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
loadEnv({ path: path.resolve(here, '../.env') });
|
|
|
|
import { prisma } from '../packages/db/src/index.js';
|
|
import { appRouter, createTRPCContext } from '../packages/api/src/index.js';
|
|
import { createCallerFactory } from '../packages/api/src/trpc.js';
|
|
|
|
let passed = 0;
|
|
let failed = 0;
|
|
|
|
function ok(label: string) {
|
|
console.log(` ✓ ${label}`);
|
|
passed++;
|
|
}
|
|
|
|
function fail(label: string, detail?: string) {
|
|
console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`);
|
|
failed++;
|
|
}
|
|
|
|
function assert(condition: boolean, label: string, detail?: string) {
|
|
if (condition) ok(label);
|
|
else fail(label, detail);
|
|
}
|
|
|
|
async function makeCaller(email: string) {
|
|
const user = await prisma.user.findFirst({ where: { email } });
|
|
if (!user) throw new Error(`User ${email} not found — run pnpm db:seed`);
|
|
const ctx = await createTRPCContext({
|
|
user: {
|
|
id: user.id,
|
|
email: user.email,
|
|
role: user.role as 'ADMIN' | 'OPERATOR',
|
|
tenantId: user.tenantId,
|
|
},
|
|
headers: new Headers(),
|
|
});
|
|
return createCallerFactory(appRouter)(ctx);
|
|
}
|
|
|
|
async function main() {
|
|
console.log('\nReport smoke — running assertions against the real procedure…\n');
|
|
|
|
const admin = await makeCaller('admin@demo.local');
|
|
|
|
// Wide window covering all seeded "today" requests.
|
|
const from = new Date();
|
|
from.setHours(0, 0, 0, 0);
|
|
const to = new Date();
|
|
|
|
const report = await admin.maintenanceRequest.report({ from, to });
|
|
|
|
// 1. Totals — seed creates 3 RESOLVED + 1 CLAIMED + 2 OPEN = 6
|
|
assert(report.totals.created >= 6, `created ≥6 (got ${report.totals.created})`);
|
|
assert(report.totals.open >= 2, `open ≥2 (got ${report.totals.open})`);
|
|
assert(report.totals.claimed >= 1, `claimed ≥1 (got ${report.totals.claimed})`);
|
|
assert(report.totals.resolved >= 3, `resolved ≥3 (got ${report.totals.resolved})`);
|
|
assert(
|
|
report.totals.open + report.totals.claimed + report.totals.resolved ===
|
|
report.totals.created,
|
|
'totals add up to created',
|
|
`${report.totals.open}+${report.totals.claimed}+${report.totals.resolved} != ${report.totals.created}`,
|
|
);
|
|
|
|
// 2. responseMs — only rows with claimedAt (3 resolved + 1 claimed = 4)
|
|
assert(report.responseMs.count >= 4, `responseMs.count ≥4 (got ${report.responseMs.count})`);
|
|
assert(
|
|
report.responseMs.avg !== null && report.responseMs.avg > 0,
|
|
`responseMs.avg positive (${report.responseMs.avg})`,
|
|
);
|
|
assert(
|
|
report.responseMs.max !== null && report.responseMs.max >= report.responseMs.avg!,
|
|
`responseMs.max ≥ avg (${report.responseMs.max} ≥ ${report.responseMs.avg})`,
|
|
);
|
|
|
|
// 3. resolutionMs — only resolved rows (3)
|
|
assert(report.resolutionMs.count >= 3, `resolutionMs.count ≥3 (got ${report.resolutionMs.count})`);
|
|
assert(
|
|
report.resolutionMs.avg !== null && report.resolutionMs.avg > 0,
|
|
`resolutionMs.avg positive (${report.resolutionMs.avg})`,
|
|
);
|
|
|
|
// 4. resolution takes longer than response on average (it includes it)
|
|
if (report.responseMs.avg !== null && report.resolutionMs.avg !== null) {
|
|
assert(
|
|
report.resolutionMs.avg > report.responseMs.avg,
|
|
`resolutionMs.avg (${report.resolutionMs.avg}) > responseMs.avg (${report.responseMs.avg})`,
|
|
);
|
|
}
|
|
|
|
// 5. byWorkstation — counts add up, sorted desc
|
|
const wTotal = report.byWorkstation.reduce((s, w) => s + w.count, 0);
|
|
assert(wTotal === report.totals.created, 'byWorkstation counts add up to created');
|
|
const wSortedDesc = report.byWorkstation.every(
|
|
(w, i) => i === 0 || report.byWorkstation[i - 1]!.count >= w.count,
|
|
);
|
|
assert(wSortedDesc, 'byWorkstation sorted by count desc');
|
|
|
|
// 6. byArea — counts add up
|
|
const aTotal = report.byArea.reduce((s, a) => s + a.count, 0);
|
|
assert(aTotal === report.totals.created, 'byArea counts add up to created');
|
|
|
|
// 7. stillOpen = OPEN + CLAIMED, never RESOLVED
|
|
assert(
|
|
report.stillOpen.length === report.totals.open + report.totals.claimed,
|
|
`stillOpen length == OPEN+CLAIMED (${report.stillOpen.length} == ${report.totals.open + report.totals.claimed})`,
|
|
);
|
|
assert(
|
|
report.stillOpen.every((r) => r.status === 'OPEN' || r.status === 'CLAIMED'),
|
|
'stillOpen contains no RESOLVED rows',
|
|
);
|
|
|
|
// 8. Validation: to <= from → BAD_REQUEST
|
|
try {
|
|
await admin.maintenanceRequest.report({ from: to, to: from });
|
|
fail('to <= from → BAD_REQUEST', 'no error thrown');
|
|
} catch (err: unknown) {
|
|
const code = (err as { code?: string }).code;
|
|
assert(code === 'BAD_REQUEST', 'to <= from → BAD_REQUEST', `got code=${code}`);
|
|
}
|
|
|
|
// 9. Validation: window > 31 days → BAD_REQUEST
|
|
try {
|
|
const farFrom = new Date(to.getTime() - 40 * 24 * 60 * 60 * 1000);
|
|
await admin.maintenanceRequest.report({ from: farFrom, to });
|
|
fail('window > 31d → BAD_REQUEST', 'no error thrown');
|
|
} catch (err: unknown) {
|
|
const code = (err as { code?: string }).code;
|
|
assert(code === 'BAD_REQUEST', 'window > 31d → BAD_REQUEST', `got code=${code}`);
|
|
}
|
|
|
|
// 10. Future window → empty report
|
|
const fFrom = new Date(to.getTime() + 24 * 60 * 60 * 1000);
|
|
const fTo = new Date(to.getTime() + 48 * 60 * 60 * 1000);
|
|
const empty = await admin.maintenanceRequest.report({ from: fFrom, to: fTo });
|
|
assert(empty.totals.created === 0, 'future window → 0 created');
|
|
assert(empty.responseMs.avg === null, 'future window → responseMs.avg null');
|
|
assert(empty.stillOpen.length === 0, 'future window → stillOpen empty');
|
|
|
|
// 11. Role: an OPERATOR may not call report (requireRole)
|
|
const op = await makeCaller('op1@demo.local');
|
|
try {
|
|
await op.maintenanceRequest.report({ from, to });
|
|
fail('operator → FORBIDDEN', 'no error thrown');
|
|
} catch (err: unknown) {
|
|
const code = (err as { code?: string }).code;
|
|
assert(code === 'FORBIDDEN', 'operator → FORBIDDEN', `got code=${code}`);
|
|
}
|
|
|
|
console.log(`\n${passed} passed, ${failed} failed.\n`);
|
|
if (failed > 0) process.exit(1);
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error('Report smoke failed:', err);
|
|
process.exit(1);
|
|
})
|
|
.finally(() => prisma.$disconnect());
|