FieldOps/docs/plans/mai-call-v0.3-shift-report.md
2026-05-30 13:04:59 +01:00

203 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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: `tsc` limpo (admin-web), `report-smoke.ts` 22/22 (contra a procedure real — agregação, `BAD_REQUEST` to≤from e >31d, janela futura vazia, `FORBIDDEN` para 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 o `BAD_REQUEST` exigido pelo AC → reescrito no padrão `createCallerFactory`.
>
> 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`](./mai-call-v0.1.md)) + Auth v0.2 ([`auth-v0.2.md`](./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)
1. **Métricas saem das linhas `MaintenanceRequest`, NÃO dos `DomainEvent`.** A linha já guarda `createdAt`, `claimedAt`, `resolvedAt`. Uma query + agregação em JS chega — volumes de um turno são dezenas/centenas de linhas. Os `DomainEvent` ficam para uma timeline futura, se pedida.
2. **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:0014:00, Tarde 14:0022:00, Noite 22:0006:00 (atravessa a meia-noite).
3. **Pertença à janela = `createdAt` dentro 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").
4. **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).
5. **Só admin-web, role ADMIN/SUPERVISOR.** O operador não vê relatórios.
6. **Timezone:** assume-se servidor e supervisor no mesmo fuso (Mangualde). O frontend calcula `from`/`to` a partir da hora local e envia instantes absolutos (superjson serializa `Date`). 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).
```ts
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):**
```ts
{
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/resolved` por `filter(r => r.status === ...)`.
- `responseMs`: para `rows.filter(r => r.claimedAt)`, calcular `r.claimedAt - r.createdAt`; `avg` = média, `max` = máximo (null se vazio).
- `resolutionMs`: para `rows.filter(r => r.resolvedAt)`, calcular `r.resolvedAt - r.createdAt`; idem.
- `byWorkstation`/`byArea`: agrupar com um `Map`, ordenar por `count` desc.
- `stillOpen`: `rows.filter(r => r.status !== 'RESOLVED')` mapeado para a forma acima, ordenado por `createdAt` asc.
> **Mediana?** Cortada na v0.3 — `avg` + `max` chegam 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).
```ts
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 de `Date.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/to` via `lib/shifts.ts` e 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.