MY QUALITY - First iteration

This commit is contained in:
Pedro Gomes 2026-06-11 15:43:35 +01:00
parent 4f8996712e
commit 1fdb9536fa
31 changed files with 1965 additions and 98 deletions

View File

@ -1,8 +1,13 @@
# FieldOps — MAI CALL v0.3 # FieldOps — MAI CALL + MY QUALITY
Modular industrial SaaS monorepo. **MAI CALL v0.3** is the latest shipped Modular industrial SaaS monorepo. Two modules are shipped:
feature: a full maintenance-request loop (offline-first, operator PIN + admin
password auth) with an **end-of-shift report** for supervisors. - **MAI CALL** — a full maintenance-request loop (offline-first, operator PIN +
admin password auth) with an **end-of-shift report** for supervisors.
- **MY QUALITY** — operators **badge in** to a workstation (operator↔posto
session); the Quality team (QCP) raises **defects** against a workstation,
which are routed in real time to the operator bound there to acknowledge and
correct. State machine OPEN → ACKNOWLEDGED → CORRECTED.
## What's here ## What's here
@ -76,10 +81,13 @@ pnpm --filter @repo/admin-web dev
http://localhost:3000/select-operator, tap **op1@demo.local**, then http://localhost:3000/select-operator, tap **op1@demo.local**, then
enter PIN **1111** on the keypad. enter PIN **1111** on the keypad.
(op2 = **2222**, op3 = **3333**) (op2 = **2222**, op3 = **3333**)
2. Tap **Pedir manutenção**. 2. **Badge in:** pick the workstation you are at. This starts your
3. Select a workstation, optionally attach a photo, write a description, operator↔posto session (the future RFID badge-in). The home shows your
and tap **Enviar pedido**. current posto with a **Sair do posto** (badge-out) button.
4. The page shows **"Pedido enviado"** once the sync completes (usually 3. Tap **Pedir manutenção**.
4. The posto is taken automatically from your session (no dropdown).
Optionally attach a photo, write a description, and tap **Enviar pedido**.
5. The page shows **"Pedido enviado"** once the sync completes (usually
within 12 seconds when online). within 12 seconds when online).
**Offline test:** **Offline test:**
@ -97,7 +105,8 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears.
1. Open http://localhost:3001. 1. Open http://localhost:3001.
With `AUTH_DEV_AUTOLOGIN=true` you land on the maintenance queue With `AUTH_DEV_AUTOLOGIN=true` you land on the maintenance queue
automatically. Without it, you see a login form — use automatically. Without it, you see a login form — use
**admin@demo.local** / **admin1234**. **admin@demo.local** / **admin1234** (or the QCP user
**qcp@demo.local** / **qcp1234**, who lands on the quality console).
2. The queue refreshes every 5 s; new requests appear automatically. 2. The queue refreshes every 5 s; new requests appear automatically.
3. Click **Aceitar** to claim a request (status: Em curso). 3. Click **Aceitar** to claim a request (status: Em curso).
4. Click **Marcar resolvido**, optionally add a note, click **Confirmar** 4. Click **Marcar resolvido**, optionally add a note, click **Confirmar**
@ -118,6 +127,30 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears.
After `pnpm db:seed`, the "Hoje" window already has 6 sample requests After `pnpm db:seed`, the "Hoje" window already has 6 sample requests
(3 resolved, 1 claimed, 2 open) so the report is never empty on first boot. (3 resolved, 1 claimed, 2 open) so the report is never empty on first boot.
### MY QUALITY — quality defects
The Quality controller (QCP) raises defects; the operator at the targeted
workstation handles them.
**As QCP (port 3001):** sign in with **qcp@demo.local** / **qcp1234** — you
land on the **Defeitos de qualidade** console (QCP users are redirected there
from `/maintenance`). Fill the **Novo defeito** form (posto, tipo, localização,
RFS, descrição, foto opcional) and click **Lançar defeito**. The queue below
polls every 5 s and shows each defect's lifecycle (who raised / acknowledged /
corrected it). Admins can reach the console from the **Qualidade** link in the
maintenance-queue header.
**As operator (port 3000):** once badged in (see above), open **Defeitos de
qualidade** from the home. Defects raised at your posto appear here (polled,
with an optional sound alert). Tap **Tomei conhecimento** (→ ACKNOWLEDGED), then
**Marcar corrigido** with an optional note (→ CORRECTED).
After `pnpm db:seed`, op1 is already badged in at **CTR04** with 3 sample
defects (1 open, 1 acknowledged, 1 corrected).
Smoke test (no browser): `pnpm tsx scripts/quality-smoke.ts` (18 assertions —
the full QCP→operator loop, role guards, and state-machine conflicts).
--- ---
## MinIO (photo storage) ## MinIO (photo storage)
@ -275,6 +308,7 @@ key-parity and ICU validation scripts to run before shipping a new language.
| `pnpm tsx scripts/maintenance-smoke.ts` | Verify the full create→claim→resolve cycle | | `pnpm tsx scripts/maintenance-smoke.ts` | Verify the full create→claim→resolve cycle |
| `pnpm tsx scripts/auth-smoke.ts` | Verify hashing, PIN/password login, and lockout | | `pnpm tsx scripts/auth-smoke.ts` | Verify hashing, PIN/password login, and lockout |
| `pnpm tsx scripts/report-smoke.ts` | Verify shift-report aggregation against seeded data | | `pnpm tsx scripts/report-smoke.ts` | Verify shift-report aggregation against seeded data |
| `pnpm tsx scripts/quality-smoke.ts` | Verify the MY QUALITY loop (badge-in, defect create→acknowledge→correct, roles) |
--- ---

View File

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react'; import { CheckCircle2, Clock, Loader2, Wrench, BarChart2, ClipboardList } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { trpc } from '@/lib/trpc/client'; import { trpc } from '@/lib/trpc/client';
@ -275,6 +275,13 @@ export function MaintenanceQueue() {
</h1> </h1>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Link
href="/quality"
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
>
<ClipboardList className="h-3 w-3" />
{t('qualityLink')}
</Link>
<Link <Link
href="/maintenance/report" href="/maintenance/report"
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent" className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"

View File

@ -1,5 +1,12 @@
import { redirect } from 'next/navigation';
import { resolveUser } from '@/lib/auth';
import { MaintenanceQueue } from './maintenance-queue'; import { MaintenanceQueue } from './maintenance-queue';
export default function MaintenancePage() { export default async function MaintenancePage() {
// QCP users have no business in the maintenance queue (and the queue
// procedure would 403 them) — send them to their quality console.
const user = await resolveUser();
if (user?.role === 'QUALITY') redirect('/quality');
return <MaintenanceQueue />; return <MaintenanceQueue />;
} }

View File

@ -0,0 +1,22 @@
import { redirect } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import type { Metadata } from 'next';
import { resolveUser } from '@/lib/auth';
import { QualityConsole } from './quality-console';
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations('quality');
return { title: t('documentTitle') };
}
export default async function QualityPage() {
const user = await resolveUser();
// Only quality / admin / supervisor may view the console; everyone else out.
if (user && !['QUALITY', 'ADMIN', 'SUPERVISOR'].includes(user.role)) {
redirect('/maintenance');
}
// Defects are raised by QCP and admins; supervisors get a read-only view.
const canCreate = user?.role === 'QUALITY' || user?.role === 'ADMIN';
return <QualityConsole canCreate={canCreate} />;
}

View File

@ -0,0 +1,438 @@
'use client';
import { useState, useRef } from 'react';
import Link from 'next/link';
import { Wrench, Camera, X, Loader2, ClipboardList, AlertTriangle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { trpc } from '@/lib/trpc/client';
import type { RouterOutputs } from '@/lib/trpc/server';
import { LanguageSwitcher } from '../language-switcher';
type Defect = RouterOutputs['qualityDefect']['queue'][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 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;
});
}
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-16 w-16 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-16 w-16 shrink-0 rounded-lg object-cover" />
);
}
// ── New-defect form ───────────────────────────────────────────────────────────
function NewDefectForm({
onCreated,
t,
}: {
onCreated: () => void;
t: ReturnType<typeof useTranslations<'quality'>>;
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [workstationId, setWorkstationId] = useState('');
const [defectType, setDefectType] = useState('');
const [location, setLocation] = useState('');
const [rfsCode, setRfsCode] = 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 = [] } = trpc.workstation.list.useQuery(undefined, {
staleTime: 60 * 60 * 1000,
});
const signUpload = trpc.storage.signPhotoUpload.useMutation();
const create = trpc.qualityDefect.create.useMutation();
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('form.photoError'));
}
}
function removePhoto() {
if (photoPreview) URL.revokeObjectURL(photoPreview);
setPhotoBlob(null);
setPhotoPreview(null);
if (fileRef.current) fileRef.current.value = '';
}
function reset() {
setWorkstationId('');
setDefectType('');
setLocation('');
setRfsCode('');
setDescription('');
removePhoto();
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!workstationId || defectType.trim().length < 1 || description.trim().length < 3) return;
setSubmitting(true);
setError(null);
try {
let photoKey: string | undefined;
if (photoBlob) {
const signed = await signUpload.mutateAsync({
contentType: 'image/jpeg',
byteSize: photoBlob.size,
category: 'quality',
});
const res = await fetch(signed.uploadUrl, {
method: 'PUT',
body: photoBlob,
headers: { 'Content-Type': 'image/jpeg' },
});
if (!res.ok) throw new Error(`Photo PUT ${res.status}`);
photoKey = signed.photoKey;
}
await create.mutateAsync({
workstationId,
defectType: defectType.trim(),
location: location.trim() || undefined,
rfsCode: rfsCode.trim() || undefined,
description: description.trim(),
photoKey,
});
reset();
onCreated();
} catch {
setError(t('form.submitError'));
} finally {
setSubmitting(false);
}
}
const canSubmit =
workstationId !== '' &&
defectType.trim().length >= 1 &&
description.trim().length >= 3 &&
!submitting;
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4 shadow-sm"
>
<h2 className="text-sm font-semibold">{t('newDefect')}</h2>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{t('form.workstation')}</span>
<select
value={workstationId}
onChange={(e) => setWorkstationId(e.target.value)}
required
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
>
<option value="">{t('form.workstationPlaceholder')}</option>
{workstations.map((ws) => (
<option key={ws.id} value={ws.id}>
{ws.code} {ws.name} · {ws.area}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{t('form.defectType')}</span>
<input
type="text"
value={defectType}
onChange={(e) => setDefectType(e.target.value)}
required
maxLength={100}
placeholder={t('form.defectTypePlaceholder')}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{t('form.location')}</span>
<input
type="text"
value={location}
onChange={(e) => setLocation(e.target.value)}
maxLength={200}
placeholder={t('form.locationPlaceholder')}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
/>
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{t('form.rfs')}</span>
<input
type="text"
value={rfsCode}
onChange={(e) => setRfsCode(e.target.value)}
maxLength={100}
placeholder={t('form.rfsPlaceholder')}
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
/>
</label>
</div>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium">{t('form.description')}</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
required
minLength={3}
maxLength={1000}
rows={2}
placeholder={t('form.descriptionPlaceholder')}
className="resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm"
/>
</label>
{/* Photo */}
<div className="flex items-center gap-3">
{photoPreview ? (
<div className="relative">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={photoPreview} alt="" className="h-16 w-16 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-3 w-3" />
</button>
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
className="flex items-center gap-2 rounded-lg border border-dashed border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
>
<Camera className="h-4 w-4" />
{t('form.photoButton')}
</button>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={handlePhotoChange}
/>
<button
type="submit"
disabled={!canSubmit}
className="ml-auto flex items-center gap-1.5 rounded-lg bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-40"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <AlertTriangle className="h-4 w-4" />}
{submitting ? t('form.submitting') : t('form.submit')}
</button>
</div>
{error && (
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
)}
</form>
);
}
// ── Defect card ───────────────────────────────────────────────────────────────
function DefectCard({
defect,
t,
tTime,
}: {
defect: Defect;
t: ReturnType<typeof useTranslations<'quality'>>;
tTime: TFn;
}) {
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('photoAlt')} />
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<p className="font-medium">
{defect.defectType}
<span className="ml-1 text-xs text-muted-foreground">
· {defect.workstation.code}
</span>
</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 line-clamp-2 text-sm text-muted-foreground">{defect.description}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 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('createdBy', { email: defect.createdBy.email, time: timeAgo(defect.createdAt, tTime) })}</span>
</div>
{defect.acknowledgedBy && defect.acknowledgedAt && (
<p className="mt-0.5 text-xs text-blue-700">
{t('acknowledgedBy', { email: defect.acknowledgedBy.email, time: timeAgo(defect.acknowledgedAt, tTime) })}
</p>
)}
{defect.correctedBy && defect.correctedAt && (
<p className="mt-0.5 text-xs text-green-700">
{t('correctedBy', { email: defect.correctedBy.email, time: timeAgo(defect.correctedAt, tTime) })}
</p>
)}
</div>
</div>
</div>
);
}
// ── Console ───────────────────────────────────────────────────────────────────
export function QualityConsole({ canCreate }: { canCreate: boolean }) {
const t = useTranslations('quality');
const tTime = useTranslations('common.timeAgo');
const [statuses, setStatuses] = useState<DefectStatus[]>(['OPEN', 'ACKNOWLEDGED']);
const { data: defects = [], refetch } = trpc.qualityDefect.queue.useQuery(
{ statuses: statuses.length > 0 ? statuses : undefined },
{ refetchInterval: 5000, refetchIntervalInBackground: false },
);
function toggleStatus(s: DefectStatus) {
setStatuses((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
}
return (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-card px-4 py-3">
<div className="mx-auto flex max-w-4xl items-center justify-between gap-4">
<h1 className="flex items-center gap-2 text-lg font-bold">
<ClipboardList className="h-5 w-5" />
{t('consoleTitle')}
</h1>
<div className="flex items-center gap-2 text-sm">
<Link
href="/maintenance"
className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent"
>
<Wrench className="h-3 w-3" />
{t('backToMaintenance')}
</Link>
<LanguageSwitcher />
</div>
</div>
</header>
<main className="mx-auto flex max-w-4xl flex-col gap-4 p-4">
{canCreate && <NewDefectForm onCreated={() => refetch()} t={t} />}
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<span className="text-xs text-muted-foreground">{t('filterStatus')}</span>
{(['OPEN', 'ACKNOWLEDGED', 'CORRECTED'] as DefectStatus[]).map((s) => (
<label key={s} className="flex cursor-pointer items-center gap-1.5 text-sm">
<input
type="checkbox"
checked={statuses.includes(s)}
onChange={() => toggleStatus(s)}
className="rounded"
/>
{t(`status.${s.toLowerCase() as 'open' | 'acknowledged' | 'corrected'}`)}
</label>
))}
<span className="ml-auto text-xs text-muted-foreground">{t('updatesEvery')}</span>
</div>
{/* Queue */}
<section>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('queueTitle')}</h2>
{defects.length === 0 ? (
<div className="py-12 text-center text-muted-foreground">
<ClipboardList className="mx-auto mb-3 h-10 w-10 opacity-30" />
<p>{t('empty')}</p>
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{defects.map((defect) => (
<DefectCard key={defect.id} defect={defect} t={t} tTime={tTime} />
))}
</div>
)}
</section>
</main>
</div>
);
}

View File

@ -23,7 +23,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const u = await authenticateCredential({ const u = await authenticateCredential({
email, email,
secret: password, secret: password,
allowedRoles: ['ADMIN', 'SUPERVISOR'], allowedRoles: ['ADMIN', 'SUPERVISOR', 'QUALITY'],
}); });
if (!u) return null; if (!u) return null;
return { return {

View File

@ -51,7 +51,48 @@
"resolveNoteLabel": "Resolution note (optional)", "resolveNoteLabel": "Resolution note (optional)",
"resolveNotePlaceholder": "Describe what was done…", "resolveNotePlaceholder": "Describe what was done…",
"documentTitleWithCount": "({count}) FieldOps — Maintenance", "documentTitleWithCount": "({count}) FieldOps — Maintenance",
"documentTitle": "FieldOps — Maintenance" "documentTitle": "FieldOps — Maintenance",
"qualityLink": "Quality"
},
"quality": {
"consoleTitle": "Quality defects",
"newDefect": "New defect",
"queueTitle": "Raised defects",
"backToMaintenance": "Maintenance",
"filterStatus": "Status:",
"updatesEvery": "Updates every 5s",
"empty": "No defects match the current filters.",
"photoAlt": "Defect photo",
"location": "Location",
"rfs": "RFS",
"createdBy": "Raised by {email} · {time}",
"acknowledgedBy": "Acknowledged by {email} · {time}",
"correctedBy": "Corrected by {email} · {time}",
"documentTitle": "FieldOps — Quality",
"status": {
"open": "Unacknowledged",
"acknowledged": "Correcting",
"corrected": "Corrected"
},
"form": {
"workstation": "Workstation",
"workstationPlaceholder": "Select a workstation…",
"defectType": "Defect type",
"defectTypePlaceholder": "e.g. Torque out of spec",
"location": "Location (optional)",
"locationPlaceholder": "e.g. Front-left seat",
"rfs": "RFS code (optional)",
"rfsPlaceholder": "e.g. RFS-1042",
"description": "Description",
"descriptionPlaceholder": "Describe the detected defect…",
"photo": "Photo (optional)",
"photoButton": "Choose photo",
"photoChange": "Change photo",
"submit": "Raise defect",
"submitting": "Raising…",
"submitError": "Error raising defect. Please try again.",
"photoError": "Could not process the photo."
}
}, },
"report": { "report": {
"pageTitle": "FieldOps — Shift report", "pageTitle": "FieldOps — Shift report",

View File

@ -51,7 +51,48 @@
"resolveNoteLabel": "Nota de resolução (opcional)", "resolveNoteLabel": "Nota de resolução (opcional)",
"resolveNotePlaceholder": "Descreve o que foi feito…", "resolveNotePlaceholder": "Descreve o que foi feito…",
"documentTitleWithCount": "({count}) FieldOps — Manutenção", "documentTitleWithCount": "({count}) FieldOps — Manutenção",
"documentTitle": "FieldOps — Manutenção" "documentTitle": "FieldOps — Manutenção",
"qualityLink": "Qualidade"
},
"quality": {
"consoleTitle": "Defeitos de qualidade",
"newDefect": "Novo defeito",
"queueTitle": "Defeitos lançados",
"backToMaintenance": "Manutenção",
"filterStatus": "Estado:",
"updatesEvery": "Atualiza a cada 5s",
"empty": "Nenhum defeito com os filtros atuais.",
"photoAlt": "Foto do defeito",
"location": "Localização",
"rfs": "RFS",
"createdBy": "Lançado por {email} · {time}",
"acknowledgedBy": "Reconhecido por {email} · {time}",
"correctedBy": "Corrigido por {email} · {time}",
"documentTitle": "FieldOps — Qualidade",
"status": {
"open": "Por reconhecer",
"acknowledged": "Em correção",
"corrected": "Corrigido"
},
"form": {
"workstation": "Posto",
"workstationPlaceholder": "Seleciona um posto…",
"defectType": "Tipo de defeito",
"defectTypePlaceholder": "Ex.: Aperto não conforme",
"location": "Localização (opcional)",
"locationPlaceholder": "Ex.: Banco dianteiro esquerdo",
"rfs": "Código RFS (opcional)",
"rfsPlaceholder": "Ex.: RFS-1042",
"description": "Descrição",
"descriptionPlaceholder": "Descreve o defeito detetado…",
"photo": "Foto (opcional)",
"photoButton": "Escolher foto",
"photoChange": "Trocar foto",
"submit": "Lançar defeito",
"submitting": "A lançar…",
"submitError": "Erro ao lançar defeito. Tenta de novo.",
"photoError": "Não foi possível processar a foto."
}
}, },
"report": { "report": {
"pageTitle": "FieldOps — Relatório de turno", "pageTitle": "FieldOps — Relatório de turno",

View File

@ -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>
);
}

View File

@ -3,7 +3,7 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; 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 { useTranslations } from 'next-intl';
import { trpc } from '@/lib/trpc/client'; import { trpc } from '@/lib/trpc/client';
import { db } from '@/lib/queue/db'; import { db } from '@/lib/queue/db';
@ -48,17 +48,17 @@ export default function NewRequestPage() {
const router = useRouter(); const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [workstationId, setWorkstationId] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [photoBlob, setPhotoBlob] = useState<Blob | null>(null); const [photoBlob, setPhotoBlob] = useState<Blob | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null); const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery( // Workstation is no longer chosen per-request: it comes from the operator's
undefined, // active badge-in session. It still travels in the queued payload so offline
{ staleTime: 60 * 60 * 1000 }, // 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>) { async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@ -121,28 +121,24 @@ export default function NewRequestPage() {
</header> </header>
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4"> <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"> <div className="flex flex-col gap-1.5">
<label htmlFor="workstation" className="text-sm font-medium"> <span className="text-sm font-medium">{t('workstationLabel')}</span>
{t('workstationLabel')} <span className="text-destructive">{t('workstationRequired')}</span> {sessionLoading ? (
</label> <p className="text-sm text-muted-foreground">{t('workstationLoading')}</p>
<select ) : session ? (
id="workstation" <div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2.5 text-sm">
value={workstationId} <MapPin className="h-4 w-4 shrink-0 text-primary" />
onChange={(e) => setWorkstationId(e.target.value)} <span className="font-medium">
required {session.workstation.code} {session.workstation.name}
disabled={wsLoading} <span className="text-xs text-muted-foreground"> · {session.workstation.area}</span>
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50" </span>
> </div>
<option value=""> ) : (
{wsLoading ? t('workstationLoading') : t('workstationPlaceholder')} <p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
</option> {t('noSession')}
{workstations.map((ws) => ( </p>
<option key={ws.id} value={ws.id}> )}
{ws.code} {ws.name} · {ws.area}
</option>
))}
</select>
</div> </div>
{/* Photo */} {/* Photo */}

View File

@ -1,5 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { Wrench } from 'lucide-react'; import { Wrench, ClipboardCheck, ChevronRight } from 'lucide-react';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { resolveUser } from '@/lib/auth'; import { resolveUser } from '@/lib/auth';
import { api } from '@/lib/trpc/server'; import { api } from '@/lib/trpc/server';
@ -7,50 +7,123 @@ import { SignOutButton } from './sign-out-button';
import { StatusBadge } from './status-badge'; import { StatusBadge } from './status-badge';
import { SyncChip } from './sync-chip'; import { SyncChip } from './sync-chip';
import { LanguageSwitcher } from './language-switcher'; import { LanguageSwitcher } from './language-switcher';
import { BadgeInPanel } from './badge-in-panel';
import { SessionBar } from './session-bar';
export default async function HomePage() { export default async function HomePage() {
const t = await getTranslations('home'); const t = await getTranslations('home');
const user = await resolveUser(); 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]; type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
let recent: RecentItem[] = []; let recent: RecentItem[] = [];
try { try {
recent = await api.maintenanceRequest.myRecent({ limit: 5 }); recent = await api.maintenanceRequest.myRecent({ limit: 5 });
} catch { } 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 ( return (
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background"> <main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
{/* ── Header ── */} {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>
<div className="flex flex-1 flex-col gap-6 p-4"> <div className="flex flex-1 flex-col gap-6 p-4">
{/* ── Sync status ── */}
<SyncChip /> <SyncChip />
{/* ── Primary CTA ── */} {/* Current workstation + badge-out */}
<Link <SessionBar
href="/maintenance/new" code={session.workstation.code}
data-testid="btn-request-maintenance" name={session.workstation.name}
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]" area={session.workstation.area}
> />
<Wrench className="h-6 w-6" />
{t('requestMaintenance')}
</Link>
{/* ── 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> <section>
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('myRequests')}</h2> <h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('myRequests')}</h2>

View File

@ -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>
);
}

View File

@ -4,7 +4,8 @@ import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { signIn } from 'next-auth/react'; 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 { interface Operator {
id: string; id: string;
@ -13,7 +14,8 @@ interface Operator {
type PickerState = type PickerState =
| { step: 'list' } | { step: 'list' }
| { step: 'pin'; operator: Operator }; | { step: 'pin'; operator: Operator }
| { step: 'workstation'; operator: Operator };
const PIN_MIN = 4; const PIN_MIN = 4;
const PIN_MAX = 6; const PIN_MAX = 6;
@ -50,15 +52,16 @@ function OperatorList({
function PinPad({ function PinPad({
operator, operator,
onBack, onBack,
onSuccess,
t, t,
tc, tc,
}: { }: {
operator: Operator; operator: Operator;
onBack: () => void; onBack: () => void;
onSuccess: () => void;
t: ReturnType<typeof useTranslations<'auth'>>; t: ReturnType<typeof useTranslations<'auth'>>;
tc: ReturnType<typeof useTranslations<'common'>>; tc: ReturnType<typeof useTranslations<'common'>>;
}) { }) {
const router = useRouter();
const [digits, setDigits] = useState(''); const [digits, setDigits] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -88,8 +91,8 @@ function PinPad({
setDigits(''); setDigits('');
setError(t('invalidPin')); setError(t('invalidPin'));
} else { } else {
router.push('/'); // Authenticated — next step is binding to a workstation (badge-in).
router.refresh(); onSuccess();
} }
} catch { } catch {
setDigits(''); 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[] }) { export function OperatorPicker({ operators }: { operators: Operator[] }) {
const t = useTranslations('auth'); const t = useTranslations('auth');
const tc = useTranslations('common'); const tc = useTranslations('common');
const ts = useTranslations('session');
const [state, setState] = useState<PickerState>({ step: 'list' }); const [state, setState] = useState<PickerState>({ step: 'list' });
if (state.step === 'pin') { if (state.step === 'pin') {
@ -192,12 +254,17 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
<PinPad <PinPad
operator={state.operator} operator={state.operator}
onBack={() => setState({ step: 'list' })} onBack={() => setState({ step: 'list' })}
onSuccess={() => setState({ step: 'workstation', operator: state.operator })}
t={t} t={t}
tc={tc} tc={tc}
/> />
); );
} }
if (state.step === 'workstation') {
return <WorkstationStep operator={state.operator} ts={ts} />;
}
return ( return (
<OperatorList <OperatorList
operators={operators} operators={operators}

View File

@ -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>
);
}

View File

@ -7,10 +7,18 @@
"common": { "common": {
"enter": "Sign in", "enter": "Sign in",
"entering": "Signing in…", "entering": "Signing in…",
"cancel": "Cancel",
"confirm": "Confirm",
"status": { "status": {
"open": "Open", "open": "Open",
"claimed": "In progress", "claimed": "In progress",
"resolved": "Resolved" "resolved": "Resolved"
},
"timeAgo": {
"now": "just now",
"minutesAgo": "{mins}m ago",
"hoursAgo": "{hours}h ago",
"daysAgo": "{days}d ago"
} }
}, },
"errors": { "errors": {
@ -32,11 +40,26 @@
"deleteDigit": "Delete", "deleteDigit": "Delete",
"switchOperator": "Switch" "switchOperator": "Switch"
}, },
"session": {
"badgeInTitle": "Which workstation are you at?",
"badgeInSubtitle": "Register your workstation to start.",
"loadingStations": "Loading workstations…",
"noStations": "No workstations configured.",
"starting": "Registering…",
"atStation": "At workstation",
"badgeOut": "Badge out",
"badgeInPrompt": "Register your workstation to start working.",
"badgeInButton": "Badge in",
"endError": "Could not badge out. Please try again."
},
"home": { "home": {
"operator": "Operator", "operator": "Operator",
"myRequests": "My requests", "myRequests": "My requests",
"requestMaintenance": "Request maintenance", "requestMaintenance": "Request maintenance",
"noRequests": "No requests yet." "noRequests": "No requests yet.",
"defects": "Quality defects",
"defectsWithCount": "{count, plural, one {# defect to handle} other {# defects to handle}}",
"noDefects": "No defects to handle"
}, },
"sync": { "sync": {
"deadLetters": "{count, plural, one {# request failed — contact your supervisor.} other {# requests failed — contact your supervisor.}}", "deadLetters": "{count, plural, one {# request failed — contact your supervisor.} other {# requests failed — contact your supervisor.}}",
@ -51,6 +74,7 @@
"workstationRequired": "*", "workstationRequired": "*",
"workstationLoading": "Loading workstations…", "workstationLoading": "Loading workstations…",
"workstationPlaceholder": "Select a workstation…", "workstationPlaceholder": "Select a workstation…",
"noSession": "Badge in to a workstation before requesting maintenance.",
"photoLabel": "Photo (optional)", "photoLabel": "Photo (optional)",
"photoPreview": "Preview", "photoPreview": "Preview",
"photoButton": "Take / choose photo", "photoButton": "Take / choose photo",
@ -66,5 +90,31 @@
"sentMessage": "The maintenance team has been notified and will handle the issue.", "sentMessage": "The maintenance team has been notified and will handle the issue.",
"pendingMessage": "Will be sent as soon as the connection is restored.", "pendingMessage": "Will be sent as soon as the connection is restored.",
"backHome": "Back to home" "backHome": "Back to home"
},
"quality": {
"title": "Defects at my station",
"subtitle": "Workstation {code}",
"empty": "No defects at your station. ✓",
"noSession": "Badge in to a workstation to see quality defects.",
"photo": "Defect photo",
"rfs": "RFS",
"location": "Location",
"raised": "Raised by {email} · {time}",
"acknowledge": "Acknowledge",
"acknowledging": "Saving…",
"correct": "Mark corrected",
"correctDialogTitle": "Mark as corrected",
"correctNoteLabel": "Correction note (optional)",
"correctNotePlaceholder": "Describe what was corrected…",
"acknowledgedBy": "Acknowledged · {time}",
"backHome": "Back to home",
"updatesEvery": "Updates every 5s",
"soundOn": "🔔 Sound on",
"soundOff": "🔕 Sound off",
"status": {
"open": "New",
"acknowledged": "Correcting",
"corrected": "Corrected"
}
} }
} }

View File

@ -7,10 +7,18 @@
"common": { "common": {
"enter": "Entrar", "enter": "Entrar",
"entering": "A entrar…", "entering": "A entrar…",
"cancel": "Cancelar",
"confirm": "Confirmar",
"status": { "status": {
"open": "Aberto", "open": "Aberto",
"claimed": "Em curso", "claimed": "Em curso",
"resolved": "Resolvido" "resolved": "Resolvido"
},
"timeAgo": {
"now": "agora",
"minutesAgo": "há {mins}m",
"hoursAgo": "há {hours}h",
"daysAgo": "há {days}d"
} }
}, },
"errors": { "errors": {
@ -32,11 +40,26 @@
"deleteDigit": "Apagar", "deleteDigit": "Apagar",
"switchOperator": "Trocar" "switchOperator": "Trocar"
}, },
"session": {
"badgeInTitle": "Em que posto estás?",
"badgeInSubtitle": "Regista o teu posto para começar.",
"loadingStations": "A carregar postos…",
"noStations": "Nenhum posto configurado.",
"starting": "A registar…",
"atStation": "No posto",
"badgeOut": "Sair do posto",
"badgeInPrompt": "Regista o teu posto para começar a trabalhar.",
"badgeInButton": "Entrar no posto",
"endError": "Não foi possível sair do posto. Tenta de novo."
},
"home": { "home": {
"operator": "Operador", "operator": "Operador",
"myRequests": "Os meus pedidos", "myRequests": "Os meus pedidos",
"requestMaintenance": "Pedir manutenção", "requestMaintenance": "Pedir manutenção",
"noRequests": "Nenhum pedido ainda." "noRequests": "Nenhum pedido ainda.",
"defects": "Defeitos de qualidade",
"defectsWithCount": "{count, plural, one {# defeito por tratar} other {# defeitos por tratar}}",
"noDefects": "Sem defeitos por tratar"
}, },
"sync": { "sync": {
"deadLetters": "{count, plural, one {# pedido com erro — contacta o supervisor.} other {# pedidos com erro — contacta o supervisor.}}", "deadLetters": "{count, plural, one {# pedido com erro — contacta o supervisor.} other {# pedidos com erro — contacta o supervisor.}}",
@ -51,6 +74,7 @@
"workstationRequired": "*", "workstationRequired": "*",
"workstationLoading": "A carregar postos…", "workstationLoading": "A carregar postos…",
"workstationPlaceholder": "Seleciona um posto…", "workstationPlaceholder": "Seleciona um posto…",
"noSession": "Entra num posto antes de pedir manutenção.",
"photoLabel": "Foto (opcional)", "photoLabel": "Foto (opcional)",
"photoPreview": "Pré-visualização", "photoPreview": "Pré-visualização",
"photoButton": "Tirar / escolher foto", "photoButton": "Tirar / escolher foto",
@ -66,5 +90,31 @@
"sentMessage": "A equipa de manutenção foi notificada e irá tratar do problema.", "sentMessage": "A equipa de manutenção foi notificada e irá tratar do problema.",
"pendingMessage": "Será enviado assim que a ligação for restabelecida.", "pendingMessage": "Será enviado assim que a ligação for restabelecida.",
"backHome": "Voltar ao início" "backHome": "Voltar ao início"
},
"quality": {
"title": "Defeitos do meu posto",
"subtitle": "Posto {code}",
"empty": "Sem defeitos no teu posto. ✓",
"noSession": "Entra num posto para veres os defeitos de qualidade.",
"photo": "Foto do defeito",
"rfs": "RFS",
"location": "Localização",
"raised": "Lançado por {email} · {time}",
"acknowledge": "Tomei conhecimento",
"acknowledging": "A registar…",
"correct": "Marcar corrigido",
"correctDialogTitle": "Marcar como corrigido",
"correctNoteLabel": "Nota de correção (opcional)",
"correctNotePlaceholder": "Descreve o que foi corrigido…",
"acknowledgedBy": "Reconhecido · {time}",
"backHome": "Voltar ao início",
"updatesEvery": "Atualiza a cada 5s",
"soundOn": "🔔 Som on",
"soundOff": "🔕 Som off",
"status": {
"open": "Novo",
"acknowledged": "Em correção",
"corrected": "Corrigido"
}
} }
} }

View File

@ -14,18 +14,23 @@ test('MAI CALL happy path: create → claim → resolve', async ({ page, context
// Use a unique description so the test can find its own card in the queue. // Use a unique description so the test can find its own card in the queue.
const desc = `E2E ${Date.now()} — ruído anormal no posto`; const desc = `E2E ${Date.now()} — ruído anormal no posto`;
// ── 0. Badge in to a workstation (the request posto now comes from the
// operator's active session, not a per-request dropdown) ────────────
await page.goto('/');
const requestBtn = page.getByTestId('btn-request-maintenance');
if (!(await requestBtn.isVisible().catch(() => false))) {
// No active session yet → pick the first workstation in the badge-in panel.
await page.getByTestId('badge-in-station').first().click();
await expect(requestBtn).toBeVisible({ timeout: 15_000 });
}
// ── 1. Operator creates a request ──────────────────────────────────────── // ── 1. Operator creates a request ────────────────────────────────────────
await page.goto('/maintenance/new'); await requestBtn.click();
await page.waitForURL('**/maintenance/new**');
// Wait for workstation options to load (select is disabled while loading)
await expect(page.locator('#workstation')).toBeEnabled({ timeout: 15_000 });
// Select the first real workstation
const firstOpt = page.locator('#workstation option:not([value=""])').first();
const wsValue = await firstOpt.getAttribute('value');
await page.selectOption('#workstation', wsValue!);
// The workstation is shown read-only from the session; just describe + submit.
await page.fill('#description', desc); await page.fill('#description', desc);
await expect(page.locator('button[type=submit]')).toBeEnabled({ timeout: 15_000 });
await page.click('button[type=submit]'); await page.click('button[type=submit]');
// ── 2. Wait for the sync to complete ───────────────────────────────────── // ── 2. Wait for the sync to complete ─────────────────────────────────────

View File

@ -11,7 +11,7 @@ import { logger } from './logger';
export type SessionUser = { export type SessionUser = {
id: string; id: string;
email: string; email: string;
role: 'ADMIN' | 'SUPERVISOR' | 'OPERATOR'; role: 'ADMIN' | 'SUPERVISOR' | 'QUALITY' | 'OPERATOR';
tenantId: string; tenantId: string;
}; };

View File

@ -4,6 +4,8 @@ import { workstationRouter } from './workstation';
import { userRouter } from './user'; import { userRouter } from './user';
import { storageRouter } from './storage'; import { storageRouter } from './storage';
import { maintenanceRequestRouter } from './maintenance-request'; import { maintenanceRequestRouter } from './maintenance-request';
import { operatorSessionRouter } from './operator-session';
import { qualityDefectRouter } from './quality-defect';
export const appRouter = router({ export const appRouter = router({
ping: pingRouter, ping: pingRouter,
@ -11,6 +13,8 @@ export const appRouter = router({
user: userRouter, user: userRouter,
storage: storageRouter, storage: storageRouter,
maintenanceRequest: maintenanceRequestRouter, maintenanceRequest: maintenanceRequestRouter,
operatorSession: operatorSessionRouter,
qualityDefect: qualityDefectRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@ -0,0 +1,53 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { protectedProcedure, router } from '../trpc';
const SESSION_INCLUDE = {
workstation: { select: { id: true, code: true, name: true, area: true } },
} as const;
// Prisma's interactive-transaction client does not support $extends, so
// tenantId is injected manually into each where/data clause inside $transaction.
export const operatorSessionRouter = router({
/** The authenticated user's active session (badge-in), or null if not badged in. */
current: protectedProcedure.query(({ ctx }) => {
return ctx.db.operatorSession.findFirst({
where: { userId: ctx.user.id, endedAt: null },
include: SESSION_INCLUDE,
orderBy: { startedAt: 'desc' },
});
}),
/** Badge-in at a workstation. Ends any previous active session for this user. */
start: protectedProcedure
.input(z.object({ workstationId: z.string().cuid() }))
.mutation(async ({ ctx, input }) => {
const tid = ctx.tenantId;
const ws = await ctx.db.workstation.findFirst({
where: { id: input.workstationId },
select: { id: true },
});
if (!ws) throw new TRPCError({ code: 'NOT_FOUND', message: 'Workstation not found.' });
return ctx.prisma.$transaction(async (tx) => {
await tx.operatorSession.updateMany({
where: { tenantId: tid, userId: ctx.user.id, endedAt: null },
data: { endedAt: new Date() },
});
return tx.operatorSession.create({
data: { tenantId: tid, userId: ctx.user.id, workstationId: input.workstationId },
include: SESSION_INCLUDE,
});
});
}),
/** Badge-out: end the active session, if any. Idempotent. */
end: protectedProcedure.mutation(async ({ ctx }) => {
await ctx.db.operatorSession.updateMany({
where: { userId: ctx.user.id, endedAt: null },
data: { endedAt: new Date() },
});
return { ok: true };
}),
});

View File

@ -0,0 +1,179 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { protectedProcedure, requireRole, router } from '../trpc';
const photoKeySchema = z
.string()
.regex(/^tenants\/[a-z0-9-]+\/quality\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/);
const statusSchema = z.enum(['OPEN', 'ACKNOWLEDGED', 'CORRECTED']);
const DEFECT_INCLUDE = {
workstation: { select: { id: true, code: true, name: true, area: true } },
createdBy: { select: { id: true, email: true } },
acknowledgedBy: { select: { id: true, email: true } },
correctedBy: { select: { id: true, email: true } },
} as const;
// Prisma's interactive-transaction client does not support $extends, so
// tenantId is injected manually into each where/data clause inside $transaction.
export const qualityDefectRouter = router({
/** QCP raises a defect against a workstation. */
create: requireRole('QUALITY', 'ADMIN')
.input(
z.object({
workstationId: z.string().cuid(),
defectType: z.string().trim().min(1).max(100),
location: z.string().trim().max(200).optional(),
description: z.string().trim().min(3).max(1000),
rfsCode: z.string().trim().max(100).optional(),
photoKey: photoKeySchema.optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const tid = ctx.tenantId;
return ctx.prisma.$transaction(async (tx) => {
const defect = await tx.qualityDefect.create({
data: {
tenantId: tid,
workstationId: input.workstationId,
createdByUserId: ctx.user.id,
defectType: input.defectType,
location: input.location,
description: input.description,
rfsCode: input.rfsCode,
photoKey: input.photoKey,
},
include: DEFECT_INCLUDE,
});
await tx.domainEvent.create({
data: {
tenantId: tid,
aggregateType: 'QualityDefect',
aggregateId: defect.id,
eventType: 'created',
payload: { workstationId: input.workstationId, createdByUserId: ctx.user.id },
},
});
return defect;
});
}),
/** QCP / admin queue of defects across the plant. */
queue: requireRole('QUALITY', 'ADMIN', 'SUPERVISOR')
.input(
z.object({
statuses: z.array(statusSchema).optional(),
limit: z.number().int().min(1).max(100).default(50),
}),
)
.query(({ ctx, input }) => {
return ctx.db.qualityDefect.findMany({
where: { ...(input.statuses?.length ? { status: { in: input.statuses } } : {}) },
include: DEFECT_INCLUDE,
orderBy: { createdAt: 'desc' },
take: input.limit,
});
}),
/** Operator: defects routed to my active session's workstation. Empty if not badged in. */
forMyStation: protectedProcedure
.input(z.object({ statuses: z.array(statusSchema).optional() }).optional())
.query(async ({ ctx, input }) => {
const session = await ctx.db.operatorSession.findFirst({
where: { userId: ctx.user.id, endedAt: null },
orderBy: { startedAt: 'desc' },
select: { workstationId: true },
});
if (!session) return [];
const statuses = input?.statuses ?? (['OPEN', 'ACKNOWLEDGED'] as const);
return ctx.db.qualityDefect.findMany({
where: { workstationId: session.workstationId, status: { in: [...statuses] } },
include: DEFECT_INCLUDE,
orderBy: { createdAt: 'desc' },
});
}),
/** Operator acknowledges a defect (OPEN -> ACKNOWLEDGED). */
acknowledge: protectedProcedure
.input(z.object({ id: z.string().cuid() }))
.mutation(async ({ ctx, input }) => {
const tid = ctx.tenantId;
return ctx.prisma.$transaction(async (tx) => {
const existing = await tx.qualityDefect.findFirst({
where: { id: input.id, tenantId: tid },
select: { status: true },
});
if (!existing) throw new TRPCError({ code: 'NOT_FOUND' });
if (existing.status !== 'OPEN') {
throw new TRPCError({
code: 'CONFLICT',
message: `Cannot acknowledge: status is ${existing.status}, expected OPEN.`,
});
}
const updated = await tx.qualityDefect.update({
where: { id: input.id },
data: {
status: 'ACKNOWLEDGED',
acknowledgedByUserId: ctx.user.id,
acknowledgedAt: new Date(),
},
include: DEFECT_INCLUDE,
});
await tx.domainEvent.create({
data: {
tenantId: tid,
aggregateType: 'QualityDefect',
aggregateId: input.id,
eventType: 'acknowledged',
payload: { acknowledgedByUserId: ctx.user.id },
},
});
return updated;
});
}),
/** Operator marks a defect corrected (ACKNOWLEDGED -> CORRECTED). */
correct: protectedProcedure
.input(z.object({ id: z.string().cuid(), correctionNote: z.string().trim().max(2000).optional() }))
.mutation(async ({ ctx, input }) => {
const tid = ctx.tenantId;
return ctx.prisma.$transaction(async (tx) => {
const existing = await tx.qualityDefect.findFirst({
where: { id: input.id, tenantId: tid },
select: { status: true },
});
if (!existing) throw new TRPCError({ code: 'NOT_FOUND' });
if (existing.status !== 'ACKNOWLEDGED') {
throw new TRPCError({
code: 'CONFLICT',
message: `Cannot correct: status is ${existing.status}, expected ACKNOWLEDGED.`,
});
}
const updated = await tx.qualityDefect.update({
where: { id: input.id },
data: {
status: 'CORRECTED',
correctedByUserId: ctx.user.id,
correctedAt: new Date(),
correctionNote: input.correctionNote,
},
include: DEFECT_INCLUDE,
});
await tx.domainEvent.create({
data: {
tenantId: tid,
aggregateType: 'QualityDefect',
aggregateId: input.id,
eventType: 'corrected',
payload: {
correctedByUserId: ctx.user.id,
correctionNote: input.correctionNote ?? null,
},
},
});
return updated;
});
}),
});

View File

@ -12,7 +12,7 @@ const EXT_MAP: Record<(typeof CONTENT_TYPES)[number], string> = {
const photoKeySchema = z const photoKeySchema = z
.string() .string()
.regex(/^tenants\/[a-z0-9-]+\/maintenance\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/); .regex(/^tenants\/[a-z0-9-]+\/(maintenance|quality)\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/);
export const storageRouter = router({ export const storageRouter = router({
signPhotoUpload: protectedProcedure signPhotoUpload: protectedProcedure
@ -20,11 +20,14 @@ export const storageRouter = router({
z.object({ z.object({
contentType: z.enum(CONTENT_TYPES), contentType: z.enum(CONTENT_TYPES),
byteSize: z.number().int().min(1).max(10 * 1024 * 1024), byteSize: z.number().int().min(1).max(10 * 1024 * 1024),
// Logical bucket inside the tenant prefix. Defaults to 'maintenance' so
// existing MAI CALL callers need no change; MY QUALITY uses 'quality'.
category: z.enum(['maintenance', 'quality']).default('maintenance'),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const ext = EXT_MAP[input.contentType]; const ext = EXT_MAP[input.contentType];
const photoKey = `tenants/${ctx.tenantId}/maintenance/${randomUUID()}.${ext}`; const photoKey = `tenants/${ctx.tenantId}/${input.category}/${randomUUID()}.${ext}`;
const storage = makeStorage(); const storage = makeStorage();
const { url: uploadUrl, expiresAt } = await storage.signPut( const { url: uploadUrl, expiresAt } = await storage.signPut(
photoKey, photoKey,

View File

@ -0,0 +1,78 @@
-- CreateEnum
CREATE TYPE "QualityDefectStatus" AS ENUM ('OPEN', 'ACKNOWLEDGED', 'CORRECTED');
-- AlterEnum
ALTER TYPE "UserRole" ADD VALUE 'QUALITY';
-- CreateTable
CREATE TABLE "OperatorSession" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"workstationId" TEXT NOT NULL,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
CONSTRAINT "OperatorSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "QualityDefect" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"workstationId" TEXT NOT NULL,
"createdByUserId" TEXT NOT NULL,
"defectType" TEXT NOT NULL,
"location" TEXT,
"description" TEXT NOT NULL,
"rfsCode" TEXT,
"photoKey" TEXT,
"status" "QualityDefectStatus" NOT NULL DEFAULT 'OPEN',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"acknowledgedByUserId" TEXT,
"acknowledgedAt" TIMESTAMP(3),
"correctedByUserId" TEXT,
"correctedAt" TIMESTAMP(3),
"correctionNote" TEXT,
CONSTRAINT "QualityDefect_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_idx" ON "OperatorSession"("tenantId");
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_userId_endedAt_idx" ON "OperatorSession"("tenantId", "userId", "endedAt");
-- CreateIndex
CREATE INDEX "OperatorSession_tenantId_workstationId_endedAt_idx" ON "OperatorSession"("tenantId", "workstationId", "endedAt");
-- CreateIndex
CREATE INDEX "QualityDefect_tenantId_status_createdAt_idx" ON "QualityDefect"("tenantId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "QualityDefect_tenantId_workstationId_status_idx" ON "QualityDefect"("tenantId", "workstationId", "status");
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OperatorSession" ADD CONSTRAINT "OperatorSession_workstationId_fkey" FOREIGN KEY ("workstationId") REFERENCES "Workstation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_workstationId_fkey" FOREIGN KEY ("workstationId") REFERENCES "Workstation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_acknowledgedByUserId_fkey" FOREIGN KEY ("acknowledgedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "QualityDefect" ADD CONSTRAINT "QualityDefect_correctedByUserId_fkey" FOREIGN KEY ("correctedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -16,6 +16,7 @@ datasource db {
enum UserRole { enum UserRole {
ADMIN ADMIN
SUPERVISOR SUPERVISOR
QUALITY
OPERATOR OPERATOR
} }
@ -25,6 +26,12 @@ enum MaintenanceRequestStatus {
RESOLVED RESOLVED
} }
enum QualityDefectStatus {
OPEN
ACKNOWLEDGED
CORRECTED
}
model Tenant { model Tenant {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
@ -34,6 +41,8 @@ model Tenant {
workstations Workstation[] workstations Workstation[]
events DomainEvent[] events DomainEvent[]
maintenanceRequests MaintenanceRequest[] maintenanceRequests MaintenanceRequest[]
operatorSessions OperatorSession[]
qualityDefects QualityDefect[]
} }
model User { model User {
@ -52,6 +61,11 @@ model User {
claimedRequests MaintenanceRequest[] @relation("claimed") claimedRequests MaintenanceRequest[] @relation("claimed")
resolvedRequests MaintenanceRequest[] @relation("resolved") resolvedRequests MaintenanceRequest[] @relation("resolved")
sessions OperatorSession[]
createdDefects QualityDefect[] @relation("defectCreated")
acknowledgedDefects QualityDefect[] @relation("defectAcknowledged")
correctedDefects QualityDefect[] @relation("defectCorrected")
@@unique([tenantId, email]) @@unique([tenantId, email])
@@index([tenantId]) @@index([tenantId])
} }
@ -65,6 +79,8 @@ model Workstation {
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
maintenanceRequests MaintenanceRequest[] maintenanceRequests MaintenanceRequest[]
operatorSessions OperatorSession[]
qualityDefects QualityDefect[]
@@unique([tenantId, code]) @@unique([tenantId, code])
@@index([tenantId]) @@index([tenantId])
@ -115,3 +131,58 @@ model MaintenanceRequest {
@@index([tenantId, status, createdAt]) @@index([tenantId, status, createdAt])
@@index([tenantId, reportedByUserId]) @@index([tenantId, reportedByUserId])
} }
/// MY QUALITY — an operator's active binding to a workstation ("badge-in").
/// At most one active session (endedAt == null) per user; starting a new one
/// ends the previous. Quality defects route to whoever has the active session
/// at the targeted workstation.
model OperatorSession {
id String @id @default(cuid())
tenantId String
userId String
workstationId String
startedAt DateTime @default(now())
endedAt DateTime?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
workstation Workstation @relation(fields: [workstationId], references: [id])
@@index([tenantId])
@@index([tenantId, userId, endedAt])
@@index([tenantId, workstationId, endedAt])
}
/// MY QUALITY — a quality defect raised by QCP against a workstation, routed to
/// the operator currently bound there. Mirrors MaintenanceRequest but in the
/// opposite direction (quality -> operator). State: OPEN -> ACKNOWLEDGED ->
/// CORRECTED.
model QualityDefect {
id String @id @default(cuid())
tenantId String
workstationId String
createdByUserId String
defectType String
location String?
description String
rfsCode String?
photoKey String?
status QualityDefectStatus @default(OPEN)
createdAt DateTime @default(now())
acknowledgedByUserId String?
acknowledgedAt DateTime?
correctedByUserId String?
correctedAt DateTime?
correctionNote String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
workstation Workstation @relation(fields: [workstationId], references: [id])
createdBy User @relation("defectCreated", fields: [createdByUserId], references: [id])
acknowledgedBy User? @relation("defectAcknowledged", fields: [acknowledgedByUserId], references: [id])
correctedBy User? @relation("defectCorrected", fields: [correctedByUserId], references: [id])
@@index([tenantId, status, createdAt])
@@index([tenantId, workstationId, status])
}

View File

@ -15,6 +15,8 @@ const prisma = new PrismaClient();
const DEMO_TENANT_NAME = 'Demo Factory'; const DEMO_TENANT_NAME = 'Demo Factory';
const DEMO_ADMIN_EMAIL = 'admin@demo.local'; const DEMO_ADMIN_EMAIL = 'admin@demo.local';
const DEMO_ADMIN_PASSWORD = 'admin1234'; const DEMO_ADMIN_PASSWORD = 'admin1234';
const DEMO_QCP_EMAIL = 'qcp@demo.local';
const DEMO_QCP_PASSWORD = 'qcp1234';
const OPERATORS = [ const OPERATORS = [
{ email: 'op1@demo.local', pin: '1111' }, { email: 'op1@demo.local', pin: '1111' },
@ -49,6 +51,15 @@ async function main() {
}, },
}); });
await prisma.user.create({
data: {
tenantId: tenant.id,
email: DEMO_QCP_EMAIL,
role: UserRole.QUALITY,
passwordHash: await hashSecret(DEMO_QCP_PASSWORD),
},
});
for (const op of OPERATORS) { for (const op of OPERATORS) {
await prisma.user.create({ await prisma.user.create({
data: { data: {
@ -161,6 +172,77 @@ async function main() {
} }
console.warn(` pedidos de exemplo: ${samples.length} criados`); console.warn(` pedidos de exemplo: ${samples.length} criados`);
// MY QUALITY — op1 is badged-in at the first workstation, and QCP has
// raised a few defects there so the operator's alerts and the QCP queue
// are non-empty on first boot.
const station = wsList[0]!;
const qcpUser = await prisma.user.findFirst({
where: { tenantId: tenant.id, email: DEMO_QCP_EMAIL },
});
await prisma.operatorSession.create({
data: {
tenantId: tenant.id,
userId: op1User.id,
workstationId: station.id,
startedAt: ago(120),
},
});
if (qcpUser) {
const defects = [
// OPEN — operator hasn't seen it yet
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Aperto não conforme',
location: 'Banco dianteiro esquerdo',
description: 'Binário fora de especificação no parafuso da calha.',
rfsCode: 'RFS-1042',
status: 'OPEN' as const,
createdAt: ago(8),
},
// ACKNOWLEDGED — operator saw it, correcting
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Clip em falta',
location: 'Painel de porta traseira direita',
description: 'Clip de fixação do painel ausente.',
rfsCode: 'RFS-1043',
status: 'ACKNOWLEDGED' as const,
createdAt: ago(35),
acknowledgedByUserId: op1User.id,
acknowledgedAt: ago(30),
},
// CORRECTED — closed loop
{
tenantId: tenant.id,
workstationId: station.id,
createdByUserId: qcpUser.id,
defectType: 'Risco na pintura',
location: 'Capot',
description: 'Risco superficial detetado no controlo visual.',
rfsCode: 'RFS-1041',
status: 'CORRECTED' as const,
createdAt: ago(90),
acknowledgedByUserId: op1User.id,
acknowledgedAt: ago(85),
correctedByUserId: op1User.id,
correctedAt: ago(70),
correctionNote: 'Polimento efetuado, defeito eliminado.',
},
];
for (const d of defects) {
await prisma.qualityDefect.create({ data: d });
}
console.warn(` defeitos de exemplo: ${defects.length} criados (op1 em ${station.code})`);
}
} }
console.warn( console.warn(
@ -169,6 +251,9 @@ async function main() {
console.warn( console.warn(
` admin: ${DEMO_ADMIN_EMAIL} / ${DEMO_ADMIN_PASSWORD}`, ` admin: ${DEMO_ADMIN_EMAIL} / ${DEMO_ADMIN_PASSWORD}`,
); );
console.warn(
` qcp: ${DEMO_QCP_EMAIL} / ${DEMO_QCP_PASSWORD}`,
);
console.warn( console.warn(
` operadores: ${OPERATORS.map((o) => `${o.email}=${o.pin}`).join(' | ')}`, ` operadores: ${OPERATORS.map((o) => `${o.email}=${o.pin}`).join(' | ')}`,
); );

View File

@ -1,5 +1,13 @@
export { prisma, type DbClient } from './client'; export { prisma, type DbClient } from './client';
export { tenantScoped, type TenantScopedClient } from './tenant-extension'; export { tenantScoped, type TenantScopedClient } from './tenant-extension';
export { Prisma, UserRole, MaintenanceRequestStatus } from '@prisma/client'; export { Prisma, UserRole, MaintenanceRequestStatus, QualityDefectStatus } from '@prisma/client';
export type { User, Tenant, Workstation, DomainEvent, MaintenanceRequest } from '@prisma/client'; export type {
User,
Tenant,
Workstation,
DomainEvent,
MaintenanceRequest,
OperatorSession,
QualityDefect,
} from '@prisma/client';
export { hashSecret, verifySecret } from './crypto'; export { hashSecret, verifySecret } from './crypto';

View File

@ -83,7 +83,14 @@ import type { PrismaClient } from '@prisma/client';
* ============================================================================ * ============================================================================
*/ */
const TENANT_SCOPED_MODELS = ['User', 'Workstation', 'DomainEvent', 'MaintenanceRequest'] as const; const TENANT_SCOPED_MODELS = [
'User',
'Workstation',
'DomainEvent',
'MaintenanceRequest',
'OperatorSession',
'QualityDefect',
] as const;
type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
function isTenantScoped(model: string | undefined): model is TenantScopedModel { function isTenantScoped(model: string | undefined): model is TenantScopedModel {

View File

@ -18,6 +18,7 @@
}, },
"devDependencies": { "devDependencies": {
"@repo/config": "workspace:*", "@repo/config": "workspace:*",
"@types/node": "22.19.19",
"rimraf": "^6.0.1", "rimraf": "^6.0.1",
"typescript": "^5.7.2" "typescript": "^5.7.2"
} }

View File

@ -1,6 +1,9 @@
{ {
"extends": "@repo/config/tsconfig/library.json", "extends": "@repo/config/tsconfig/base.json",
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" "noEmit": true,
} "module": "ESNext",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"]
} }

15
pnpm-lock.yaml generated
View File

@ -70,10 +70,10 @@ importers:
version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-auth: next-auth:
specifier: 5.0.0-beta.25 specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
next-intl: next-intl:
specifier: ^4.13.0 specifier: ^4.13.0
version: 4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3) version: 4.13.0(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
pino: pino:
specifier: ^9.5.0 specifier: ^9.5.0
version: 9.14.0 version: 9.14.0
@ -167,10 +167,10 @@ importers:
version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
next-auth: next-auth:
specifier: 5.0.0-beta.25 specifier: 5.0.0-beta.25
version: 5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6) version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
next-intl: next-intl:
specifier: ^4.13.0 specifier: ^4.13.0
version: 4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3) version: 4.13.0(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
pino: pino:
specifier: ^9.5.0 specifier: ^9.5.0
version: 9.14.0 version: 9.14.0
@ -346,6 +346,9 @@ importers:
'@repo/config': '@repo/config':
specifier: workspace:* specifier: workspace:*
version: link:../config version: link:../config
'@types/node':
specifier: 22.19.19
version: 22.19.19
rimraf: rimraf:
specifier: ^6.0.1 specifier: ^6.0.1
version: 6.1.3 version: 6.1.3
@ -7142,7 +7145,7 @@ snapshots:
neo-async@2.6.2: {} neo-async@2.6.2: {}
next-auth@5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6): next-auth@5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
dependencies: dependencies:
'@auth/core': 0.37.2 '@auth/core': 0.37.2
next: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) next: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@ -7150,7 +7153,7 @@ snapshots:
next-intl-swc-plugin-extractor@4.13.0: {} next-intl-swc-plugin-extractor@4.13.0: {}
next-intl@4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3): next-intl@4.13.0(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
dependencies: dependencies:
'@formatjs/intl-localematcher': 0.8.9 '@formatjs/intl-localematcher': 0.8.9
'@parcel/watcher': 2.5.6 '@parcel/watcher': 2.5.6

155
scripts/quality-smoke.ts Normal file
View File

@ -0,0 +1,155 @@
/**
* MY QUALITY smoke test exercises the operatorSession + qualityDefect
* procedures end-to-end via tRPC callers (same pattern as report-smoke.ts):
* QCP raises a defect -> the badged-in operator sees it -> acknowledges ->
* corrects. Plus role guards and state-machine conflicts.
*
* Run: pnpm tsx scripts/quality-smoke.ts
* Requires: Docker Postgres running + pnpm db:seed already done.
* NOTE: this creates a defect and starts/ends op2's session; re-run db:seed
* afterwards for a pristine demo state.
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { config as loadEnv } from 'dotenv';
const here = path.dirname(fileURLToPath(import.meta.url));
loadEnv({ path: path.resolve(here, '../.env') });
import { prisma } from '../packages/db/src/index.js';
import { appRouter, createTRPCContext } from '../packages/api/src/index.js';
import { createCallerFactory } from '../packages/api/src/trpc.js';
let passed = 0;
let failed = 0;
function ok(label: string) {
console.log(`${label}`);
passed++;
}
function fail(label: string, detail?: string) {
console.error(`${label}${detail ? `${detail}` : ''}`);
failed++;
}
function assert(condition: boolean, label: string, detail?: string) {
if (condition) ok(label);
else fail(label, detail);
}
function codeOf(err: unknown): string | undefined {
return (err as { code?: string }).code;
}
async function makeCaller(email: string) {
const user = await prisma.user.findFirst({ where: { email } });
if (!user) throw new Error(`User ${email} not found — run pnpm db:seed`);
const ctx = await createTRPCContext({
user: {
id: user.id,
email: user.email,
role: user.role as 'ADMIN' | 'SUPERVISOR' | 'QUALITY' | 'OPERATOR',
tenantId: user.tenantId,
},
headers: new Headers(),
});
return createCallerFactory(appRouter)(ctx);
}
async function main() {
console.log('\nMY QUALITY smoke — running assertions against the real procedures…\n');
const qcp = await makeCaller('qcp@demo.local');
const op1 = await makeCaller('op1@demo.local');
const op2 = await makeCaller('op2@demo.local');
// 1. op1 has a seeded active session at CTR04
const session = await op1.operatorSession.current();
assert(!!session, 'op1 has an active session');
assert(session?.workstation.code === 'CTR04', `op1 session at CTR04 (got ${session?.workstation.code})`);
const workstationId = session!.workstationId;
// 2. QCP raises a defect at op1's workstation
const created = await qcp.qualityDefect.create({
workstationId,
defectType: 'Smoke — aperto',
location: 'Banco traseiro',
description: 'Defeito de teste criado pelo smoke.',
rfsCode: 'RFS-SMOKE',
});
assert(created.status === 'OPEN', `created defect is OPEN (got ${created.status})`);
assert(created.workstation.code === 'CTR04', 'created defect at CTR04');
const defectId = created.id;
// 3. op1 sees it in forMyStation
const mine = await op1.qualityDefect.forMyStation();
assert(mine.some((d) => d.id === defectId), 'op1 sees the new defect at their station');
// 4. op2 (no session at CTR04) does NOT see it
const op2current = await op2.operatorSession.current();
if (!op2current) {
const op2defects = await op2.qualityDefect.forMyStation();
assert(op2defects.length === 0, 'op2 (not badged in) sees no defects');
} else {
ok('op2 already had a session (skipping no-session check)');
}
// 5. op1 acknowledges (OPEN -> ACKNOWLEDGED)
const ack = await op1.qualityDefect.acknowledge({ id: defectId });
assert(ack.status === 'ACKNOWLEDGED', `acknowledge -> ACKNOWLEDGED (got ${ack.status})`);
assert(ack.acknowledgedBy?.email === 'op1@demo.local', 'acknowledgedBy is op1');
// 6. acknowledge again -> CONFLICT
try {
await op1.qualityDefect.acknowledge({ id: defectId });
fail('re-acknowledge -> CONFLICT', 'no error thrown');
} catch (err) {
assert(codeOf(err) === 'CONFLICT', 're-acknowledge -> CONFLICT', `got ${codeOf(err)}`);
}
// 7. op1 corrects (ACKNOWLEDGED -> CORRECTED)
const corrected = await op1.qualityDefect.correct({ id: defectId, correctionNote: 'Corrigido no smoke.' });
assert(corrected.status === 'CORRECTED', `correct -> CORRECTED (got ${corrected.status})`);
assert(corrected.correctedBy?.email === 'op1@demo.local', 'correctedBy is op1');
// 8. correct again -> CONFLICT
try {
await op1.qualityDefect.correct({ id: defectId });
fail('re-correct -> CONFLICT', 'no error thrown');
} catch (err) {
assert(codeOf(err) === 'CONFLICT', 're-correct -> CONFLICT', `got ${codeOf(err)}`);
}
// 9. corrected defect drops out of the operator's default forMyStation (OPEN+ACK)
const mineAfter = await op1.qualityDefect.forMyStation();
assert(!mineAfter.some((d) => d.id === defectId), 'CORRECTED defect no longer in forMyStation default');
// 10. operator may NOT create a defect (requireRole QUALITY/ADMIN)
try {
await op1.qualityDefect.create({ workstationId, defectType: 'x', description: 'nope nope' });
fail('operator create -> FORBIDDEN', 'no error thrown');
} catch (err) {
assert(codeOf(err) === 'FORBIDDEN', 'operator create -> FORBIDDEN', `got ${codeOf(err)}`);
}
// 11. QCP queue includes the defect we created
const queue = await qcp.qualityDefect.queue({ statuses: ['CORRECTED'] });
assert(queue.some((d) => d.id === defectId), 'QCP queue lists the corrected defect');
// 12. operatorSession start/end roundtrip on op2
const startedAt2 = await op2.operatorSession.start({ workstationId });
assert(startedAt2.workstationId === workstationId, 'op2 badge-in starts a session');
const op2now = await op2.operatorSession.current();
assert(op2now?.id === startedAt2.id, 'op2 current() returns the started session');
await op2.operatorSession.end();
const op2ended = await op2.operatorSession.current();
assert(op2ended === null, 'op2 badge-out clears the active session');
console.log(`\n${passed} passed, ${failed} failed.\n`);
if (failed > 0) process.exit(1);
}
main()
.catch((err) => {
console.error('Quality smoke failed:', err);
process.exit(1);
})
.finally(() => prisma.$disconnect());