217 lines
7.9 KiB
TypeScript

'use client';
import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { ArrowLeft, Camera, X, MapPin } 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 [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);
// Workstation is no longer chosen per-request: it comes from the operator's
// active badge-in session. It still travels in the queued payload so offline
// submissions remain self-contained even if the operator later changes posto.
const { data: session, isLoading: sessionLoading } = trpc.operatorSession.current.useQuery();
const workstationId = session?.workstationId ?? '';
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 — read-only, from the active session */}
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">{t('workstationLabel')}</span>
{sessionLoading ? (
<p className="text-sm text-muted-foreground">{t('workstationLoading')}</p>
) : session ? (
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2.5 text-sm">
<MapPin className="h-4 w-4 shrink-0 text-primary" />
<span className="font-medium">
{session.workstation.code} {session.workstation.name}
<span className="text-xs text-muted-foreground"> · {session.workstation.area}</span>
</span>
</div>
) : (
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
{t('noSession')}
</p>
)}
</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>
);
}