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
378 lines
13 KiB
TypeScript
378 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from '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';
|
|
|
|
type Status = 'OPEN' | 'CLAIMED' | 'RESOLVED';
|
|
type QueueItem = RouterOutputs['maintenanceRequest']['queue']['items'][number];
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
|
|
function timeAgo(date: Date | string): string {
|
|
const diffMs = Date.now() - new Date(date).getTime();
|
|
const mins = Math.floor(diffMs / 60_000);
|
|
if (mins < 1) return 'agora';
|
|
if (mins < 60) return `há ${mins}m`;
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return `há ${hours}h`;
|
|
return `há ${Math.floor(hours / 24)}d`;
|
|
}
|
|
|
|
function playBeep() {
|
|
try {
|
|
const ctx = new AudioContext();
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.type = 'sine';
|
|
osc.frequency.value = 880;
|
|
gain.gain.setValueAtTime(0.2, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
|
osc.start(ctx.currentTime);
|
|
osc.stop(ctx.currentTime + 0.4);
|
|
} catch {
|
|
// AudioContext may be blocked before user interaction — ignore.
|
|
}
|
|
}
|
|
|
|
const STATUS_LABEL: Record<Status, string> = {
|
|
OPEN: 'Aberto',
|
|
CLAIMED: 'Em curso',
|
|
RESOLVED: 'Resolvido',
|
|
};
|
|
|
|
const STATUS_CLASS: Record<Status, string> = {
|
|
OPEN: 'bg-orange-100 text-orange-700',
|
|
CLAIMED: 'bg-blue-100 text-blue-700',
|
|
RESOLVED: 'bg-green-100 text-green-700',
|
|
};
|
|
|
|
// ── Thumbnail ───────────────────────────────────────────────────────────────
|
|
|
|
function Thumbnail({ photoKey }: { photoKey: string | null }) {
|
|
const { data } = trpc.storage.signPhotoDownload.useQuery(
|
|
{ photoKey: photoKey! },
|
|
{ enabled: !!photoKey, staleTime: 50_000 },
|
|
);
|
|
if (!photoKey) {
|
|
return <div className="h-16 w-16 shrink-0 rounded-lg bg-muted" />;
|
|
}
|
|
if (!data?.url) {
|
|
return <div className="h-16 w-16 shrink-0 animate-pulse rounded-lg bg-muted" />;
|
|
}
|
|
return (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={data.url} alt="Foto" className="h-16 w-16 shrink-0 rounded-lg object-cover" />
|
|
);
|
|
}
|
|
|
|
// ── Request card ────────────────────────────────────────────────────────────
|
|
|
|
function RequestCard({
|
|
item,
|
|
onClaim,
|
|
onResolve,
|
|
claiming,
|
|
}: {
|
|
item: QueueItem;
|
|
onClaim: () => void;
|
|
onResolve: () => void;
|
|
claiming: boolean;
|
|
}) {
|
|
return (
|
|
<div data-testid="request-card" className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm">
|
|
{/* Top row: thumbnail + main info */}
|
|
<div className="flex gap-3">
|
|
<Thumbnail photoKey={item.photoKey} />
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium">
|
|
{item.workstation.code} — {item.workstation.name}{' '}
|
|
<span className="text-xs text-muted-foreground">· {item.workstation.area}</span>
|
|
</p>
|
|
<p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{item.description}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Reportado por {item.reportedBy.email} · {timeAgo(item.createdAt)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer: badge + actions */}
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span
|
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[item.status as Status]}`}
|
|
>
|
|
{STATUS_LABEL[item.status as Status]}
|
|
</span>
|
|
|
|
{item.status === 'OPEN' && (
|
|
<button
|
|
onClick={onClaim}
|
|
disabled={claiming}
|
|
className="flex items-center gap-1.5 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{claiming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wrench className="h-4 w-4" />}
|
|
Aceitar
|
|
</button>
|
|
)}
|
|
|
|
{item.status === 'CLAIMED' && (
|
|
<div className="flex items-center gap-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
Aceite por {item.claimedBy?.email ?? '?'} · {timeAgo(item.claimedAt!)}
|
|
</p>
|
|
<button
|
|
onClick={onResolve}
|
|
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:opacity-90"
|
|
>
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
Marcar resolvido
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{item.status === 'RESOLVED' && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Resolvido por {item.resolvedBy?.email ?? '?'} · {timeAgo(item.resolvedAt!)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Resolve dialog ──────────────────────────────────────────────────────────
|
|
|
|
function ResolveDialog({
|
|
onConfirm,
|
|
onCancel,
|
|
note,
|
|
onNoteChange,
|
|
resolving,
|
|
}: {
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
note: string;
|
|
onNoteChange: (v: string) => void;
|
|
resolving: boolean;
|
|
}) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
|
<div className="w-full max-w-md rounded-2xl bg-card p-6 shadow-xl">
|
|
<h2 className="mb-4 text-lg font-semibold">Marcar como resolvido</h2>
|
|
<label className="mb-1 block text-sm font-medium">
|
|
Nota de resolução (opcional)
|
|
</label>
|
|
<textarea
|
|
value={note}
|
|
onChange={(e) => onNoteChange(e.target.value)}
|
|
rows={3}
|
|
placeholder="Descreve o que foi feito…"
|
|
className="mb-4 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={onCancel}
|
|
className="rounded-lg px-4 py-2 text-sm hover:bg-accent"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
disabled={resolving}
|
|
className="flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{resolving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
Confirmar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Main queue component ────────────────────────────────────────────────────
|
|
|
|
export function MaintenanceQueue() {
|
|
const [statuses, setStatuses] = useState<Status[]>(['OPEN', 'CLAIMED']);
|
|
const [area, setArea] = useState('');
|
|
const [resolveId, setResolveId] = useState<string | null>(null);
|
|
const [resolutionNote, setResolutionNote] = useState('');
|
|
const [soundEnabled, setSoundEnabled] = useState(false);
|
|
|
|
const { data, refetch } = trpc.maintenanceRequest.queue.useQuery(
|
|
{
|
|
statuses: statuses.length > 0 ? statuses : undefined,
|
|
area: area || undefined,
|
|
},
|
|
{ refetchInterval: 5000, refetchIntervalInBackground: false },
|
|
);
|
|
|
|
const items = data?.items ?? [];
|
|
const openCount = items.filter((i) => i.status === 'OPEN').length;
|
|
|
|
// Document title badge
|
|
useEffect(() => {
|
|
document.title =
|
|
openCount > 0 ? `(${openCount}) FieldOps — Manutenção` : 'FieldOps — Manutenção';
|
|
}, [openCount]);
|
|
|
|
// Audio notification for new OPEN requests
|
|
const prevOpenIds = useRef(new Set<string>());
|
|
useEffect(() => {
|
|
const currentIds = new Set(items.filter((i) => i.status === 'OPEN').map((i) => i.id));
|
|
const hasNew = [...currentIds].some((id) => !prevOpenIds.current.has(id));
|
|
if (hasNew && prevOpenIds.current.size > 0 && soundEnabled) {
|
|
playBeep();
|
|
}
|
|
prevOpenIds.current = currentIds;
|
|
}, [items, soundEnabled]);
|
|
|
|
const claimMutation = trpc.maintenanceRequest.claim.useMutation({
|
|
onSuccess: () => refetch(),
|
|
});
|
|
const resolveMutation = trpc.maintenanceRequest.resolve.useMutation({
|
|
onSuccess: () => {
|
|
setResolveId(null);
|
|
refetch();
|
|
},
|
|
});
|
|
|
|
const areas = [...new Set(items.map((i) => i.workstation.area))].sort();
|
|
|
|
function toggleStatus(s: Status) {
|
|
setStatuses((prev) =>
|
|
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s],
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
{/* Header */}
|
|
<header className="sticky top-0 z-10 border-b border-border bg-card px-4 py-3">
|
|
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-lg font-bold">
|
|
{openCount > 0 ? (
|
|
<span>
|
|
<span className="mr-1.5 inline-flex h-6 w-6 items-center justify-center rounded-full bg-orange-500 text-xs text-white">
|
|
{openCount}
|
|
</span>
|
|
pedidos abertos
|
|
</span>
|
|
) : (
|
|
'Fila de manutenção'
|
|
)}
|
|
</h1>
|
|
</div>
|
|
<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
|
|
onClick={() => setSoundEnabled((v) => !v)}
|
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
|
soundEnabled
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{soundEnabled ? '🔔 Som on' : '🔕 Som off'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mx-auto mt-2 flex max-w-4xl flex-wrap items-center gap-3">
|
|
<span className="text-xs text-muted-foreground">Estado:</span>
|
|
{(['OPEN', 'CLAIMED', 'RESOLVED'] as Status[]).map((s) => (
|
|
<label key={s} className="flex cursor-pointer items-center gap-1.5 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={statuses.includes(s)}
|
|
onChange={() => toggleStatus(s)}
|
|
className="rounded"
|
|
/>
|
|
{STATUS_LABEL[s]}
|
|
</label>
|
|
))}
|
|
|
|
{areas.length > 0 && (
|
|
<>
|
|
<span className="text-xs text-muted-foreground">Área:</span>
|
|
<select
|
|
value={area}
|
|
onChange={(e) => setArea(e.target.value)}
|
|
className="rounded-lg border border-border bg-card px-2 py-1 text-sm"
|
|
>
|
|
<option value="">Todas</option>
|
|
{areas.map((a) => (
|
|
<option key={a} value={a}>
|
|
{a}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</>
|
|
)}
|
|
|
|
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Clock className="h-3 w-3" />
|
|
Atualiza a cada 5s
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Cards */}
|
|
<main className="mx-auto max-w-4xl p-4">
|
|
{items.length === 0 ? (
|
|
<div className="py-16 text-center text-muted-foreground">
|
|
<Wrench className="mx-auto mb-3 h-10 w-10 opacity-30" />
|
|
<p>Nenhum pedido com os filtros actuais.</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{items.map((item) => (
|
|
<RequestCard
|
|
key={item.id}
|
|
item={item}
|
|
onClaim={() => claimMutation.mutate({ id: item.id })}
|
|
onResolve={() => {
|
|
setResolveId(item.id);
|
|
setResolutionNote('');
|
|
}}
|
|
claiming={
|
|
claimMutation.isPending &&
|
|
claimMutation.variables?.id === item.id
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Resolve dialog */}
|
|
{resolveId && (
|
|
<ResolveDialog
|
|
note={resolutionNote}
|
|
onNoteChange={setResolutionNote}
|
|
onConfirm={() =>
|
|
resolveMutation.mutate({
|
|
id: resolveId,
|
|
resolutionNote: resolutionNote.trim() || undefined,
|
|
})
|
|
}
|
|
onCancel={() => setResolveId(null)}
|
|
resolving={resolveMutation.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|