MAI CALL - step 11

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)
This commit is contained in:
2026-05-16 16:41:16 +01:00
parent 03c15fd069
commit 617c81357f
19 changed files with 735 additions and 152 deletions
@@ -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 ?? '<no-path>'}:`, error.message);
}
},
});
};
export { handler as GET, handler as POST };
+34
View File
@@ -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;
}
}
+7 -14
View File
@@ -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 (
<html lang="en">
<body
style={{
fontFamily: 'system-ui, sans-serif',
margin: 0,
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{children}
<html lang="pt">
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
@@ -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 `${mins}m`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h`;
return `${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 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">
<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>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { MaintenanceQueue } from './maintenance-queue';
export default function MaintenancePage() {
return <MaintenanceQueue />;
}
+4 -7
View File
@@ -1,8 +1,5 @@
export default function Page() {
return (
<main style={{ textAlign: 'center' }}>
<h1 style={{ fontSize: '2rem', marginBottom: '0.5rem' }}>FieldOps Admin</h1>
<p style={{ color: '#64748b' }}>Coming soon.</p>
</main>
);
import { redirect } from 'next/navigation';
export default function RootPage() {
redirect('/maintenance');
}
+36
View File
@@ -0,0 +1,36 @@
'use client';
import { useState, type ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import { trpc } from '@/lib/trpc/client';
function makeTrpcClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
transformer: superjson,
}),
],
});
}
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: { staleTime: 10 * 1000, refetchOnWindowFocus: false },
},
}),
);
const [trpcClient] = useState(makeTrpcClient);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
AUTH_DEV_AUTOLOGIN: z
.string()
.optional()
.transform((v) => v === 'true'),
LOG_LEVEL: z
.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace'])
.default('info'),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url().optional(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
AUTH_DEV_AUTOLOGIN: process.env.AUTH_DEV_AUTOLOGIN,
LOG_LEVEL: process.env.LOG_LEVEL,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
emptyStringAsUndefined: true,
});
+18
View File
@@ -0,0 +1,18 @@
import { prisma } from '@repo/db';
import type { SessionUser } from '@repo/api';
// v0.1 admin-web auth: AUTH_DEV_AUTOLOGIN=true → always admin@demo.local.
// No session/cookie mechanism needed for the demo phase.
export async function resolveUser(): Promise<SessionUser | null> {
if (process.env['AUTH_DEV_AUTOLOGIN'] !== 'true') return null;
const admin = await prisma.user.findFirst({ where: { email: 'admin@demo.local' } });
if (!admin) return null;
return {
id: admin.id,
email: admin.email,
role: admin.role as 'ADMIN',
tenantId: admin.tenantId,
};
}
+6
View File
@@ -0,0 +1,6 @@
'use client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@repo/api';
export const trpc = createTRPCReact<AppRouter>();
+19
View File
@@ -0,0 +1,19 @@
import 'server-only';
import { cache } from 'react';
import { headers } from 'next/headers';
import { appRouter, createCallerFactory, createTRPCContext, type AppRouter } from '@repo/api';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { resolveUser } from '../auth';
const createContext = cache(async () => {
const user = await resolveUser();
const h = await headers();
return createTRPCContext({ user, headers: h });
});
const createCaller = createCallerFactory(appRouter);
export const api = createCaller(createContext);
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;
+8
View File
@@ -1,8 +1,16 @@
import type { NextConfig } from 'next';
const config: NextConfig = {
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'],
reactStrictMode: true,
poweredByHeader: false,
serverExternalPackages: [
'pino',
'pino-pretty',
'@aws-sdk/client-s3',
'@aws-sdk/s3-request-presigner',
'@smithy/node-http-handler',
],
};
export default config;
+19 -2
View File
@@ -12,17 +12,34 @@
"clean": "rimraf .next .turbo node_modules"
},
"dependencies": {
"next": "^15.1.3",
"@repo/api": "workspace:*",
"@repo/db": "workspace:*",
"@repo/storage": "workspace:*",
"@repo/ui": "workspace:*",
"@t3-oss/env-nextjs": "^0.11.1",
"@tanstack/react-query": "^5.62.10",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"lucide-react": "^0.469.0",
"next": "15.3.9",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"superjson": "^2.2.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@repo/config": "workspace:*",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"dotenv-cli": "^8.0.0",
"postcss": "^8.4.49",
"rimraf": "^6.0.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2"
}
}
+9
View File
@@ -0,0 +1,9 @@
// Minimal Pages Router override — prevents the default next/document-based
// error page from breaking App Router production builds.
function ErrorPage({ statusCode }: { statusCode?: number }) {
return <p>Erro {statusCode ?? 'desconhecido'}</p>;
}
ErrorPage.getInitialProps = ({ res, err }: { res?: { statusCode: number }; err?: { statusCode: number } }) => ({
statusCode: res?.statusCode ?? err?.statusCode ?? 500,
});
export default ErrorPage;
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+13
View File
@@ -0,0 +1,13 @@
import type { Config } from 'tailwindcss';
import preset from '@repo/config/tailwind/preset';
const config: Config = {
presets: [preset],
content: [
'./app/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
'../../packages/ui/src/**/*.{ts,tsx}',
],
};
export default config;