localization support
O que mudou
Infra (por app):
i18n/locales.ts — lista de locales (pt, en), default pt, labels para o seletor
i18n/request.ts — lê o cookie NEXT_LOCALE, carrega as mensagens
messages/pt.json + messages/en.json — todas as strings extraídas
next.config.ts — envolvido com withNextIntl (operator-pwa: withPWA(withNextIntl(...)))
app/layout.tsx — <html lang={locale}> dinâmico, NextIntlClientProvider
app/language-switcher.tsx — seletor PT | EN (cookie + router.refresh())
23 ficheiros de UI atualizados — todos os textos visíveis agora usam t('...') ou getTranslations.
Datas no relatório passaram de toLocaleString('pt-PT') fixo para useFormatter() do next-intl — localizam-se automaticamente.
Plurais em ICU no sync-chip: {count, plural, one {# pedido...} other {# pedidos...}}.
Resultado dos testes:
pnpm test:e2e — 3/3 ✓
pnpm test:e2e:auth — 4/4 ✓
tsc --noEmit em ambas as apps — limpo ✓
Para adicionar uma língua futura: criar messages/<locale>.json + adicionar o locale a i18n/locales.ts em cada app. O seletor aparece automaticamente.
This commit is contained in:
parent
2093f12d0a
commit
35e7027881
15
.env.example
15
.env.example
@ -22,14 +22,13 @@ AUTH_SECRET="dev-secret-do-not-use-in-production-please-change-me"
|
||||
# must consciously opt in by editing their .env. See README "Auth" section.
|
||||
AUTH_DEV_AUTOLOGIN="false"
|
||||
|
||||
# Base URL Auth.js uses to build callback/redirect URLs. It MUST match the host
|
||||
# of the app being served. This shared .env can hold only ONE value, set here to
|
||||
# the operator-pwa (:3000). The admin-web (:3001) therefore needs its OWN
|
||||
# AUTH_URL whenever autologin is OFF (real-login dev, or production) — otherwise
|
||||
# Auth.js redirects admin users to :3000 and the admin login breaks.
|
||||
# - Local with autologin ON: this value is harmless (middleware never redirects).
|
||||
# - E2E real-login: e2e/playwright.auth.config.ts passes AUTH_URL=:3001 to admin.
|
||||
# - Production: give EACH app its own AUTH_URL (per-app env), not this shared file.
|
||||
# Base URL Auth.js uses to build callback/redirect URLs. Must match the host of
|
||||
# the app being served. This shared .env holds ONE value — the operator-pwa
|
||||
# (:3000). The admin-web (:3001) gets its own from apps/admin-web/.env.admin,
|
||||
# which its `dev` script loads with precedence over this file — so admin login
|
||||
# works with autologin OFF without any extra step.
|
||||
# - Production: each app still gets its own AUTH_URL from the deploy env
|
||||
# (a value already in the process wins over .env.admin).
|
||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||
AUTH_URL="http://localhost:3000"
|
||||
|
||||
|
||||
34
README.md
34
README.md
@ -232,6 +232,28 @@ you want end-to-end confidence before a demo:
|
||||
|
||||
---
|
||||
|
||||
## Languages (i18n)
|
||||
|
||||
Both apps support **Portuguese (PT, default) and English (EN)**. The language is
|
||||
stored in a `NEXT_LOCALE` cookie and selected via the **PT | EN** switcher in the
|
||||
app header.
|
||||
|
||||
### Changing language
|
||||
|
||||
Click the **PT | EN** pill in the header of either app. The preference is saved
|
||||
in a cookie (1-year expiry) — no account change required.
|
||||
|
||||
### Adding a new language
|
||||
|
||||
1. Create `apps/<app>/messages/<locale>.json` (copy `en.json` as a starting point).
|
||||
2. Add the locale to `LOCALES` in `apps/<app>/i18n/locales.ts`.
|
||||
3. That's it — the switcher picks it up automatically.
|
||||
|
||||
See **[docs/i18n.md](docs/i18n.md)** for the full guide, including the
|
||||
key-parity and ICU validation scripts to run before shipping a new language.
|
||||
|
||||
---
|
||||
|
||||
## Common commands
|
||||
|
||||
| Command | What it does |
|
||||
@ -299,12 +321,12 @@ by changing the endpoint env var.
|
||||
|
||||
**`Tenant not found`** — the seed was wiped. Run `pnpm db:seed`.
|
||||
|
||||
**Admin login redirects to `:3000` / the operator picker** — the shared `.env`
|
||||
sets `AUTH_URL=http://localhost:3000`, which is correct for the operator-pwa but
|
||||
wrong for the admin-web (:3001). With `AUTH_DEV_AUTOLOGIN=true` it never bites
|
||||
(the middleware doesn't redirect). With autologin OFF, give the admin-web its own
|
||||
`AUTH_URL=http://localhost:3001` (the E2E auth config does this). In production,
|
||||
each app must have its own `AUTH_URL`.
|
||||
**Admin login redirects to `:3000` / the operator picker** — the admin-web needs
|
||||
its own `AUTH_URL=http://localhost:3001` (the shared `.env` points at the operator
|
||||
on :3000). This is handled automatically: the admin `dev` script loads
|
||||
`apps/admin-web/.env.admin` with precedence. If you deleted or edited that file
|
||||
and hit this, restore `AUTH_URL="http://localhost:3001"` there. In production,
|
||||
each app gets its own `AUTH_URL` from the deploy environment.
|
||||
|
||||
**`DATABASE_URL not found`** — `.env` is missing or Docker Postgres is not
|
||||
running. Run `docker compose up -d` then retry.
|
||||
|
||||
9
apps/admin-web/.env.admin
Normal file
9
apps/admin-web/.env.admin
Normal file
@ -0,0 +1,9 @@
|
||||
# Per-app AUTH_URL for admin-web (port 3001).
|
||||
#
|
||||
# The shared root .env sets AUTH_URL=http://localhost:3000 (correct for the
|
||||
# operator-pwa). Auth.js needs each app to know its OWN base URL, otherwise the
|
||||
# admin redirects unauthenticated users to :3000 and login breaks when autologin
|
||||
# is off. This file gives admin-web its own value; the dev/start scripts load it
|
||||
# BEFORE the root .env, and dotenv never overrides an already-set variable, so
|
||||
# this wins. Not a secret — safe to commit.
|
||||
AUTH_URL="http://localhost:3001"
|
||||
33
apps/admin-web/app/language-switcher.tsx
Normal file
33
apps/admin-web/app/language-switcher.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { LOCALES, LOCALE_LABELS, type Locale } from '@/i18n/locales';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const router = useRouter();
|
||||
const current = useLocale() as Locale;
|
||||
|
||||
function switchTo(locale: Locale) {
|
||||
document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-border bg-muted p-0.5">
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => switchTo(l)}
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
|
||||
l === current
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{LOCALE_LABELS[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,17 +1,27 @@
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages, getTranslations } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { Providers } from './providers';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FieldOps — Manutenção',
|
||||
description: 'Backoffice de manutenção industrial.',
|
||||
};
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('metadata');
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="pt">
|
||||
<html lang={locale}>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<Providers>{children}</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -2,10 +2,13 @@
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { signIn } from 'next-auth/react';
|
||||
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations('auth');
|
||||
const tc = useTranslations('common');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -20,13 +23,13 @@ export function LoginForm() {
|
||||
try {
|
||||
const result = await signIn('credentials', { email, password, redirect: false });
|
||||
if (result?.error) {
|
||||
setError('Email ou password incorretos. Tente novamente.');
|
||||
setError(t('invalidCredentials'));
|
||||
} else {
|
||||
router.push('/maintenance');
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
setError('Erro inesperado. Tente novamente.');
|
||||
setError(t('unexpectedError'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -36,7 +39,7 @@ export function LoginForm() {
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="email" className="text-sm font-medium">
|
||||
Email
|
||||
{t('emailLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
@ -46,13 +49,13 @@ export function LoginForm() {
|
||||
autoComplete="email"
|
||||
disabled={busy}
|
||||
className="rounded-lg border border-border bg-background px-3 py-2.5 text-sm outline-none focus:ring-2 focus:ring-primary disabled:opacity-50"
|
||||
placeholder="admin@demo.local"
|
||||
placeholder={t('emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="password" className="text-sm font-medium">
|
||||
Password
|
||||
{t('passwordLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
@ -72,7 +75,7 @@ export function LoginForm() {
|
||||
disabled={busy}
|
||||
className="mt-2 w-full rounded-xl bg-primary py-3 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90 active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'A entrar…' : 'Entrar'}
|
||||
{busy ? tc('entering') : tc('enter')}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { LoginForm } from './login-form';
|
||||
|
||||
export default function LoginPage() {
|
||||
export default async function LoginPage() {
|
||||
const t = await getTranslations('auth');
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
|
||||
<header className="text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">FieldOps</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Acesso à consola de manutenção
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
|
||||
</header>
|
||||
<LoginForm />
|
||||
</main>
|
||||
|
||||
@ -3,22 +3,26 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import type { RouterOutputs } from '@/lib/trpc/server';
|
||||
import { LanguageSwitcher } from '../language-switcher';
|
||||
|
||||
type Status = 'OPEN' | 'CLAIMED' | 'RESOLVED';
|
||||
type QueueItem = RouterOutputs['maintenanceRequest']['queue']['items'][number];
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(date: Date | string): string {
|
||||
type TFn = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
function timeAgo(date: Date | string, t: TFn): 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`;
|
||||
if (mins < 1) return t('timeAgo.now');
|
||||
if (mins < 60) return t('timeAgo.minutesAgo', { mins });
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `há ${hours}h`;
|
||||
return `há ${Math.floor(hours / 24)}d`;
|
||||
if (hours < 24) return t('timeAgo.hoursAgo', { hours });
|
||||
return t('timeAgo.daysAgo', { days: Math.floor(hours / 24) });
|
||||
}
|
||||
|
||||
function playBeep() {
|
||||
@ -39,12 +43,6 @@ function playBeep() {
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
@ -53,7 +51,7 @@ const STATUS_CLASS: Record<Status, string> = {
|
||||
|
||||
// ── Thumbnail ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Thumbnail({ photoKey }: { photoKey: string | null }) {
|
||||
function Thumbnail({ photoKey, alt }: { photoKey: string | null; alt: string }) {
|
||||
const { data } = trpc.storage.signPhotoDownload.useQuery(
|
||||
{ photoKey: photoKey! },
|
||||
{ enabled: !!photoKey, staleTime: 50_000 },
|
||||
@ -66,7 +64,7 @@ function Thumbnail({ photoKey }: { photoKey: string | null }) {
|
||||
}
|
||||
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" />
|
||||
<img src={data.url} alt={alt} className="h-16 w-16 shrink-0 rounded-lg object-cover" />
|
||||
);
|
||||
}
|
||||
|
||||
@ -77,17 +75,21 @@ function RequestCard({
|
||||
onClaim,
|
||||
onResolve,
|
||||
claiming,
|
||||
t,
|
||||
tc,
|
||||
}: {
|
||||
item: QueueItem;
|
||||
onClaim: () => void;
|
||||
onResolve: () => void;
|
||||
claiming: boolean;
|
||||
t: ReturnType<typeof useTranslations<'maintenance'>>;
|
||||
tc: ReturnType<typeof useTranslations<'common'>>;
|
||||
}) {
|
||||
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} />
|
||||
<Thumbnail photoKey={item.photoKey} alt={t('photo')} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">
|
||||
{item.workstation.code} — {item.workstation.name}{' '}
|
||||
@ -95,17 +97,15 @@ function RequestCard({
|
||||
</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)}
|
||||
{t('reportedBy', { email: item.reportedBy.email, time: timeAgo(item.createdAt, tc) })}
|
||||
</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 className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[item.status as Status]}`}>
|
||||
{tc(`status.${item.status.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
|
||||
</span>
|
||||
|
||||
{item.status === 'OPEN' && (
|
||||
@ -115,28 +115,28 @@ function RequestCard({
|
||||
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
|
||||
{t('accept')}
|
||||
</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!)}
|
||||
{t('claimedBy', { email: item.claimedBy?.email ?? '?', time: timeAgo(item.claimedAt!, tc) })}
|
||||
</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
|
||||
{t('markResolved')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.status === 'RESOLVED' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Resolvido por {item.resolvedBy?.email ?? '?'} · {timeAgo(item.resolvedAt!)}
|
||||
{t('resolvedBy', { email: item.resolvedBy?.email ?? '?', time: timeAgo(item.resolvedAt!, tc) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -152,33 +152,34 @@ function ResolveDialog({
|
||||
note,
|
||||
onNoteChange,
|
||||
resolving,
|
||||
t,
|
||||
tc,
|
||||
}: {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
note: string;
|
||||
onNoteChange: (v: string) => void;
|
||||
resolving: boolean;
|
||||
t: ReturnType<typeof useTranslations<'maintenance'>>;
|
||||
tc: ReturnType<typeof useTranslations<'common'>>;
|
||||
}) {
|
||||
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>
|
||||
<h2 className="mb-4 text-lg font-semibold">{t('resolveDialogTitle')}</h2>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Nota de resolução (opcional)
|
||||
{t('resolveNoteLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => onNoteChange(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Descreve o que foi feito…"
|
||||
placeholder={t('resolveNotePlaceholder')}
|
||||
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 onClick={onCancel} className="rounded-lg px-4 py-2 text-sm hover:bg-accent">
|
||||
{tc('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
@ -186,7 +187,7 @@ function ResolveDialog({
|
||||
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
|
||||
{tc('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -197,6 +198,9 @@ function ResolveDialog({
|
||||
// ── Main queue component ────────────────────────────────────────────────────
|
||||
|
||||
export function MaintenanceQueue() {
|
||||
const t = useTranslations('maintenance');
|
||||
const tc = useTranslations('common');
|
||||
|
||||
const [statuses, setStatuses] = useState<Status[]>(['OPEN', 'CLAIMED']);
|
||||
const [area, setArea] = useState('');
|
||||
const [resolveId, setResolveId] = useState<string | null>(null);
|
||||
@ -217,8 +221,10 @@ export function MaintenanceQueue() {
|
||||
// Document title badge
|
||||
useEffect(() => {
|
||||
document.title =
|
||||
openCount > 0 ? `(${openCount}) FieldOps — Manutenção` : 'FieldOps — Manutenção';
|
||||
}, [openCount]);
|
||||
openCount > 0
|
||||
? t('documentTitleWithCount', { count: openCount })
|
||||
: t('documentTitle');
|
||||
}, [openCount, t]);
|
||||
|
||||
// Audio notification for new OPEN requests
|
||||
const prevOpenIds = useRef(new Set<string>());
|
||||
@ -261,10 +267,10 @@ export function MaintenanceQueue() {
|
||||
<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
|
||||
{t('openRequestsTitle', { count: openCount })}
|
||||
</span>
|
||||
) : (
|
||||
'Fila de manutenção'
|
||||
t('queueTitle')
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
@ -274,7 +280,7 @@ export function MaintenanceQueue() {
|
||||
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
|
||||
{t('reportLink')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSoundEnabled((v) => !v)}
|
||||
@ -284,14 +290,15 @@ export function MaintenanceQueue() {
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{soundEnabled ? '🔔 Som on' : '🔕 Som off'}
|
||||
{soundEnabled ? t('soundOn') : t('soundOff')}
|
||||
</button>
|
||||
<LanguageSwitcher />
|
||||
</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>
|
||||
<span className="text-xs text-muted-foreground">{t('filterStatus')}</span>
|
||||
{(['OPEN', 'CLAIMED', 'RESOLVED'] as Status[]).map((s) => (
|
||||
<label key={s} className="flex cursor-pointer items-center gap-1.5 text-sm">
|
||||
<input
|
||||
@ -300,19 +307,19 @@ export function MaintenanceQueue() {
|
||||
onChange={() => toggleStatus(s)}
|
||||
className="rounded"
|
||||
/>
|
||||
{STATUS_LABEL[s]}
|
||||
{tc(`status.${s.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
|
||||
</label>
|
||||
))}
|
||||
|
||||
{areas.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">Área:</span>
|
||||
<span className="text-xs text-muted-foreground">{t('filterArea')}</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>
|
||||
<option value="">{tc('allAreas')}</option>
|
||||
{areas.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
@ -324,7 +331,7 @@ export function MaintenanceQueue() {
|
||||
|
||||
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
Atualiza a cada 5s
|
||||
{t('updatesEvery')}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@ -334,7 +341,7 @@ export function MaintenanceQueue() {
|
||||
{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>
|
||||
<p>{t('emptyQueue')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
@ -342,6 +349,8 @@ export function MaintenanceQueue() {
|
||||
<RequestCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
t={t}
|
||||
tc={tc}
|
||||
onClaim={() => claimMutation.mutate({ id: item.id })}
|
||||
onResolve={() => {
|
||||
setResolveId(item.id);
|
||||
@ -362,6 +371,8 @@ export function MaintenanceQueue() {
|
||||
<ResolveDialog
|
||||
note={resolutionNote}
|
||||
onNoteChange={setResolutionNote}
|
||||
t={t}
|
||||
tc={tc}
|
||||
onConfirm={() =>
|
||||
resolveMutation.mutate({
|
||||
id: resolveId,
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Metadata } from 'next';
|
||||
import { ReportView } from './report-view';
|
||||
|
||||
export const metadata = { title: 'FieldOps — Relatório de turno' };
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('report');
|
||||
return { title: t('pageTitle') };
|
||||
}
|
||||
|
||||
export default function ReportPage() {
|
||||
return <ReportView />;
|
||||
|
||||
@ -3,40 +3,30 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Printer, AlertCircle } from 'lucide-react';
|
||||
import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { SHIFTS, shiftWindow, todayWindow, type ShiftKey } from '@/lib/shifts';
|
||||
|
||||
// ── Duration helper ─────────────────────────────────────────────────────────
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null) return '—';
|
||||
type TFn = ReturnType<typeof useTranslations<'report'>>;
|
||||
|
||||
function formatDuration(ms: number | null, t: TFn): string {
|
||||
if (ms === null) return t('duration.dash');
|
||||
const totalMin = Math.round(ms / 60_000);
|
||||
if (totalMin < 1) return '< 1 min';
|
||||
if (totalMin < 60) return `${totalMin} min`;
|
||||
if (totalMin < 1) return t('duration.lessThan1Min');
|
||||
if (totalMin < 60) return t('duration.minutes', { n: totalMin });
|
||||
const h = Math.floor(totalMin / 60);
|
||||
const m = totalMin % 60;
|
||||
return m > 0 ? `${h} h ${m} min` : `${h} h`;
|
||||
return m > 0 ? t('duration.hoursMinutes', { h, m }) : t('duration.hours', { h });
|
||||
}
|
||||
|
||||
function formatDateTime(d: Date | string): string {
|
||||
const dt = new Date(d);
|
||||
return dt.toLocaleString('pt-PT', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
// ── Status ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(d: Date | string): string {
|
||||
return new Date(d).toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit' });
|
||||
}
|
||||
|
||||
// ── Window label ─────────────────────────────────────────────────────────────
|
||||
|
||||
function windowLabel(from: Date, to: Date): string {
|
||||
return `${formatDateTime(from)} → ${formatDateTime(to)}`;
|
||||
}
|
||||
const STATUS_CLASS: Record<'OPEN' | 'CLAIMED', string> = {
|
||||
OPEN: 'bg-orange-100 text-orange-700',
|
||||
CLAIMED: 'bg-blue-100 text-blue-700',
|
||||
};
|
||||
|
||||
// ── Metric card ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -52,16 +42,6 @@ function MetricCard({ label, value, sub }: { label: string; value: string; sub?:
|
||||
);
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<'OPEN' | 'CLAIMED', string> = {
|
||||
OPEN: 'Aberto',
|
||||
CLAIMED: 'Em curso',
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<'OPEN' | 'CLAIMED', string> = {
|
||||
OPEN: 'bg-orange-100 text-orange-700',
|
||||
CLAIMED: 'bg-blue-100 text-blue-700',
|
||||
};
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
type WindowState =
|
||||
@ -87,7 +67,14 @@ function localDateTimeStr(d: Date): string {
|
||||
return `${localDateStr(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const DATE_TIME_FMT = { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' } as const;
|
||||
const DATE_FMT = { day: '2-digit', month: '2-digit' } as const;
|
||||
|
||||
export function ReportView() {
|
||||
const t = useTranslations('report');
|
||||
const tc = useTranslations('common');
|
||||
const format = useFormatter();
|
||||
|
||||
const [windowState, setWindowState] = useState<WindowState>({ type: 'today' });
|
||||
const [dayInput, setDayInput] = useState(() => localDateStr(new Date()));
|
||||
const [customActive, setCustomActive] = useState(false);
|
||||
@ -100,7 +87,7 @@ export function ReportView() {
|
||||
|
||||
// Stabilise the window so the query key only changes when the user picks a
|
||||
// new window. Without this, the 'today' mode recomputes `to = new Date()` on
|
||||
// every render → new query key → fetch loop. Re-selecting "Hoje" refreshes.
|
||||
// every render → new query key → fetch loop. Re-selecting "Today" refreshes.
|
||||
const win = useMemo(() => computeWindow(windowState), [windowState]);
|
||||
|
||||
const { data, isLoading, error } = trpc.maintenanceRequest.report.useQuery(
|
||||
@ -126,8 +113,16 @@ export function ReportView() {
|
||||
setWindowState({ type: 'custom', from, to });
|
||||
}
|
||||
|
||||
const activeShift =
|
||||
windowState.type === 'shift' ? windowState.key : null;
|
||||
const activeShift = windowState.type === 'shift' ? windowState.key : null;
|
||||
|
||||
const range = `${format.dateTime(win.from, DATE_TIME_FMT)} → ${format.dateTime(win.to, DATE_TIME_FMT)}`;
|
||||
|
||||
const windowLabelText =
|
||||
windowState.type === 'today'
|
||||
? t('windowLabel.today', { range })
|
||||
: windowState.type === 'shift'
|
||||
? t(`windowLabel.${windowState.key}`, { range })
|
||||
: t('windowLabel.custom', { range });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background print:bg-white">
|
||||
@ -140,25 +135,25 @@ export function ReportView() {
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Fila
|
||||
{t('backToQueue')}
|
||||
</Link>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<h1 className="text-lg font-bold">Relatório de turno</h1>
|
||||
<h1 className="text-lg font-bold">{t('title')}</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-muted px-3 py-1.5 text-sm font-medium hover:bg-accent"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Imprimir
|
||||
{t('print')}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Print header (only in print) ── */}
|
||||
<div className="hidden print:block px-8 pt-6 pb-2">
|
||||
<p className="text-lg font-bold">FieldOps — Relatório de manutenção</p>
|
||||
<p className="text-sm text-gray-600">{windowLabel(win.from, win.to)}</p>
|
||||
<p className="text-lg font-bold">{t('printHeader')}</p>
|
||||
<p className="text-sm text-gray-600">{range}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Window selector (hidden in print) ── */}
|
||||
@ -174,7 +169,7 @@ export function ReportView() {
|
||||
: 'bg-card border border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
Hoje
|
||||
{t('today')}
|
||||
</button>
|
||||
{(Object.keys(SHIFTS) as ShiftKey[]).map((key) => (
|
||||
<button
|
||||
@ -186,7 +181,7 @@ export function ReportView() {
|
||||
: 'bg-card border border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
{SHIFTS[key].label}
|
||||
{t(`shiftButton.${key}`)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@ -211,7 +206,7 @@ export function ReportView() {
|
||||
: 'bg-card border border-border hover:bg-accent'
|
||||
}`}
|
||||
>
|
||||
Personalizado
|
||||
{t('custom')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -224,7 +219,7 @@ export function ReportView() {
|
||||
onChange={(e) => setCustomPending((p) => ({ ...p, from: e.target.value }))}
|
||||
className="rounded-lg border border-border bg-card px-2 py-1 text-sm"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">até</span>
|
||||
<span className="text-sm text-muted-foreground">{t('customUntil')}</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customPending.to}
|
||||
@ -235,27 +230,20 @@ export function ReportView() {
|
||||
onClick={applyCustom}
|
||||
className="rounded-lg bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Aplicar
|
||||
{t('customApply')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active window label */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{windowState.type === 'shift'
|
||||
? `Turno d${windowState.key === 'manha' ? 'a Manhã' : windowState.key === 'tarde' ? 'a Tarde' : 'a Noite'} — `
|
||||
: windowState.type === 'today'
|
||||
? 'Hoje — '
|
||||
: 'Personalizado — '}
|
||||
{windowLabel(win.from, win.to)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{windowLabelText}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ── */}
|
||||
<main className="mx-auto max-w-4xl px-4 py-6 print:px-8 print:py-4">
|
||||
{isLoading && (
|
||||
<p className="py-16 text-center text-muted-foreground">A carregar…</p>
|
||||
<p className="py-16 text-center text-muted-foreground">{tc('loading')}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
@ -267,7 +255,7 @@ export function ReportView() {
|
||||
|
||||
{data && data.totals.created === 0 && (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<p className="text-lg">Sem pedidos nesta janela.</p>
|
||||
<p className="text-lg">{t('emptyWindow')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -276,41 +264,41 @@ export function ReportView() {
|
||||
{/* Summary cards */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||
Resumo
|
||||
{t('sections.summary')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 print:grid-cols-3">
|
||||
<MetricCard label="Pedidos" value={String(data.totals.created)} />
|
||||
<MetricCard label="Resolvidos" value={String(data.totals.resolved)} />
|
||||
<MetricCard label={t('metrics.created')} value={String(data.totals.created)} />
|
||||
<MetricCard label={t('metrics.resolved')} value={String(data.totals.resolved)} />
|
||||
<MetricCard
|
||||
label="Em aberto"
|
||||
label={t('metrics.open')}
|
||||
value={String(data.totals.open + data.totals.claimed)}
|
||||
sub={
|
||||
data.totals.open > 0 || data.totals.claimed > 0
|
||||
? `${data.totals.open} aberto · ${data.totals.claimed} em curso`
|
||||
? t('metrics.openSub', { open: data.totals.open, claimed: data.totals.claimed })
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Resposta média"
|
||||
value={formatDuration(data.responseMs.avg)}
|
||||
label={t('metrics.responseAvg')}
|
||||
value={formatDuration(data.responseMs.avg, t)}
|
||||
sub={
|
||||
data.responseMs.count > 0
|
||||
? `sobre ${data.responseMs.count} pedido${data.responseMs.count > 1 ? 's' : ''}`
|
||||
: 'sem dados'
|
||||
? t('metrics.requestsSub', { count: data.responseMs.count })
|
||||
: t('metrics.noData')
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Resolução média"
|
||||
value={formatDuration(data.resolutionMs.avg)}
|
||||
label={t('metrics.resolutionAvg')}
|
||||
value={formatDuration(data.resolutionMs.avg, t)}
|
||||
sub={
|
||||
data.resolutionMs.count > 0
|
||||
? `sobre ${data.resolutionMs.count} pedido${data.resolutionMs.count > 1 ? 's' : ''}`
|
||||
: 'sem dados'
|
||||
? t('metrics.requestsSub', { count: data.resolutionMs.count })
|
||||
: t('metrics.noData')
|
||||
}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Pior resposta"
|
||||
value={formatDuration(data.responseMs.max)}
|
||||
label={t('metrics.responseMax')}
|
||||
value={formatDuration(data.responseMs.max, t)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@ -319,16 +307,16 @@ export function ReportView() {
|
||||
{data.byWorkstation.length > 0 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||
Por posto
|
||||
{t('sections.byWorkstation')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-xl border border-border print:border-gray-300">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50 text-left print:bg-gray-50 print:border-gray-300">
|
||||
<th className="px-4 py-2 font-medium">Código</th>
|
||||
<th className="px-4 py-2 font-medium">Nome</th>
|
||||
<th className="px-4 py-2 font-medium">Área</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Pedidos</th>
|
||||
<th className="px-4 py-2 font-medium">{t('table.code')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('table.name')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('table.area')}</th>
|
||||
<th className="px-4 py-2 text-right font-medium">{t('table.requests')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@ -357,7 +345,7 @@ export function ReportView() {
|
||||
{data.byArea.length > 1 && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||
Por área
|
||||
{t('sections.byArea')}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{data.byArea.map((a) => (
|
||||
@ -378,10 +366,10 @@ export function ReportView() {
|
||||
{/* Still open */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||
Em aberto à hora do relatório
|
||||
{t('sections.stillOpen')}
|
||||
</h2>
|
||||
{data.stillOpen.length === 0 ? (
|
||||
<p className="text-sm text-green-600">Nada em aberto neste turno. ✓</p>
|
||||
<p className="text-sm text-green-600">{t('allClear')}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.stillOpen.map((r) => (
|
||||
@ -400,13 +388,16 @@ export function ReportView() {
|
||||
{r.description}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground print:text-gray-500">
|
||||
Reportado por {r.reportedByEmail} · {formatDate(r.createdAt)}
|
||||
{t('stillOpenReportedBy', {
|
||||
email: r.reportedByEmail,
|
||||
date: format.dateTime(new Date(r.createdAt), DATE_FMT),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium print:border print:bg-transparent ${STATUS_CLASS[r.status]}`}
|
||||
>
|
||||
{STATUS_LABEL[r.status]}
|
||||
{tc(`status.${r.status.toLowerCase() as 'open' | 'claimed'}`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
8
apps/admin-web/i18n/locales.ts
Normal file
8
apps/admin-web/i18n/locales.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const LOCALES = ['pt', 'en'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'pt';
|
||||
export const LOCALE_LABELS: Record<Locale, string> = { pt: 'PT', en: 'EN' };
|
||||
|
||||
export function isLocale(v: string | undefined): v is Locale {
|
||||
return !!v && (LOCALES as readonly string[]).includes(v);
|
||||
}
|
||||
12
apps/admin-web/i18n/request.ts
Normal file
12
apps/admin-web/i18n/request.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { DEFAULT_LOCALE, isLocale } from './locales';
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const cookie = (await cookies()).get('NEXT_LOCALE')?.value;
|
||||
const locale = isLocale(cookie) ? cookie : DEFAULT_LOCALE;
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
@ -1,9 +1,9 @@
|
||||
export type ShiftKey = 'manha' | 'tarde' | 'noite';
|
||||
|
||||
export const SHIFTS: Record<ShiftKey, { label: string; startHour: number; endHour: number }> = {
|
||||
manha: { label: 'Manhã', startHour: 6, endHour: 14 },
|
||||
tarde: { label: 'Tarde', startHour: 14, endHour: 22 },
|
||||
noite: { label: 'Noite', startHour: 22, endHour: 6 },
|
||||
export const SHIFTS: Record<ShiftKey, { startHour: number; endHour: number }> = {
|
||||
manha: { startHour: 6, endHour: 14 },
|
||||
tarde: { startHour: 14, endHour: 22 },
|
||||
noite: { startHour: 22, endHour: 6 },
|
||||
};
|
||||
|
||||
/** Given a shift and a day (Date at local midnight), returns [from, to). */
|
||||
|
||||
113
apps/admin-web/messages/en.json
Normal file
113
apps/admin-web/messages/en.json
Normal file
@ -0,0 +1,113 @@
|
||||
{
|
||||
"metadata": {
|
||||
"title": "FieldOps — Maintenance",
|
||||
"description": "Industrial maintenance backoffice."
|
||||
},
|
||||
"common": {
|
||||
"enter": "Sign in",
|
||||
"entering": "Signing in…",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading…",
|
||||
"allAreas": "All",
|
||||
"status": {
|
||||
"open": "Open",
|
||||
"claimed": "In progress",
|
||||
"resolved": "Resolved"
|
||||
},
|
||||
"timeAgo": {
|
||||
"now": "just now",
|
||||
"minutesAgo": "{mins}m ago",
|
||||
"hoursAgo": "{hours}h ago",
|
||||
"daysAgo": "{days}d ago"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "admin@demo.local",
|
||||
"passwordLabel": "Password",
|
||||
"invalidCredentials": "Incorrect email or password. Please try again.",
|
||||
"unexpectedError": "Unexpected error. Please try again.",
|
||||
"title": "FieldOps",
|
||||
"subtitle": "Maintenance console access"
|
||||
},
|
||||
"maintenance": {
|
||||
"queueTitle": "Maintenance queue",
|
||||
"openRequestsTitle": "{count} open requests",
|
||||
"reportLink": "Shift report",
|
||||
"soundOn": "🔔 Sound on",
|
||||
"soundOff": "🔕 Sound off",
|
||||
"filterStatus": "Status:",
|
||||
"filterArea": "Area:",
|
||||
"updatesEvery": "Updates every 5s",
|
||||
"emptyQueue": "No requests match the current filters.",
|
||||
"photo": "Photo",
|
||||
"reportedBy": "Reported by {email} · {time}",
|
||||
"claimedBy": "Accepted by {email} · {time}",
|
||||
"resolvedBy": "Resolved by {email} · {time}",
|
||||
"accept": "Accept",
|
||||
"markResolved": "Mark resolved",
|
||||
"resolveDialogTitle": "Mark as resolved",
|
||||
"resolveNoteLabel": "Resolution note (optional)",
|
||||
"resolveNotePlaceholder": "Describe what was done…",
|
||||
"documentTitleWithCount": "({count}) FieldOps — Maintenance",
|
||||
"documentTitle": "FieldOps — Maintenance"
|
||||
},
|
||||
"report": {
|
||||
"pageTitle": "FieldOps — Shift report",
|
||||
"title": "Shift report",
|
||||
"print": "Print",
|
||||
"printHeader": "FieldOps — Maintenance report",
|
||||
"backToQueue": "Queue",
|
||||
"today": "Today",
|
||||
"custom": "Custom",
|
||||
"customUntil": "to",
|
||||
"customApply": "Apply",
|
||||
"loading": "Loading…",
|
||||
"emptyWindow": "No requests in this window.",
|
||||
"windowLabel": {
|
||||
"today": "Today — {range}",
|
||||
"manha": "Morning Shift — {range}",
|
||||
"tarde": "Afternoon Shift — {range}",
|
||||
"noite": "Night Shift — {range}",
|
||||
"custom": "Custom — {range}"
|
||||
},
|
||||
"shiftButton": {
|
||||
"manha": "Morning",
|
||||
"tarde": "Afternoon",
|
||||
"noite": "Night"
|
||||
},
|
||||
"sections": {
|
||||
"summary": "Summary",
|
||||
"byWorkstation": "By workstation",
|
||||
"byArea": "By area",
|
||||
"stillOpen": "Open at report time"
|
||||
},
|
||||
"metrics": {
|
||||
"created": "Requests",
|
||||
"resolved": "Resolved",
|
||||
"open": "Open",
|
||||
"responseAvg": "Avg response",
|
||||
"resolutionAvg": "Avg resolution",
|
||||
"responseMax": "Worst response",
|
||||
"openSub": "{open} open · {claimed} in progress",
|
||||
"requestsSub": "{count, plural, one {over # request} other {over # requests}}",
|
||||
"noData": "no data"
|
||||
},
|
||||
"table": {
|
||||
"code": "Code",
|
||||
"name": "Name",
|
||||
"area": "Area",
|
||||
"requests": "Requests"
|
||||
},
|
||||
"stillOpenReportedBy": "Reported by {email} · {date}",
|
||||
"allClear": "Nothing open in this shift. ✓",
|
||||
"duration": {
|
||||
"lessThan1Min": "< 1 min",
|
||||
"minutes": "{n} min",
|
||||
"hours": "{h} h",
|
||||
"hoursMinutes": "{h} h {m} min",
|
||||
"dash": "—"
|
||||
}
|
||||
}
|
||||
}
|
||||
113
apps/admin-web/messages/pt.json
Normal file
113
apps/admin-web/messages/pt.json
Normal file
@ -0,0 +1,113 @@
|
||||
{
|
||||
"metadata": {
|
||||
"title": "FieldOps — Manutenção",
|
||||
"description": "Backoffice de manutenção industrial."
|
||||
},
|
||||
"common": {
|
||||
"enter": "Entrar",
|
||||
"entering": "A entrar…",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"loading": "A carregar…",
|
||||
"allAreas": "Todas",
|
||||
"status": {
|
||||
"open": "Aberto",
|
||||
"claimed": "Em curso",
|
||||
"resolved": "Resolvido"
|
||||
},
|
||||
"timeAgo": {
|
||||
"now": "agora",
|
||||
"minutesAgo": "há {mins}m",
|
||||
"hoursAgo": "há {hours}h",
|
||||
"daysAgo": "há {days}d"
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "admin@demo.local",
|
||||
"passwordLabel": "Password",
|
||||
"invalidCredentials": "Email ou password incorretos. Tente novamente.",
|
||||
"unexpectedError": "Erro inesperado. Tente novamente.",
|
||||
"title": "FieldOps",
|
||||
"subtitle": "Acesso à consola de manutenção"
|
||||
},
|
||||
"maintenance": {
|
||||
"queueTitle": "Fila de manutenção",
|
||||
"openRequestsTitle": "{count} pedidos abertos",
|
||||
"reportLink": "Relatório de turno",
|
||||
"soundOn": "🔔 Som on",
|
||||
"soundOff": "🔕 Som off",
|
||||
"filterStatus": "Estado:",
|
||||
"filterArea": "Área:",
|
||||
"updatesEvery": "Atualiza a cada 5s",
|
||||
"emptyQueue": "Nenhum pedido com os filtros actuais.",
|
||||
"photo": "Foto",
|
||||
"reportedBy": "Reportado por {email} · {time}",
|
||||
"claimedBy": "Aceite por {email} · {time}",
|
||||
"resolvedBy": "Resolvido por {email} · {time}",
|
||||
"accept": "Aceitar",
|
||||
"markResolved": "Marcar resolvido",
|
||||
"resolveDialogTitle": "Marcar como resolvido",
|
||||
"resolveNoteLabel": "Nota de resolução (opcional)",
|
||||
"resolveNotePlaceholder": "Descreve o que foi feito…",
|
||||
"documentTitleWithCount": "({count}) FieldOps — Manutenção",
|
||||
"documentTitle": "FieldOps — Manutenção"
|
||||
},
|
||||
"report": {
|
||||
"pageTitle": "FieldOps — Relatório de turno",
|
||||
"title": "Relatório de turno",
|
||||
"print": "Imprimir",
|
||||
"printHeader": "FieldOps — Relatório de manutenção",
|
||||
"backToQueue": "Fila",
|
||||
"today": "Hoje",
|
||||
"custom": "Personalizado",
|
||||
"customUntil": "até",
|
||||
"customApply": "Aplicar",
|
||||
"loading": "A carregar…",
|
||||
"emptyWindow": "Sem pedidos nesta janela.",
|
||||
"windowLabel": {
|
||||
"today": "Hoje — {range}",
|
||||
"manha": "Turno da Manhã — {range}",
|
||||
"tarde": "Turno da Tarde — {range}",
|
||||
"noite": "Turno da Noite — {range}",
|
||||
"custom": "Personalizado — {range}"
|
||||
},
|
||||
"shiftButton": {
|
||||
"manha": "Manhã",
|
||||
"tarde": "Tarde",
|
||||
"noite": "Noite"
|
||||
},
|
||||
"sections": {
|
||||
"summary": "Resumo",
|
||||
"byWorkstation": "Por posto",
|
||||
"byArea": "Por área",
|
||||
"stillOpen": "Em aberto à hora do relatório"
|
||||
},
|
||||
"metrics": {
|
||||
"created": "Pedidos",
|
||||
"resolved": "Resolvidos",
|
||||
"open": "Em aberto",
|
||||
"responseAvg": "Resposta média",
|
||||
"resolutionAvg": "Resolução média",
|
||||
"responseMax": "Pior resposta",
|
||||
"openSub": "{open} aberto · {claimed} em curso",
|
||||
"requestsSub": "{count, plural, one {sobre # pedido} other {sobre # pedidos}}",
|
||||
"noData": "sem dados"
|
||||
},
|
||||
"table": {
|
||||
"code": "Código",
|
||||
"name": "Nome",
|
||||
"area": "Área",
|
||||
"requests": "Pedidos"
|
||||
},
|
||||
"stillOpenReportedBy": "Reportado por {email} · {date}",
|
||||
"allClear": "Nada em aberto neste turno. ✓",
|
||||
"duration": {
|
||||
"lessThan1Min": "< 1 min",
|
||||
"minutes": "{n} min",
|
||||
"hours": "{h} h",
|
||||
"hoursMinutes": "{h} h {m} min",
|
||||
"dash": "—"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import './env'; // Validate env vars at build time
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||
|
||||
const config: NextConfig = {
|
||||
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'],
|
||||
reactStrictMode: true,
|
||||
@ -14,4 +17,4 @@ const config: NextConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
export default withNextIntl(config);
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "dotenv -e ../../.env -- next dev --port 3001",
|
||||
"dev": "dotenv -e ./.env.admin -e ../../.env -- next dev --port 3001",
|
||||
"build": "dotenv -e ../../.env -- next build",
|
||||
"start": "dotenv -e ../../.env -- next start --port 3001",
|
||||
"lint": "next lint",
|
||||
@ -24,6 +24,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "15.3.9",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next-intl": "^4.13.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^11.3.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export default function ErrorPage({
|
||||
error,
|
||||
@ -9,16 +10,18 @@ export default function ErrorPage({
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations('errors');
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<h1 className="text-4xl font-bold">500</h1>
|
||||
<p className="text-muted-foreground">Ocorreu um erro inesperado.</p>
|
||||
<h1 className="text-4xl font-bold">{t('title500')}</h1>
|
||||
<p className="text-muted-foreground">{t('message500')}</p>
|
||||
<button onClick={reset} className="text-sm underline underline-offset-4">
|
||||
Tentar novamente
|
||||
{t('retry')}
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
|
||||
33
apps/operator-pwa/app/language-switcher.tsx
Normal file
33
apps/operator-pwa/app/language-switcher.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
import { LOCALES, LOCALE_LABELS, type Locale } from '@/i18n/locales';
|
||||
|
||||
export function LanguageSwitcher() {
|
||||
const router = useRouter();
|
||||
const current = useLocale() as Locale;
|
||||
|
||||
function switchTo(locale: Locale) {
|
||||
document.cookie = `NEXT_LOCALE=${locale}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-border bg-muted p-0.5">
|
||||
{LOCALES.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => switchTo(l)}
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
|
||||
l === current
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{LOCALE_LABELS[l]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,19 +1,24 @@
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages, getTranslations } from 'next-intl/server';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Providers } from './providers';
|
||||
import { SyncProvider } from './sync-provider';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FieldOps — Operator',
|
||||
description: 'Industrial operator console.',
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const t = await getTranslations('metadata');
|
||||
return {
|
||||
title: t('title'),
|
||||
description: t('description'),
|
||||
manifest: '/manifest.webmanifest',
|
||||
applicationName: 'FieldOps Operator',
|
||||
applicationName: t('appName'),
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'FieldOps Operator',
|
||||
title: t('appName'),
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#0f172a',
|
||||
@ -21,13 +26,18 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang={locale}>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<Providers>
|
||||
<SyncProvider>{children}</SyncProvider>
|
||||
</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -4,11 +4,11 @@ import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Camera, X } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { db } from '@/lib/queue/db';
|
||||
import { runSync } from '@/lib/queue/sync';
|
||||
|
||||
// Resize to max 1600px on longest side and compress to JPEG q=0.8.
|
||||
function compressImage(file: File): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
@ -44,6 +44,7 @@ function compressImage(file: File): Promise<Blob> {
|
||||
}
|
||||
|
||||
export default function NewRequestPage() {
|
||||
const t = useTranslations('maintenance');
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@ -56,7 +57,7 @@ export default function NewRequestPage() {
|
||||
|
||||
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 60 * 60 * 1000 }, // 1h — serves from cache when offline
|
||||
{ staleTime: 60 * 60 * 1000 },
|
||||
);
|
||||
|
||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@ -68,7 +69,7 @@ export default function NewRequestPage() {
|
||||
setPhotoBlob(compressed);
|
||||
setPhotoPreview(URL.createObjectURL(compressed));
|
||||
} catch {
|
||||
setError('Não foi possível processar a foto. Tenta de novo.');
|
||||
setError(t('photoError'));
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,8 +90,6 @@ export default function NewRequestPage() {
|
||||
try {
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
|
||||
// Enqueue in IndexedDB immediately — returns control to the user
|
||||
// regardless of network state. The SyncProvider will drain the queue.
|
||||
await db.pending.add({
|
||||
clientRequestId,
|
||||
workstationId,
|
||||
@ -100,12 +99,11 @@ export default function NewRequestPage() {
|
||||
retries: 0,
|
||||
});
|
||||
|
||||
// Attempt immediate sync if online (fire-and-forget)
|
||||
if (navigator.onLine) runSync().catch(() => {});
|
||||
|
||||
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao guardar pedido. Tenta de novo.');
|
||||
setError(err instanceof Error ? err.message : t('saveError'));
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
@ -119,14 +117,14 @@ export default function NewRequestPage() {
|
||||
<Link href="/" className="rounded-md p-1 hover:bg-accent">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">Novo pedido de manutenção</h1>
|
||||
<h1 className="text-base font-semibold">{t('newTitle')}</h1>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
|
||||
{/* Posto */}
|
||||
{/* Workstation */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="workstation" className="text-sm font-medium">
|
||||
Posto <span className="text-destructive">*</span>
|
||||
{t('workstationLabel')} <span className="text-destructive">{t('workstationRequired')}</span>
|
||||
</label>
|
||||
<select
|
||||
id="workstation"
|
||||
@ -137,7 +135,7 @@ export default function NewRequestPage() {
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{wsLoading ? 'A carregar postos…' : 'Seleciona um posto…'}
|
||||
{wsLoading ? t('workstationLoading') : t('workstationPlaceholder')}
|
||||
</option>
|
||||
{workstations.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
@ -147,13 +145,13 @@ export default function NewRequestPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Foto */}
|
||||
{/* Photo */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Foto (opcional)</span>
|
||||
<span className="text-sm font-medium">{t('photoLabel')}</span>
|
||||
{photoPreview ? (
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={photoPreview} alt="Pré-visualização" className="h-48 w-full rounded-lg object-cover" />
|
||||
<img src={photoPreview} alt={t('photoPreview')} className="h-48 w-full rounded-lg object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={removePhoto}
|
||||
@ -169,7 +167,7 @@ export default function NewRequestPage() {
|
||||
className="flex h-24 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Camera className="h-5 w-5" />
|
||||
Tirar / escolher foto
|
||||
{t('photoButton')}
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
@ -182,10 +180,10 @@ export default function NewRequestPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Descrição */}
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
|
||||
<span>Descrição <span className="text-destructive">*</span></span>
|
||||
<span>{t('descriptionLabel')} <span className="text-destructive">{t('descriptionRequired')}</span></span>
|
||||
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{descLen}/1000
|
||||
</span>
|
||||
@ -198,7 +196,7 @@ export default function NewRequestPage() {
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
rows={4}
|
||||
placeholder="Descreve o problema…"
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
className="w-full resize-none rounded-lg border border-border bg-card px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
@ -213,7 +211,7 @@ export default function NewRequestPage() {
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? 'A guardar…' : 'Enviar pedido'}
|
||||
{submitting ? t('submitting') : t('submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -3,11 +3,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { CheckCircle2, Clock } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { db } from '@/lib/queue/db';
|
||||
import { subscribeBroadcast } from '@/lib/queue/broadcast';
|
||||
|
||||
export function SentStatus({ cid }: { cid: string }) {
|
||||
const [inQueue, setInQueue] = useState<boolean | null>(null); // null = loading
|
||||
const t = useTranslations('maintenance');
|
||||
const [inQueue, setInQueue] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function check() {
|
||||
@ -35,7 +37,7 @@ export function SentStatus({ cid }: { cid: string }) {
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">
|
||||
{pending ? 'Pedido em fila' : 'Pedido enviado'}
|
||||
{pending ? t('pendingTitle') : t('sentTitle')}
|
||||
</h1>
|
||||
{cid && (
|
||||
<p className="font-mono text-xs text-muted-foreground" data-testid="request-cid">
|
||||
@ -43,16 +45,14 @@ export function SentStatus({ cid }: { cid: string }) {
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pending
|
||||
? 'Será enviado assim que a ligação for restabelecida.'
|
||||
: 'A equipa de manutenção foi notificada e irá tratar do problema.'}
|
||||
{pending ? t('pendingMessage') : t('sentMessage')}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Voltar ao início
|
||||
{t('backHome')}
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
export default function NotFound() {
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getTranslations('errors');
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<h1 className="text-4xl font-bold">404</h1>
|
||||
<p className="text-muted-foreground">Página não encontrada.</p>
|
||||
<h1 className="text-4xl font-bold">{t('title404')}</h1>
|
||||
<p className="text-muted-foreground">{t('message404')}</p>
|
||||
<a href="/" className="text-sm underline underline-offset-4">
|
||||
Voltar ao início
|
||||
{t('backHome')}
|
||||
</a>
|
||||
</main>
|
||||
);
|
||||
|
||||
@ -1,15 +1,17 @@
|
||||
import Link from 'next/link';
|
||||
import { Wrench } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { resolveUser } from '@/lib/auth';
|
||||
import { api } from '@/lib/trpc/server';
|
||||
import { SignOutButton } from './sign-out-button';
|
||||
import { StatusBadge } from './status-badge';
|
||||
import { SyncChip } from './sync-chip';
|
||||
import { LanguageSwitcher } from './language-switcher';
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations('home');
|
||||
const user = await resolveUser();
|
||||
|
||||
// myRecent is a protectedProcedure — fails gracefully when there is no session.
|
||||
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
|
||||
let recent: RecentItem[] = [];
|
||||
try {
|
||||
@ -23,12 +25,15 @@ export default async function HomePage() {
|
||||
{/* ── Header ── */}
|
||||
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Operador</p>
|
||||
<p className="text-xs text-muted-foreground">{t('operator')}</p>
|
||||
<p className="text-sm font-medium" data-testid="current-user">
|
||||
{user?.email ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 p-4">
|
||||
@ -42,15 +47,15 @@ export default async function HomePage() {
|
||||
className="flex items-center justify-center gap-3 rounded-2xl bg-primary px-6 py-10 text-lg font-semibold text-primary-foreground shadow-sm transition-opacity hover:opacity-90 active:scale-[0.98]"
|
||||
>
|
||||
<Wrench className="h-6 w-6" />
|
||||
Pedir manutenção
|
||||
{t('requestMaintenance')}
|
||||
</Link>
|
||||
|
||||
{/* ── Recent requests ── */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Os meus pedidos</h2>
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('myRequests')}</h2>
|
||||
|
||||
{recent.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nenhum pedido ainda.</p>
|
||||
<p className="text-sm text-muted-foreground">{t('noRequests')}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{recent.map((req) => (
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { ArrowLeft, Delete } from 'lucide-react';
|
||||
|
||||
@ -10,8 +11,6 @@ interface Operator {
|
||||
email: string;
|
||||
}
|
||||
|
||||
// ── State types ──────────────────────────────────────────────────────────────
|
||||
|
||||
type PickerState =
|
||||
| { step: 'list' }
|
||||
| { step: 'pin'; operator: Operator };
|
||||
@ -19,20 +18,18 @@ type PickerState =
|
||||
const PIN_MIN = 4;
|
||||
const PIN_MAX = 6;
|
||||
|
||||
// ── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function OperatorList({
|
||||
operators,
|
||||
onSelect,
|
||||
t,
|
||||
}: {
|
||||
operators: Operator[];
|
||||
onSelect: (op: Operator) => void;
|
||||
t: ReturnType<typeof useTranslations<'auth'>>;
|
||||
}) {
|
||||
if (operators.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Nenhum operador encontrado. Execute <code>pnpm db:seed</code>.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t('noOperators')}</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@ -53,9 +50,13 @@ function OperatorList({
|
||||
function PinPad({
|
||||
operator,
|
||||
onBack,
|
||||
t,
|
||||
tc,
|
||||
}: {
|
||||
operator: Operator;
|
||||
onBack: () => void;
|
||||
t: ReturnType<typeof useTranslations<'auth'>>;
|
||||
tc: ReturnType<typeof useTranslations<'common'>>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [digits, setDigits] = useState('');
|
||||
@ -85,14 +86,14 @@ function PinPad({
|
||||
});
|
||||
if (result?.error) {
|
||||
setDigits('');
|
||||
setError('PIN incorreto ou conta bloqueada. Tente novamente.');
|
||||
setError(t('invalidPin'));
|
||||
} else {
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
}
|
||||
} catch {
|
||||
setDigits('');
|
||||
setError('Erro inesperado. Tente novamente.');
|
||||
setError(t('unexpectedError'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -108,12 +109,12 @@ function PinPad({
|
||||
onClick={onBack}
|
||||
disabled={busy}
|
||||
className="rounded-lg p-2 hover:bg-accent disabled:opacity-50"
|
||||
aria-label="Voltar"
|
||||
aria-label={t('back')}
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Operador selecionado</p>
|
||||
<p className="text-xs text-muted-foreground">{t('operatorSelected')}</p>
|
||||
<p className="text-sm font-medium">{operator.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -150,7 +151,7 @@ function PinPad({
|
||||
onClick={erase}
|
||||
disabled={busy || digits.length === 0}
|
||||
className="flex items-center justify-center rounded-2xl border border-border bg-card py-5 text-lg font-medium transition-colors hover:bg-accent active:scale-[0.97] disabled:opacity-40"
|
||||
aria-label="Apagar"
|
||||
aria-label={t('deleteDigit')}
|
||||
>
|
||||
<Delete className="h-5 w-5" />
|
||||
</button>
|
||||
@ -175,15 +176,15 @@ function PinPad({
|
||||
disabled={digits.length < PIN_MIN || busy}
|
||||
className="w-full rounded-xl bg-primary py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 active:scale-[0.98] disabled:opacity-40"
|
||||
>
|
||||
{busy ? 'A entrar…' : 'Entrar'}
|
||||
{busy ? tc('entering') : tc('enter')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
||||
const t = useTranslations('auth');
|
||||
const tc = useTranslations('common');
|
||||
const [state, setState] = useState<PickerState>({ step: 'list' });
|
||||
|
||||
if (state.step === 'pin') {
|
||||
@ -191,6 +192,8 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
||||
<PinPad
|
||||
operator={state.operator}
|
||||
onBack={() => setState({ step: 'list' })}
|
||||
t={t}
|
||||
tc={tc}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -199,6 +202,7 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
||||
<OperatorList
|
||||
operators={operators}
|
||||
onSelect={(op) => setState({ step: 'pin', operator: op })}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { prisma } from '@repo/db';
|
||||
import { resolveUser } from '@/lib/auth';
|
||||
import { OperatorPicker } from './operator-picker';
|
||||
@ -7,6 +8,7 @@ import { OperatorPicker } from './operator-picker';
|
||||
// the login step. prisma is used directly (bypassing the tRPC auth layer) so
|
||||
// the page works even when AUTH_DEV_AUTOLOGIN=false.
|
||||
export default async function SelectOperatorPage() {
|
||||
const t = await getTranslations('auth');
|
||||
const user = await resolveUser();
|
||||
if (user) redirect('/');
|
||||
|
||||
@ -19,8 +21,8 @@ export default async function SelectOperatorPage() {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
|
||||
<header className="text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Quem és tu?</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Escolhe o teu perfil para continuar.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{t('pickerTitle')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('pickerSubtitle')}</p>
|
||||
</header>
|
||||
<OperatorPicker operators={operators} />
|
||||
</main>
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { signOut } from 'next-auth/react';
|
||||
|
||||
export function SignOutButton() {
|
||||
const t = useTranslations('auth');
|
||||
return (
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/select-operator' })}
|
||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||
>
|
||||
Trocar
|
||||
{t('switchOperator')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,20 @@
|
||||
const CONFIG = {
|
||||
OPEN: { label: 'Aberto', className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
CLAIMED: { label: 'Em curso', className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' },
|
||||
RESOLVED: { label: 'Resolvido',className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
const STATUS_CLASS = {
|
||||
OPEN: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
CLAIMED: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
RESOLVED: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
} as const;
|
||||
|
||||
export function StatusBadge({ status }: { status: keyof typeof CONFIG }) {
|
||||
const { label, className } = CONFIG[status];
|
||||
type Status = keyof typeof STATUS_CLASS;
|
||||
|
||||
export function StatusBadge({ status }: { status: Status }) {
|
||||
const t = useTranslations('common');
|
||||
return (
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
||||
{label}
|
||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[status]}`}>
|
||||
{t(`status.${status.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSyncState } from './sync-provider';
|
||||
|
||||
export function SyncChip() {
|
||||
const t = useTranslations('sync');
|
||||
const { pendingCount, deadLetterCount } = useSyncState();
|
||||
|
||||
if (deadLetterCount > 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{deadLetterCount} pedido{deadLetterCount > 1 ? 's' : ''} com erro — contacta o supervisor.
|
||||
{t('deadLetters', { count: deadLetterCount })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -17,7 +19,7 @@ export function SyncChip() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
||||
<span className="h-2 w-2 rounded-full bg-orange-400" />
|
||||
{pendingCount} pedido{pendingCount > 1 ? 's' : ''} por enviar
|
||||
{t('pending', { count: pendingCount })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -25,7 +27,7 @@ export function SyncChip() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 text-xs text-green-700">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
Tudo sincronizado
|
||||
{t('synced')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast';
|
||||
import { runSync } from '@/lib/queue/sync';
|
||||
import { db } from '@/lib/queue/db';
|
||||
@ -22,6 +23,7 @@ const SyncCtx = createContext<SyncState>({ pendingCount: 0, deadLetterCount: 0 }
|
||||
export const useSyncState = () => useContext(SyncCtx);
|
||||
|
||||
export function SyncProvider({ children }: { children: ReactNode }) {
|
||||
const t = useTranslations('sync');
|
||||
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
|
||||
const [failedIds, setFailedIds] = useState<string[]>([]);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@ -85,11 +87,11 @@ export function SyncProvider({ children }: { children: ReactNode }) {
|
||||
key={id}
|
||||
className="flex items-center justify-between rounded-lg bg-destructive px-4 py-3 text-sm text-destructive-foreground shadow-lg"
|
||||
>
|
||||
<span>Pedido {id.slice(0, 8)}… falhou — contacta o supervisor.</span>
|
||||
<span>{t('requestFailed', { id: id.slice(0, 8) })}</span>
|
||||
<button
|
||||
onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))}
|
||||
className="ml-4 shrink-0 opacity-80 hover:opacity-100"
|
||||
aria-label="Fechar"
|
||||
aria-label={t('close')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
8
apps/operator-pwa/i18n/locales.ts
Normal file
8
apps/operator-pwa/i18n/locales.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export const LOCALES = ['pt', 'en'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'pt';
|
||||
export const LOCALE_LABELS: Record<Locale, string> = { pt: 'PT', en: 'EN' };
|
||||
|
||||
export function isLocale(v: string | undefined): v is Locale {
|
||||
return !!v && (LOCALES as readonly string[]).includes(v);
|
||||
}
|
||||
12
apps/operator-pwa/i18n/request.ts
Normal file
12
apps/operator-pwa/i18n/request.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { DEFAULT_LOCALE, isLocale } from './locales';
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const cookie = (await cookies()).get('NEXT_LOCALE')?.value;
|
||||
const locale = isLocale(cookie) ? cookie : DEFAULT_LOCALE;
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
70
apps/operator-pwa/messages/en.json
Normal file
70
apps/operator-pwa/messages/en.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"metadata": {
|
||||
"title": "FieldOps — Operator",
|
||||
"description": "Industrial operator console.",
|
||||
"appName": "FieldOps Operator"
|
||||
},
|
||||
"common": {
|
||||
"enter": "Sign in",
|
||||
"entering": "Signing in…",
|
||||
"status": {
|
||||
"open": "Open",
|
||||
"claimed": "In progress",
|
||||
"resolved": "Resolved"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"title500": "500",
|
||||
"message500": "An unexpected error occurred.",
|
||||
"retry": "Try again",
|
||||
"title404": "404",
|
||||
"message404": "Page not found.",
|
||||
"backHome": "Back to home"
|
||||
},
|
||||
"auth": {
|
||||
"pickerTitle": "Who are you?",
|
||||
"pickerSubtitle": "Choose your profile to continue.",
|
||||
"noOperators": "No operators found. Run pnpm db:seed.",
|
||||
"back": "Back",
|
||||
"operatorSelected": "Selected operator",
|
||||
"invalidPin": "Incorrect PIN or account locked. Please try again.",
|
||||
"unexpectedError": "Unexpected error. Please try again.",
|
||||
"deleteDigit": "Delete",
|
||||
"switchOperator": "Switch"
|
||||
},
|
||||
"home": {
|
||||
"operator": "Operator",
|
||||
"myRequests": "My requests",
|
||||
"requestMaintenance": "Request maintenance",
|
||||
"noRequests": "No requests yet."
|
||||
},
|
||||
"sync": {
|
||||
"deadLetters": "{count, plural, one {# request failed — contact your supervisor.} other {# requests failed — contact your supervisor.}}",
|
||||
"pending": "{count, plural, one {# request pending} other {# requests pending}}",
|
||||
"synced": "All synced",
|
||||
"requestFailed": "Request {id}… failed — contact your supervisor.",
|
||||
"close": "Close"
|
||||
},
|
||||
"maintenance": {
|
||||
"newTitle": "New maintenance request",
|
||||
"workstationLabel": "Workstation",
|
||||
"workstationRequired": "*",
|
||||
"workstationLoading": "Loading workstations…",
|
||||
"workstationPlaceholder": "Select a workstation…",
|
||||
"photoLabel": "Photo (optional)",
|
||||
"photoPreview": "Preview",
|
||||
"photoButton": "Take / choose photo",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionRequired": "*",
|
||||
"descriptionPlaceholder": "Describe the problem…",
|
||||
"photoError": "Could not process the photo. Please try again.",
|
||||
"saveError": "Error saving request. Please try again.",
|
||||
"submit": "Submit request",
|
||||
"submitting": "Saving…",
|
||||
"sentTitle": "Request submitted",
|
||||
"pendingTitle": "Request queued",
|
||||
"sentMessage": "The maintenance team has been notified and will handle the issue.",
|
||||
"pendingMessage": "Will be sent as soon as the connection is restored.",
|
||||
"backHome": "Back to home"
|
||||
}
|
||||
}
|
||||
70
apps/operator-pwa/messages/pt.json
Normal file
70
apps/operator-pwa/messages/pt.json
Normal file
@ -0,0 +1,70 @@
|
||||
{
|
||||
"metadata": {
|
||||
"title": "FieldOps — Operador",
|
||||
"description": "Consola de operador industrial.",
|
||||
"appName": "FieldOps Operador"
|
||||
},
|
||||
"common": {
|
||||
"enter": "Entrar",
|
||||
"entering": "A entrar…",
|
||||
"status": {
|
||||
"open": "Aberto",
|
||||
"claimed": "Em curso",
|
||||
"resolved": "Resolvido"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"title500": "500",
|
||||
"message500": "Ocorreu um erro inesperado.",
|
||||
"retry": "Tentar novamente",
|
||||
"title404": "404",
|
||||
"message404": "Página não encontrada.",
|
||||
"backHome": "Voltar ao início"
|
||||
},
|
||||
"auth": {
|
||||
"pickerTitle": "Quem és tu?",
|
||||
"pickerSubtitle": "Escolhe o teu perfil para continuar.",
|
||||
"noOperators": "Nenhum operador encontrado. Execute pnpm db:seed.",
|
||||
"back": "Voltar",
|
||||
"operatorSelected": "Operador selecionado",
|
||||
"invalidPin": "PIN incorreto ou conta bloqueada. Tente novamente.",
|
||||
"unexpectedError": "Erro inesperado. Tente novamente.",
|
||||
"deleteDigit": "Apagar",
|
||||
"switchOperator": "Trocar"
|
||||
},
|
||||
"home": {
|
||||
"operator": "Operador",
|
||||
"myRequests": "Os meus pedidos",
|
||||
"requestMaintenance": "Pedir manutenção",
|
||||
"noRequests": "Nenhum pedido ainda."
|
||||
},
|
||||
"sync": {
|
||||
"deadLetters": "{count, plural, one {# pedido com erro — contacta o supervisor.} other {# pedidos com erro — contacta o supervisor.}}",
|
||||
"pending": "{count, plural, one {# pedido por enviar} other {# pedidos por enviar}}",
|
||||
"synced": "Tudo sincronizado",
|
||||
"requestFailed": "Pedido {id}… falhou — contacta o supervisor.",
|
||||
"close": "Fechar"
|
||||
},
|
||||
"maintenance": {
|
||||
"newTitle": "Novo pedido de manutenção",
|
||||
"workstationLabel": "Posto",
|
||||
"workstationRequired": "*",
|
||||
"workstationLoading": "A carregar postos…",
|
||||
"workstationPlaceholder": "Seleciona um posto…",
|
||||
"photoLabel": "Foto (opcional)",
|
||||
"photoPreview": "Pré-visualização",
|
||||
"photoButton": "Tirar / escolher foto",
|
||||
"descriptionLabel": "Descrição",
|
||||
"descriptionRequired": "*",
|
||||
"descriptionPlaceholder": "Descreve o problema…",
|
||||
"photoError": "Não foi possível processar a foto. Tenta de novo.",
|
||||
"saveError": "Erro ao guardar pedido. Tenta de novo.",
|
||||
"submit": "Enviar pedido",
|
||||
"submitting": "A guardar…",
|
||||
"sentTitle": "Pedido enviado",
|
||||
"pendingTitle": "Pedido em fila",
|
||||
"sentMessage": "A equipa de manutenção foi notificada e irá tratar do problema.",
|
||||
"pendingMessage": "Será enviado assim que a ligação for restabelecida.",
|
||||
"backHome": "Voltar ao início"
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import withPWAInit from '@ducanh2912/next-pwa';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
import './env';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||
|
||||
const withPWA = withPWAInit({
|
||||
dest: 'public',
|
||||
cacheOnFrontEndNav: true,
|
||||
@ -29,4 +32,4 @@ const nextConfig: NextConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
export default withPWA(nextConfig);
|
||||
export default withPWA(withNextIntl(nextConfig));
|
||||
|
||||
@ -17,15 +17,16 @@
|
||||
"@repo/db": "workspace:*",
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/ui": "workspace:*",
|
||||
"dexie": "^4.0.10",
|
||||
"@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",
|
||||
"dexie": "^4.0.10",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "15.3.9",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next-intl": "^4.13.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-pretty": "^11.3.0",
|
||||
"react": "^19.0.0",
|
||||
|
||||
138
docs/i18n.md
Normal file
138
docs/i18n.md
Normal file
@ -0,0 +1,138 @@
|
||||
# Internationalization (i18n)
|
||||
|
||||
FieldOps ships in **Portuguese (PT, default)** and **English (EN)**. Both apps
|
||||
(`operator-pwa` and `admin-web`) are fully translated. This doc explains how it
|
||||
works and **how to add a new language**.
|
||||
|
||||
## How it works
|
||||
|
||||
- Library: [`next-intl`](https://next-intl.dev) (Next.js 15 App Router).
|
||||
- **No locale in the URL.** This is an internal app (no SEO), so we use
|
||||
next-intl's "without i18n routing" mode. The locale comes from a
|
||||
**`NEXT_LOCALE` cookie**, not a `/en/...` path prefix. This keeps every route
|
||||
unchanged and avoids any clash with the auth middleware.
|
||||
- **Default is `pt`.** A missing or invalid cookie falls back to Portuguese.
|
||||
- Dates, numbers and relative times are formatted by next-intl's `useFormatter`,
|
||||
so they follow the active locale automatically (no hardcoded `pt-PT`).
|
||||
- Translations live **per app** (not in a shared package) — the two apps share
|
||||
only a handful of strings, not enough to justify a shared package yet.
|
||||
|
||||
## File structure (same in each app)
|
||||
|
||||
```
|
||||
apps/<app>/
|
||||
i18n/
|
||||
locales.ts # LOCALES, DEFAULT_LOCALE, LOCALE_LABELS, isLocale()
|
||||
request.ts # reads the NEXT_LOCALE cookie, loads the messages file
|
||||
messages/
|
||||
pt.json # Portuguese (default)
|
||||
en.json # English
|
||||
app/
|
||||
layout.tsx # <html lang={locale}>, NextIntlClientProvider
|
||||
language-switcher.tsx # the PT | EN pill
|
||||
next.config.ts # wrapped with withNextIntl(...)
|
||||
```
|
||||
|
||||
Messages are organised into namespaces (`common`, `auth`, `maintenance`,
|
||||
`report`, `home`, `sync`, `errors`, `metadata`) — see the JSON files.
|
||||
|
||||
## Changing language (end user)
|
||||
|
||||
Click the **PT | EN** pill in the app header. The choice is saved in the
|
||||
`NEXT_LOCALE` cookie (1-year expiry); server components re-render via
|
||||
`router.refresh()`. No account or DB change involved.
|
||||
|
||||
> The switcher currently appears on the **authenticated** screens (operator home,
|
||||
> maintenance queue), not on the login/picker pages. A first-time user sees the
|
||||
> initial login in PT; once they switch, the cookie persists across logins.
|
||||
|
||||
## Adding a new language
|
||||
|
||||
Example: adding **French (`fr`)** to the admin-web. Repeat for operator-pwa.
|
||||
|
||||
1. **Create the messages file.** Copy `apps/admin-web/messages/en.json` to
|
||||
`apps/admin-web/messages/fr.json` and translate every value. **Keep every key
|
||||
identical** — only translate the values. Leave ICU placeholders
|
||||
(`{email}`, `{count}`) and plural structure (`{count, plural, one {…} other {…}}`)
|
||||
intact.
|
||||
|
||||
2. **Register the locale** in `apps/admin-web/i18n/locales.ts`:
|
||||
```ts
|
||||
export const LOCALES = ['pt', 'en', 'fr'] as const;
|
||||
export const LOCALE_LABELS: Record<Locale, string> = { pt: 'PT', en: 'EN', fr: 'FR' };
|
||||
```
|
||||
|
||||
3. **Repeat steps 1–2 for `operator-pwa`** (copy its `en.json`, add `fr` to its
|
||||
`locales.ts`).
|
||||
|
||||
4. **Verify** (see below). The **FR** button then appears in the switcher
|
||||
automatically — no other code changes needed.
|
||||
|
||||
> Translating the *values* needs someone who speaks the language. Everything
|
||||
> else is mechanical.
|
||||
|
||||
## Keeping translations healthy
|
||||
|
||||
Two failure modes are NOT caught by `tsc` or the E2E tests (the E2E only run in
|
||||
the default PT), so check them whenever you touch translations:
|
||||
|
||||
**1. Key parity** — every locale file must have exactly the same keys, or a
|
||||
missing key renders as raw text (or throws) when a user switches to it:
|
||||
|
||||
```sh
|
||||
node -e '
|
||||
const fs=require("fs");
|
||||
function flat(o,p=""){let k=[];for(const key in o){const np=p?p+"."+key:key;if(typeof o[key]==="object"&&o[key]!==null)k=k.concat(flat(o[key],np));else k.push(np);}return k;}
|
||||
for(const app of ["admin-web","operator-pwa"]){
|
||||
const files=fs.readdirSync(`apps/${app}/messages`).filter(f=>f.endsWith(".json"));
|
||||
const sets=files.map(f=>[f,new Set(flat(JSON.parse(fs.readFileSync(`apps/${app}/messages/${f}`))))]);
|
||||
const base=sets[0][1];
|
||||
for(const [f,s] of sets){
|
||||
const missing=[...base].filter(k=>!s.has(k));
|
||||
const extra=[...s].filter(k=>!base.has(k));
|
||||
console.log(`[${app}] ${f}: ${s.size} keys`, missing.length||extra.length?`MISSING ${missing} EXTRA ${extra}`:"OK");
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**2. ICU syntax** — a malformed plural (e.g. a missing brace) only throws when
|
||||
that exact message renders, which the PT-only E2E may never hit:
|
||||
|
||||
```sh
|
||||
node -e '
|
||||
const fs=require("fs"),path=require("path");
|
||||
const dir=fs.readdirSync("node_modules/.pnpm").find(d=>d.startsWith("intl-messageformat@"));
|
||||
const {IntlMessageFormat}=require(path.resolve("node_modules/.pnpm",dir,"node_modules/intl-messageformat"));
|
||||
function flat(o,p=""){let r={};for(const k in o){const np=p?p+"."+k:k;if(typeof o[k]==="object"&&o[k]!==null)Object.assign(r,flat(o[k],np));else r[np]=o[k];}return r;}
|
||||
let errors=0;
|
||||
for(const app of ["admin-web","operator-pwa"]){
|
||||
for(const f of fs.readdirSync(`apps/${app}/messages`).filter(f=>f.endsWith(".json"))){
|
||||
const loc=f.replace(".json","");
|
||||
const msgs=flat(JSON.parse(fs.readFileSync(`apps/${app}/messages/${f}`)));
|
||||
for(const [key,val] of Object.entries(msgs)){
|
||||
try{new IntlMessageFormat(val,loc);}catch(e){console.log(`ICU ERROR [${app}/${loc}] ${key}: ${e.message}`);errors++;}
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(errors?`${errors} ERRORS`:"ALL ICU OK");'
|
||||
```
|
||||
|
||||
Both should report OK before shipping a new language.
|
||||
|
||||
## What is intentionally NOT translated
|
||||
|
||||
- **Dynamic content** — workstation names, areas, request descriptions. These
|
||||
are customer data, kept in whatever language the user typed.
|
||||
- **Backend tRPC error messages** — almost never surface to the user (the UI has
|
||||
its own messages).
|
||||
- The `Credentials` provider labels in `lib/auth.ts` (internal to Auth.js; the
|
||||
custom login UI is what users actually see).
|
||||
|
||||
## Future improvements (not done yet)
|
||||
|
||||
- Persist the locale per tenant/user in the DB (currently cookie-only).
|
||||
- Show the language switcher on the login/picker pages too.
|
||||
- A small E2E that switches to EN and asserts a translated string (today the
|
||||
EN path is covered only by the parity + ICU checks above).
|
||||
- next-intl strict message-key typing (autocomplete + compile-time error on
|
||||
unknown keys).
|
||||
175
docs/plans/i18n-pt-en.md
Normal file
175
docs/plans/i18n-pt-en.md
Normal file
@ -0,0 +1,175 @@
|
||||
# Plano — i18n (infra + extração PT-PT / EN)
|
||||
|
||||
> Autor: Opus 4.8 (sessão de design, 2026-05-30). Destinado a implementação pelo Sonnet.
|
||||
> Pré-requisitos: MAI CALL v0.1 + Auth v0.2 + v0.3 + verificação E2E, todos implementados. Estado verificado contra o repo.
|
||||
> **Motivo:** Pedro quer a app pronta para vários idiomas antes de empilhar mais módulos (o custo de extrair strings cresce com cada módulo). Começar com **PT-PT (default) + EN**. Tradução real só destas duas; a infra fica pronta para adicionar línguas com um ficheiro `.json`.
|
||||
|
||||
## Objetivo numa frase
|
||||
|
||||
Toda a UI das duas apps passa de **texto fixo em português** para **chaves de tradução** (`t('...')`), com ficheiros `pt.json` + `en.json` por app, e um seletor de idioma — sem mexer nas rotas nem partir os testes E2E.
|
||||
|
||||
## Estado atual (medido)
|
||||
|
||||
- **148 strings** hardcoded em **23 ficheiros** (59 em operator-pwa, 89 em admin-web). Inventário completo confirmado por levantamento.
|
||||
- Inclui strings **dinâmicas** (`Reportado por {email}`, `há {n}m`, `{count} pedidos`) e **datas** via `toLocaleString('pt-PT')` fixo (report-view.tsx).
|
||||
- `<html lang>` está **inconsistente**: operator-pwa diz `"en"` (errado, app é PT), admin-web diz `"pt"`. Ambos passam a dinâmicos.
|
||||
|
||||
## Decisões fixadas (não revisitar sem motivo forte)
|
||||
|
||||
1. **Biblioteca: `next-intl`** (padrão de facto para Next 15 App Router, suporta Server e Client Components, formatação de datas/números/plurais por locale via ICU).
|
||||
2. **SEM i18n routing** (modo "without i18n routing" do next-intl). O locale **não** vai no URL (`/en/...`). Vem de um **cookie `NEXT_LOCALE`**. Justificação crítica:
|
||||
- É uma app **interna** (sem SEO) — URLs por locale não trazem valor.
|
||||
- **Não precisa do middleware do next-intl** → **não colide com o middleware de auth** que já existe em ambas as apps. Este é o ponto que torna a integração barata.
|
||||
- Não muda nenhuma rota nem `<Link href>` existente.
|
||||
3. **Default = `pt`.** Cookie ausente ou inválido → `pt`. Isto é deliberado e **protege os testes E2E**: os specs procuram texto PT exato; com PT como default, passam sem alteração — **desde que as traduções PT sejam verbatim das strings atuais** (copiar exatamente, incluindo reticências `…`, acentos, e maiúsculas).
|
||||
4. **Mensagens por-app**, não um package partilhado. Cada app tem `messages/pt.json` + `messages/en.json` com namespaces internos. Só ~9 strings se repetem entre apps (estados, turnos, "Entrar") — não justifica um `@repo/i18n` partilhado e a complexidade de monorepo que traz. (Extrair um `common` partilhado fica para quando houver um 3º consumidor.)
|
||||
5. **Datas/números/tempos relativos** passam a usar o **formatter do next-intl** (`useFormatter`/`getFormatter`), não `toLocaleString('pt-PT')`. O locale do formatter vem do contexto → datas ficam localizadas automaticamente.
|
||||
6. **Persistência do locale: só cookie (+ seletor) nesta fase.** Guardar a preferência por-tenant ou por-utilizador na BD fica para depois (corte consciente — ver §Cortes). O cookie não fecha essa porta.
|
||||
|
||||
## Estrutura de ficheiros (por app, exemplo admin-web)
|
||||
|
||||
```
|
||||
apps/admin-web/
|
||||
i18n/
|
||||
request.ts # getRequestConfig — lê o cookie, carrega as mensagens
|
||||
locales.ts # LOCALES = ['pt','en'] as const, DEFAULT_LOCALE='pt', labels
|
||||
messages/
|
||||
pt.json
|
||||
en.json
|
||||
app/
|
||||
layout.tsx # <html lang={locale}>, NextIntlClientProvider (via Providers)
|
||||
language-switcher.tsx # client component: PT | EN → set cookie + router.refresh()
|
||||
next.config.ts # withNextIntl(...) composto com o config existente
|
||||
```
|
||||
|
||||
Namespaces sugeridos (chaves dentro do JSON), para manter organizado:
|
||||
- `common` — botões/labels repetidos: `enter`, `cancel`, `confirm`, `back`, `loading`, `close`, estados `status.open/claimed/resolved`.
|
||||
- `auth` — login/picker.
|
||||
- `home` — home do operador.
|
||||
- `maintenance` — fila + criar pedido.
|
||||
- `report` — relatório de turno.
|
||||
- `errors` — 404/500/genéricos.
|
||||
|
||||
## Abordagem técnica (exemplos — implementar exatamente neste padrão)
|
||||
|
||||
### `i18n/locales.ts`
|
||||
```ts
|
||||
export const LOCALES = ['pt', 'en'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
export const DEFAULT_LOCALE: Locale = 'pt';
|
||||
export const LOCALE_LABELS: Record<Locale, string> = { pt: 'PT', en: 'EN' };
|
||||
export function isLocale(v: string | undefined): v is Locale {
|
||||
return !!v && (LOCALES as readonly string[]).includes(v);
|
||||
}
|
||||
```
|
||||
|
||||
### `i18n/request.ts` (next-intl, sem routing)
|
||||
```ts
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { DEFAULT_LOCALE, isLocale } from './locales';
|
||||
|
||||
export default getRequestConfig(async () => {
|
||||
const cookie = (await cookies()).get('NEXT_LOCALE')?.value;
|
||||
const locale = isLocale(cookie) ? cookie : DEFAULT_LOCALE;
|
||||
return {
|
||||
locale,
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### `next.config.ts`
|
||||
```ts
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||
// admin-web: export default withNextIntl(nextConfig);
|
||||
// operator-pwa: export default withPWA(withNextIntl(nextConfig)); // compor com o PWA
|
||||
```
|
||||
|
||||
### `app/layout.tsx`
|
||||
```tsx
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getLocale, getMessages } from 'next-intl/server';
|
||||
|
||||
export default async function RootLayout({ children }) {
|
||||
const locale = await getLocale();
|
||||
const messages = await getMessages();
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||
<Providers>{children}</Providers>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
> Nota: o `NextIntlClientProvider` envolve os `Providers` existentes (client). As mensagens são passadas do server → client uma vez.
|
||||
|
||||
### Uso nos componentes
|
||||
- **Client component:** `const t = useTranslations('report'); ... t('print')`.
|
||||
- **Server component:** `const t = await getTranslations('home');`.
|
||||
- **Datas/tempos:** `const format = useFormatter(); format.dateTime(d, {day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit'})` e `format.relativeTime(date)` para os "há 5m". Remove o `'pt-PT'` fixo.
|
||||
- **Plurais** (`{count} pedido(s)`): ICU no JSON →
|
||||
`"pendingCount": "{count, plural, one {# pedido por enviar} other {# pedidos por enviar}}"` (pt) / `one {# request pending} other {# requests pending}` (en).
|
||||
|
||||
### `language-switcher.tsx` (client)
|
||||
```tsx
|
||||
'use client';
|
||||
// dois botões PT | EN. onClick:
|
||||
// document.cookie = `NEXT_LOCALE=${l}; path=/; max-age=31536000`;
|
||||
// router.refresh(); // re-render server components com o novo locale
|
||||
```
|
||||
Colocar: na home do operador (`app/page.tsx` header) e no header da fila (`maintenance-queue.tsx`).
|
||||
|
||||
## `lib/shifts.ts` — caso especial
|
||||
|
||||
Os labels `Manhã/Tarde/Noite` estão numa constante de lógica, não num componente. **Não traduzir no shifts.ts.** Em vez disso, manter as `ShiftKey` (`manha/tarde/noite`) e traduzir no componente: `t('report.shift.' + key)`. O `shifts.ts` perde o campo `label` (ou mantém-no só como fallback técnico não usado na UI).
|
||||
|
||||
## Passos de implementação (ordenados)
|
||||
|
||||
### Passo 1 — admin-web completo (infra + extração + seletor)
|
||||
**Faz:** toda a infra (§Estrutura + §Abordagem) na admin-web; extrai as **89 strings** para `messages/pt.json` (verbatim) + traduz para `messages/en.json`; datas do report via formatter; `<html lang>` dinâmico; seletor de idioma no header da fila; trata os plurais e o `shifts.ts`.
|
||||
**AC:**
|
||||
- App arranca; com cookie ausente está 100% em PT, **idêntica ao atual** (texto verbatim).
|
||||
- Seletor → EN traduz toda a UI da admin-web; volta a PT sem recarregar manualmente.
|
||||
- `pnpm test:e2e` (a parte da admin: report + queue) **continua verde sem alterar os specs** (porque PT é default e verbatim).
|
||||
- `cd apps/admin-web && npx tsc --noEmit` limpo.
|
||||
|
||||
### Passo 2 — operator-pwa completo
|
||||
**Faz:** mesma infra (atenção: **compor `withNextIntl` com `withPWA`** no next.config); extrai as **59 strings**; trata `timeAgo`/`sync-chip`/`status-badge` (plurais ICU); corrige `<html lang="en">` → dinâmico; seletor de idioma na home (ou no picker).
|
||||
**AC:**
|
||||
- operator-pwa 100% PT (verbatim) + EN.
|
||||
- `pnpm test:e2e` happy-path **verde sem alterar specs**; `pnpm test:e2e:auth` (login operador usa "Entrar", "PIN incorreto…") **verde** — confirmar que essas strings PT estão verbatim.
|
||||
- typecheck limpo. O Service Worker (PWA) continua a funcionar (o config compõe, não substitui).
|
||||
|
||||
### Passo 3 — Limpeza, cortes e verificação final
|
||||
**Faz:** documentar os cortes (abaixo); README ganha uma secção curta "Idiomas" (como mudar, como adicionar uma língua = novo `.json`); correr a bateria toda.
|
||||
**AC:** `pnpm typecheck` (sem novos erros além do pré-existente em @repo/storage), `pnpm test:e2e` (3/3) e `pnpm test:e2e:auth` (4/4) verdes. README documenta adicionar um idioma.
|
||||
|
||||
## Cortes propositados — o que NÃO entra
|
||||
|
||||
| Cortado | Porquê | Quando volta |
|
||||
|---|---|---|
|
||||
| Persistir locale por-tenant/utilizador na BD | Cookie + seletor chega para PT/EN; não fecha a porta | quando um cliente pedir locale fixo por fábrica |
|
||||
| Traduzir mensagens de erro do tRPC (backend) | Quase nunca chegam ao utilizador (a UI tem mensagens próprias); 1 só está em PT | se passarem a ser mostradas ao utilizador |
|
||||
| Strings do `Credentials` provider em `lib/auth.ts` (`name`/`label`) | São do fluxo interno do Auth.js; usamos UI própria (picker/form), nunca se veem | se usarmos a UI default do Auth.js |
|
||||
| `pages/_error.tsx` (Pages Router legacy) | Fallback que praticamente não dispara no App Router | se virar relevante |
|
||||
| Conteúdo dinâmico (nomes de postos, áreas, descrições) | São dados do cliente, ficam na língua dele — não é tradução | nunca (por design) |
|
||||
| Línguas além de PT/EN, RTL | Fora de âmbito agora | quando houver cliente que peça |
|
||||
|
||||
## Sequência crítica e riscos
|
||||
|
||||
- **Fazer a admin-web inteira primeiro (Passo 1)** valida a abordagem numa app sem PWA antes de a replicar. Só depois o operator-pwa (Passo 2), que tem o wrapper do PWA a compor.
|
||||
- **Risco principal — partir os E2E.** Os specs procuram texto PT exato. Mitigação tripla: (a) PT é o default; (b) as traduções PT são **verbatim** (copiar, não reescrever); (c) correr ambos os E2E no fim de cada passo.
|
||||
- **Risco — compor `withNextIntl` com `withPWA`** no operator. Mitigação: `withPWA(withNextIntl(nextConfig))`; testar que o SW ainda gera e que a app arranca.
|
||||
- **Risco — Server vs Client.** `useTranslations` só em client components; `getTranslations` em server. O inventário marca quais são quais; em caso de dúvida, o componente que já usa `'use client'` usa o hook.
|
||||
- Nada disto toca em lógica de negócio, API, ou base de dados → o risco é de UI/config, apanhável pelos E2E.
|
||||
|
||||
## Anexo — onde estão as 148 strings (do inventário)
|
||||
|
||||
**operator-pwa (59):** layout.tsx(3), page.tsx(4), error.tsx(3), not-found.tsx(3), select-operator/page.tsx(2), select-operator/operator-picker.tsx(8), sign-out-button.tsx(1), status-badge.tsx(3), sync-chip.tsx(3), maintenance/new/page.tsx(14), maintenance/sent/sent-status.tsx(5), sync-provider.tsx(2), lib/auth.ts(3 — corte), pages/_error.tsx(2 — corte).
|
||||
|
||||
**admin-web (89):** layout.tsx(2), login/page.tsx(2), login/login-form.tsx(7), maintenance/maintenance-queue.tsx(31), maintenance/report/report-view.tsx(35), maintenance/report/page.tsx(1), lib/shifts.ts(3 — via componente), lib/auth.ts(3 — corte), pages/_error.tsx(2 — corte).
|
||||
@ -57,9 +57,10 @@ export default defineConfig({
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
// AUTH_URL must point to the admin server — .env has it at 3000 (operator)
|
||||
// which causes Auth.js to redirect unauthenticated users to localhost:3000.
|
||||
env: { AUTH_DEV_AUTOLOGIN: 'false', AUTH_URL: ADMIN_URL },
|
||||
// AUTH_URL is no longer overridden here — the admin-web `dev` script loads
|
||||
// apps/admin-web/.env.admin (AUTH_URL=:3001) with precedence over the root
|
||||
// .env, so the app knows its own base URL. See apps/admin-web/.env.admin.
|
||||
env: { AUTH_DEV_AUTOLOGIN: 'false' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
416
pnpm-lock.yaml
generated
416
pnpm-lock.yaml
generated
@ -70,7 +70,10 @@ importers:
|
||||
version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.25
|
||||
version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
version: 5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
next-intl:
|
||||
specifier: ^4.13.0
|
||||
version: 4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
@ -164,7 +167,10 @@ importers:
|
||||
version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.25
|
||||
version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
version: 5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
next-intl:
|
||||
specifier: ^4.13.0
|
||||
version: 4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
@ -1273,6 +1279,18 @@ packages:
|
||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@formatjs/fast-memoize@3.1.5':
|
||||
resolution: {integrity: sha512-KLi3fan6WnCHmigd9pmEEN8Hid0v4wiFBW576M/d07KMWYecf1CvyMI3n34vCmHT4AoVqG2n702kiHbXjzZX2A==}
|
||||
|
||||
'@formatjs/icu-messageformat-parser@3.5.10':
|
||||
resolution: {integrity: sha512-XeJihYLy1lCe19xfK1KWKG/betBOK2rB0luL8lSkjfvJj0zP+LTJvkC+RKd0jsFI8mWxN71LrarHSrEXE8xxOQ==}
|
||||
|
||||
'@formatjs/icu-skeleton-parser@2.1.9':
|
||||
resolution: {integrity: sha512-rsxswgHMfU1zUgB2byc08fesf83wLGjFnzLCEtuf00mx2doiqc6pYrf67raI37XqdRcGUviQepk2UKGqpng74Q==}
|
||||
|
||||
'@formatjs/intl-localematcher@0.8.9':
|
||||
resolution: {integrity: sha512-GmB0F/gYh4Hdl4rLWjgDsgT+x4pB54fkJeRh8kAZ4XFzKeCK8dGs+SBJWXO42QZtOUni+IDWKNuCw6wiL4lTvw==}
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@ -1541,6 +1559,94 @@ packages:
|
||||
'@panva/hkdf@1.2.1':
|
||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.6':
|
||||
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.6':
|
||||
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.6':
|
||||
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.6':
|
||||
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.6':
|
||||
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.6':
|
||||
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.6':
|
||||
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.6':
|
||||
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.6':
|
||||
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.6':
|
||||
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.6':
|
||||
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@parcel/watcher@2.5.6':
|
||||
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
@ -1628,6 +1734,9 @@ packages:
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
'@schummar/icu-type-parser@1.21.5':
|
||||
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
|
||||
|
||||
'@smithy/core@3.24.3':
|
||||
resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
@ -1670,12 +1779,102 @@ packages:
|
||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.40':
|
||||
resolution: {integrity: sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-darwin-x64@1.15.40':
|
||||
resolution: {integrity: sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.40':
|
||||
resolution: {integrity: sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.40':
|
||||
resolution: {integrity: sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.40':
|
||||
resolution: {integrity: sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.40':
|
||||
resolution: {integrity: sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.40':
|
||||
resolution: {integrity: sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.40':
|
||||
resolution: {integrity: sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.40':
|
||||
resolution: {integrity: sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.40':
|
||||
resolution: {integrity: sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.40':
|
||||
resolution: {integrity: sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.40':
|
||||
resolution: {integrity: sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@swc/core@1.15.40':
|
||||
resolution: {integrity: sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@swc/helpers': '>=0.5.17'
|
||||
peerDependenciesMeta:
|
||||
'@swc/helpers':
|
||||
optional: true
|
||||
|
||||
'@swc/counter@0.1.3':
|
||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@swc/types@0.1.26':
|
||||
resolution: {integrity: sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==}
|
||||
|
||||
'@t3-oss/env-core@0.11.1':
|
||||
resolution: {integrity: sha512-MaxOwEoG1ntCFoKJsS7nqwgcxLW1SJw238AJwfJeaz3P/8GtkxXZsPPolsz1AdYvUTbe3XvqZ/VCdfjt+3zmKw==}
|
||||
peerDependencies:
|
||||
@ -2600,6 +2799,9 @@ packages:
|
||||
help-me@5.0.0:
|
||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||
|
||||
icu-minify@4.13.0:
|
||||
resolution: {integrity: sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==}
|
||||
|
||||
idb@7.1.1:
|
||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||
|
||||
@ -2633,6 +2835,9 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
intl-messageformat@11.2.7:
|
||||
resolution: {integrity: sha512-+q6Ktg119nULZEpZ8YTuGOst9MyEzFtjD63FTGBlN1mLz0Z/MOUYDIvnpVKwq17eezIEh+cfJIebfJoCetpiNw==}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -2926,6 +3131,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
negotiator@1.0.0:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
neo-async@2.6.2:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
@ -2945,6 +3154,19 @@ packages:
|
||||
nodemailer:
|
||||
optional: true
|
||||
|
||||
next-intl-swc-plugin-extractor@4.13.0:
|
||||
resolution: {integrity: sha512-6S/fJI0KXvLCL8nhBo9P8eGaJPzmwJBTCzX0NaUIj0VyU8U89d//T+vjMLdNIXl5MlLaYH7B9MbAjb8Mvu+tqQ==}
|
||||
|
||||
next-intl@4.13.0:
|
||||
resolution: {integrity: sha512-OvNq2v5XLx4EkQOsAhVE9g+6zdb83XHusADCXXtIW4LILYnjEVaeINdr1lkVWKSjzwNUiMSlH5N4K0OQTRiv6A==}
|
||||
peerDependencies:
|
||||
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
|
||||
typescript: '*'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
next@15.3.9:
|
||||
resolution: {integrity: sha512-bat50ogkh2esjfkbqmVocL5QunR9RGCSO2oQKFjKeDcEylIgw3JY6CMfGnzoVfXJ9SDLHI546sHmsk90D2ivwQ==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
@ -2966,6 +3188,9 @@ packages:
|
||||
sass:
|
||||
optional: true
|
||||
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-fetch-native@1.6.7:
|
||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||
|
||||
@ -3112,6 +3337,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
po-parser@2.1.1:
|
||||
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -3770,6 +3998,11 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
use-intl@4.13.0:
|
||||
resolution: {integrity: sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==}
|
||||
peerDependencies:
|
||||
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
@ -5090,6 +5323,18 @@ snapshots:
|
||||
'@eslint/core': 0.17.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@formatjs/fast-memoize@3.1.5': {}
|
||||
|
||||
'@formatjs/icu-messageformat-parser@3.5.10':
|
||||
dependencies:
|
||||
'@formatjs/icu-skeleton-parser': 2.1.9
|
||||
|
||||
'@formatjs/icu-skeleton-parser@2.1.9': {}
|
||||
|
||||
'@formatjs/intl-localematcher@0.8.9':
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.5
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
'@humanfs/types': 0.15.0
|
||||
@ -5273,6 +5518,66 @@ snapshots:
|
||||
|
||||
'@panva/hkdf@1.2.1': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-arm64@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-darwin-x64@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-freebsd-x64@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-glibc@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm-musl@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-glibc@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-arm64-musl@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-glibc@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-linux-x64-musl@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-arm64@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-ia32@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher-win32-x64@2.5.6':
|
||||
optional: true
|
||||
|
||||
'@parcel/watcher@2.5.6':
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
is-glob: 4.0.3
|
||||
node-addon-api: 7.1.1
|
||||
picomatch: 4.0.4
|
||||
optionalDependencies:
|
||||
'@parcel/watcher-android-arm64': 2.5.6
|
||||
'@parcel/watcher-darwin-arm64': 2.5.6
|
||||
'@parcel/watcher-darwin-x64': 2.5.6
|
||||
'@parcel/watcher-freebsd-x64': 2.5.6
|
||||
'@parcel/watcher-linux-arm-glibc': 2.5.6
|
||||
'@parcel/watcher-linux-arm-musl': 2.5.6
|
||||
'@parcel/watcher-linux-arm64-glibc': 2.5.6
|
||||
'@parcel/watcher-linux-arm64-musl': 2.5.6
|
||||
'@parcel/watcher-linux-x64-glibc': 2.5.6
|
||||
'@parcel/watcher-linux-x64-musl': 2.5.6
|
||||
'@parcel/watcher-win32-arm64': 2.5.6
|
||||
'@parcel/watcher-win32-ia32': 2.5.6
|
||||
'@parcel/watcher-win32-x64': 2.5.6
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@playwright/test@1.60.0':
|
||||
@ -5362,6 +5667,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
rollup: 2.80.0
|
||||
|
||||
'@schummar/icu-type-parser@1.21.5': {}
|
||||
|
||||
'@smithy/core@3.24.3':
|
||||
dependencies:
|
||||
'@aws-crypto/crc32': 5.2.0
|
||||
@ -5419,12 +5726,70 @@ snapshots:
|
||||
magic-string: 0.25.9
|
||||
string.prototype.matchall: 4.0.12
|
||||
|
||||
'@swc/core-darwin-arm64@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-darwin-x64@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm-gnueabihf@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-gnu@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-ppc64-gnu@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-s390x-gnu@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-ia32-msvc@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core-win32-x64-msvc@1.15.40':
|
||||
optional: true
|
||||
|
||||
'@swc/core@1.15.40':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/types': 0.1.26
|
||||
optionalDependencies:
|
||||
'@swc/core-darwin-arm64': 1.15.40
|
||||
'@swc/core-darwin-x64': 1.15.40
|
||||
'@swc/core-linux-arm-gnueabihf': 1.15.40
|
||||
'@swc/core-linux-arm64-gnu': 1.15.40
|
||||
'@swc/core-linux-arm64-musl': 1.15.40
|
||||
'@swc/core-linux-ppc64-gnu': 1.15.40
|
||||
'@swc/core-linux-s390x-gnu': 1.15.40
|
||||
'@swc/core-linux-x64-gnu': 1.15.40
|
||||
'@swc/core-linux-x64-musl': 1.15.40
|
||||
'@swc/core-win32-arm64-msvc': 1.15.40
|
||||
'@swc/core-win32-ia32-msvc': 1.15.40
|
||||
'@swc/core-win32-x64-msvc': 1.15.40
|
||||
|
||||
'@swc/counter@0.1.3': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@swc/types@0.1.26':
|
||||
dependencies:
|
||||
'@swc/counter': 0.1.3
|
||||
|
||||
'@t3-oss/env-core@0.11.1(typescript@5.9.3)(zod@3.25.76)':
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
@ -6010,8 +6375,7 @@ snapshots:
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
optional: true
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dexie@4.4.2: {}
|
||||
|
||||
@ -6481,6 +6845,10 @@ snapshots:
|
||||
|
||||
help-me@5.0.0: {}
|
||||
|
||||
icu-minify@4.13.0:
|
||||
dependencies:
|
||||
'@formatjs/icu-messageformat-parser': 3.5.10
|
||||
|
||||
idb@7.1.1: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
@ -6509,6 +6877,11 @@ snapshots:
|
||||
hasown: 2.0.3
|
||||
side-channel: 1.1.0
|
||||
|
||||
intl-messageformat@11.2.7:
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.5
|
||||
'@formatjs/icu-messageformat-parser': 3.5.10
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
@ -6765,14 +7138,35 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next-auth@5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
|
||||
next-auth@5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@auth/core': 0.37.2
|
||||
next: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
react: 19.2.6
|
||||
|
||||
next-intl-swc-plugin-extractor@4.13.0: {}
|
||||
|
||||
next-intl@4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.8.9
|
||||
'@parcel/watcher': 2.5.6
|
||||
'@swc/core': 1.15.40
|
||||
icu-minify: 4.13.0
|
||||
negotiator: 1.0.0
|
||||
next: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
next-intl-swc-plugin-extractor: 4.13.0
|
||||
po-parser: 2.1.1
|
||||
react: 19.2.6
|
||||
use-intl: 4.13.0(react@19.2.6)
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@swc/helpers'
|
||||
|
||||
next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@next/env': 15.3.9
|
||||
@ -6799,6 +7193,8 @@ snapshots:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
node-addon-api@7.1.1: {}
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-releases@2.0.44: {}
|
||||
@ -6947,6 +7343,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
po-parser@2.1.1: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss-import@15.1.0(postcss@8.5.14):
|
||||
@ -7593,6 +7991,14 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
use-intl@4.13.0(react@19.2.6):
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.5
|
||||
'@schummar/icu-type-parser': 1.21.5
|
||||
icu-minify: 4.13.0
|
||||
intl-messageformat: 11.2.7
|
||||
react: 19.2.6
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
watchpack@2.5.1:
|
||||
|
||||
@ -10,8 +10,10 @@ packages:
|
||||
# - esbuild: prebuilt native binary; used transitively by tsx.
|
||||
# - sharp: prebuilt native binary; used by Next.js image optimization.
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
'@swc/core': true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
sharp: true
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user