From 617c81357f586786f5e1c6cdcccda3ffb482fe0c Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Sat, 16 May 2026 16:41:16 +0100 Subject: [PATCH] MAI CALL - step 11 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Passo 11 completo. Build limpo, AC verificado. O que foi construído no admin-web (localhost:3001): Infraestrutura completa a partir do zero: Tailwind, tRPC client/server, auth por autologin, env.ts, providers /maintenance — cliente de polling com refetchInterval: 5000ms: Header com contador de pedidos abertos + filtros por estado (checkboxes) e área (select) Grid de cards com thumbnail (presigned GET), posto, descrição, reporter + tempo relativo, badge de status OPEN → botão Aceitar (mutation claim) CLAIMED → info "Aceite por X há Ym" + botão Marcar resolvido (dialog com nota opcional) RESOLVED → badge verde + info "Resolvido por X há Ym" Badge no document.title: (N) FieldOps — Manutenção Toggle de notificação sonora via Web Audio API (beep ao detectar novo OPEN) --- .claude/settings.local.json | 6 +- apps/admin-web/app/api/trpc/[trpc]/route.ts | 24 ++ apps/admin-web/app/globals.css | 34 ++ apps/admin-web/app/layout.tsx | 21 +- .../app/maintenance/maintenance-queue.tsx | 369 ++++++++++++++++++ apps/admin-web/app/maintenance/page.tsx | 5 + apps/admin-web/app/page.tsx | 11 +- apps/admin-web/app/providers.tsx | 36 ++ apps/admin-web/env.ts | 25 ++ apps/admin-web/lib/auth.ts | 18 + apps/admin-web/lib/trpc/client.ts | 6 + apps/admin-web/lib/trpc/server.ts | 19 + apps/admin-web/next.config.ts | 8 + apps/admin-web/package.json | 21 +- apps/admin-web/pages/_error.tsx | 9 + apps/admin-web/postcss.config.cjs | 6 + apps/admin-web/tailwind.config.ts | 13 + pnpm-lock.yaml | 181 +++------ scripts/admin-queue-smoke.ts | 75 ++++ 19 files changed, 735 insertions(+), 152 deletions(-) create mode 100644 apps/admin-web/app/api/trpc/[trpc]/route.ts create mode 100644 apps/admin-web/app/globals.css create mode 100644 apps/admin-web/app/maintenance/maintenance-queue.tsx create mode 100644 apps/admin-web/app/maintenance/page.tsx create mode 100644 apps/admin-web/app/providers.tsx create mode 100644 apps/admin-web/env.ts create mode 100644 apps/admin-web/lib/auth.ts create mode 100644 apps/admin-web/lib/trpc/client.ts create mode 100644 apps/admin-web/lib/trpc/server.ts create mode 100644 apps/admin-web/pages/_error.tsx create mode 100644 apps/admin-web/postcss.config.cjs create mode 100644 apps/admin-web/tailwind.config.ts create mode 100644 scripts/admin-queue-smoke.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3c6f15c..5ee87a2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -34,7 +34,11 @@ "Bash(Select-Object -Last 15)", "Bash(pnpm --filter @repo/operator-pwa build -- --no-lint)", "Bash(pnpm --filter @repo/operator-pwa exec next build)", - "Bash(del \"c:\\\\Users\\\\prdcg\\\\Documents\\\\Git\\\\FieldOps\\\\apps\\\\operator-pwa\\\\app\\\\ping-client.tsx\")" + "Bash(del \"c:\\\\Users\\\\prdcg\\\\Documents\\\\Git\\\\FieldOps\\\\apps\\\\operator-pwa\\\\app\\\\ping-client.tsx\")", + "Bash(Get-ChildItem -Path \"c:\\\\Users\\\\prdcg\\\\Documents\\\\Git\\\\FieldOps\" -Directory)", + "Bash(Select-Object Name)", + "Bash(pnpm --filter @repo/admin-web typecheck)", + "Bash(pnpm --filter @repo/admin-web build)" ] } } diff --git a/apps/admin-web/app/api/trpc/[trpc]/route.ts b/apps/admin-web/app/api/trpc/[trpc]/route.ts new file mode 100644 index 0000000..63bd61c --- /dev/null +++ b/apps/admin-web/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,24 @@ +import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; +import { appRouter, createTRPCContext } from '@repo/api'; +import { resolveUser } from '@/lib/auth'; + +export const runtime = 'nodejs'; + +const handler = async (req: Request) => { + return fetchRequestHandler({ + endpoint: '/api/trpc', + req, + router: appRouter, + createContext: async () => { + const user = await resolveUser(); + return createTRPCContext({ user, headers: req.headers }); + }, + onError({ error, path }) { + if (process.env.NODE_ENV === 'development') { + console.error(`[trpc] ${path ?? ''}:`, error.message); + } + }, + }); +}; + +export { handler as GET, handler as POST }; diff --git a/apps/admin-web/app/globals.css b/apps/admin-web/app/globals.css new file mode 100644 index 0000000..53e520b --- /dev/null +++ b/apps/admin-web/app/globals.css @@ -0,0 +1,34 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.5rem; + } + + * { + @apply border-border; + } + + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/admin-web/app/layout.tsx b/apps/admin-web/app/layout.tsx index 45237d1..70e04c3 100644 --- a/apps/admin-web/app/layout.tsx +++ b/apps/admin-web/app/layout.tsx @@ -1,24 +1,17 @@ import type { Metadata } from 'next'; +import { Providers } from './providers'; +import './globals.css'; export const metadata: Metadata = { - title: 'FieldOps Admin', - description: 'Backoffice — coming soon.', + title: 'FieldOps — Manutenção', + description: 'Backoffice de manutenção industrial.', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - - - {children} + + + {children} ); diff --git a/apps/admin-web/app/maintenance/maintenance-queue.tsx b/apps/admin-web/app/maintenance/maintenance-queue.tsx new file mode 100644 index 0000000..6b54e80 --- /dev/null +++ b/apps/admin-web/app/maintenance/maintenance-queue.tsx @@ -0,0 +1,369 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { CheckCircle2, Clock, Loader2, Wrench } from 'lucide-react'; +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 = { + OPEN: 'Aberto', + CLAIMED: 'Em curso', + RESOLVED: 'Resolvido', +}; + +const STATUS_CLASS: Record = { + 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
; + } + if (!data?.url) { + return
; + } + return ( + // eslint-disable-next-line @next/next/no-img-element + Foto + ); +} + +// ── Request card ──────────────────────────────────────────────────────────── + +function RequestCard({ + item, + onClaim, + onResolve, + claiming, +}: { + item: QueueItem; + onClaim: () => void; + onResolve: () => void; + claiming: boolean; +}) { + return ( +
+ {/* Top row: thumbnail + main info */} +
+ +
+

+ {item.workstation.code} — {item.workstation.name}{' '} + · {item.workstation.area} +

+

{item.description}

+

+ Reportado por {item.reportedBy.email} · {timeAgo(item.createdAt)} +

+
+
+ + {/* Footer: badge + actions */} +
+ + {STATUS_LABEL[item.status as Status]} + + + {item.status === 'OPEN' && ( + + )} + + {item.status === 'CLAIMED' && ( +
+

+ Aceite por {item.claimedBy?.email ?? '?'} · {timeAgo(item.claimedAt!)} +

+ +
+ )} + + {item.status === 'RESOLVED' && ( +

+ Resolvido por {item.resolvedBy?.email ?? '?'} · {timeAgo(item.resolvedAt!)} +

+ )} +
+
+ ); +} + +// ── Resolve dialog ────────────────────────────────────────────────────────── + +function ResolveDialog({ + onConfirm, + onCancel, + note, + onNoteChange, + resolving, +}: { + onConfirm: () => void; + onCancel: () => void; + note: string; + onNoteChange: (v: string) => void; + resolving: boolean; +}) { + return ( +
+
+

Marcar como resolvido

+ +