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
+
setSoundEnabled((v) => !v)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
diff --git a/apps/admin-web/app/maintenance/report/page.tsx b/apps/admin-web/app/maintenance/report/page.tsx
new file mode 100644
index 0000000..f1d111d
--- /dev/null
+++ b/apps/admin-web/app/maintenance/report/page.tsx
@@ -0,0 +1,7 @@
+import { ReportView } from './report-view';
+
+export const metadata = { title: 'FieldOps — Relatório de turno' };
+
+export default function ReportPage() {
+ return ;
+}
diff --git a/apps/admin-web/app/maintenance/report/report-view.tsx b/apps/admin-web/app/maintenance/report/report-view.tsx
new file mode 100644
index 0000000..9afd523
--- /dev/null
+++ b/apps/admin-web/app/maintenance/report/report-view.tsx
@@ -0,0 +1,421 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+import Link from 'next/link';
+import { ArrowLeft, Printer, AlertCircle } from 'lucide-react';
+import { trpc } from '@/lib/trpc/client';
+import { SHIFTS, shiftWindow, todayWindow, type ShiftKey } from '@/lib/shifts';
+
+// ── Duration helper ─────────────────────────────────────────────────────────
+
+function formatDuration(ms: number | null): string {
+ if (ms === null) return '—';
+ const totalMin = Math.round(ms / 60_000);
+ if (totalMin < 1) return '< 1 min';
+ if (totalMin < 60) return `${totalMin} min`;
+ const h = Math.floor(totalMin / 60);
+ const m = totalMin % 60;
+ return m > 0 ? `${h} h ${m} min` : `${h} h`;
+}
+
+function formatDateTime(d: Date | string): string {
+ const dt = new Date(d);
+ return dt.toLocaleString('pt-PT', {
+ day: '2-digit',
+ month: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+}
+
+function formatDate(d: Date | string): string {
+ return new Date(d).toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit' });
+}
+
+// ── Window label ─────────────────────────────────────────────────────────────
+
+function windowLabel(from: Date, to: Date): string {
+ return `${formatDateTime(from)} → ${formatDateTime(to)}`;
+}
+
+// ── Metric card ──────────────────────────────────────────────────────────────
+
+function MetricCard({ label, value, sub }: { label: string; value: string; sub?: string }) {
+ return (
+
+
+ {label}
+
+
{value}
+ {sub &&
{sub}
}
+
+ );
+}
+
+const STATUS_LABEL: Record<'OPEN' | 'CLAIMED', string> = {
+ OPEN: 'Aberto',
+ CLAIMED: 'Em curso',
+};
+
+const STATUS_CLASS: Record<'OPEN' | 'CLAIMED', string> = {
+ OPEN: 'bg-orange-100 text-orange-700',
+ CLAIMED: 'bg-blue-100 text-blue-700',
+};
+
+// ── Main component ───────────────────────────────────────────────────────────
+
+type WindowState =
+ | { type: 'shift'; key: ShiftKey; day: Date }
+ | { type: 'today' }
+ | { type: 'custom'; from: Date; to: Date };
+
+function computeWindow(state: WindowState): { from: Date; to: Date } {
+ const now = new Date();
+ if (state.type === 'today') return todayWindow(now);
+ if (state.type === 'shift') return shiftWindow(state.key, state.day);
+ return { from: state.from, to: state.to };
+}
+
+function localDateStr(d: Date): string {
+ const y = d.getFullYear();
+ const m = String(d.getMonth() + 1).padStart(2, '0');
+ const day = String(d.getDate()).padStart(2, '0');
+ return `${y}-${m}-${day}`;
+}
+
+function localDateTimeStr(d: Date): string {
+ return `${localDateStr(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
+}
+
+export function ReportView() {
+ const [windowState, setWindowState] = useState({ type: 'today' });
+ const [dayInput, setDayInput] = useState(() => localDateStr(new Date()));
+ const [customActive, setCustomActive] = useState(false);
+ const [customPending, setCustomPending] = useState(() => {
+ const now = new Date();
+ const midnight = new Date(now);
+ midnight.setHours(0, 0, 0, 0);
+ return { from: localDateTimeStr(midnight), to: localDateTimeStr(now) };
+ });
+
+ // Stabilise the window so the query key only changes when the user picks a
+ // new window. Without this, the 'today' mode recomputes `to = new Date()` on
+ // every render → new query key → fetch loop. Re-selecting "Hoje" refreshes.
+ const win = useMemo(() => computeWindow(windowState), [windowState]);
+
+ const { data, isLoading, error } = trpc.maintenanceRequest.report.useQuery(
+ { from: win.from, to: win.to },
+ { staleTime: 30_000 },
+ );
+
+ function selectShift(key: ShiftKey) {
+ const day = new Date(dayInput + 'T00:00:00');
+ setCustomActive(false);
+ setWindowState({ type: 'shift', key, day });
+ }
+
+ function selectToday() {
+ setCustomActive(false);
+ setWindowState({ type: 'today' });
+ }
+
+ function applyCustom() {
+ const from = new Date(customPending.from);
+ const to = new Date(customPending.to);
+ if (isNaN(from.getTime()) || isNaN(to.getTime()) || to <= from) return;
+ setWindowState({ type: 'custom', from, to });
+ }
+
+ const activeShift =
+ windowState.type === 'shift' ? windowState.key : null;
+
+ return (
+
+ {/* ── Header (hidden in print) ── */}
+
+
+
+
+
+ Fila
+
+
/
+
Relatório de turno
+
+
window.print()}
+ className="flex items-center gap-1.5 rounded-lg bg-muted px-3 py-1.5 text-sm font-medium hover:bg-accent"
+ >
+
+ Imprimir
+
+
+
+
+ {/* ── Print header (only in print) ── */}
+
+
FieldOps — Relatório de manutenção
+
{windowLabel(win.from, win.to)}
+
+
+ {/* ── Window selector (hidden in print) ── */}
+
+
+ {/* ── Body ── */}
+
+ {isLoading && (
+ A carregar…
+ )}
+
+ {error && (
+
+ )}
+
+ {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
+
+
+
+
+
+ Código
+ Nome
+ Área
+ Pedidos
+
+
+
+ {data.byWorkstation.map((ws, i) => (
+
+ {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());