13 KiB
Plano — MAI CALL v0.3 (relatório de fim de turno)
ESTADO: IMPLEMENTADO E VERIFICADO (2026-05-30). Os 5 passos estão feitos. Verificado:
tsclimpo (admin-web),report-smoke.ts22/22 (contra a procedure real — agregação,BAD_REQUESTto≤from e >31d, janela futura vazia,FORBIDDENpara operador), E2E MAI CALL verde. Revisão Opus corrigiu 2 defeitos pós-implementação: (1) fetch storm no modo "Hoje" (janela instabilizava a query key a cada render →useMemo); (2) smoke re-implementava a agregação em vez de chamar a procedure e não cobria oBAD_REQUESTexigido pelo AC → reescrito no padrãocreateCallerFactory.Autor: Opus 4.8 (sessão de design, 2026-05-30). Destinado a implementação pelo Sonnet. Pré-requisitos: MAI CALL v0.1 (
mai-call-v0.1.md) + Auth v0.2 (auth-v0.2.md), ambos implementados. Estado do código verificado contra o repo.
Objetivo numa frase
Dar ao supervisor de manutenção um relatório de fim de turno na admin-web: quantos pedidos, tempo médio de resposta (o número de ROI que a fábrica já mede), tempo de resolução, repartição por posto/área, e o que fica em aberto para o turno seguinte. Visível e imprimível (PDF via browser).
Decisões fixadas (não revisitar sem motivo forte)
- Métricas saem das linhas
MaintenanceRequest, NÃO dosDomainEvent. A linha já guardacreatedAt,claimedAt,resolvedAt. Uma query + agregação em JS chega — volumes de um turno são dezenas/centenas de linhas. OsDomainEventficam para uma timeline futura, se pedida. - Janela = turnos predefinidos + intervalo livre. Atalhos Manhã / Tarde / Noite / Hoje + modo Personalizado (de/até). As horas dos turnos são uma constante hardcoded (configurável mais tarde — mesma postura do PT hardcoded). Default: Manhã 06:00–14:00, Tarde 14:00–22:00, Noite 22:00–06:00 (atravessa a meia-noite).
- Pertença à janela =
createdAtdentro de [from, to). "Pedidos abertos neste turno." O estado/tempos de cada pedido são os atuais (a resolução pode acontecer depois do fim do turno — é normal e o relatório mostra-o como "em aberto à hora do relatório"). - On-demand, sem persistência. O relatório é recalculado a cada abertura; não se guarda nenhum "snapshot". Sem email automático, sem agendamento, sem export CSV/PDF próprio (o Imprimir do browser dá o PDF).
- Só admin-web, role ADMIN/SUPERVISOR. O operador não vê relatórios.
- Timezone: assume-se servidor e supervisor no mesmo fuso (Mangualde). O frontend calcula
from/toa partir da hora local e envia instantes absolutos (superjson serializaDate). Multi-fuso fica para depois.
1. Modelo de dados
Nenhuma alteração de schema. Tudo o que é preciso já existe em MaintenanceRequest (createdAt, claimedAt, resolvedAt, status, workstationId, relations). Este é o ponto-chave que torna a v0.3 barata.
2. API — maintenanceRequest.report
Nova procedure no router existente packages/api/src/routers/maintenance-request.ts. requireRole('ADMIN','SUPERVISOR') (o helper já existe).
report: requireRole('ADMIN', 'SUPERVISOR')
.input(z.object({
from: z.date(),
to: z.date(),
}))
.query(async ({ ctx, input }) => {
// Buscar as linhas criadas na janela (tenant-scoped via ctx.db).
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' },
});
// ... agregação em JS (ver abaixo) ...
return { window: { from: input.from, to: input.to }, totals, responseMs, resolutionMs, byWorkstation, byArea, stillOpen };
}),
Validação: acrescentar to > from (senão BAD_REQUEST) e cap de janela a, p.ex., 31 dias (to - from <= 31d) para evitar queries enormes.
Forma do output (decidida — implementar exatamente assim):
{
window: { from: Date, to: Date },
totals: {
created: number, // total na janela
open: number, // dos criados na janela, status atual OPEN
claimed: number, // ... CLAIMED
resolved: number, // ... RESOLVED
},
responseMs: { // claimedAt - createdAt, só para os que foram aceites
count: number, // quantos têm claimedAt
avg: number | null, // média em ms (null se count=0)
max: number | null, // pior caso em ms
},
resolutionMs: { // resolvedAt - createdAt, só para os resolvidos
count: number,
avg: number | null,
max: number | null,
},
byWorkstation: Array<{ // ordenado por count desc
workstationId: string, code: string, name: string, area: string, count: number,
}>,
byArea: Array<{ area: string, count: number }>, // ordenado por count desc
stillOpen: Array<{ // criados na janela e ainda OPEN ou CLAIMED à hora do relatório
id: string, code: string, name: string, area: string,
description: string, status: 'OPEN' | 'CLAIMED',
reportedByEmail: string, createdAt: Date,
}>,
}
Agregação (JS puro sobre rows):
totals.created = rows.length;open/claimed/resolvedporfilter(r => r.status === ...).responseMs: pararows.filter(r => r.claimedAt), calcularr.claimedAt - r.createdAt;avg= média,max= máximo (null se vazio).resolutionMs: pararows.filter(r => r.resolvedAt), calcularr.resolvedAt - r.createdAt; idem.byWorkstation/byArea: agrupar com umMap, ordenar porcountdesc.stillOpen:rows.filter(r => r.status !== 'RESOLVED')mapeado para a forma acima, ordenado porcreatedAtasc.
Mediana? Cortada na v0.3 —
avg+maxchegam para a história de ROI. Acrescentar depois é trivial (sort + elemento do meio).
3. Janela de turno — helper no frontend
Ficheiro novo apps/admin-web/lib/shifts.ts. As horas vivem aqui (constante).
export type ShiftKey = 'manha' | 'tarde' | 'noite';
export const SHIFTS: Record<ShiftKey, { label: string; startHour: number; endHour: number }> = {
manha: { label: 'Manhã', startHour: 6, endHour: 14 },
tarde: { label: 'Tarde', startHour: 14, endHour: 22 },
noite: { label: 'Noite', startHour: 22, endHour: 6 }, // atravessa a meia-noite
};
/** Dado um turno e o dia (Date à meia-noite local), devolve [from, to). */
export function shiftWindow(shift: ShiftKey, day: Date): { from: Date; to: Date } {
const s = SHIFTS[shift];
const from = new Date(day); from.setHours(s.startHour, 0, 0, 0);
const to = new Date(day);
if (s.endHour <= s.startHour) to.setDate(to.getDate() + 1); // noite → dia seguinte
to.setHours(s.endHour, 0, 0, 0);
return { from, to };
}
/** "Hoje" = início do dia local até agora. `now` injetado para testabilidade. */
export function todayWindow(now: Date): { from: Date; to: Date } {
const from = new Date(now); from.setHours(0, 0, 0, 0);
return { from, to: now };
}
Nota:
new Date()aqui é runtime de browser/app — sem problema (a restrição deDate.now()é só dos scripts de workflow, não se aplica).
4. UI — admin-web
4a. Página /maintenance/report
apps/admin-web/app/maintenance/report/page.tsx (server component fino) + report-view.tsx (client component com a query e o seletor). Seguir o padrão de tRPC client da página da fila existente (apps/admin-web/app/maintenance/maintenance-queue.tsx): trpc.maintenanceRequest.report.useQuery({ from, to }).
Seletor de janela (topo):
- Botões de atalho: Manhã | Tarde | Noite | Hoje. Ao clicar, calcula
from/tovialib/shifts.tse dispara a query. - Um seletor de dia (
<input type="date">) que se aplica aos turnos (default: hoje). Para "Noite", a janela é desse dia 22:00 → dia seguinte 06:00. - Botão Personalizado revela dois
<input type="datetime-local">(de/até) + botão "Aplicar". - Mostrar sempre, por extenso, a janela ativa: "Turno da Manhã — 30/05 06:00 → 14:00".
Corpo do relatório (quando há dados):
- Cartões de resumo (grid): Pedidos (created), Resolvidos, Em aberto, Tempo médio de resposta, Tempo médio de resolução, Pior resposta (max). Formatar durações como "7 min", "1 h 12 min" (helper
formatDuration(ms)). - Tabela por posto: code — name • area | nº pedidos.
- Lista "Em aberto à hora do relatório": posto, descrição (truncada), estado (badge Aberto/Em curso), reportado por, há quanto tempo. Se vazio: "Nada em aberto neste turno. ✓".
- Estado vazio (created = 0): "Sem pedidos nesta janela."
4b. Link a partir da fila
No header da página da fila (maintenance-queue.tsx ou no layout do /maintenance), acrescentar um link/botão "Relatório de turno" → /maintenance/report. E na página do relatório, um link de volta "← Fila".
4c. Impressão (PDF via browser)
- Botão "Imprimir" →
window.print(). - CSS de impressão (
@media print): esconder os botões/seletor e a navegação, manter só o título + janela + métricas + tabelas. Garantir que a tabela e a lista não cortam mal. Um cabeçalho de impressão com "FieldOps — Relatório de manutenção" + a janela. - Sem geração de PDF do lado do servidor — o "Imprimir → Guardar como PDF" do browser é o entregável v0.3.
5. Seed — dados de exemplo para o relatório não nascer vazio
Estender packages/db/prisma/seed.ts para criar alguns MaintenanceRequest de exemplo com timestamps variados ao longo de "hoje" (uns OPEN, uns CLAIMED, uns RESOLVED com tempos diferentes), distribuídos pelos 3 postos. Assim a demo do relatório mostra números logo após o seed.
- Usar
new Date()com offsets (ex.: criado há 3h, aceite 8 min depois, resolvido 35 min depois). - Idempotência: o seed já apaga e recria o tenant — os exemplos entram nessa recriação.
- Não partir o E2E do MAI CALL: esse teste filtra pelo seu próprio
descúnico, por isso dados extra não interferem. Confirmar na execução do E2E.
6. Cortes propositados — o que NÃO entra na v0.3
| Cortado | Porquê | Quando volta |
|---|---|---|
| Email/geração automática ao fim do turno | Requer scheduler + infra de email | v0.4 ou pós-piloto |
| Export CSV/PDF próprio | "Imprimir → PDF" do browser chega | módulo Insights |
| Persistir snapshots de relatórios | Recalcular on-demand é barato e sempre atual | se auditoria pedir |
| Mediana / percentis | avg + max contam a história | quando pedirem |
| Horas de turno configuráveis na UI | Constante hardcoded chega (PT hardcoded já é a norma) | com onboarding |
| Timeline de eventos (DomainEvent) por pedido | As linhas chegam para métricas | se pedirem drill-down |
| Gráficos/tendências entre turnos | Uma janela de cada vez na v0.3 | módulo Insights & Sim |
7. Plano de implementação (passos pequenos, ordenados)
Passo 1 — API report
Faz: §2 — procedure report com Zod (incl. to > from, cap 31 dias) e a agregação em JS exatamente na forma de output especificada.
AC: pnpm tsx scripts/report-smoke.ts (novo, ver Passo 5) calcula corretamente totals/responseMs/resolutionMs/byWorkstation/stillOpen sobre dados conhecidos. to <= from → BAD_REQUEST.
Passo 2 — Helper de turnos
Faz: §3 — apps/admin-web/lib/shifts.ts (SHIFTS, shiftWindow, todayWindow).
AC: verificação rápida: shiftWindow('noite', dia) devolve de 22:00 desse dia a 06:00 do dia seguinte; shiftWindow('manha', dia) 06:00→14:00; todayWindow(now) início-do-dia→now.
Passo 3 — Página do relatório + seletor
Faz: §4a + §4b — página /maintenance/report, seletor (atalhos + dia + personalizado), cartões/tabelas/lista, link de/para a fila. formatDuration(ms).
AC: abrir /maintenance/report; escolher "Hoje" mostra métricas dos dados do seed; trocar para "Manhã/Tarde/Noite" recalcula; "Personalizado" com um intervalo conhecido bate certo. Estado vazio aparece quando a janela não tem pedidos.
Passo 4 — Impressão
Faz: §4c — botão Imprimir + CSS @media print.
AC: no preview de impressão, o relatório aparece limpo (sem botões/seletor/nav), com título + janela + métricas + tabelas legíveis.
Passo 5 — Seed de exemplo + smoke + docs
Faz: §5 (seed com pedidos de exemplo), scripts/report-smoke.ts, e README (documentar a página /maintenance/report + o comando do smoke).
AC: após pnpm db:seed, o relatório "Hoje" não está vazio; pnpm tsx scripts/report-smoke.ts verde; pnpm test:e2e continua verde (dados de seed não partem o happy-path).
Sequência crítica: Passo 1 (API) é a fundação e é testável isoladamente pelo smoke. Passo 2 é independente (helper puro). Passo 3 depende de 1+2. Passos 4 e 5 fecham. Tudo isto é admin-web + um procedure — não toca na operator-pwa nem na auth, por isso o risco de regressão é baixo.
Risco principal: fusos/limites de dia no helper de turnos (especialmente o turno da noite a atravessar a meia-noite). Mitigação: o AC do Passo 2 testa explicitamente esse caso.