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
This commit is contained in:
parent
98f6444736
commit
fdfa936461
24
README.md
24
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
|
Modular industrial SaaS monorepo. **MAI CALL v0.3** is the latest shipped
|
||||||
feature: a full maintenance-request loop that runs on factory Wi-Fi (and
|
feature: a full maintenance-request loop (offline-first, operator PIN + admin
|
||||||
survives losing it).
|
password auth) with an **end-of-shift report** for supervisors.
|
||||||
|
|
||||||
## What's here
|
## 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
|
5. The document tab title shows `(N) FieldOps — Manutenção` when there are
|
||||||
open requests.
|
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)
|
## 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. | — |
|
| **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 |
|
| **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 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 |
|
| **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 |
|
| **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 |
|
| **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/storage-smoke.ts` | Verify MinIO presigned upload/download |
|
||||||
| `pnpm tsx scripts/maintenance-smoke.ts` | Verify the full create→claim→resolve cycle |
|
| `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/auth-smoke.ts` | Verify hashing, PIN/password login, and lockout |
|
||||||
|
| `pnpm tsx scripts/report-smoke.ts` | Verify shift-report aggregation against seeded data |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -32,3 +32,19 @@
|
|||||||
@apply bg-background text-foreground;
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
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 { trpc } from '@/lib/trpc/client';
|
||||||
import type { RouterOutputs } from '@/lib/trpc/server';
|
import type { RouterOutputs } from '@/lib/trpc/server';
|
||||||
|
|
||||||
@ -268,6 +269,13 @@ export function MaintenanceQueue() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Link
|
||||||
|
href="/maintenance/report"
|
||||||
|
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
<BarChart2 className="h-3 w-3" />
|
||||||
|
Relatório de turno
|
||||||
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSoundEnabled((v) => !v)}
|
onClick={() => setSoundEnabled((v) => !v)}
|
||||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
|||||||
7
apps/admin-web/app/maintenance/report/page.tsx
Normal file
7
apps/admin-web/app/maintenance/report/page.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { ReportView } from './report-view';
|
||||||
|
|
||||||
|
export const metadata = { title: 'FieldOps — Relatório de turno' };
|
||||||
|
|
||||||
|
export default function ReportPage() {
|
||||||
|
return <ReportView />;
|
||||||
|
}
|
||||||
421
apps/admin-web/app/maintenance/report/report-view.tsx
Normal file
421
apps/admin-web/app/maintenance/report/report-view.tsx
Normal file
@ -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 (
|
||||||
|
<div className="rounded-xl border border-border bg-card p-4 print:border-gray-300">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{value}</p>
|
||||||
|
{sub && <p className="mt-0.5 text-xs text-muted-foreground print:text-gray-500">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<WindowState>({ 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 (
|
||||||
|
<div className="min-h-screen bg-background print:bg-white">
|
||||||
|
{/* ── Header (hidden in print) ── */}
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-card px-4 py-3 print:hidden">
|
||||||
|
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/maintenance"
|
||||||
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Fila
|
||||||
|
</Link>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<h1 className="text-lg font-bold">Relatório de turno</h1>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => 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"
|
||||||
|
>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
Imprimir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ── Print header (only in print) ── */}
|
||||||
|
<div className="hidden print:block px-8 pt-6 pb-2">
|
||||||
|
<p className="text-lg font-bold">FieldOps — Relatório de manutenção</p>
|
||||||
|
<p className="text-sm text-gray-600">{windowLabel(win.from, win.to)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Window selector (hidden in print) ── */}
|
||||||
|
<div className="border-b border-border bg-muted/30 px-4 py-3 print:hidden">
|
||||||
|
<div className="mx-auto max-w-4xl space-y-3">
|
||||||
|
{/* Shift shortcuts + day picker */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={selectToday}
|
||||||
|
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
|
windowState.type === 'today'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-card border border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Hoje
|
||||||
|
</button>
|
||||||
|
{(Object.keys(SHIFTS) as ShiftKey[]).map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => selectShift(key)}
|
||||||
|
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
|
activeShift === key
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-card border border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{SHIFTS[key].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dayInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setCustomActive((v) => !v)}
|
||||||
|
className={`rounded-full px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
|
customActive || windowState.type === 'custom'
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-card border border-border hover:bg-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Personalizado
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom range inputs */}
|
||||||
|
{customActive && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={customPending.from}
|
||||||
|
onChange={(e) => setCustomPending((p) => ({ ...p, from: e.target.value }))}
|
||||||
|
className="rounded-lg border border-border bg-card px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">até</span>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={customPending.to}
|
||||||
|
onChange={(e) => setCustomPending((p) => ({ ...p, to: e.target.value }))}
|
||||||
|
className="rounded-lg border border-border bg-card px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={applyCustom}
|
||||||
|
className="rounded-lg bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||||
|
>
|
||||||
|
Aplicar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active window label */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{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)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ── */}
|
||||||
|
<main className="mx-auto max-w-4xl px-4 py-6 print:px-8 print:py-4">
|
||||||
|
{isLoading && (
|
||||||
|
<p className="py-16 text-center text-muted-foreground">A carregar…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-destructive">
|
||||||
|
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||||
|
<p className="text-sm">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.totals.created === 0 && (
|
||||||
|
<div className="py-16 text-center text-muted-foreground">
|
||||||
|
<p className="text-lg">Sem pedidos nesta janela.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && data.totals.created > 0 && (
|
||||||
|
<div className="space-y-8 print:space-y-6">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||||
|
Resumo
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 print:grid-cols-3">
|
||||||
|
<MetricCard label="Pedidos" value={String(data.totals.created)} />
|
||||||
|
<MetricCard label="Resolvidos" value={String(data.totals.resolved)} />
|
||||||
|
<MetricCard
|
||||||
|
label="Em aberto"
|
||||||
|
value={String(data.totals.open + data.totals.claimed)}
|
||||||
|
sub={
|
||||||
|
data.totals.open > 0 || data.totals.claimed > 0
|
||||||
|
? `${data.totals.open} aberto · ${data.totals.claimed} em curso`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Resposta média"
|
||||||
|
value={formatDuration(data.responseMs.avg)}
|
||||||
|
sub={
|
||||||
|
data.responseMs.count > 0
|
||||||
|
? `sobre ${data.responseMs.count} pedido${data.responseMs.count > 1 ? 's' : ''}`
|
||||||
|
: 'sem dados'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Resolução média"
|
||||||
|
value={formatDuration(data.resolutionMs.avg)}
|
||||||
|
sub={
|
||||||
|
data.resolutionMs.count > 0
|
||||||
|
? `sobre ${data.resolutionMs.count} pedido${data.resolutionMs.count > 1 ? 's' : ''}`
|
||||||
|
: 'sem dados'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
label="Pior resposta"
|
||||||
|
value={formatDuration(data.responseMs.max)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* By workstation */}
|
||||||
|
{data.byWorkstation.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||||
|
Por posto
|
||||||
|
</h2>
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border print:border-gray-300">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-muted/50 text-left print:bg-gray-50 print:border-gray-300">
|
||||||
|
<th className="px-4 py-2 font-medium">Código</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Nome</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Área</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Pedidos</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.byWorkstation.map((ws, i) => (
|
||||||
|
<tr
|
||||||
|
key={ws.workstationId}
|
||||||
|
className={`border-b border-border last:border-0 print:border-gray-200 ${
|
||||||
|
i % 2 === 0 ? '' : 'bg-muted/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{ws.code}</td>
|
||||||
|
<td className="px-4 py-2">{ws.name}</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground print:text-gray-500">
|
||||||
|
{ws.area}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium">{ws.count}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* By area */}
|
||||||
|
{data.byArea.length > 1 && (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||||
|
Por área
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{data.byArea.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.area}
|
||||||
|
className="rounded-lg border border-border bg-card px-4 py-2 print:border-gray-300"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{a.area}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground print:text-gray-500">
|
||||||
|
{a.count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Still open */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||||
|
Em aberto à hora do relatório
|
||||||
|
</h2>
|
||||||
|
{data.stillOpen.length === 0 ? (
|
||||||
|
<p className="text-sm text-green-600">Nada em aberto neste turno. ✓</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.stillOpen.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="flex flex-col gap-1 rounded-xl border border-border bg-card px-4 py-3 print:border-gray-300 sm:flex-row sm:items-center sm:gap-4"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium">
|
||||||
|
{r.code} — {r.name}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground print:text-gray-500">
|
||||||
|
· {r.area}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground print:text-gray-500">
|
||||||
|
{r.description}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground print:text-gray-500">
|
||||||
|
Reportado por {r.reportedByEmail} · {formatDate(r.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium print:border print:bg-transparent ${STATUS_CLASS[r.status]}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABEL[r.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/admin-web/lib/shifts.ts
Normal file
25
apps/admin-web/lib/shifts.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
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 },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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 };
|
||||||
|
}
|
||||||
200
docs/plans/mai-call-v0.3-shift-report.md
Normal file
200
docs/plans/mai-call-v0.3-shift-report.md
Normal file
@ -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<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.
|
||||||
@ -204,4 +204,111 @@ export const maintenanceRequestRouter = router({
|
|||||||
if (!request) throw new TRPCError({ code: 'NOT_FOUND' });
|
if (!request) throw new TRPCError({ code: 'NOT_FOUND' });
|
||||||
return request;
|
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<string, number>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -64,6 +64,105 @@ async function main() {
|
|||||||
data: WORKSTATIONS.map((ws) => ({ tenantId: tenant.id, ...ws })),
|
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(
|
console.warn(
|
||||||
`Seed complete — tenant=${tenant.id} (${tenant.name})`,
|
`Seed complete — tenant=${tenant.id} (${tenant.name})`,
|
||||||
);
|
);
|
||||||
|
|||||||
170
scripts/report-smoke.ts
Normal file
170
scripts/report-smoke.ts
Normal file
@ -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());
|
||||||
Loading…
x
Reference in New Issue
Block a user