diff --git a/README.md b/README.md index b159b84..a334c50 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# FieldOps — MAI CALL v0.1 +# FieldOps — MAI CALL v0.3 -Modular industrial SaaS monorepo. **MAI CALL v0.1** is the first shipped -feature: a full maintenance-request loop that runs on factory Wi-Fi (and -survives losing it). +Modular industrial SaaS monorepo. **MAI CALL v0.3** is the latest shipped +feature: a full maintenance-request loop (offline-first, operator PIN + admin +password auth) with an **end-of-shift report** for supervisors. ## What's here @@ -105,6 +105,19 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears. 5. The document tab title shows `(N) FieldOps — Manutenção` when there are open requests. +### Shift report (admin-web only) + +1. In the maintenance queue, click **Relatório de turno** (top-right header). +2. Choose a window — **Manhã** (06–14 h), **Tarde** (14–22 h), **Noite** (22–06 h), + **Hoje** (midnight → now), or **Personalizado** (free date-time range). +3. The report shows: total/resolved/open counts, avg & max response time, + avg resolution time, breakdown by workstation and area, and a list of + requests still open at report time. +4. Click **Imprimir** to print / save as PDF via the browser. + +After `pnpm db:seed`, the "Hoje" window already has 6 sample requests +(3 resolved, 1 claimed, 2 open) so the report is never empty on first boot. + --- ## MinIO (photo storage) @@ -152,7 +165,7 @@ Expected: **1 passed** in ~30 s. | **No real authentication in dev** | `AUTH_DEV_AUTOLOGIN=true` lets anyone in as admin (dev/test only — ignored when `NODE_ENV=production`). Real auth is implemented: admin uses email + password, operators use list + PIN. | — | | **Operator picker + PIN, not TAG/card** | Operator identity is chosen from a list and confirmed with a PIN, rather than read from an RFID badge. | MY QUALITY module | | **No multi-tenant onboarding UI** | Tenants are created via `pnpm db:seed` / SQL only. | when 2nd customer onboards | -| **No SLAs, alerts, or timers** | `DomainEvent` rows are written and ready; reporting is not built yet. | v0.2 | +| **No scheduled reports** | The shift report is on-demand (open the page, print). Auto-email at shift end requires a scheduler + email service. | v0.4 or post-pilot | | **Single photo per request** | No video, audio, or multiple photos. | when pilot asks | | **Safari / iOS Background Sync** | Background Sync API is not supported on Safari; sync falls back to main-thread polling every 10 s when the tab is open. | acceptable for pilot | | **No push notifications** | Polling at 5 s on the admin-web tab is the notification mechanism. | if pilot requires it | @@ -188,6 +201,7 @@ Expected: **1 passed** in ~30 s. | `pnpm tsx scripts/storage-smoke.ts` | Verify MinIO presigned upload/download | | `pnpm tsx scripts/maintenance-smoke.ts` | Verify the full create→claim→resolve cycle | | `pnpm tsx scripts/auth-smoke.ts` | Verify hashing, PIN/password login, and lockout | +| `pnpm tsx scripts/report-smoke.ts` | Verify shift-report aggregation against seeded data | --- diff --git a/apps/admin-web/app/globals.css b/apps/admin-web/app/globals.css index 53e520b..64296ce 100644 --- a/apps/admin-web/app/globals.css +++ b/apps/admin-web/app/globals.css @@ -32,3 +32,19 @@ @apply bg-background text-foreground; } } + +@media print { + @page { + margin: 1.5cm; + size: A4 portrait; + } + + /* Avoid page breaks inside cards and table rows */ + tr, .print\:break-inside-avoid { + break-inside: avoid; + } + + section { + break-inside: avoid; + } +} diff --git a/apps/admin-web/app/maintenance/maintenance-queue.tsx b/apps/admin-web/app/maintenance/maintenance-queue.tsx index 828c75e..be07d5b 100644 --- a/apps/admin-web/app/maintenance/maintenance-queue.tsx +++ b/apps/admin-web/app/maintenance/maintenance-queue.tsx @@ -1,7 +1,8 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; -import { CheckCircle2, Clock, Loader2, Wrench } from 'lucide-react'; +import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react'; +import Link from 'next/link'; import { trpc } from '@/lib/trpc/client'; import type { RouterOutputs } from '@/lib/trpc/server'; @@ -268,6 +269,13 @@ export function MaintenanceQueue() {
+ + + Relatório de turno + +
+ + + {/* ── Print header (only in print) ── */} +
+

FieldOps — Relatório de manutenção

+

{windowLabel(win.from, win.to)}

+
+ + {/* ── Window selector (hidden in print) ── */} +
+
+ {/* Shift shortcuts + day picker */} +
+ + {(Object.keys(SHIFTS) as ShiftKey[]).map((key) => ( + + ))} + + { + setDayInput(e.target.value); + if (windowState.type === 'shift') { + const day = new Date(e.target.value + 'T00:00:00'); + setWindowState({ type: 'shift', key: windowState.key, day }); + } + }} + className="rounded-lg border border-border bg-card px-2 py-1 text-sm" + /> + + +
+ + {/* Custom range inputs */} + {customActive && ( +
+ setCustomPending((p) => ({ ...p, from: e.target.value }))} + className="rounded-lg border border-border bg-card px-2 py-1 text-sm" + /> + até + setCustomPending((p) => ({ ...p, to: e.target.value }))} + className="rounded-lg border border-border bg-card px-2 py-1 text-sm" + /> + +
+ )} + + {/* Active window label */} +

+ {windowState.type === 'shift' + ? `Turno d${windowState.key === 'manha' ? 'a Manhã' : windowState.key === 'tarde' ? 'a Tarde' : 'a Noite'} — ` + : windowState.type === 'today' + ? 'Hoje — ' + : 'Personalizado — '} + {windowLabel(win.from, win.to)} +

+
+
+ + {/* ── Body ── */} +
+ {isLoading && ( +

A carregar…

+ )} + + {error && ( +
+ +

{error.message}

+
+ )} + + {data && data.totals.created === 0 && ( +
+

Sem pedidos nesta janela.

+
+ )} + + {data && data.totals.created > 0 && ( +
+ {/* Summary cards */} +
+

+ Resumo +

+
+ + + 0 || data.totals.claimed > 0 + ? `${data.totals.open} aberto · ${data.totals.claimed} em curso` + : undefined + } + /> + 0 + ? `sobre ${data.responseMs.count} pedido${data.responseMs.count > 1 ? 's' : ''}` + : 'sem dados' + } + /> + 0 + ? `sobre ${data.resolutionMs.count} pedido${data.resolutionMs.count > 1 ? 's' : ''}` + : 'sem dados' + } + /> + +
+
+ + {/* By workstation */} + {data.byWorkstation.length > 0 && ( +
+

+ Por posto +

+
+ + + + + + + + + + + {data.byWorkstation.map((ws, i) => ( + + + + + + + ))} + +
CódigoNomeÁreaPedidos
{ws.code}{ws.name} + {ws.area} + {ws.count}
+
+
+ )} + + {/* By area */} + {data.byArea.length > 1 && ( +
+

+ Por área +

+
+ {data.byArea.map((a) => ( +
+ {a.area} + + {a.count} + +
+ ))} +
+
+ )} + + {/* Still open */} +
+

+ Em aberto à hora do relatório +

+ {data.stillOpen.length === 0 ? ( +

Nada em aberto neste turno. ✓

+ ) : ( +
+ {data.stillOpen.map((r) => ( +
+
+

+ {r.code} — {r.name}{' '} + + · {r.area} + +

+

+ {r.description} +

+

+ Reportado por {r.reportedByEmail} · {formatDate(r.createdAt)} +

+
+ + {STATUS_LABEL[r.status]} + +
+ ))} +
+ )} +
+
+ )} +
+ + ); +} diff --git a/apps/admin-web/lib/shifts.ts b/apps/admin-web/lib/shifts.ts new file mode 100644 index 0000000..44a7f2f --- /dev/null +++ b/apps/admin-web/lib/shifts.ts @@ -0,0 +1,25 @@ +export type ShiftKey = 'manha' | 'tarde' | 'noite'; + +export const SHIFTS: Record = { + manha: { label: 'Manhã', startHour: 6, endHour: 14 }, + tarde: { label: 'Tarde', startHour: 14, endHour: 22 }, + noite: { label: 'Noite', startHour: 22, endHour: 6 }, +}; + +/** Given a shift and a day (Date at local midnight), returns [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 crosses midnight + to.setHours(s.endHour, 0, 0, 0); + return { from, to }; +} + +/** "Hoje" = start of local day up to now. `now` injected for testability. */ +export function todayWindow(now: Date): { from: Date; to: Date } { + const from = new Date(now); + from.setHours(0, 0, 0, 0); + return { from, to: now }; +} diff --git a/docs/plans/mai-call-v0.3-shift-report.md b/docs/plans/mai-call-v0.3-shift-report.md new file mode 100644 index 0000000..0373c68 --- /dev/null +++ b/docs/plans/mai-call-v0.3-shift-report.md @@ -0,0 +1,200 @@ +# 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`](./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 = { + 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** (``) que se aplica aos turnos (default: hoje). Para "Noite", a janela é desse dia 22:00 → dia seguinte 06:00. +- Botão **Personalizado** revela dois `` (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. diff --git a/packages/api/src/routers/maintenance-request.ts b/packages/api/src/routers/maintenance-request.ts index 0bdcbf4..dae010b 100644 --- a/packages/api/src/routers/maintenance-request.ts +++ b/packages/api/src/routers/maintenance-request.ts @@ -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(); + 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, + }; + }), }); diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts index 853b679..45b17d5 100644 --- a/packages/db/prisma/seed.ts +++ b/packages/db/prisma/seed.ts @@ -64,6 +64,105 @@ async function main() { data: WORKSTATIONS.map((ws) => ({ tenantId: tenant.id, ...ws })), }); + // Seed sample maintenance requests so the shift report isn't empty. + const [wsList, adminUser, op1User] = await Promise.all([ + prisma.workstation.findMany({ where: { tenantId: tenant.id } }), + prisma.user.findFirst({ where: { tenantId: tenant.id, email: DEMO_ADMIN_EMAIL } }), + prisma.user.findFirst({ where: { tenantId: tenant.id, email: 'op1@demo.local' } }), + ]); + + if (wsList.length && adminUser && op1User) { + const now = new Date(); + const ago = (minutes: number) => new Date(now.getTime() - minutes * 60_000); + + // 6 sample requests spread across "today" + const samples = [ + // RESOLVED — created 4h ago, claimed 7 min later, resolved 40 min after that + { + tenantId: tenant.id, + workstationId: wsList[0]!.id, + reportedByUserId: op1User.id, + description: 'Sensor de pressão com leitura incorreta.', + status: 'RESOLVED' as const, + createdAt: ago(240), + claimedAt: ago(233), + claimedByUserId: adminUser.id, + resolvedAt: ago(193), + resolvedByUserId: adminUser.id, + resolutionNote: 'Sensor substituído e calibrado.', + clientRequestId: '00000000-0000-0000-0000-000000000001', + }, + // RESOLVED — created 3h ago, claimed 12 min later, resolved 55 min after + { + tenantId: tenant.id, + workstationId: wsList[1 % wsList.length]!.id, + reportedByUserId: op1User.id, + description: 'Correia de transporte partida na zona B.', + status: 'RESOLVED' as const, + createdAt: ago(180), + claimedAt: ago(168), + claimedByUserId: adminUser.id, + resolvedAt: ago(113), + resolvedByUserId: adminUser.id, + resolutionNote: 'Correia substituída.', + clientRequestId: '00000000-0000-0000-0000-000000000002', + }, + // RESOLVED — created 2h ago, claimed 5 min later, resolved 20 min after + { + tenantId: tenant.id, + workstationId: wsList[2 % wsList.length]!.id, + reportedByUserId: op1User.id, + description: 'Falha no painel de controlo, sem resposta ao toque.', + status: 'RESOLVED' as const, + createdAt: ago(120), + claimedAt: ago(115), + claimedByUserId: adminUser.id, + resolvedAt: ago(95), + resolvedByUserId: adminUser.id, + resolutionNote: 'Reinício do painel resolveu a falha.', + clientRequestId: '00000000-0000-0000-0000-000000000003', + }, + // CLAIMED — created 90 min ago, claimed 15 min later, not yet resolved + { + tenantId: tenant.id, + workstationId: wsList[0]!.id, + reportedByUserId: op1User.id, + description: 'Vibração excessiva no eixo principal.', + status: 'CLAIMED' as const, + createdAt: ago(90), + claimedAt: ago(75), + claimedByUserId: adminUser.id, + clientRequestId: '00000000-0000-0000-0000-000000000004', + }, + // OPEN — created 45 min ago + { + tenantId: tenant.id, + workstationId: wsList[1 % wsList.length]!.id, + reportedByUserId: op1User.id, + description: 'Fuga de óleo hidráulico visível no piso.', + status: 'OPEN' as const, + createdAt: ago(45), + clientRequestId: '00000000-0000-0000-0000-000000000005', + }, + // OPEN — created 10 min ago + { + tenantId: tenant.id, + workstationId: wsList[2 % wsList.length]!.id, + reportedByUserId: op1User.id, + description: 'Alarme sonoro ativo sem causa identificada.', + status: 'OPEN' as const, + createdAt: ago(10), + clientRequestId: '00000000-0000-0000-0000-000000000006', + }, + ]; + + for (const s of samples) { + await prisma.maintenanceRequest.create({ data: s }); + } + + console.warn(` pedidos de exemplo: ${samples.length} criados`); + } + console.warn( `Seed complete — tenant=${tenant.id} (${tenant.name})`, ); diff --git a/scripts/report-smoke.ts b/scripts/report-smoke.ts new file mode 100644 index 0000000..3eb023c --- /dev/null +++ b/scripts/report-smoke.ts @@ -0,0 +1,170 @@ +/** + * Report smoke test — exercises the real maintenanceRequest.report procedure + * (aggregation + Zod validation + role) via a tRPC caller, the same pattern as + * maintenance-smoke.ts. + * Run: pnpm tsx scripts/report-smoke.ts + * Requires: Docker Postgres running + pnpm db:seed already done. + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { config as loadEnv } from 'dotenv'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +loadEnv({ path: path.resolve(here, '../.env') }); + +import { prisma } from '../packages/db/src/index.js'; +import { appRouter, createTRPCContext } from '../packages/api/src/index.js'; +import { createCallerFactory } from '../packages/api/src/trpc.js'; + +let passed = 0; +let failed = 0; + +function ok(label: string) { + console.log(` ✓ ${label}`); + passed++; +} + +function fail(label: string, detail?: string) { + console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`); + failed++; +} + +function assert(condition: boolean, label: string, detail?: string) { + if (condition) ok(label); + else fail(label, detail); +} + +async function makeCaller(email: string) { + const user = await prisma.user.findFirst({ where: { email } }); + if (!user) throw new Error(`User ${email} not found — run pnpm db:seed`); + const ctx = await createTRPCContext({ + user: { + id: user.id, + email: user.email, + role: user.role as 'ADMIN' | 'OPERATOR', + tenantId: user.tenantId, + }, + headers: new Headers(), + }); + return createCallerFactory(appRouter)(ctx); +} + +async function main() { + console.log('\nReport smoke — running assertions against the real procedure…\n'); + + const admin = await makeCaller('admin@demo.local'); + + // Wide window covering all seeded "today" requests. + const from = new Date(); + from.setHours(0, 0, 0, 0); + const to = new Date(); + + const report = await admin.maintenanceRequest.report({ from, to }); + + // 1. Totals — seed creates 3 RESOLVED + 1 CLAIMED + 2 OPEN = 6 + assert(report.totals.created >= 6, `created ≥6 (got ${report.totals.created})`); + assert(report.totals.open >= 2, `open ≥2 (got ${report.totals.open})`); + assert(report.totals.claimed >= 1, `claimed ≥1 (got ${report.totals.claimed})`); + assert(report.totals.resolved >= 3, `resolved ≥3 (got ${report.totals.resolved})`); + assert( + report.totals.open + report.totals.claimed + report.totals.resolved === + report.totals.created, + 'totals add up to created', + `${report.totals.open}+${report.totals.claimed}+${report.totals.resolved} != ${report.totals.created}`, + ); + + // 2. responseMs — only rows with claimedAt (3 resolved + 1 claimed = 4) + assert(report.responseMs.count >= 4, `responseMs.count ≥4 (got ${report.responseMs.count})`); + assert( + report.responseMs.avg !== null && report.responseMs.avg > 0, + `responseMs.avg positive (${report.responseMs.avg})`, + ); + assert( + report.responseMs.max !== null && report.responseMs.max >= report.responseMs.avg!, + `responseMs.max ≥ avg (${report.responseMs.max} ≥ ${report.responseMs.avg})`, + ); + + // 3. resolutionMs — only resolved rows (3) + assert(report.resolutionMs.count >= 3, `resolutionMs.count ≥3 (got ${report.resolutionMs.count})`); + assert( + report.resolutionMs.avg !== null && report.resolutionMs.avg > 0, + `resolutionMs.avg positive (${report.resolutionMs.avg})`, + ); + + // 4. resolution takes longer than response on average (it includes it) + if (report.responseMs.avg !== null && report.resolutionMs.avg !== null) { + assert( + report.resolutionMs.avg > report.responseMs.avg, + `resolutionMs.avg (${report.resolutionMs.avg}) > responseMs.avg (${report.responseMs.avg})`, + ); + } + + // 5. byWorkstation — counts add up, sorted desc + const wTotal = report.byWorkstation.reduce((s, w) => s + w.count, 0); + assert(wTotal === report.totals.created, 'byWorkstation counts add up to created'); + const wSortedDesc = report.byWorkstation.every( + (w, i) => i === 0 || report.byWorkstation[i - 1]!.count >= w.count, + ); + assert(wSortedDesc, 'byWorkstation sorted by count desc'); + + // 6. byArea — counts add up + const aTotal = report.byArea.reduce((s, a) => s + a.count, 0); + assert(aTotal === report.totals.created, 'byArea counts add up to created'); + + // 7. stillOpen = OPEN + CLAIMED, never RESOLVED + assert( + report.stillOpen.length === report.totals.open + report.totals.claimed, + `stillOpen length == OPEN+CLAIMED (${report.stillOpen.length} == ${report.totals.open + report.totals.claimed})`, + ); + assert( + report.stillOpen.every((r) => r.status === 'OPEN' || r.status === 'CLAIMED'), + 'stillOpen contains no RESOLVED rows', + ); + + // 8. Validation: to <= from → BAD_REQUEST + try { + await admin.maintenanceRequest.report({ from: to, to: from }); + fail('to <= from → BAD_REQUEST', 'no error thrown'); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + assert(code === 'BAD_REQUEST', 'to <= from → BAD_REQUEST', `got code=${code}`); + } + + // 9. Validation: window > 31 days → BAD_REQUEST + try { + const farFrom = new Date(to.getTime() - 40 * 24 * 60 * 60 * 1000); + await admin.maintenanceRequest.report({ from: farFrom, to }); + fail('window > 31d → BAD_REQUEST', 'no error thrown'); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + assert(code === 'BAD_REQUEST', 'window > 31d → BAD_REQUEST', `got code=${code}`); + } + + // 10. Future window → empty report + const fFrom = new Date(to.getTime() + 24 * 60 * 60 * 1000); + const fTo = new Date(to.getTime() + 48 * 60 * 60 * 1000); + const empty = await admin.maintenanceRequest.report({ from: fFrom, to: fTo }); + assert(empty.totals.created === 0, 'future window → 0 created'); + assert(empty.responseMs.avg === null, 'future window → responseMs.avg null'); + assert(empty.stillOpen.length === 0, 'future window → stillOpen empty'); + + // 11. Role: an OPERATOR may not call report (requireRole) + const op = await makeCaller('op1@demo.local'); + try { + await op.maintenanceRequest.report({ from, to }); + fail('operator → FORBIDDEN', 'no error thrown'); + } catch (err: unknown) { + const code = (err as { code?: string }).code; + assert(code === 'FORBIDDEN', 'operator → FORBIDDEN', `got code=${code}`); + } + + console.log(`\n${passed} passed, ${failed} failed.\n`); + if (failed > 0) process.exit(1); +} + +main() + .catch((err) => { + console.error('Report smoke failed:', err); + process.exit(1); + }) + .finally(() => prisma.$disconnect());