MAI CALL - v0.3

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
This commit is contained in:
2026-05-30 12:51:14 +01:00
parent 98f6444736
commit fdfa936461
10 changed files with 1073 additions and 6 deletions
@@ -204,4 +204,111 @@ export const maintenanceRequestRouter = router({
if (!request) throw new TRPCError({ code: 'NOT_FOUND' });
return request;
}),
report: requireRole('ADMIN', 'SUPERVISOR')
.input(
z.object({
from: z.date(),
to: z.date(),
}),
)
.query(async ({ ctx, input }) => {
if (input.to <= input.from) {
throw new TRPCError({ code: 'BAD_REQUEST', message: '"to" deve ser posterior a "from".' });
}
const MAX_WINDOW_MS = 31 * 24 * 60 * 60 * 1000;
if (input.to.getTime() - input.from.getTime() > MAX_WINDOW_MS) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Janela máxima: 31 dias.' });
}
const rows = await ctx.db.maintenanceRequest.findMany({
where: { createdAt: { gte: input.from, lt: input.to } },
select: {
id: true,
status: true,
description: true,
createdAt: true,
claimedAt: true,
resolvedAt: true,
workstation: { select: { id: true, code: true, name: true, area: true } },
reportedBy: { select: { email: true } },
},
orderBy: { createdAt: 'asc' },
});
const totals = {
created: rows.length,
open: rows.filter((r) => r.status === 'OPEN').length,
claimed: rows.filter((r) => r.status === 'CLAIMED').length,
resolved: rows.filter((r) => r.status === 'RESOLVED').length,
};
const responseTimes = rows
.filter((r) => r.claimedAt)
.map((r) => r.claimedAt!.getTime() - r.createdAt.getTime());
const responseMs = {
count: responseTimes.length,
avg:
responseTimes.length > 0
? Math.round(responseTimes.reduce((s, v) => s + v, 0) / responseTimes.length)
: null,
max: responseTimes.length > 0 ? Math.max(...responseTimes) : null,
};
const resolutionTimes = rows
.filter((r) => r.resolvedAt)
.map((r) => r.resolvedAt!.getTime() - r.createdAt.getTime());
const resolutionMs = {
count: resolutionTimes.length,
avg:
resolutionTimes.length > 0
? Math.round(resolutionTimes.reduce((s, v) => s + v, 0) / resolutionTimes.length)
: null,
max: resolutionTimes.length > 0 ? Math.max(...resolutionTimes) : null,
};
const wMap = new Map<
string,
{ workstationId: string; code: string; name: string; area: string; count: number }
>();
for (const r of rows) {
const ws = r.workstation;
if (!wMap.has(ws.id)) {
wMap.set(ws.id, { workstationId: ws.id, code: ws.code, name: ws.name, area: ws.area, count: 0 });
}
wMap.get(ws.id)!.count++;
}
const byWorkstation = [...wMap.values()].sort((a, b) => b.count - a.count);
const aMap = new Map<string, number>();
for (const r of rows) {
aMap.set(r.workstation.area, (aMap.get(r.workstation.area) ?? 0) + 1);
}
const byArea = [...aMap.entries()]
.map(([area, count]) => ({ area, count }))
.sort((a, b) => b.count - a.count);
const stillOpen = rows
.filter((r) => r.status !== 'RESOLVED')
.map((r) => ({
id: r.id,
code: r.workstation.code,
name: r.workstation.name,
area: r.workstation.area,
description: r.description,
status: r.status as 'OPEN' | 'CLAIMED',
reportedByEmail: r.reportedBy.email,
createdAt: r.createdAt,
}));
return {
window: { from: input.from, to: input.to },
totals,
responseMs,
resolutionMs,
byWorkstation,
byArea,
stillOpen,
};
}),
});