MY QUALITY - First iteration

This commit is contained in:
2026-06-11 15:43:35 +01:00
parent 4f8996712e
commit 1fdb9536fa
31 changed files with 1965 additions and 98 deletions
@@ -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}