FieldOps/docs/plans/mai-call-v0.3-shift-report.md
Pedro Gomes fdfa936461 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
2026-05-30 12:51:14 +01:00

12 KiB
Raw Blame History

Plano — MAI CALL v0.3 (relatório de fim de turno)

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)

  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).

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/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).

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."

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 <= fromBAD_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.