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:
parent
03c15fd069
commit
617c81357f
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
24
apps/admin-web/app/api/trpc/[trpc]/route.ts
Normal file
24
apps/admin-web/app/api/trpc/[trpc]/route.ts
Normal file
@ -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
apps/admin-web/app/globals.css
Normal file
34
apps/admin-web/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
369
apps/admin-web/app/maintenance/maintenance-queue.tsx
Normal file
369
apps/admin-web/app/maintenance/maintenance-queue.tsx
Normal file
@ -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<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
apps/admin-web/app/maintenance/page.tsx
Normal file
5
apps/admin-web/app/maintenance/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { MaintenanceQueue } from './maintenance-queue';
|
||||
|
||||
export default function MaintenancePage() {
|
||||
return <MaintenanceQueue />;
|
||||
}
|
||||
@ -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
apps/admin-web/app/providers.tsx
Normal file
36
apps/admin-web/app/providers.tsx
Normal 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
apps/admin-web/env.ts
Normal file
25
apps/admin-web/env.ts
Normal 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
apps/admin-web/lib/auth.ts
Normal file
18
apps/admin-web/lib/auth.ts
Normal 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
apps/admin-web/lib/trpc/client.ts
Normal file
6
apps/admin-web/lib/trpc/client.ts
Normal 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
apps/admin-web/lib/trpc/server.ts
Normal file
19
apps/admin-web/lib/trpc/server.ts
Normal 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>;
|
||||
@ -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;
|
||||
|
||||
@ -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
apps/admin-web/pages/_error.tsx
Normal file
9
apps/admin-web/pages/_error.tsx
Normal 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
apps/admin-web/postcss.config.cjs
Normal file
6
apps/admin-web/postcss.config.cjs
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
13
apps/admin-web/tailwind.config.ts
Normal file
13
apps/admin-web/tailwind.config.ts
Normal 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;
|
||||
181
pnpm-lock.yaml
generated
181
pnpm-lock.yaml
generated
@ -35,15 +35,57 @@ importers:
|
||||
|
||||
apps/admin-web:
|
||||
dependencies:
|
||||
'@repo/api':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/api
|
||||
'@repo/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db
|
||||
'@repo/storage':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/storage
|
||||
'@repo/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/ui
|
||||
'@t3-oss/env-nextjs':
|
||||
specifier: ^0.11.1
|
||||
version: 0.11.1(typescript@5.9.3)(zod@3.25.76)
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.62.10
|
||||
version: 5.100.10(react@19.2.6)
|
||||
'@trpc/client':
|
||||
specifier: ^11.0.0
|
||||
version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
|
||||
'@trpc/react-query':
|
||||
specifier: ^11.0.0
|
||||
version: 11.17.0(@tanstack/react-query@5.100.10(react@19.2.6))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.6)(typescript@5.9.3)
|
||||
'@trpc/server':
|
||||
specifier: ^11.0.0
|
||||
version: 11.17.0(typescript@5.9.3)
|
||||
lucide-react:
|
||||
specifier: ^0.469.0
|
||||
version: 0.469.0(react@19.2.6)
|
||||
next:
|
||||
specifier: ^15.1.3
|
||||
version: 15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
specifier: 15.3.9
|
||||
version: 15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
pino-pretty:
|
||||
specifier: ^11.3.0
|
||||
version: 11.3.0
|
||||
react:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.6
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.6(react@19.2.6)
|
||||
superjson:
|
||||
specifier: ^2.2.2
|
||||
version: 2.2.6
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@repo/config':
|
||||
specifier: workspace:*
|
||||
@ -57,12 +99,21 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.0.2
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
autoprefixer:
|
||||
specifier: ^10.4.20
|
||||
version: 10.5.0(postcss@8.5.14)
|
||||
dotenv-cli:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
postcss:
|
||||
specifier: ^8.4.49
|
||||
version: 8.5.14
|
||||
rimraf:
|
||||
specifier: ^6.0.1
|
||||
version: 6.1.3
|
||||
tailwindcss:
|
||||
specifier: ^3.4.17
|
||||
version: 3.4.19(tsx@4.22.0)
|
||||
typescript:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.3
|
||||
@ -889,9 +940,6 @@ packages:
|
||||
'@next/env@15.3.9':
|
||||
resolution: {integrity: sha512-I7wMCjlHc85EvAebNYJCRBZ+shdrGhcIXBviWmDzGYXwTQ+WrYpfg1LBOnTK1Bn3b+ud5apesNObXKEGqi/C3g==}
|
||||
|
||||
'@next/env@15.5.18':
|
||||
resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==}
|
||||
|
||||
'@next/eslint-plugin-next@15.5.18':
|
||||
resolution: {integrity: sha512-w4MYq8M26a8PNrfto0JosLf5/3ssln1rsyP96g2DkC8uFVymStM5DLSz5ElxxrPRg2XnTMnFo3kREFlhYvxhWw==}
|
||||
|
||||
@ -901,24 +949,12 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.18':
|
||||
resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@15.3.5':
|
||||
resolution: {integrity: sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@15.5.18':
|
||||
resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.3.5':
|
||||
resolution: {integrity: sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==}
|
||||
engines: {node: '>= 10'}
|
||||
@ -926,13 +962,6 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.18':
|
||||
resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.3.5':
|
||||
resolution: {integrity: sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==}
|
||||
engines: {node: '>= 10'}
|
||||
@ -940,13 +969,6 @@ packages:
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.18':
|
||||
resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.3.5':
|
||||
resolution: {integrity: sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==}
|
||||
engines: {node: '>= 10'}
|
||||
@ -954,13 +976,6 @@ packages:
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.18':
|
||||
resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.3.5':
|
||||
resolution: {integrity: sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==}
|
||||
engines: {node: '>= 10'}
|
||||
@ -968,37 +983,18 @@ packages:
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.18':
|
||||
resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.3.5':
|
||||
resolution: {integrity: sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.18':
|
||||
resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.3.5':
|
||||
resolution: {integrity: sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.18':
|
||||
resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@nodable/entities@2.1.0':
|
||||
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
|
||||
|
||||
@ -1871,27 +1867,6 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
next@15.5.18:
|
||||
resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.1.0
|
||||
'@playwright/test': ^1.51.1
|
||||
babel-plugin-react-compiler: '*'
|
||||
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
|
||||
sass: ^1.3.0
|
||||
peerDependenciesMeta:
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@playwright/test':
|
||||
optional: true
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
node-fetch-native@1.6.7:
|
||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||
|
||||
@ -3065,8 +3040,6 @@ snapshots:
|
||||
|
||||
'@next/env@15.3.9': {}
|
||||
|
||||
'@next/env@15.5.18': {}
|
||||
|
||||
'@next/eslint-plugin-next@15.5.18':
|
||||
dependencies:
|
||||
fast-glob: 3.3.1
|
||||
@ -3074,51 +3047,27 @@ snapshots:
|
||||
'@next/swc-darwin-arm64@15.3.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-arm64@15.5.18':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@15.3.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@15.5.18':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.3.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.5.18':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.3.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.5.18':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.3.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.5.18':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@15.3.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@15.5.18':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.3.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.5.18':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.3.5':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.5.18':
|
||||
optional: true
|
||||
|
||||
'@nodable/entities@2.1.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@ -3983,30 +3932,6 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@next/env': 15.5.18
|
||||
'@swc/helpers': 0.5.15
|
||||
caniuse-lite: 1.0.30001792
|
||||
postcss: 8.4.31
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
styled-jsx: 5.1.6(react@19.2.6)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.5.18
|
||||
'@next/swc-darwin-x64': 15.5.18
|
||||
'@next/swc-linux-arm64-gnu': 15.5.18
|
||||
'@next/swc-linux-arm64-musl': 15.5.18
|
||||
'@next/swc-linux-x64-gnu': 15.5.18
|
||||
'@next/swc-linux-x64-musl': 15.5.18
|
||||
'@next/swc-win32-arm64-msvc': 15.5.18
|
||||
'@next/swc-win32-x64-msvc': 15.5.18
|
||||
'@playwright/test': 1.60.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-releases@2.0.44: {}
|
||||
|
||||
75
scripts/admin-queue-smoke.ts
Normal file
75
scripts/admin-queue-smoke.ts
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* AC verification for Passo 11:
|
||||
* Request created by op1 appears in admin queue; claim moves to CLAIMED;
|
||||
* resolve moves to RESOLVED.
|
||||
*/
|
||||
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';
|
||||
|
||||
async function makeCaller(email: string) {
|
||||
const user = await prisma.user.findFirst({ where: { email } });
|
||||
if (!user) throw new Error(`${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() {
|
||||
const op1 = await makeCaller('op1@demo.local');
|
||||
const admin = await makeCaller('admin@demo.local');
|
||||
const ws = (await admin.workstation.list())[0];
|
||||
if (!ws) throw new Error('No workstations');
|
||||
|
||||
// Create a request via op1
|
||||
console.log('1. op1 creates request...');
|
||||
const { id } = await op1.maintenanceRequest.create({
|
||||
workstationId: ws.id,
|
||||
description: 'Pedido para teste da fila admin',
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
});
|
||||
console.log(` id=${id} ✓`);
|
||||
|
||||
// Queue shows the request (≤5s polling simulated by immediate check)
|
||||
console.log('2. Admin queue shows the request...');
|
||||
const { items } = await admin.maintenanceRequest.queue({ statuses: ['OPEN'] });
|
||||
const found = items.find((i) => i.id === id);
|
||||
if (!found) throw new Error('Request not in queue');
|
||||
if (found.status !== 'OPEN') throw new Error(`Expected OPEN, got ${found.status}`);
|
||||
console.log(` Found in queue with status=${found.status} ✓`);
|
||||
console.log(` Posto: ${found.workstation.code}, Reporter: ${found.reportedBy.email} ✓`);
|
||||
|
||||
// Claim
|
||||
console.log('3. Admin claims the request...');
|
||||
const claimed = await admin.maintenanceRequest.claim({ id });
|
||||
if (claimed.status !== 'CLAIMED') throw new Error(`Expected CLAIMED, got ${claimed.status}`);
|
||||
const afterClaim = await admin.maintenanceRequest.queue({ statuses: ['CLAIMED'] });
|
||||
if (!afterClaim.items.find((i) => i.id === id)) throw new Error('Not in CLAIMED queue');
|
||||
console.log(` status=CLAIMED, claimedBy=${claimed.claimedBy?.email} ✓`);
|
||||
|
||||
// Resolve
|
||||
console.log('4. Admin resolves the request...');
|
||||
const resolved = await admin.maintenanceRequest.resolve({ id, resolutionNote: 'Peça substituída' });
|
||||
if (resolved.status !== 'RESOLVED') throw new Error(`Expected RESOLVED, got ${resolved.status}`);
|
||||
const afterResolve = await admin.maintenanceRequest.queue({ statuses: ['RESOLVED'] });
|
||||
if (!afterResolve.items.find((i) => i.id === id)) throw new Error('Not in RESOLVED queue');
|
||||
console.log(` status=RESOLVED, resolvedBy=${resolved.resolvedBy?.email} ✓`);
|
||||
|
||||
await prisma.$disconnect();
|
||||
console.log('\nPasso 11 AC PASSED');
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Passo 11 AC FAILED:', err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user