MY QUALITY - First iteration
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MapPin } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
|
||||
interface Workstation {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
area: string;
|
||||
}
|
||||
|
||||
/** Home-screen badge-in: pick a workstation to start an operator session. */
|
||||
export function BadgeInPanel({ workstations }: { workstations: Workstation[] }) {
|
||||
const ts = useTranslations('session');
|
||||
const router = useRouter();
|
||||
const startSession = trpc.operatorSession.start.useMutation({
|
||||
onSuccess: () => router.refresh(),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="rounded-2xl border-2 border-dashed border-border bg-card p-5 text-center">
|
||||
<MapPin className="mx-auto mb-2 h-7 w-7 text-primary" />
|
||||
<h2 className="text-base font-semibold">{ts('badgeInTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{ts('badgeInPrompt')}</p>
|
||||
</div>
|
||||
|
||||
{workstations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{ts('noStations')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{workstations.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
data-testid="badge-in-station"
|
||||
onClick={() => startSession.mutate({ workstationId: ws.id })}
|
||||
disabled={startSession.isPending}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-border bg-card px-5 py-4 text-left transition-colors hover:bg-accent active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
<MapPin className="h-5 w-5 shrink-0 text-primary" />
|
||||
<span>
|
||||
<span className="block text-base font-medium">
|
||||
{ws.code} — {ws.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{ws.area}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{startSession.isPending && (
|
||||
<p className="text-center text-sm text-muted-foreground">{ts('starting')}</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Camera, X } from 'lucide-react';
|
||||
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';
|
||||
@@ -48,17 +48,17 @@ export default function NewRequestPage() {
|
||||
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 },
|
||||
);
|
||||
// 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];
|
||||
@@ -121,28 +121,24 @@ export default function NewRequestPage() {
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
|
||||
{/* Workstation */}
|
||||
{/* Workstation — read-only, from the active session */}
|
||||
<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>
|
||||
<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 */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { Wrench } from 'lucide-react';
|
||||
import { Wrench, ClipboardCheck, ChevronRight } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { resolveUser } from '@/lib/auth';
|
||||
import { api } from '@/lib/trpc/server';
|
||||
@@ -7,50 +7,123 @@ import { SignOutButton } from './sign-out-button';
|
||||
import { StatusBadge } from './status-badge';
|
||||
import { SyncChip } from './sync-chip';
|
||||
import { LanguageSwitcher } from './language-switcher';
|
||||
import { BadgeInPanel } from './badge-in-panel';
|
||||
import { SessionBar } from './session-bar';
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations('home');
|
||||
const user = await resolveUser();
|
||||
|
||||
// Current badge-in session (operator bound to a workstation).
|
||||
let session: Awaited<ReturnType<typeof api.operatorSession.current>> = null;
|
||||
try {
|
||||
session = await api.operatorSession.current();
|
||||
} catch {
|
||||
// No auth / error — treat as not badged in.
|
||||
}
|
||||
|
||||
const header = (
|
||||
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t('operator')}</p>
|
||||
<p className="text-sm font-medium" data-testid="current-user">
|
||||
{user?.email ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
// ── Not badged in: prompt to pick a workstation ──
|
||||
if (!session) {
|
||||
let workstations: Awaited<ReturnType<typeof api.workstation.list>> = [];
|
||||
try {
|
||||
workstations = await api.workstation.list();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
||||
{header}
|
||||
<div className="flex flex-1 flex-col gap-6 p-4">
|
||||
<SyncChip />
|
||||
<BadgeInPanel workstations={workstations} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Badged in: full home ──
|
||||
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
|
||||
let recent: RecentItem[] = [];
|
||||
try {
|
||||
recent = await api.maintenanceRequest.myRecent({ limit: 5 });
|
||||
} catch {
|
||||
// No session or other error — show empty list without crashing.
|
||||
// ignore
|
||||
}
|
||||
|
||||
let openDefects = 0;
|
||||
try {
|
||||
const defects = await api.qualityDefect.forMyStation();
|
||||
openDefects = defects.filter((d) => d.status === 'OPEN').length;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
||||
{/* ── Header ── */}
|
||||
<header className="flex items-center justify-between border-b border-border bg-card px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{t('operator')}</p>
|
||||
<p className="text-sm font-medium" data-testid="current-user">
|
||||
{user?.email ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
{header}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 p-4">
|
||||
{/* ── Sync status ── */}
|
||||
<SyncChip />
|
||||
|
||||
{/* ── Primary CTA ── */}
|
||||
<Link
|
||||
href="/maintenance/new"
|
||||
data-testid="btn-request-maintenance"
|
||||
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" />
|
||||
{t('requestMaintenance')}
|
||||
</Link>
|
||||
{/* Current workstation + badge-out */}
|
||||
<SessionBar
|
||||
code={session.workstation.code}
|
||||
name={session.workstation.name}
|
||||
area={session.workstation.area}
|
||||
/>
|
||||
|
||||
{/* ── Recent requests ── */}
|
||||
{/* Primary CTAs */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Link
|
||||
href="/maintenance/new"
|
||||
data-testid="btn-request-maintenance"
|
||||
className="flex items-center justify-center gap-3 rounded-2xl bg-primary px-6 py-8 text-lg font-semibold text-primary-foreground shadow-sm transition-opacity hover:opacity-90 active:scale-[0.98]"
|
||||
>
|
||||
<Wrench className="h-6 w-6" />
|
||||
{t('requestMaintenance')}
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/quality"
|
||||
data-testid="btn-quality-defects"
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card px-6 py-5 transition-colors hover:bg-accent active:scale-[0.98]"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-primary" />
|
||||
<span>
|
||||
<span className="block text-base font-semibold">{t('defects')}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{openDefects > 0 ? t('defectsWithCount', { count: openDefects }) : t('noDefects')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{openDefects > 0 && (
|
||||
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-orange-500 px-1.5 text-xs font-semibold text-white">
|
||||
{openDefects}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Recent requests */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('myRequests')}</h2>
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { ArrowLeft, Delete } from 'lucide-react';
|
||||
import { ArrowLeft, Delete, MapPin } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
|
||||
interface Operator {
|
||||
id: string;
|
||||
@@ -13,7 +14,8 @@ interface Operator {
|
||||
|
||||
type PickerState =
|
||||
| { step: 'list' }
|
||||
| { step: 'pin'; operator: Operator };
|
||||
| { step: 'pin'; operator: Operator }
|
||||
| { step: 'workstation'; operator: Operator };
|
||||
|
||||
const PIN_MIN = 4;
|
||||
const PIN_MAX = 6;
|
||||
@@ -50,15 +52,16 @@ function OperatorList({
|
||||
function PinPad({
|
||||
operator,
|
||||
onBack,
|
||||
onSuccess,
|
||||
t,
|
||||
tc,
|
||||
}: {
|
||||
operator: Operator;
|
||||
onBack: () => void;
|
||||
onSuccess: () => void;
|
||||
t: ReturnType<typeof useTranslations<'auth'>>;
|
||||
tc: ReturnType<typeof useTranslations<'common'>>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [digits, setDigits] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -88,8 +91,8 @@ function PinPad({
|
||||
setDigits('');
|
||||
setError(t('invalidPin'));
|
||||
} else {
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
// Authenticated — next step is binding to a workstation (badge-in).
|
||||
onSuccess();
|
||||
}
|
||||
} catch {
|
||||
setDigits('');
|
||||
@@ -182,9 +185,68 @@ function PinPad({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkstationStep({
|
||||
operator,
|
||||
ts,
|
||||
}: {
|
||||
operator: Operator;
|
||||
ts: ReturnType<typeof useTranslations<'session'>>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { data: workstations = [], isLoading } = trpc.workstation.list.useQuery(undefined, {
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const startSession = trpc.operatorSession.start.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{operator.email}</p>
|
||||
<h2 className="mt-1 text-xl font-bold tracking-tight">{ts('badgeInTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{ts('badgeInSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{ts('loadingStations')}</p>
|
||||
) : workstations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{ts('noStations')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{workstations.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => startSession.mutate({ workstationId: ws.id })}
|
||||
disabled={startSession.isPending}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-border bg-card px-6 py-5 text-left transition-colors hover:bg-accent active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
<MapPin className="h-5 w-5 shrink-0 text-primary" />
|
||||
<span>
|
||||
<span className="block text-base font-medium">
|
||||
{ws.code} — {ws.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{ws.area}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{startSession.isPending && (
|
||||
<p className="text-center text-sm text-muted-foreground">{ts('starting')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
||||
const t = useTranslations('auth');
|
||||
const tc = useTranslations('common');
|
||||
const ts = useTranslations('session');
|
||||
const [state, setState] = useState<PickerState>({ step: 'list' });
|
||||
|
||||
if (state.step === 'pin') {
|
||||
@@ -192,12 +254,17 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
||||
<PinPad
|
||||
operator={state.operator}
|
||||
onBack={() => setState({ step: 'list' })}
|
||||
onSuccess={() => setState({ step: 'workstation', operator: state.operator })}
|
||||
t={t}
|
||||
tc={tc}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === 'workstation') {
|
||||
return <WorkstationStep operator={state.operator} ts={ts} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<OperatorList
|
||||
operators={operators}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MapPin, LogOut } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
|
||||
/** Header chip showing the operator's current workstation, with a badge-out button. */
|
||||
export function SessionBar({ code, name, area }: { code: string; name: string; area: string }) {
|
||||
const ts = useTranslations('session');
|
||||
const router = useRouter();
|
||||
const endSession = trpc.operatorSession.end.useMutation({
|
||||
onSuccess: () => router.refresh(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<MapPin className="h-5 w-5 shrink-0 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs text-muted-foreground">{ts('atStation')}</p>
|
||||
<p className="truncate text-sm font-medium">
|
||||
{code} — {name} <span className="text-xs text-muted-foreground">· {area}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => endSession.mutate()}
|
||||
disabled={endSession.isPending}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{ts('badgeOut')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user