Pedro Gomes fdfa936461 MAI CALL - v0.3
O que o supervisor encontra agora:

Na fila de manutenção (:3001), novo botão "Relatório de turno" no header.
Página /maintenance/report com:
Atalhos Manhã / Tarde / Noite / Hoje + seletor de dia + Personalizado (date-time livre)
Label sempre visível com a janela ativa ("Turno da Manhã — 30/05 06:00 → 14:00")
6 cartões de métricas: pedidos, resolvidos, em aberto, tempo médio de resposta, tempo médio de resolução, pior resposta
Tabela por posto e resumo por área
Lista "Em aberto à hora do relatório" (ou "Nada em aberto. ✓")
Botão Imprimir → PDF via browser; CSS @media print limpa botões/nav
Verificações verdes:

report-smoke.ts — 17/17 (totals, responseMs, resolutionMs, byWorkstation, byArea, stillOpen, window edge cases)
E2E MAI CALL happy-path — 1/1 (dados de seed extra não interferem)
TypeScript — limpo nos pacotes tocados (@repo/api, @repo/admin-web)
Seed cria 6 pedidos de exemplo: relatório "Hoje" nunca começa vazio

+

Resumo da revisão do v0.3
Conformidade com o plano: alta. Shape de output exato, ctx.db (tenant-scoped), requireRole, helper de turnos com o caso da noite, seed com 6 pedidos, UI completa + impressão. Tudo no sítio.

Dois defeitos reais que escaparam ao typecheck e ao E2E — corrigidos:

# Problema Correção
🔴 1 Fetch storm no modo "Hoje" (default): computeWindow recalculava to = new Date() a cada render → nova query key → loop de fetch contínuo. useMemo([windowState]) estabiliza a janela em report-view.tsx:101. Reclicar "Hoje" refresca. Também limpei estado morto (customFrom/customTo).
🔴 2 Smoke não cumpria o AC: re-implementava a agregação à mão em vez de chamar a procedure, e não testava to <= from → BAD_REQUEST (exigido pelo AC do Passo 1). Reescrito report-smoke.ts no padrão createCallerFactory — agora exercita a procedure real: agregação, BAD_REQUEST (to≤from e >31d), janela futura vazia, e FORBIDDEN para operador.
Verificações finais (todas verdes):

tsc --noEmit admin-web — limpo
report-smoke.ts — 22/22 (agora contra a procedure real)
E2E MAI CALL — 1 passed
2026-05-30 12:51:14 +01:00

185 lines
6.1 KiB
TypeScript

import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
// Load repo-root .env so DATABASE_URL is visible when this script runs from
// any CWD (pnpm invokes it from packages/db).
const here = path.dirname(fileURLToPath(import.meta.url));
loadEnv({ path: path.resolve(here, '../../../.env') });
const { PrismaClient, UserRole } = await import('@prisma/client');
const { hashSecret } = await import('../src/crypto.js');
const prisma = new PrismaClient();
const DEMO_TENANT_NAME = 'Demo Factory';
const DEMO_ADMIN_EMAIL = 'admin@demo.local';
const DEMO_ADMIN_PASSWORD = 'admin1234';
const OPERATORS = [
{ email: 'op1@demo.local', pin: '1111' },
{ email: 'op2@demo.local', pin: '2222' },
{ email: 'op3@demo.local', pin: '3333' },
];
const WORKSTATIONS = [
{ code: 'CTR04', name: 'Controlo 04', area: 'Montagem' },
{ code: 'QVN_RTL_2', name: 'Retificação Visual 2', area: 'Qualidade' },
{ code: 'MTG_01', name: 'Montagem 01', area: 'Montagem' },
];
async function main() {
// Idempotent: if a prior run created the demo tenant, wipe it and recreate.
// Cascade deletes on the relations handle the children.
const existing = await prisma.tenant.findFirst({ where: { name: DEMO_TENANT_NAME } });
if (existing) {
await prisma.tenant.delete({ where: { id: existing.id } });
}
const tenant = await prisma.tenant.create({
data: { name: DEMO_TENANT_NAME },
});
await prisma.user.create({
data: {
tenantId: tenant.id,
email: DEMO_ADMIN_EMAIL,
role: UserRole.ADMIN,
passwordHash: await hashSecret(DEMO_ADMIN_PASSWORD),
},
});
for (const op of OPERATORS) {
await prisma.user.create({
data: {
tenantId: tenant.id,
email: op.email,
role: UserRole.OPERATOR,
passwordHash: await hashSecret(op.pin),
},
});
}
await prisma.workstation.createMany({
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})`,
);
console.warn(
` admin: ${DEMO_ADMIN_EMAIL} / ${DEMO_ADMIN_PASSWORD}`,
);
console.warn(
` operadores: ${OPERATORS.map((o) => `${o.email}=${o.pin}`).join(' | ')}`,
);
}
main()
.catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});