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.
|
# must consciously opt in by editing their .env. See README "Auth" section.
|
||||||
AUTH_DEV_AUTOLOGIN="false"
|
AUTH_DEV_AUTOLOGIN="false"
|
||||||
|
|
||||||
# Base URL Auth.js uses to build callback/redirect URLs. It MUST match the host
|
# Base URL Auth.js uses to build callback/redirect URLs. Must match the host of
|
||||||
# of the app being served. This shared .env can hold only ONE value, set here to
|
# the app being served. This shared .env holds ONE value — the operator-pwa
|
||||||
# the operator-pwa (:3000). The admin-web (:3001) therefore needs its OWN
|
# (:3000). The admin-web (:3001) gets its own from apps/admin-web/.env.admin,
|
||||||
# AUTH_URL whenever autologin is OFF (real-login dev, or production) — otherwise
|
# which its `dev` script loads with precedence over this file — so admin login
|
||||||
# Auth.js redirects admin users to :3000 and the admin login breaks.
|
# works with autologin OFF without any extra step.
|
||||||
# - Local with autologin ON: this value is harmless (middleware never redirects).
|
# - Production: each app still gets its own AUTH_URL from the deploy env
|
||||||
# - E2E real-login: e2e/playwright.auth.config.ts passes AUTH_URL=:3001 to admin.
|
# (a value already in the process wins over .env.admin).
|
||||||
# - Production: give EACH app its own AUTH_URL (per-app env), not this shared file.
|
|
||||||
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
NEXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
AUTH_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
|
## Common commands
|
||||||
|
|
||||||
| Command | What it does |
|
| 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`.
|
**`Tenant not found`** — the seed was wiped. Run `pnpm db:seed`.
|
||||||
|
|
||||||
**Admin login redirects to `:3000` / the operator picker** — the shared `.env`
|
**Admin login redirects to `:3000` / the operator picker** — the admin-web needs
|
||||||
sets `AUTH_URL=http://localhost:3000`, which is correct for the operator-pwa but
|
its own `AUTH_URL=http://localhost:3001` (the shared `.env` points at the operator
|
||||||
wrong for the admin-web (:3001). With `AUTH_DEV_AUTOLOGIN=true` it never bites
|
on :3000). This is handled automatically: the admin `dev` script loads
|
||||||
(the middleware doesn't redirect). With autologin OFF, give the admin-web its own
|
`apps/admin-web/.env.admin` with precedence. If you deleted or edited that file
|
||||||
`AUTH_URL=http://localhost:3001` (the E2E auth config does this). In production,
|
and hit this, restore `AUTH_URL="http://localhost:3001"` there. In production,
|
||||||
each app must have its own `AUTH_URL`.
|
each app gets its own `AUTH_URL` from the deploy environment.
|
||||||
|
|
||||||
**`DATABASE_URL not found`** — `.env` is missing or Docker Postgres is not
|
**`DATABASE_URL not found`** — `.env` is missing or Docker Postgres is not
|
||||||
running. Run `docker compose up -d` then retry.
|
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 type { Metadata } from 'next';
|
||||||
import { Providers } from './providers';
|
import { Providers } from './providers';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: 'FieldOps — Manutenção',
|
const t = await getTranslations('metadata');
|
||||||
description: 'Backoffice de manutenção industrial.',
|
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 (
|
return (
|
||||||
<html lang="pt">
|
<html lang={locale}>
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<Providers>{children}</Providers>
|
<Providers>{children}</Providers>
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const t = useTranslations('auth');
|
||||||
|
const tc = useTranslations('common');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -20,13 +23,13 @@ export function LoginForm() {
|
|||||||
try {
|
try {
|
||||||
const result = await signIn('credentials', { email, password, redirect: false });
|
const result = await signIn('credentials', { email, password, redirect: false });
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setError('Email ou password incorretos. Tente novamente.');
|
setError(t('invalidCredentials'));
|
||||||
} else {
|
} else {
|
||||||
router.push('/maintenance');
|
router.push('/maintenance');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Erro inesperado. Tente novamente.');
|
setError(t('unexpectedError'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@ -36,7 +39,7 @@ export function LoginForm() {
|
|||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="email" className="text-sm font-medium">
|
<label htmlFor="email" className="text-sm font-medium">
|
||||||
Email
|
{t('emailLabel')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
@ -46,13 +49,13 @@ export function LoginForm() {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={busy}
|
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"
|
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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="password" className="text-sm font-medium">
|
<label htmlFor="password" className="text-sm font-medium">
|
||||||
Password
|
{t('passwordLabel')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
@ -72,7 +75,7 @@ export function LoginForm() {
|
|||||||
disabled={busy}
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { LoginForm } from './login-form';
|
import { LoginForm } from './login-form';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default async function LoginPage() {
|
||||||
|
const t = await getTranslations('auth');
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
|
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
|
||||||
<header className="text-center">
|
<header className="text-center">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">FieldOps</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t('title')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
|
||||||
Acesso à consola de manutenção
|
|
||||||
</p>
|
|
||||||
</header>
|
</header>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -3,22 +3,26 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react';
|
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { trpc } from '@/lib/trpc/client';
|
import { trpc } from '@/lib/trpc/client';
|
||||||
import type { RouterOutputs } from '@/lib/trpc/server';
|
import type { RouterOutputs } from '@/lib/trpc/server';
|
||||||
|
import { LanguageSwitcher } from '../language-switcher';
|
||||||
|
|
||||||
type Status = 'OPEN' | 'CLAIMED' | 'RESOLVED';
|
type Status = 'OPEN' | 'CLAIMED' | 'RESOLVED';
|
||||||
type QueueItem = RouterOutputs['maintenanceRequest']['queue']['items'][number];
|
type QueueItem = RouterOutputs['maintenanceRequest']['queue']['items'][number];
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── 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 diffMs = Date.now() - new Date(date).getTime();
|
||||||
const mins = Math.floor(diffMs / 60_000);
|
const mins = Math.floor(diffMs / 60_000);
|
||||||
if (mins < 1) return 'agora';
|
if (mins < 1) return t('timeAgo.now');
|
||||||
if (mins < 60) return `há ${mins}m`;
|
if (mins < 60) return t('timeAgo.minutesAgo', { mins });
|
||||||
const hours = Math.floor(mins / 60);
|
const hours = Math.floor(mins / 60);
|
||||||
if (hours < 24) return `há ${hours}h`;
|
if (hours < 24) return t('timeAgo.hoursAgo', { hours });
|
||||||
return `há ${Math.floor(hours / 24)}d`;
|
return t('timeAgo.daysAgo', { days: Math.floor(hours / 24) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function playBeep() {
|
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> = {
|
const STATUS_CLASS: Record<Status, string> = {
|
||||||
OPEN: 'bg-orange-100 text-orange-700',
|
OPEN: 'bg-orange-100 text-orange-700',
|
||||||
CLAIMED: 'bg-blue-100 text-blue-700',
|
CLAIMED: 'bg-blue-100 text-blue-700',
|
||||||
@ -53,7 +51,7 @@ const STATUS_CLASS: Record<Status, string> = {
|
|||||||
|
|
||||||
// ── Thumbnail ───────────────────────────────────────────────────────────────
|
// ── Thumbnail ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Thumbnail({ photoKey }: { photoKey: string | null }) {
|
function Thumbnail({ photoKey, alt }: { photoKey: string | null; alt: string }) {
|
||||||
const { data } = trpc.storage.signPhotoDownload.useQuery(
|
const { data } = trpc.storage.signPhotoDownload.useQuery(
|
||||||
{ photoKey: photoKey! },
|
{ photoKey: photoKey! },
|
||||||
{ enabled: !!photoKey, staleTime: 50_000 },
|
{ enabled: !!photoKey, staleTime: 50_000 },
|
||||||
@ -66,7 +64,7 @@ function Thumbnail({ photoKey }: { photoKey: string | null }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// 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,
|
onClaim,
|
||||||
onResolve,
|
onResolve,
|
||||||
claiming,
|
claiming,
|
||||||
|
t,
|
||||||
|
tc,
|
||||||
}: {
|
}: {
|
||||||
item: QueueItem;
|
item: QueueItem;
|
||||||
onClaim: () => void;
|
onClaim: () => void;
|
||||||
onResolve: () => void;
|
onResolve: () => void;
|
||||||
claiming: boolean;
|
claiming: boolean;
|
||||||
|
t: ReturnType<typeof useTranslations<'maintenance'>>;
|
||||||
|
tc: ReturnType<typeof useTranslations<'common'>>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div data-testid="request-card" className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm">
|
<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 */}
|
{/* Top row: thumbnail + main info */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Thumbnail photoKey={item.photoKey} />
|
<Thumbnail photoKey={item.photoKey} alt={t('photo')} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="font-medium">
|
<p className="font-medium">
|
||||||
{item.workstation.code} — {item.workstation.name}{' '}
|
{item.workstation.code} — {item.workstation.name}{' '}
|
||||||
@ -95,17 +97,15 @@ function RequestCard({
|
|||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{item.description}</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer: badge + actions */}
|
{/* Footer: badge + actions */}
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<span
|
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[item.status as Status]}`}>
|
||||||
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'}`)}
|
||||||
>
|
|
||||||
{STATUS_LABEL[item.status as Status]}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{item.status === 'OPEN' && (
|
{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"
|
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" />}
|
{claiming ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wrench className="h-4 w-4" />}
|
||||||
Aceitar
|
{t('accept')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.status === 'CLAIMED' && (
|
{item.status === 'CLAIMED' && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={onResolve}
|
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"
|
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" />
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
Marcar resolvido
|
{t('markResolved')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.status === 'RESOLVED' && (
|
{item.status === 'RESOLVED' && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -152,33 +152,34 @@ function ResolveDialog({
|
|||||||
note,
|
note,
|
||||||
onNoteChange,
|
onNoteChange,
|
||||||
resolving,
|
resolving,
|
||||||
|
t,
|
||||||
|
tc,
|
||||||
}: {
|
}: {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
note: string;
|
note: string;
|
||||||
onNoteChange: (v: string) => void;
|
onNoteChange: (v: string) => void;
|
||||||
resolving: boolean;
|
resolving: boolean;
|
||||||
|
t: ReturnType<typeof useTranslations<'maintenance'>>;
|
||||||
|
tc: ReturnType<typeof useTranslations<'common'>>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
<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">
|
<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">
|
<label className="mb-1 block text-sm font-medium">
|
||||||
Nota de resolução (opcional)
|
{t('resolveNoteLabel')}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(e) => onNoteChange(e.target.value)}
|
onChange={(e) => onNoteChange(e.target.value)}
|
||||||
rows={3}
|
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"
|
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">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm hover:bg-accent">
|
||||||
onClick={onCancel}
|
{tc('cancel')}
|
||||||
className="rounded-lg px-4 py-2 text-sm hover:bg-accent"
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
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"
|
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" />}
|
{resolving && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
Confirmar
|
{tc('confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -197,6 +198,9 @@ function ResolveDialog({
|
|||||||
// ── Main queue component ────────────────────────────────────────────────────
|
// ── Main queue component ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function MaintenanceQueue() {
|
export function MaintenanceQueue() {
|
||||||
|
const t = useTranslations('maintenance');
|
||||||
|
const tc = useTranslations('common');
|
||||||
|
|
||||||
const [statuses, setStatuses] = useState<Status[]>(['OPEN', 'CLAIMED']);
|
const [statuses, setStatuses] = useState<Status[]>(['OPEN', 'CLAIMED']);
|
||||||
const [area, setArea] = useState('');
|
const [area, setArea] = useState('');
|
||||||
const [resolveId, setResolveId] = useState<string | null>(null);
|
const [resolveId, setResolveId] = useState<string | null>(null);
|
||||||
@ -217,8 +221,10 @@ export function MaintenanceQueue() {
|
|||||||
// Document title badge
|
// Document title badge
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title =
|
document.title =
|
||||||
openCount > 0 ? `(${openCount}) FieldOps — Manutenção` : 'FieldOps — Manutenção';
|
openCount > 0
|
||||||
}, [openCount]);
|
? t('documentTitleWithCount', { count: openCount })
|
||||||
|
: t('documentTitle');
|
||||||
|
}, [openCount, t]);
|
||||||
|
|
||||||
// Audio notification for new OPEN requests
|
// Audio notification for new OPEN requests
|
||||||
const prevOpenIds = useRef(new Set<string>());
|
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">
|
<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}
|
{openCount}
|
||||||
</span>
|
</span>
|
||||||
pedidos abertos
|
{t('openRequestsTitle', { count: openCount })}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'Fila de manutenção'
|
t('queueTitle')
|
||||||
)}
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</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"
|
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" />
|
<BarChart2 className="h-3 w-3" />
|
||||||
Relatório de turno
|
{t('reportLink')}
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSoundEnabled((v) => !v)}
|
onClick={() => setSoundEnabled((v) => !v)}
|
||||||
@ -284,14 +290,15 @@ export function MaintenanceQueue() {
|
|||||||
: 'bg-muted text-muted-foreground'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{soundEnabled ? '🔔 Som on' : '🔕 Som off'}
|
{soundEnabled ? t('soundOn') : t('soundOff')}
|
||||||
</button>
|
</button>
|
||||||
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="mx-auto mt-2 flex max-w-4xl flex-wrap items-center gap-3">
|
<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) => (
|
{(['OPEN', 'CLAIMED', 'RESOLVED'] as Status[]).map((s) => (
|
||||||
<label key={s} className="flex cursor-pointer items-center gap-1.5 text-sm">
|
<label key={s} className="flex cursor-pointer items-center gap-1.5 text-sm">
|
||||||
<input
|
<input
|
||||||
@ -300,19 +307,19 @@ export function MaintenanceQueue() {
|
|||||||
onChange={() => toggleStatus(s)}
|
onChange={() => toggleStatus(s)}
|
||||||
className="rounded"
|
className="rounded"
|
||||||
/>
|
/>
|
||||||
{STATUS_LABEL[s]}
|
{tc(`status.${s.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{areas.length > 0 && (
|
{areas.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs text-muted-foreground">Área:</span>
|
<span className="text-xs text-muted-foreground">{t('filterArea')}</span>
|
||||||
<select
|
<select
|
||||||
value={area}
|
value={area}
|
||||||
onChange={(e) => setArea(e.target.value)}
|
onChange={(e) => setArea(e.target.value)}
|
||||||
className="rounded-lg border border-border bg-card px-2 py-1 text-sm"
|
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) => (
|
{areas.map((a) => (
|
||||||
<option key={a} value={a}>
|
<option key={a} value={a}>
|
||||||
{a}
|
{a}
|
||||||
@ -324,7 +331,7 @@ export function MaintenanceQueue() {
|
|||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="ml-auto flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Atualiza a cada 5s
|
{t('updatesEvery')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -334,7 +341,7 @@ export function MaintenanceQueue() {
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="py-16 text-center text-muted-foreground">
|
<div className="py-16 text-center text-muted-foreground">
|
||||||
<Wrench className="mx-auto mb-3 h-10 w-10 opacity-30" />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
@ -342,6 +349,8 @@ export function MaintenanceQueue() {
|
|||||||
<RequestCard
|
<RequestCard
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
|
t={t}
|
||||||
|
tc={tc}
|
||||||
onClaim={() => claimMutation.mutate({ id: item.id })}
|
onClaim={() => claimMutation.mutate({ id: item.id })}
|
||||||
onResolve={() => {
|
onResolve={() => {
|
||||||
setResolveId(item.id);
|
setResolveId(item.id);
|
||||||
@ -362,6 +371,8 @@ export function MaintenanceQueue() {
|
|||||||
<ResolveDialog
|
<ResolveDialog
|
||||||
note={resolutionNote}
|
note={resolutionNote}
|
||||||
onNoteChange={setResolutionNote}
|
onNoteChange={setResolutionNote}
|
||||||
|
t={t}
|
||||||
|
tc={tc}
|
||||||
onConfirm={() =>
|
onConfirm={() =>
|
||||||
resolveMutation.mutate({
|
resolveMutation.mutate({
|
||||||
id: resolveId,
|
id: resolveId,
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
import { ReportView } from './report-view';
|
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() {
|
export default function ReportPage() {
|
||||||
return <ReportView />;
|
return <ReportView />;
|
||||||
|
|||||||
@ -3,40 +3,30 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Printer, AlertCircle } from 'lucide-react';
|
import { ArrowLeft, Printer, AlertCircle } from 'lucide-react';
|
||||||
|
import { useTranslations, useFormatter } from 'next-intl';
|
||||||
import { trpc } from '@/lib/trpc/client';
|
import { trpc } from '@/lib/trpc/client';
|
||||||
import { SHIFTS, shiftWindow, todayWindow, type ShiftKey } from '@/lib/shifts';
|
import { SHIFTS, shiftWindow, todayWindow, type ShiftKey } from '@/lib/shifts';
|
||||||
|
|
||||||
// ── Duration helper ─────────────────────────────────────────────────────────
|
// ── Duration helper ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatDuration(ms: number | null): string {
|
type TFn = ReturnType<typeof useTranslations<'report'>>;
|
||||||
if (ms === null) return '—';
|
|
||||||
|
function formatDuration(ms: number | null, t: TFn): string {
|
||||||
|
if (ms === null) return t('duration.dash');
|
||||||
const totalMin = Math.round(ms / 60_000);
|
const totalMin = Math.round(ms / 60_000);
|
||||||
if (totalMin < 1) return '< 1 min';
|
if (totalMin < 1) return t('duration.lessThan1Min');
|
||||||
if (totalMin < 60) return `${totalMin} min`;
|
if (totalMin < 60) return t('duration.minutes', { n: totalMin });
|
||||||
const h = Math.floor(totalMin / 60);
|
const h = Math.floor(totalMin / 60);
|
||||||
const m = 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 {
|
// ── Status ───────────────────────────────────────────────────────────────────
|
||||||
const dt = new Date(d);
|
|
||||||
return dt.toLocaleString('pt-PT', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(d: Date | string): string {
|
const STATUS_CLASS: Record<'OPEN' | 'CLAIMED', string> = {
|
||||||
return new Date(d).toLocaleDateString('pt-PT', { day: '2-digit', month: '2-digit' });
|
OPEN: 'bg-orange-100 text-orange-700',
|
||||||
}
|
CLAIMED: 'bg-blue-100 text-blue-700',
|
||||||
|
};
|
||||||
// ── Window label ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function windowLabel(from: Date, to: Date): string {
|
|
||||||
return `${formatDateTime(from)} → ${formatDateTime(to)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Metric card ──────────────────────────────────────────────────────────────
|
// ── 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 ───────────────────────────────────────────────────────────
|
// ── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type WindowState =
|
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')}`;
|
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() {
|
export function ReportView() {
|
||||||
|
const t = useTranslations('report');
|
||||||
|
const tc = useTranslations('common');
|
||||||
|
const format = useFormatter();
|
||||||
|
|
||||||
const [windowState, setWindowState] = useState<WindowState>({ type: 'today' });
|
const [windowState, setWindowState] = useState<WindowState>({ type: 'today' });
|
||||||
const [dayInput, setDayInput] = useState(() => localDateStr(new Date()));
|
const [dayInput, setDayInput] = useState(() => localDateStr(new Date()));
|
||||||
const [customActive, setCustomActive] = useState(false);
|
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
|
// 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
|
// 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 win = useMemo(() => computeWindow(windowState), [windowState]);
|
||||||
|
|
||||||
const { data, isLoading, error } = trpc.maintenanceRequest.report.useQuery(
|
const { data, isLoading, error } = trpc.maintenanceRequest.report.useQuery(
|
||||||
@ -126,8 +113,16 @@ export function ReportView() {
|
|||||||
setWindowState({ type: 'custom', from, to });
|
setWindowState({ type: 'custom', from, to });
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeShift =
|
const activeShift = windowState.type === 'shift' ? windowState.key : null;
|
||||||
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 (
|
return (
|
||||||
<div className="min-h-screen bg-background print:bg-white">
|
<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"
|
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Fila
|
{t('backToQueue')}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-muted-foreground">/</span>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.print()}
|
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"
|
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" />
|
<Printer className="h-4 w-4" />
|
||||||
Imprimir
|
{t('print')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Print header (only in print) ── */}
|
{/* ── Print header (only in print) ── */}
|
||||||
<div className="hidden print:block px-8 pt-6 pb-2">
|
<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-lg font-bold">{t('printHeader')}</p>
|
||||||
<p className="text-sm text-gray-600">{windowLabel(win.from, win.to)}</p>
|
<p className="text-sm text-gray-600">{range}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Window selector (hidden in print) ── */}
|
{/* ── Window selector (hidden in print) ── */}
|
||||||
@ -174,7 +169,7 @@ export function ReportView() {
|
|||||||
: 'bg-card border border-border hover:bg-accent'
|
: 'bg-card border border-border hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Hoje
|
{t('today')}
|
||||||
</button>
|
</button>
|
||||||
{(Object.keys(SHIFTS) as ShiftKey[]).map((key) => (
|
{(Object.keys(SHIFTS) as ShiftKey[]).map((key) => (
|
||||||
<button
|
<button
|
||||||
@ -186,7 +181,7 @@ export function ReportView() {
|
|||||||
: 'bg-card border border-border hover:bg-accent'
|
: 'bg-card border border-border hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{SHIFTS[key].label}
|
{t(`shiftButton.${key}`)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@ -211,7 +206,7 @@ export function ReportView() {
|
|||||||
: 'bg-card border border-border hover:bg-accent'
|
: 'bg-card border border-border hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Personalizado
|
{t('custom')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -224,7 +219,7 @@ export function ReportView() {
|
|||||||
onChange={(e) => setCustomPending((p) => ({ ...p, from: e.target.value }))}
|
onChange={(e) => setCustomPending((p) => ({ ...p, from: e.target.value }))}
|
||||||
className="rounded-lg border border-border bg-card px-2 py-1 text-sm"
|
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
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
value={customPending.to}
|
value={customPending.to}
|
||||||
@ -235,27 +230,20 @@ export function ReportView() {
|
|||||||
onClick={applyCustom}
|
onClick={applyCustom}
|
||||||
className="rounded-lg bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:opacity-90"
|
className="rounded-lg bg-primary px-3 py-1 text-sm font-medium text-primary-foreground hover:opacity-90"
|
||||||
>
|
>
|
||||||
Aplicar
|
{t('customApply')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active window label */}
|
{/* Active window label */}
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">{windowLabelText}</p>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Body ── */}
|
{/* ── Body ── */}
|
||||||
<main className="mx-auto max-w-4xl px-4 py-6 print:px-8 print:py-4">
|
<main className="mx-auto max-w-4xl px-4 py-6 print:px-8 print:py-4">
|
||||||
{isLoading && (
|
{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 && (
|
{error && (
|
||||||
@ -267,7 +255,7 @@ export function ReportView() {
|
|||||||
|
|
||||||
{data && data.totals.created === 0 && (
|
{data && data.totals.created === 0 && (
|
||||||
<div className="py-16 text-center text-muted-foreground">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -276,41 +264,41 @@ export function ReportView() {
|
|||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||||
Resumo
|
{t('sections.summary')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 print:grid-cols-3">
|
<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={t('metrics.created')} value={String(data.totals.created)} />
|
||||||
<MetricCard label="Resolvidos" value={String(data.totals.resolved)} />
|
<MetricCard label={t('metrics.resolved')} value={String(data.totals.resolved)} />
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Em aberto"
|
label={t('metrics.open')}
|
||||||
value={String(data.totals.open + data.totals.claimed)}
|
value={String(data.totals.open + data.totals.claimed)}
|
||||||
sub={
|
sub={
|
||||||
data.totals.open > 0 || data.totals.claimed > 0
|
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
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Resposta média"
|
label={t('metrics.responseAvg')}
|
||||||
value={formatDuration(data.responseMs.avg)}
|
value={formatDuration(data.responseMs.avg, t)}
|
||||||
sub={
|
sub={
|
||||||
data.responseMs.count > 0
|
data.responseMs.count > 0
|
||||||
? `sobre ${data.responseMs.count} pedido${data.responseMs.count > 1 ? 's' : ''}`
|
? t('metrics.requestsSub', { count: data.responseMs.count })
|
||||||
: 'sem dados'
|
: t('metrics.noData')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Resolução média"
|
label={t('metrics.resolutionAvg')}
|
||||||
value={formatDuration(data.resolutionMs.avg)}
|
value={formatDuration(data.resolutionMs.avg, t)}
|
||||||
sub={
|
sub={
|
||||||
data.resolutionMs.count > 0
|
data.resolutionMs.count > 0
|
||||||
? `sobre ${data.resolutionMs.count} pedido${data.resolutionMs.count > 1 ? 's' : ''}`
|
? t('metrics.requestsSub', { count: data.resolutionMs.count })
|
||||||
: 'sem dados'
|
: t('metrics.noData')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetricCard
|
<MetricCard
|
||||||
label="Pior resposta"
|
label={t('metrics.responseMax')}
|
||||||
value={formatDuration(data.responseMs.max)}
|
value={formatDuration(data.responseMs.max, t)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -319,16 +307,16 @@ export function ReportView() {
|
|||||||
{data.byWorkstation.length > 0 && (
|
{data.byWorkstation.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||||
Por posto
|
{t('sections.byWorkstation')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="overflow-hidden rounded-xl border border-border print:border-gray-300">
|
<div className="overflow-hidden rounded-xl border border-border print:border-gray-300">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-muted/50 text-left print:bg-gray-50 print:border-gray-300">
|
<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">{t('table.code')}</th>
|
||||||
<th className="px-4 py-2 font-medium">Nome</th>
|
<th className="px-4 py-2 font-medium">{t('table.name')}</th>
|
||||||
<th className="px-4 py-2 font-medium">Área</th>
|
<th className="px-4 py-2 font-medium">{t('table.area')}</th>
|
||||||
<th className="px-4 py-2 text-right font-medium">Pedidos</th>
|
<th className="px-4 py-2 text-right font-medium">{t('table.requests')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -357,7 +345,7 @@ export function ReportView() {
|
|||||||
{data.byArea.length > 1 && (
|
{data.byArea.length > 1 && (
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
||||||
Por área
|
{t('sections.byArea')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{data.byArea.map((a) => (
|
{data.byArea.map((a) => (
|
||||||
@ -378,10 +366,10 @@ export function ReportView() {
|
|||||||
{/* Still open */}
|
{/* Still open */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground print:text-gray-500">
|
<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>
|
</h2>
|
||||||
{data.stillOpen.length === 0 ? (
|
{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">
|
<div className="space-y-2">
|
||||||
{data.stillOpen.map((r) => (
|
{data.stillOpen.map((r) => (
|
||||||
@ -400,13 +388,16 @@ export function ReportView() {
|
|||||||
{r.description}
|
{r.description}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground print:text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<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]}`}
|
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>
|
</span>
|
||||||
</div>
|
</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 type ShiftKey = 'manha' | 'tarde' | 'noite';
|
||||||
|
|
||||||
export const SHIFTS: Record<ShiftKey, { label: string; startHour: number; endHour: number }> = {
|
export const SHIFTS: Record<ShiftKey, { startHour: number; endHour: number }> = {
|
||||||
manha: { label: 'Manhã', startHour: 6, endHour: 14 },
|
manha: { startHour: 6, endHour: 14 },
|
||||||
tarde: { label: 'Tarde', startHour: 14, endHour: 22 },
|
tarde: { startHour: 14, endHour: 22 },
|
||||||
noite: { label: 'Noite', startHour: 22, endHour: 6 },
|
noite: { startHour: 22, endHour: 6 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Given a shift and a day (Date at local midnight), returns [from, to). */
|
/** 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 type { NextConfig } from 'next';
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
import './env'; // Validate env vars at build time
|
import './env'; // Validate env vars at build time
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||||
|
|
||||||
const config: NextConfig = {
|
const config: NextConfig = {
|
||||||
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'],
|
transpilePackages: ['@repo/db', '@repo/api', '@repo/ui', '@repo/storage'],
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
@ -14,4 +17,4 @@ const config: NextConfig = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default withNextIntl(config);
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"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",
|
"build": "dotenv -e ../../.env -- next build",
|
||||||
"start": "dotenv -e ../../.env -- next start --port 3001",
|
"start": "dotenv -e ../../.env -- next start --port 3001",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
@ -24,6 +24,7 @@
|
|||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "15.3.9",
|
"next": "15.3.9",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
|
"next-intl": "^4.13.0",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"pino-pretty": "^11.3.0",
|
"pino-pretty": "^11.3.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export default function ErrorPage({
|
export default function ErrorPage({
|
||||||
error,
|
error,
|
||||||
@ -9,16 +10,18 @@ export default function ErrorPage({
|
|||||||
error: Error & { digest?: string };
|
error: Error & { digest?: string };
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations('errors');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
|
<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>
|
<h1 className="text-4xl font-bold">{t('title500')}</h1>
|
||||||
<p className="text-muted-foreground">Ocorreu um erro inesperado.</p>
|
<p className="text-muted-foreground">{t('message500')}</p>
|
||||||
<button onClick={reset} className="text-sm underline underline-offset-4">
|
<button onClick={reset} className="text-sm underline underline-offset-4">
|
||||||
Tentar novamente
|
{t('retry')}
|
||||||
</button>
|
</button>
|
||||||
</main>
|
</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 type { Metadata, Viewport } from 'next';
|
||||||
import { Providers } from './providers';
|
import { Providers } from './providers';
|
||||||
import { SyncProvider } from './sync-provider';
|
import { SyncProvider } from './sync-provider';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: 'FieldOps — Operator',
|
const t = await getTranslations('metadata');
|
||||||
description: 'Industrial operator console.',
|
return {
|
||||||
|
title: t('title'),
|
||||||
|
description: t('description'),
|
||||||
manifest: '/manifest.webmanifest',
|
manifest: '/manifest.webmanifest',
|
||||||
applicationName: 'FieldOps Operator',
|
applicationName: t('appName'),
|
||||||
appleWebApp: {
|
appleWebApp: {
|
||||||
capable: true,
|
capable: true,
|
||||||
title: 'FieldOps Operator',
|
title: t('appName'),
|
||||||
statusBarStyle: 'default',
|
statusBarStyle: 'default',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
export const viewport: Viewport = {
|
||||||
themeColor: '#0f172a',
|
themeColor: '#0f172a',
|
||||||
@ -21,13 +26,18 @@ export const viewport: Viewport = {
|
|||||||
initialScale: 1,
|
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 (
|
return (
|
||||||
<html lang="en">
|
<html lang={locale}>
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<Providers>
|
<Providers>
|
||||||
<SyncProvider>{children}</SyncProvider>
|
<SyncProvider>{children}</SyncProvider>
|
||||||
</Providers>
|
</Providers>
|
||||||
|
</NextIntlClientProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import { useState, useRef } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Camera, X } from 'lucide-react';
|
import { ArrowLeft, Camera, X } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { trpc } from '@/lib/trpc/client';
|
import { trpc } from '@/lib/trpc/client';
|
||||||
import { db } from '@/lib/queue/db';
|
import { db } from '@/lib/queue/db';
|
||||||
import { runSync } from '@/lib/queue/sync';
|
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> {
|
function compressImage(file: File): Promise<Blob> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@ -44,6 +44,7 @@ function compressImage(file: File): Promise<Blob> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function NewRequestPage() {
|
export default function NewRequestPage() {
|
||||||
|
const t = useTranslations('maintenance');
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@ -56,7 +57,7 @@ export default function NewRequestPage() {
|
|||||||
|
|
||||||
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
|
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
|
||||||
undefined,
|
undefined,
|
||||||
{ staleTime: 60 * 60 * 1000 }, // 1h — serves from cache when offline
|
{ staleTime: 60 * 60 * 1000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
@ -68,7 +69,7 @@ export default function NewRequestPage() {
|
|||||||
setPhotoBlob(compressed);
|
setPhotoBlob(compressed);
|
||||||
setPhotoPreview(URL.createObjectURL(compressed));
|
setPhotoPreview(URL.createObjectURL(compressed));
|
||||||
} catch {
|
} 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 {
|
try {
|
||||||
const clientRequestId = crypto.randomUUID();
|
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({
|
await db.pending.add({
|
||||||
clientRequestId,
|
clientRequestId,
|
||||||
workstationId,
|
workstationId,
|
||||||
@ -100,12 +99,11 @@ export default function NewRequestPage() {
|
|||||||
retries: 0,
|
retries: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attempt immediate sync if online (fire-and-forget)
|
|
||||||
if (navigator.onLine) runSync().catch(() => {});
|
if (navigator.onLine) runSync().catch(() => {});
|
||||||
|
|
||||||
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
||||||
} catch (err) {
|
} 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);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,14 +117,14 @@ export default function NewRequestPage() {
|
|||||||
<Link href="/" className="rounded-md p-1 hover:bg-accent">
|
<Link href="/" className="rounded-md p-1 hover:bg-accent">
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-base font-semibold">Novo pedido de manutenção</h1>
|
<h1 className="text-base font-semibold">{t('newTitle')}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
|
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
|
||||||
{/* Posto */}
|
{/* Workstation */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="workstation" className="text-sm font-medium">
|
<label htmlFor="workstation" className="text-sm font-medium">
|
||||||
Posto <span className="text-destructive">*</span>
|
{t('workstationLabel')} <span className="text-destructive">{t('workstationRequired')}</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="workstation"
|
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"
|
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{wsLoading ? 'A carregar postos…' : 'Seleciona um posto…'}
|
{wsLoading ? t('workstationLoading') : t('workstationPlaceholder')}
|
||||||
</option>
|
</option>
|
||||||
{workstations.map((ws) => (
|
{workstations.map((ws) => (
|
||||||
<option key={ws.id} value={ws.id}>
|
<option key={ws.id} value={ws.id}>
|
||||||
@ -147,13 +145,13 @@ export default function NewRequestPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Foto */}
|
{/* Photo */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<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 ? (
|
{photoPreview ? (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={removePhoto}
|
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"
|
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" />
|
<Camera className="h-5 w-5" />
|
||||||
Tirar / escolher foto
|
{t('photoButton')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@ -182,10 +180,10 @@ export default function NewRequestPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Descrição */}
|
{/* Description */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
|
<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'}`}>
|
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||||
{descLen}/1000
|
{descLen}/1000
|
||||||
</span>
|
</span>
|
||||||
@ -198,7 +196,7 @@ export default function NewRequestPage() {
|
|||||||
minLength={3}
|
minLength={3}
|
||||||
maxLength={1000}
|
maxLength={1000}
|
||||||
rows={4}
|
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"
|
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>
|
</div>
|
||||||
@ -213,7 +211,7 @@ export default function NewRequestPage() {
|
|||||||
disabled={!canSubmit}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -3,11 +3,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { CheckCircle2, Clock } from 'lucide-react';
|
import { CheckCircle2, Clock } from 'lucide-react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { db } from '@/lib/queue/db';
|
import { db } from '@/lib/queue/db';
|
||||||
import { subscribeBroadcast } from '@/lib/queue/broadcast';
|
import { subscribeBroadcast } from '@/lib/queue/broadcast';
|
||||||
|
|
||||||
export function SentStatus({ cid }: { cid: string }) {
|
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(() => {
|
useEffect(() => {
|
||||||
async function check() {
|
async function check() {
|
||||||
@ -35,7 +37,7 @@ export function SentStatus({ cid }: { cid: string }) {
|
|||||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||||
)}
|
)}
|
||||||
<h1 className="text-2xl font-bold">
|
<h1 className="text-2xl font-bold">
|
||||||
{pending ? 'Pedido em fila' : 'Pedido enviado'}
|
{pending ? t('pendingTitle') : t('sentTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
{cid && (
|
{cid && (
|
||||||
<p className="font-mono text-xs text-muted-foreground" data-testid="request-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>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{pending
|
{pending ? t('pendingMessage') : t('sentMessage')}
|
||||||
? 'Será enviado assim que a ligação for restabelecida.'
|
|
||||||
: 'A equipa de manutenção foi notificada e irá tratar do problema.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
||||||
>
|
>
|
||||||
Voltar ao início
|
{t('backHome')}
|
||||||
</Link>
|
</Link>
|
||||||
</main>
|
</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 (
|
return (
|
||||||
<main className="flex min-h-screen flex-col items-center justify-center gap-4 p-6 text-center">
|
<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>
|
<h1 className="text-4xl font-bold">{t('title404')}</h1>
|
||||||
<p className="text-muted-foreground">Página não encontrada.</p>
|
<p className="text-muted-foreground">{t('message404')}</p>
|
||||||
<a href="/" className="text-sm underline underline-offset-4">
|
<a href="/" className="text-sm underline underline-offset-4">
|
||||||
Voltar ao início
|
{t('backHome')}
|
||||||
</a>
|
</a>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Wrench } from 'lucide-react';
|
import { Wrench } from 'lucide-react';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { resolveUser } from '@/lib/auth';
|
import { resolveUser } from '@/lib/auth';
|
||||||
import { api } from '@/lib/trpc/server';
|
import { api } from '@/lib/trpc/server';
|
||||||
import { SignOutButton } from './sign-out-button';
|
import { SignOutButton } from './sign-out-button';
|
||||||
import { StatusBadge } from './status-badge';
|
import { StatusBadge } from './status-badge';
|
||||||
import { SyncChip } from './sync-chip';
|
import { SyncChip } from './sync-chip';
|
||||||
|
import { LanguageSwitcher } from './language-switcher';
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
|
const t = await getTranslations('home');
|
||||||
const user = await resolveUser();
|
const user = await resolveUser();
|
||||||
|
|
||||||
// myRecent is a protectedProcedure — fails gracefully when there is no session.
|
|
||||||
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
|
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
|
||||||
let recent: RecentItem[] = [];
|
let recent: RecentItem[] = [];
|
||||||
try {
|
try {
|
||||||
@ -23,12 +25,15 @@ export default async function HomePage() {
|
|||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
|
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
|
||||||
<div>
|
<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">
|
<p className="text-sm font-medium" data-testid="current-user">
|
||||||
{user?.email ?? '—'}
|
{user?.email ?? '—'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<LanguageSwitcher />
|
||||||
<SignOutButton />
|
<SignOutButton />
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-6 p-4">
|
<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]"
|
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" />
|
<Wrench className="h-6 w-6" />
|
||||||
Pedir manutenção
|
{t('requestMaintenance')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* ── Recent requests ── */}
|
{/* ── Recent requests ── */}
|
||||||
<section>
|
<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 ? (
|
{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">
|
<ul className="flex flex-col gap-2">
|
||||||
{recent.map((req) => (
|
{recent.map((req) => (
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { ArrowLeft, Delete } from 'lucide-react';
|
import { ArrowLeft, Delete } from 'lucide-react';
|
||||||
|
|
||||||
@ -10,8 +11,6 @@ interface Operator {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── State types ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type PickerState =
|
type PickerState =
|
||||||
| { step: 'list' }
|
| { step: 'list' }
|
||||||
| { step: 'pin'; operator: Operator };
|
| { step: 'pin'; operator: Operator };
|
||||||
@ -19,20 +18,18 @@ type PickerState =
|
|||||||
const PIN_MIN = 4;
|
const PIN_MIN = 4;
|
||||||
const PIN_MAX = 6;
|
const PIN_MAX = 6;
|
||||||
|
|
||||||
// ── Sub-components ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function OperatorList({
|
function OperatorList({
|
||||||
operators,
|
operators,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
t,
|
||||||
}: {
|
}: {
|
||||||
operators: Operator[];
|
operators: Operator[];
|
||||||
onSelect: (op: Operator) => void;
|
onSelect: (op: Operator) => void;
|
||||||
|
t: ReturnType<typeof useTranslations<'auth'>>;
|
||||||
}) {
|
}) {
|
||||||
if (operators.length === 0) {
|
if (operators.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t('noOperators')}</p>
|
||||||
Nenhum operador encontrado. Execute <code>pnpm db:seed</code>.
|
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@ -53,9 +50,13 @@ function OperatorList({
|
|||||||
function PinPad({
|
function PinPad({
|
||||||
operator,
|
operator,
|
||||||
onBack,
|
onBack,
|
||||||
|
t,
|
||||||
|
tc,
|
||||||
}: {
|
}: {
|
||||||
operator: Operator;
|
operator: Operator;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
t: ReturnType<typeof useTranslations<'auth'>>;
|
||||||
|
tc: ReturnType<typeof useTranslations<'common'>>;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [digits, setDigits] = useState('');
|
const [digits, setDigits] = useState('');
|
||||||
@ -85,14 +86,14 @@ function PinPad({
|
|||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
setDigits('');
|
setDigits('');
|
||||||
setError('PIN incorreto ou conta bloqueada. Tente novamente.');
|
setError(t('invalidPin'));
|
||||||
} else {
|
} else {
|
||||||
router.push('/');
|
router.push('/');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setDigits('');
|
setDigits('');
|
||||||
setError('Erro inesperado. Tente novamente.');
|
setError(t('unexpectedError'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@ -108,12 +109,12 @@ function PinPad({
|
|||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
className="rounded-lg p-2 hover:bg-accent disabled:opacity-50"
|
className="rounded-lg p-2 hover:bg-accent disabled:opacity-50"
|
||||||
aria-label="Voltar"
|
aria-label={t('back')}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-5 w-5" />
|
<ArrowLeft className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<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>
|
<p className="text-sm font-medium">{operator.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -150,7 +151,7 @@ function PinPad({
|
|||||||
onClick={erase}
|
onClick={erase}
|
||||||
disabled={busy || digits.length === 0}
|
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"
|
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" />
|
<Delete className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@ -175,15 +176,15 @@ function PinPad({
|
|||||||
disabled={digits.length < PIN_MIN || busy}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main component ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
||||||
|
const t = useTranslations('auth');
|
||||||
|
const tc = useTranslations('common');
|
||||||
const [state, setState] = useState<PickerState>({ step: 'list' });
|
const [state, setState] = useState<PickerState>({ step: 'list' });
|
||||||
|
|
||||||
if (state.step === 'pin') {
|
if (state.step === 'pin') {
|
||||||
@ -191,6 +192,8 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
|||||||
<PinPad
|
<PinPad
|
||||||
operator={state.operator}
|
operator={state.operator}
|
||||||
onBack={() => setState({ step: 'list' })}
|
onBack={() => setState({ step: 'list' })}
|
||||||
|
t={t}
|
||||||
|
tc={tc}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -199,6 +202,7 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
|||||||
<OperatorList
|
<OperatorList
|
||||||
operators={operators}
|
operators={operators}
|
||||||
onSelect={(op) => setState({ step: 'pin', operator: op })}
|
onSelect={(op) => setState({ step: 'pin', operator: op })}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
import { prisma } from '@repo/db';
|
import { prisma } from '@repo/db';
|
||||||
import { resolveUser } from '@/lib/auth';
|
import { resolveUser } from '@/lib/auth';
|
||||||
import { OperatorPicker } from './operator-picker';
|
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 login step. prisma is used directly (bypassing the tRPC auth layer) so
|
||||||
// the page works even when AUTH_DEV_AUTOLOGIN=false.
|
// the page works even when AUTH_DEV_AUTOLOGIN=false.
|
||||||
export default async function SelectOperatorPage() {
|
export default async function SelectOperatorPage() {
|
||||||
|
const t = await getTranslations('auth');
|
||||||
const user = await resolveUser();
|
const user = await resolveUser();
|
||||||
if (user) redirect('/');
|
if (user) redirect('/');
|
||||||
|
|
||||||
@ -19,8 +21,8 @@ export default async function SelectOperatorPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
|
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-8 p-6">
|
||||||
<header className="text-center">
|
<header className="text-center">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Quem és tu?</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{t('pickerTitle')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">Escolhe o teu perfil para continuar.</p>
|
<p className="mt-1 text-sm text-muted-foreground">{t('pickerSubtitle')}</p>
|
||||||
</header>
|
</header>
|
||||||
<OperatorPicker operators={operators} />
|
<OperatorPicker operators={operators} />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { signOut } from 'next-auth/react';
|
import { signOut } from 'next-auth/react';
|
||||||
|
|
||||||
export function SignOutButton() {
|
export function SignOutButton() {
|
||||||
|
const t = useTranslations('auth');
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut({ callbackUrl: '/select-operator' })}
|
onClick={() => signOut({ callbackUrl: '/select-operator' })}
|
||||||
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
||||||
>
|
>
|
||||||
Trocar
|
{t('switchOperator')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,20 @@
|
|||||||
const CONFIG = {
|
'use client';
|
||||||
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' },
|
import { useTranslations } from 'next-intl';
|
||||||
RESOLVED: { label: 'Resolvido',className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
|
||||||
|
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;
|
} as const;
|
||||||
|
|
||||||
export function StatusBadge({ status }: { status: keyof typeof CONFIG }) {
|
type Status = keyof typeof STATUS_CLASS;
|
||||||
const { label, className } = CONFIG[status];
|
|
||||||
|
export function StatusBadge({ status }: { status: Status }) {
|
||||||
|
const t = useTranslations('common');
|
||||||
return (
|
return (
|
||||||
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${className}`}>
|
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[status]}`}>
|
||||||
{label}
|
{t(`status.${status.toLowerCase() as 'open' | 'claimed' | 'resolved'}`)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { useSyncState } from './sync-provider';
|
import { useSyncState } from './sync-provider';
|
||||||
|
|
||||||
export function SyncChip() {
|
export function SyncChip() {
|
||||||
|
const t = useTranslations('sync');
|
||||||
const { pendingCount, deadLetterCount } = useSyncState();
|
const { pendingCount, deadLetterCount } = useSyncState();
|
||||||
|
|
||||||
if (deadLetterCount > 0) {
|
if (deadLetterCount > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -17,7 +19,7 @@ export function SyncChip() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-orange-50 px-3 py-2 text-xs text-orange-700">
|
<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" />
|
<span className="h-2 w-2 rounded-full bg-orange-400" />
|
||||||
{pendingCount} pedido{pendingCount > 1 ? 's' : ''} por enviar
|
{t('pending', { count: pendingCount })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -25,7 +27,7 @@ export function SyncChip() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 text-xs text-green-700">
|
<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" />
|
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||||
Tudo sincronizado
|
{t('synced')}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast';
|
import { subscribeBroadcast, type SyncMessage } from '@/lib/queue/broadcast';
|
||||||
import { runSync } from '@/lib/queue/sync';
|
import { runSync } from '@/lib/queue/sync';
|
||||||
import { db } from '@/lib/queue/db';
|
import { db } from '@/lib/queue/db';
|
||||||
@ -22,6 +23,7 @@ const SyncCtx = createContext<SyncState>({ pendingCount: 0, deadLetterCount: 0 }
|
|||||||
export const useSyncState = () => useContext(SyncCtx);
|
export const useSyncState = () => useContext(SyncCtx);
|
||||||
|
|
||||||
export function SyncProvider({ children }: { children: ReactNode }) {
|
export function SyncProvider({ children }: { children: ReactNode }) {
|
||||||
|
const t = useTranslations('sync');
|
||||||
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
|
const [state, setState] = useState<SyncState>({ pendingCount: 0, deadLetterCount: 0 });
|
||||||
const [failedIds, setFailedIds] = useState<string[]>([]);
|
const [failedIds, setFailedIds] = useState<string[]>([]);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
@ -85,11 +87,11 @@ export function SyncProvider({ children }: { children: ReactNode }) {
|
|||||||
key={id}
|
key={id}
|
||||||
className="flex items-center justify-between rounded-lg bg-destructive px-4 py-3 text-sm text-destructive-foreground shadow-lg"
|
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
|
<button
|
||||||
onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))}
|
onClick={() => setFailedIds((prev) => prev.filter((x) => x !== id))}
|
||||||
className="ml-4 shrink-0 opacity-80 hover:opacity-100"
|
className="ml-4 shrink-0 opacity-80 hover:opacity-100"
|
||||||
aria-label="Fechar"
|
aria-label={t('close')}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</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 type { NextConfig } from 'next';
|
||||||
import withPWAInit from '@ducanh2912/next-pwa';
|
import withPWAInit from '@ducanh2912/next-pwa';
|
||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
import './env';
|
import './env';
|
||||||
|
|
||||||
|
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||||
|
|
||||||
const withPWA = withPWAInit({
|
const withPWA = withPWAInit({
|
||||||
dest: 'public',
|
dest: 'public',
|
||||||
cacheOnFrontEndNav: true,
|
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/db": "workspace:*",
|
||||||
"@repo/domain": "workspace:*",
|
"@repo/domain": "workspace:*",
|
||||||
"@repo/ui": "workspace:*",
|
"@repo/ui": "workspace:*",
|
||||||
"dexie": "^4.0.10",
|
|
||||||
"@t3-oss/env-nextjs": "^0.11.1",
|
"@t3-oss/env-nextjs": "^0.11.1",
|
||||||
"@tanstack/react-query": "^5.62.10",
|
"@tanstack/react-query": "^5.62.10",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
"@trpc/react-query": "^11.0.0",
|
"@trpc/react-query": "^11.0.0",
|
||||||
"@trpc/server": "^11.0.0",
|
"@trpc/server": "^11.0.0",
|
||||||
|
"dexie": "^4.0.10",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"next": "15.3.9",
|
"next": "15.3.9",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
|
"next-intl": "^4.13.0",
|
||||||
"pino": "^9.5.0",
|
"pino": "^9.5.0",
|
||||||
"pino-pretty": "^11.3.0",
|
"pino-pretty": "^11.3.0",
|
||||||
"react": "^19.0.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,
|
timeout: 120_000,
|
||||||
stdout: 'pipe',
|
stdout: 'pipe',
|
||||||
stderr: 'pipe',
|
stderr: 'pipe',
|
||||||
// AUTH_URL must point to the admin server — .env has it at 3000 (operator)
|
// AUTH_URL is no longer overridden here — the admin-web `dev` script loads
|
||||||
// which causes Auth.js to redirect unauthenticated users to localhost:3000.
|
// apps/admin-web/.env.admin (AUTH_URL=:3001) with precedence over the root
|
||||||
env: { AUTH_DEV_AUTOLOGIN: 'false', AUTH_URL: ADMIN_URL },
|
// .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)
|
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:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.25
|
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:
|
pino:
|
||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.14.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)
|
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:
|
next-auth:
|
||||||
specifier: 5.0.0-beta.25
|
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:
|
pino:
|
||||||
specifier: ^9.5.0
|
specifier: ^9.5.0
|
||||||
version: 9.14.0
|
version: 9.14.0
|
||||||
@ -1273,6 +1279,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
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':
|
'@humanfs/core@0.19.2':
|
||||||
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==}
|
||||||
engines: {node: '>=18.18.0'}
|
engines: {node: '>=18.18.0'}
|
||||||
@ -1541,6 +1559,94 @@ packages:
|
|||||||
'@panva/hkdf@1.2.1':
|
'@panva/hkdf@1.2.1':
|
||||||
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
|
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':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@ -1628,6 +1734,9 @@ packages:
|
|||||||
rollup:
|
rollup:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@schummar/icu-type-parser@1.21.5':
|
||||||
|
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
|
||||||
|
|
||||||
'@smithy/core@3.24.3':
|
'@smithy/core@3.24.3':
|
||||||
resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==}
|
resolution: {integrity: sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==}
|
||||||
engines: {node: '>=18.0.0'}
|
engines: {node: '>=18.0.0'}
|
||||||
@ -1670,12 +1779,102 @@ packages:
|
|||||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
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':
|
'@swc/counter@0.1.3':
|
||||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
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':
|
'@t3-oss/env-core@0.11.1':
|
||||||
resolution: {integrity: sha512-MaxOwEoG1ntCFoKJsS7nqwgcxLW1SJw238AJwfJeaz3P/8GtkxXZsPPolsz1AdYvUTbe3XvqZ/VCdfjt+3zmKw==}
|
resolution: {integrity: sha512-MaxOwEoG1ntCFoKJsS7nqwgcxLW1SJw238AJwfJeaz3P/8GtkxXZsPPolsz1AdYvUTbe3XvqZ/VCdfjt+3zmKw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2600,6 +2799,9 @@ packages:
|
|||||||
help-me@5.0.0:
|
help-me@5.0.0:
|
||||||
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
|
||||||
|
|
||||||
|
icu-minify@4.13.0:
|
||||||
|
resolution: {integrity: sha512-SIFMeUHZJjzS5RvIGvybKvWoHjDm9cGVEs2EpJ8PmywOdJLWyblPm7TdPLLoUtkJtwQD7iGhl2WMptZ+N0on+w==}
|
||||||
|
|
||||||
idb@7.1.1:
|
idb@7.1.1:
|
||||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||||
|
|
||||||
@ -2633,6 +2835,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
intl-messageformat@11.2.7:
|
||||||
|
resolution: {integrity: sha512-+q6Ktg119nULZEpZ8YTuGOst9MyEzFtjD63FTGBlN1mLz0Z/MOUYDIvnpVKwq17eezIEh+cfJIebfJoCetpiNw==}
|
||||||
|
|
||||||
is-array-buffer@3.0.5:
|
is-array-buffer@3.0.5:
|
||||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -2926,6 +3131,10 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
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:
|
neo-async@2.6.2:
|
||||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||||
|
|
||||||
@ -2945,6 +3154,19 @@ packages:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
optional: true
|
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:
|
next@15.3.9:
|
||||||
resolution: {integrity: sha512-bat50ogkh2esjfkbqmVocL5QunR9RGCSO2oQKFjKeDcEylIgw3JY6CMfGnzoVfXJ9SDLHI546sHmsk90D2ivwQ==}
|
resolution: {integrity: sha512-bat50ogkh2esjfkbqmVocL5QunR9RGCSO2oQKFjKeDcEylIgw3JY6CMfGnzoVfXJ9SDLHI546sHmsk90D2ivwQ==}
|
||||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||||
@ -2966,6 +3188,9 @@ packages:
|
|||||||
sass:
|
sass:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
node-addon-api@7.1.1:
|
||||||
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
node-fetch-native@1.6.7:
|
node-fetch-native@1.6.7:
|
||||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||||
|
|
||||||
@ -3112,6 +3337,9 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
po-parser@2.1.1:
|
||||||
|
resolution: {integrity: sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -3770,6 +3998,11 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
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:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@ -5090,6 +5323,18 @@ snapshots:
|
|||||||
'@eslint/core': 0.17.0
|
'@eslint/core': 0.17.0
|
||||||
levn: 0.4.1
|
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':
|
'@humanfs/core@0.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@humanfs/types': 0.15.0
|
'@humanfs/types': 0.15.0
|
||||||
@ -5273,6 +5518,66 @@ snapshots:
|
|||||||
|
|
||||||
'@panva/hkdf@1.2.1': {}
|
'@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': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@playwright/test@1.60.0':
|
'@playwright/test@1.60.0':
|
||||||
@ -5362,6 +5667,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
rollup: 2.80.0
|
rollup: 2.80.0
|
||||||
|
|
||||||
|
'@schummar/icu-type-parser@1.21.5': {}
|
||||||
|
|
||||||
'@smithy/core@3.24.3':
|
'@smithy/core@3.24.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@aws-crypto/crc32': 5.2.0
|
'@aws-crypto/crc32': 5.2.0
|
||||||
@ -5419,12 +5726,70 @@ snapshots:
|
|||||||
magic-string: 0.25.9
|
magic-string: 0.25.9
|
||||||
string.prototype.matchall: 4.0.12
|
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/counter@0.1.3': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
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)':
|
'@t3-oss/env-core@0.11.1(typescript@5.9.3)(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
@ -6010,8 +6375,7 @@ snapshots:
|
|||||||
|
|
||||||
destr@2.0.5: {}
|
destr@2.0.5: {}
|
||||||
|
|
||||||
detect-libc@2.1.2:
|
detect-libc@2.1.2: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
dexie@4.4.2: {}
|
dexie@4.4.2: {}
|
||||||
|
|
||||||
@ -6481,6 +6845,10 @@ snapshots:
|
|||||||
|
|
||||||
help-me@5.0.0: {}
|
help-me@5.0.0: {}
|
||||||
|
|
||||||
|
icu-minify@4.13.0:
|
||||||
|
dependencies:
|
||||||
|
'@formatjs/icu-messageformat-parser': 3.5.10
|
||||||
|
|
||||||
idb@7.1.1: {}
|
idb@7.1.1: {}
|
||||||
|
|
||||||
ieee754@1.2.1: {}
|
ieee754@1.2.1: {}
|
||||||
@ -6509,6 +6877,11 @@ snapshots:
|
|||||||
hasown: 2.0.3
|
hasown: 2.0.3
|
||||||
side-channel: 1.1.0
|
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:
|
is-array-buffer@3.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@ -6765,14 +7138,35 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
neo-async@2.6.2: {}
|
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:
|
dependencies:
|
||||||
'@auth/core': 0.37.2
|
'@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)
|
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
|
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):
|
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:
|
dependencies:
|
||||||
'@next/env': 15.3.9
|
'@next/env': 15.3.9
|
||||||
@ -6799,6 +7193,8 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
|
node-addon-api@7.1.1: {}
|
||||||
|
|
||||||
node-fetch-native@1.6.7: {}
|
node-fetch-native@1.6.7: {}
|
||||||
|
|
||||||
node-releases@2.0.44: {}
|
node-releases@2.0.44: {}
|
||||||
@ -6947,6 +7343,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
po-parser@2.1.1: {}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss-import@15.1.0(postcss@8.5.14):
|
postcss-import@15.1.0(postcss@8.5.14):
|
||||||
@ -7593,6 +7991,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
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: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
watchpack@2.5.1:
|
watchpack@2.5.1:
|
||||||
|
|||||||
@ -10,8 +10,10 @@ packages:
|
|||||||
# - esbuild: prebuilt native binary; used transitively by tsx.
|
# - esbuild: prebuilt native binary; used transitively by tsx.
|
||||||
# - sharp: prebuilt native binary; used by Next.js image optimization.
|
# - sharp: prebuilt native binary; used by Next.js image optimization.
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
|
'@parcel/watcher': true
|
||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
'@prisma/engines': true
|
'@prisma/engines': true
|
||||||
|
'@swc/core': true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
prisma: true
|
prisma: true
|
||||||
sharp: true
|
sharp: true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user