290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import Link from 'next/link';
|
|
import { ArrowLeft, ClipboardCheck, MapPin, CheckCircle2, Loader2, AlertTriangle } from 'lucide-react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { trpc } from '@/lib/trpc/client';
|
|
import type { RouterOutputs } from '@/lib/trpc/server';
|
|
|
|
type Defect = RouterOutputs['qualityDefect']['forMyStation'][number];
|
|
type DefectStatus = 'OPEN' | 'ACKNOWLEDGED' | 'CORRECTED';
|
|
type TFn = (key: string, values?: Record<string, string | number>) => string;
|
|
|
|
function timeAgo(date: Date | string, t: TFn): string {
|
|
const diffMs = Date.now() - new Date(date).getTime();
|
|
const mins = Math.floor(diffMs / 60_000);
|
|
if (mins < 1) return t('now');
|
|
if (mins < 60) return t('minutesAgo', { mins });
|
|
const hours = Math.floor(mins / 60);
|
|
if (hours < 24) return t('hoursAgo', { hours });
|
|
return t('daysAgo', { days: Math.floor(hours / 24) });
|
|
}
|
|
|
|
function playBeep() {
|
|
try {
|
|
const ctx = new AudioContext();
|
|
const osc = ctx.createOscillator();
|
|
const gain = ctx.createGain();
|
|
osc.connect(gain);
|
|
gain.connect(ctx.destination);
|
|
osc.type = 'sine';
|
|
osc.frequency.value = 880;
|
|
gain.gain.setValueAtTime(0.2, ctx.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.4);
|
|
osc.start(ctx.currentTime);
|
|
osc.stop(ctx.currentTime + 0.4);
|
|
} catch {
|
|
// AudioContext may be blocked before user interaction — ignore.
|
|
}
|
|
}
|
|
|
|
const STATUS_CLASS: Record<DefectStatus, string> = {
|
|
OPEN: 'bg-orange-100 text-orange-700',
|
|
ACKNOWLEDGED: 'bg-blue-100 text-blue-700',
|
|
CORRECTED: 'bg-green-100 text-green-700',
|
|
};
|
|
|
|
function Thumbnail({ photoKey, alt }: { photoKey: string | null; alt: string }) {
|
|
const { data } = trpc.storage.signPhotoDownload.useQuery(
|
|
{ photoKey: photoKey! },
|
|
{ enabled: !!photoKey, staleTime: 50_000 },
|
|
);
|
|
if (!photoKey) return null;
|
|
if (!data?.url) {
|
|
return <div className="h-20 w-20 shrink-0 animate-pulse rounded-lg bg-muted" />;
|
|
}
|
|
return (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={data.url} alt={alt} className="h-20 w-20 shrink-0 rounded-lg object-cover" />
|
|
);
|
|
}
|
|
|
|
function CorrectDialog({
|
|
onConfirm,
|
|
onCancel,
|
|
note,
|
|
onNoteChange,
|
|
busy,
|
|
t,
|
|
tc,
|
|
}: {
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
note: string;
|
|
onNoteChange: (v: string) => void;
|
|
busy: boolean;
|
|
t: ReturnType<typeof useTranslations<'quality'>>;
|
|
tc: ReturnType<typeof useTranslations<'common'>>;
|
|
}) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 sm:items-center">
|
|
<div className="w-full max-w-md rounded-2xl bg-card p-6 shadow-xl">
|
|
<h2 className="mb-4 text-lg font-semibold">{t('correctDialogTitle')}</h2>
|
|
<label className="mb-1 block text-sm font-medium">{t('correctNoteLabel')}</label>
|
|
<textarea
|
|
value={note}
|
|
onChange={(e) => onNoteChange(e.target.value)}
|
|
rows={3}
|
|
placeholder={t('correctNotePlaceholder')}
|
|
className="mb-4 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
/>
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={onCancel} className="rounded-lg px-4 py-2 text-sm hover:bg-accent">
|
|
{tc('cancel')}
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
disabled={busy}
|
|
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"
|
|
>
|
|
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{tc('confirm')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DefectCard({
|
|
defect,
|
|
onAcknowledge,
|
|
onCorrect,
|
|
acknowledging,
|
|
t,
|
|
tc,
|
|
}: {
|
|
defect: Defect;
|
|
onAcknowledge: () => void;
|
|
onCorrect: () => void;
|
|
acknowledging: boolean;
|
|
t: ReturnType<typeof useTranslations<'quality'>>;
|
|
tc: ReturnType<typeof useTranslations<'common'>>;
|
|
}) {
|
|
const status = defect.status as DefectStatus;
|
|
return (
|
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm">
|
|
<div className="flex gap-3">
|
|
<Thumbnail photoKey={defect.photoKey} alt={t('photo')} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<p className="font-semibold">{defect.defectType}</p>
|
|
<span className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${STATUS_CLASS[status]}`}>
|
|
{t(`status.${status.toLowerCase() as 'open' | 'acknowledged' | 'corrected'}`)}
|
|
</span>
|
|
</div>
|
|
{defect.location && (
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{t('location')}: {defect.location}
|
|
</p>
|
|
)}
|
|
<p className="mt-1 text-sm text-muted-foreground">{defect.description}</p>
|
|
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
{defect.rfsCode && (
|
|
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">
|
|
{t('rfs')} {defect.rfsCode}
|
|
</span>
|
|
)}
|
|
<span>{t('raised', { email: defect.createdBy.email, time: timeAgo(defect.createdAt, tc) })}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-3">
|
|
{status === 'OPEN' && (
|
|
<button
|
|
onClick={onAcknowledge}
|
|
disabled={acknowledging}
|
|
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"
|
|
>
|
|
{acknowledging ? <Loader2 className="h-4 w-4 animate-spin" /> : <ClipboardCheck className="h-4 w-4" />}
|
|
{t('acknowledge')}
|
|
</button>
|
|
)}
|
|
{status === 'ACKNOWLEDGED' && (
|
|
<>
|
|
{defect.acknowledgedAt && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('acknowledgedBy', { time: timeAgo(defect.acknowledgedAt, tc) })}
|
|
</p>
|
|
)}
|
|
<button
|
|
onClick={onCorrect}
|
|
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" />
|
|
{t('correct')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function QualityDefectsPage() {
|
|
const t = useTranslations('quality');
|
|
const tc = useTranslations('common');
|
|
const tcTime = useTranslations('common.timeAgo');
|
|
|
|
const [correctId, setCorrectId] = useState<string | null>(null);
|
|
const [correctionNote, setCorrectionNote] = useState('');
|
|
const [soundEnabled, setSoundEnabled] = useState(false);
|
|
|
|
const { data: session } = trpc.operatorSession.current.useQuery();
|
|
const { data: defects = [], refetch } = trpc.qualityDefect.forMyStation.useQuery(undefined, {
|
|
refetchInterval: 5000,
|
|
refetchIntervalInBackground: false,
|
|
});
|
|
|
|
const acknowledge = trpc.qualityDefect.acknowledge.useMutation({ onSuccess: () => refetch() });
|
|
const correct = trpc.qualityDefect.correct.useMutation({
|
|
onSuccess: () => {
|
|
setCorrectId(null);
|
|
refetch();
|
|
},
|
|
});
|
|
|
|
const openIds = defects.filter((d) => d.status === 'OPEN').map((d) => d.id);
|
|
|
|
// Beep when a new OPEN defect arrives.
|
|
const prevOpenIds = useRef(new Set<string>());
|
|
useEffect(() => {
|
|
const current = new Set(openIds);
|
|
const hasNew = [...current].some((id) => !prevOpenIds.current.has(id));
|
|
if (hasNew && prevOpenIds.current.size > 0 && soundEnabled) playBeep();
|
|
prevOpenIds.current = current;
|
|
}, [openIds, soundEnabled]);
|
|
|
|
return (
|
|
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
|
<header className="sticky top-0 z-10 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" aria-label={t('backHome')}>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</Link>
|
|
<div className="min-w-0 flex-1">
|
|
<h1 className="text-base font-semibold">{t('title')}</h1>
|
|
{session && (
|
|
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
<MapPin className="h-3 w-3" />
|
|
{t('subtitle', { code: session.workstation.code })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setSoundEnabled((v) => !v)}
|
|
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
|
soundEnabled ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'
|
|
}`}
|
|
>
|
|
{soundEnabled ? t('soundOn') : t('soundOff')}
|
|
</button>
|
|
</header>
|
|
|
|
<div className="flex flex-1 flex-col gap-3 p-4">
|
|
{!session ? (
|
|
<div className="py-16 text-center text-muted-foreground">
|
|
<AlertTriangle className="mx-auto mb-3 h-10 w-10 opacity-30" />
|
|
<p>{t('noSession')}</p>
|
|
</div>
|
|
) : defects.length === 0 ? (
|
|
<div className="py-16 text-center text-muted-foreground">
|
|
<ClipboardCheck className="mx-auto mb-3 h-10 w-10 opacity-30" />
|
|
<p>{t('empty')}</p>
|
|
</div>
|
|
) : (
|
|
defects.map((defect) => (
|
|
<DefectCard
|
|
key={defect.id}
|
|
defect={defect}
|
|
t={t}
|
|
tc={tcTime}
|
|
acknowledging={acknowledge.isPending && acknowledge.variables?.id === defect.id}
|
|
onAcknowledge={() => acknowledge.mutate({ id: defect.id })}
|
|
onCorrect={() => {
|
|
setCorrectId(defect.id);
|
|
setCorrectionNote('');
|
|
}}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{correctId && (
|
|
<CorrectDialog
|
|
note={correctionNote}
|
|
onNoteChange={setCorrectionNote}
|
|
t={t}
|
|
tc={tc}
|
|
busy={correct.isPending}
|
|
onConfirm={() =>
|
|
correct.mutate({ id: correctId, correctionNote: correctionNote.trim() || undefined })
|
|
}
|
|
onCancel={() => setCorrectId(null)}
|
|
/>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|