203 lines
13 KiB
Markdown
203 lines
13 KiB
Markdown
# 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:00–14:00, Tarde 14:00–22:00, Noite 22:00–06: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.
|