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:
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user