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.
221 lines
7.7 KiB
TypeScript
221 lines
7.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useRef } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { ArrowLeft, Camera, X } from 'lucide-react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { trpc } from '@/lib/trpc/client';
|
|
import { db } from '@/lib/queue/db';
|
|
import { runSync } from '@/lib/queue/sync';
|
|
|
|
function compressImage(file: File): Promise<Blob> {
|
|
return new Promise((resolve, reject) => {
|
|
const img = new Image();
|
|
const url = URL.createObjectURL(file);
|
|
img.onload = () => {
|
|
URL.revokeObjectURL(url);
|
|
const MAX = 1600;
|
|
let { width, height } = img;
|
|
if (width > MAX || height > MAX) {
|
|
if (width >= height) {
|
|
height = Math.round((height * MAX) / width);
|
|
width = MAX;
|
|
} else {
|
|
width = Math.round((width * MAX) / height);
|
|
height = MAX;
|
|
}
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return reject(new Error('Canvas context unavailable'));
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
canvas.toBlob(
|
|
(blob) => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))),
|
|
'image/jpeg',
|
|
0.8,
|
|
);
|
|
};
|
|
img.onerror = () => reject(new Error('Image load failed'));
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
export default function NewRequestPage() {
|
|
const t = useTranslations('maintenance');
|
|
const router = useRouter();
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [workstationId, setWorkstationId] = useState('');
|
|
const [description, setDescription] = useState('');
|
|
const [photoBlob, setPhotoBlob] = useState<Blob | null>(null);
|
|
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
|
|
undefined,
|
|
{ staleTime: 60 * 60 * 1000 },
|
|
);
|
|
|
|
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
try {
|
|
const compressed = await compressImage(file);
|
|
if (photoPreview) URL.revokeObjectURL(photoPreview);
|
|
setPhotoBlob(compressed);
|
|
setPhotoPreview(URL.createObjectURL(compressed));
|
|
} catch {
|
|
setError(t('photoError'));
|
|
}
|
|
}
|
|
|
|
function removePhoto() {
|
|
if (photoPreview) URL.revokeObjectURL(photoPreview);
|
|
setPhotoBlob(null);
|
|
setPhotoPreview(null);
|
|
if (fileRef.current) fileRef.current.value = '';
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!workstationId || description.trim().length < 3) return;
|
|
|
|
setSubmitting(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const clientRequestId = crypto.randomUUID();
|
|
|
|
await db.pending.add({
|
|
clientRequestId,
|
|
workstationId,
|
|
description: description.trim(),
|
|
photoBlob: photoBlob ?? undefined,
|
|
queuedAt: Date.now(),
|
|
retries: 0,
|
|
});
|
|
|
|
if (navigator.onLine) runSync().catch(() => {});
|
|
|
|
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t('saveError'));
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const descLen = description.length;
|
|
const canSubmit = workstationId !== '' && descLen >= 3 && descLen <= 1000 && !submitting;
|
|
|
|
return (
|
|
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
|
<header className="flex items-center gap-3 border-b border-border bg-card px-4 py-3">
|
|
<Link href="/" className="rounded-md p-1 hover:bg-accent">
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Link>
|
|
<h1 className="text-base font-semibold">{t('newTitle')}</h1>
|
|
</header>
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
|
|
{/* Workstation */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="workstation" className="text-sm font-medium">
|
|
{t('workstationLabel')} <span className="text-destructive">{t('workstationRequired')}</span>
|
|
</label>
|
|
<select
|
|
id="workstation"
|
|
value={workstationId}
|
|
onChange={(e) => setWorkstationId(e.target.value)}
|
|
required
|
|
disabled={wsLoading}
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50"
|
|
>
|
|
<option value="">
|
|
{wsLoading ? t('workstationLoading') : t('workstationPlaceholder')}
|
|
</option>
|
|
{workstations.map((ws) => (
|
|
<option key={ws.id} value={ws.id}>
|
|
{ws.code} — {ws.name} · {ws.area}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Photo */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<span className="text-sm font-medium">{t('photoLabel')}</span>
|
|
{photoPreview ? (
|
|
<div className="relative">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img src={photoPreview} alt={t('photoPreview')} className="h-48 w-full rounded-lg object-cover" />
|
|
<button
|
|
type="button"
|
|
onClick={removePhoto}
|
|
className="absolute right-2 top-2 rounded-full bg-black/60 p-1 text-white hover:bg-black/80"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => fileRef.current?.click()}
|
|
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" />
|
|
{t('photoButton')}
|
|
</button>
|
|
)}
|
|
<input
|
|
ref={fileRef}
|
|
type="file"
|
|
accept="image/*"
|
|
capture="environment"
|
|
className="hidden"
|
|
onChange={handlePhotoChange}
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div className="flex flex-col gap-1.5">
|
|
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
|
|
<span>{t('descriptionLabel')} <span className="text-destructive">{t('descriptionRequired')}</span></span>
|
|
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
|
{descLen}/1000
|
|
</span>
|
|
</label>
|
|
<textarea
|
|
id="description"
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
required
|
|
minLength={3}
|
|
maxLength={1000}
|
|
rows={4}
|
|
placeholder={t('descriptionPlaceholder')}
|
|
className="w-full resize-none rounded-lg border border-border bg-card px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
|
)}
|
|
|
|
<div className="mt-auto">
|
|
<button
|
|
type="submit"
|
|
disabled={!canSubmit}
|
|
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
|
>
|
|
{submitting ? t('submitting') : t('submit')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</main>
|
|
);
|
|
}
|