diff --git a/README.md b/README.md
index 8fa4779..5161d27 100644
--- a/README.md
+++ b/README.md
@@ -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
-feature: a full maintenance-request loop (offline-first, operator PIN + admin
-password auth) with an **end-of-shift report** for supervisors.
+Modular industrial SaaS monorepo. Two modules are shipped:
+
+- **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
@@ -76,10 +81,13 @@ pnpm --filter @repo/admin-web dev
http://localhost:3000/select-operator, tap **op1@demo.local**, then
enter PIN **1111** on the keypad.
(op2 = **2222**, op3 = **3333**)
-2. Tap **Pedir manutenção**.
-3. Select a workstation, optionally attach a photo, write a description,
- and tap **Enviar pedido**.
-4. The page shows **"Pedido enviado"** once the sync completes (usually
+2. **Badge in:** pick the workstation you are at. This starts your
+ operator↔posto session (the future RFID badge-in). The home shows your
+ current posto with a **Sair do posto** (badge-out) button.
+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 1–2 seconds when online).
**Offline test:**
@@ -97,7 +105,8 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears.
1. Open http://localhost:3001.
With `AUTH_DEV_AUTOLOGIN=true` you land on the maintenance queue
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.
3. Click **Aceitar** to claim a request (status: Em curso).
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
(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)
@@ -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/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/quality-smoke.ts` | Verify the MY QUALITY loop (badge-in, defect create→acknowledge→correct, roles) |
---
diff --git a/apps/admin-web/app/maintenance/maintenance-queue.tsx b/apps/admin-web/app/maintenance/maintenance-queue.tsx
index bdd4ea3..14592ed 100644
--- a/apps/admin-web/app/maintenance/maintenance-queue.tsx
+++ b/apps/admin-web/app/maintenance/maintenance-queue.tsx
@@ -1,7 +1,7 @@
'use client';
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 { useTranslations } from 'next-intl';
import { trpc } from '@/lib/trpc/client';
@@ -275,6 +275,13 @@ export function MaintenanceQueue() {
+
+
+ {t('qualityLink')}
+
;
}
diff --git a/apps/admin-web/app/quality/page.tsx b/apps/admin-web/app/quality/page.tsx
new file mode 100644
index 0000000..2457473
--- /dev/null
+++ b/apps/admin-web/app/quality/page.tsx
@@ -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
{
+ 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 ;
+}
diff --git a/apps/admin-web/app/quality/quality-console.tsx b/apps/admin-web/app/quality/quality-console.tsx
new file mode 100644
index 0000000..9fa410e
--- /dev/null
+++ b/apps/admin-web/app/quality/quality-console.tsx
@@ -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;
+
+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 {
+ 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 = {
+ 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
;
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+}
+
+// ── New-defect form ───────────────────────────────────────────────────────────
+
+function NewDefectForm({
+ onCreated,
+ t,
+}: {
+ onCreated: () => void;
+ t: ReturnType>;
+}) {
+ const fileRef = useRef(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(null);
+ const [photoPreview, setPhotoPreview] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(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) {
+ 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 (
+
+ );
+}
+
+// ── Defect card ───────────────────────────────────────────────────────────────
+
+function DefectCard({
+ defect,
+ t,
+ tTime,
+}: {
+ defect: Defect;
+ t: ReturnType>;
+ tTime: TFn;
+}) {
+ const status = defect.status as DefectStatus;
+ return (
+
+
+
+
+
+
+ {defect.defectType}
+
+ · {defect.workstation.code}
+
+
+
+ {t(`status.${status.toLowerCase() as 'open' | 'acknowledged' | 'corrected'}`)}
+
+
+ {defect.location && (
+
+ {t('location')}: {defect.location}
+
+ )}
+
{defect.description}
+
+ {defect.rfsCode && (
+
+ {t('rfs')} {defect.rfsCode}
+
+ )}
+ {t('createdBy', { email: defect.createdBy.email, time: timeAgo(defect.createdAt, tTime) })}
+
+ {defect.acknowledgedBy && defect.acknowledgedAt && (
+
+ {t('acknowledgedBy', { email: defect.acknowledgedBy.email, time: timeAgo(defect.acknowledgedAt, tTime) })}
+
+ )}
+ {defect.correctedBy && defect.correctedAt && (
+
+ {t('correctedBy', { email: defect.correctedBy.email, time: timeAgo(defect.correctedAt, tTime) })}
+
+ )}
+
+
+
+ );
+}
+
+// ── Console ───────────────────────────────────────────────────────────────────
+
+export function QualityConsole({ canCreate }: { canCreate: boolean }) {
+ const t = useTranslations('quality');
+ const tTime = useTranslations('common.timeAgo');
+
+ const [statuses, setStatuses] = useState(['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 (
+
+
+
+
+ {canCreate && refetch()} t={t} />}
+
+ {/* Filters */}
+
+ {t('filterStatus')}
+ {(['OPEN', 'ACKNOWLEDGED', 'CORRECTED'] as DefectStatus[]).map((s) => (
+
+ toggleStatus(s)}
+ className="rounded"
+ />
+ {t(`status.${s.toLowerCase() as 'open' | 'acknowledged' | 'corrected'}`)}
+
+ ))}
+ {t('updatesEvery')}
+
+
+ {/* Queue */}
+
+ {t('queueTitle')}
+ {defects.length === 0 ? (
+
+ ) : (
+
+ {defects.map((defect) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/admin-web/lib/auth.ts b/apps/admin-web/lib/auth.ts
index a7eff68..cb9f549 100644
--- a/apps/admin-web/lib/auth.ts
+++ b/apps/admin-web/lib/auth.ts
@@ -23,7 +23,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
const u = await authenticateCredential({
email,
secret: password,
- allowedRoles: ['ADMIN', 'SUPERVISOR'],
+ allowedRoles: ['ADMIN', 'SUPERVISOR', 'QUALITY'],
});
if (!u) return null;
return {
diff --git a/apps/admin-web/messages/en.json b/apps/admin-web/messages/en.json
index a41d52f..39fcb65 100644
--- a/apps/admin-web/messages/en.json
+++ b/apps/admin-web/messages/en.json
@@ -51,7 +51,48 @@
"resolveNoteLabel": "Resolution note (optional)",
"resolveNotePlaceholder": "Describe what was done…",
"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": {
"pageTitle": "FieldOps — Shift report",
diff --git a/apps/admin-web/messages/pt.json b/apps/admin-web/messages/pt.json
index 75c89fe..f4ce440 100644
--- a/apps/admin-web/messages/pt.json
+++ b/apps/admin-web/messages/pt.json
@@ -51,7 +51,48 @@
"resolveNoteLabel": "Nota de resolução (opcional)",
"resolveNotePlaceholder": "Descreve o que foi feito…",
"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": {
"pageTitle": "FieldOps — Relatório de turno",
diff --git a/apps/operator-pwa/app/badge-in-panel.tsx b/apps/operator-pwa/app/badge-in-panel.tsx
new file mode 100644
index 0000000..b35fd1b
--- /dev/null
+++ b/apps/operator-pwa/app/badge-in-panel.tsx
@@ -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 (
+
+
+
+
{ts('badgeInTitle')}
+
{ts('badgeInPrompt')}
+
+
+ {workstations.length === 0 ? (
+ {ts('noStations')}
+ ) : (
+
+ {workstations.map((ws) => (
+ 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"
+ >
+
+
+
+ {ws.code} — {ws.name}
+
+ {ws.area}
+
+
+ ))}
+
+ )}
+
+ {startSession.isPending && (
+ {ts('starting')}
+ )}
+
+ );
+}
diff --git a/apps/operator-pwa/app/maintenance/new/page.tsx b/apps/operator-pwa/app/maintenance/new/page.tsx
index e4ea655..ce7b443 100644
--- a/apps/operator-pwa/app/maintenance/new/page.tsx
+++ b/apps/operator-pwa/app/maintenance/new/page.tsx
@@ -3,7 +3,7 @@
import { useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
-import { ArrowLeft, Camera, X } from 'lucide-react';
+import { ArrowLeft, Camera, X, MapPin } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { trpc } from '@/lib/trpc/client';
import { db } from '@/lib/queue/db';
@@ -48,17 +48,17 @@ export default function NewRequestPage() {
const router = useRouter();
const fileRef = useRef(null);
- const [workstationId, setWorkstationId] = useState('');
const [description, setDescription] = useState('');
const [photoBlob, setPhotoBlob] = useState(null);
const [photoPreview, setPhotoPreview] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
- const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
- undefined,
- { staleTime: 60 * 60 * 1000 },
- );
+ // Workstation is no longer chosen per-request: it comes from the operator's
+ // active badge-in session. It still travels in the queued payload so offline
+ // submissions remain self-contained even if the operator later changes posto.
+ const { data: session, isLoading: sessionLoading } = trpc.operatorSession.current.useQuery();
+ const workstationId = session?.workstationId ?? '';
async function handlePhotoChange(e: React.ChangeEvent) {
const file = e.target.files?.[0];
@@ -121,28 +121,24 @@ export default function NewRequestPage() {
- {/* Workstation */}
+ {/* Workstation — read-only, from the active session */}
-
- {t('workstationLabel')} {t('workstationRequired')}
-
-
setWorkstationId(e.target.value)}
- required
- disabled={wsLoading}
- className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50"
- >
-
- {wsLoading ? t('workstationLoading') : t('workstationPlaceholder')}
-
- {workstations.map((ws) => (
-
- {ws.code} — {ws.name} · {ws.area}
-
- ))}
-
+
{t('workstationLabel')}
+ {sessionLoading ? (
+
{t('workstationLoading')}
+ ) : session ? (
+
+
+
+ {session.workstation.code} — {session.workstation.name}
+ · {session.workstation.area}
+
+
+ ) : (
+
+ {t('noSession')}
+
+ )}
{/* Photo */}
diff --git a/apps/operator-pwa/app/page.tsx b/apps/operator-pwa/app/page.tsx
index df36d3f..db5b327 100644
--- a/apps/operator-pwa/app/page.tsx
+++ b/apps/operator-pwa/app/page.tsx
@@ -1,5 +1,5 @@
import Link from 'next/link';
-import { Wrench } from 'lucide-react';
+import { Wrench, ClipboardCheck, ChevronRight } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { resolveUser } from '@/lib/auth';
import { api } from '@/lib/trpc/server';
@@ -7,50 +7,123 @@ import { SignOutButton } from './sign-out-button';
import { StatusBadge } from './status-badge';
import { SyncChip } from './sync-chip';
import { LanguageSwitcher } from './language-switcher';
+import { BadgeInPanel } from './badge-in-panel';
+import { SessionBar } from './session-bar';
export default async function HomePage() {
const t = await getTranslations('home');
const user = await resolveUser();
+ // Current badge-in session (operator bound to a workstation).
+ let session: Awaited> = null;
+ try {
+ session = await api.operatorSession.current();
+ } catch {
+ // No auth / error — treat as not badged in.
+ }
+
+ const header = (
+
+
+
{t('operator')}
+
+ {user?.email ?? '—'}
+
+
+
+
+
+
+
+ );
+
+ // ── Not badged in: prompt to pick a workstation ──
+ if (!session) {
+ let workstations: Awaited> = [];
+ try {
+ workstations = await api.workstation.list();
+ } catch {
+ // ignore
+ }
+ return (
+
+ {header}
+
+
+
+
+
+ );
+ }
+
+ // ── Badged in: full home ──
type RecentItem = Awaited>[number];
let recent: RecentItem[] = [];
try {
recent = await api.maintenanceRequest.myRecent({ limit: 5 });
} catch {
- // No session or other error — show empty list without crashing.
+ // ignore
+ }
+
+ let openDefects = 0;
+ try {
+ const defects = await api.qualityDefect.forMyStation();
+ openDefects = defects.filter((d) => d.status === 'OPEN').length;
+ } catch {
+ // ignore
}
return (
- {/* ── Header ── */}
-
-
-
{t('operator')}
-
- {user?.email ?? '—'}
-
-
-
-
-
-
-
+ {header}
- {/* ── Sync status ── */}
- {/* ── Primary CTA ── */}
-
-
- {t('requestMaintenance')}
-
+ {/* Current workstation + badge-out */}
+
- {/* ── Recent requests ── */}
+ {/* Primary CTAs */}
+
+
+
+ {t('requestMaintenance')}
+
+
+
+
+
+
+ {t('defects')}
+
+ {openDefects > 0 ? t('defectsWithCount', { count: openDefects }) : t('noDefects')}
+
+
+
+
+ {openDefects > 0 && (
+
+ {openDefects}
+
+ )}
+
+
+
+
+
+ {/* Recent requests */}
{t('myRequests')}
diff --git a/apps/operator-pwa/app/quality/page.tsx b/apps/operator-pwa/app/quality/page.tsx
new file mode 100644
index 0000000..e4dd648
--- /dev/null
+++ b/apps/operator-pwa/app/quality/page.tsx
@@ -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;
+
+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 = {
+ 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
;
+ }
+ return (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ );
+}
+
+function CorrectDialog({
+ onConfirm,
+ onCancel,
+ note,
+ onNoteChange,
+ busy,
+ t,
+ tc,
+}: {
+ onConfirm: () => void;
+ onCancel: () => void;
+ note: string;
+ onNoteChange: (v: string) => void;
+ busy: boolean;
+ t: ReturnType>;
+ tc: ReturnType>;
+}) {
+ return (
+
+
+
{t('correctDialogTitle')}
+
{t('correctNoteLabel')}
+
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"
+ />
+
+
+ {tc('cancel')}
+
+
+ {busy && }
+ {tc('confirm')}
+
+
+
+
+ );
+}
+
+function DefectCard({
+ defect,
+ onAcknowledge,
+ onCorrect,
+ acknowledging,
+ t,
+ tc,
+}: {
+ defect: Defect;
+ onAcknowledge: () => void;
+ onCorrect: () => void;
+ acknowledging: boolean;
+ t: ReturnType>;
+ tc: ReturnType>;
+}) {
+ const status = defect.status as DefectStatus;
+ return (
+
+
+
+
+
+
{defect.defectType}
+
+ {t(`status.${status.toLowerCase() as 'open' | 'acknowledged' | 'corrected'}`)}
+
+
+ {defect.location && (
+
+ {t('location')}: {defect.location}
+
+ )}
+
{defect.description}
+
+ {defect.rfsCode && (
+
+ {t('rfs')} {defect.rfsCode}
+
+ )}
+ {t('raised', { email: defect.createdBy.email, time: timeAgo(defect.createdAt, tc) })}
+
+
+
+
+
+ {status === 'OPEN' && (
+
+ {acknowledging ? : }
+ {t('acknowledge')}
+
+ )}
+ {status === 'ACKNOWLEDGED' && (
+ <>
+ {defect.acknowledgedAt && (
+
+ {t('acknowledgedBy', { time: timeAgo(defect.acknowledgedAt, tc) })}
+
+ )}
+
+
+ {t('correct')}
+
+ >
+ )}
+
+
+ );
+}
+
+export default function QualityDefectsPage() {
+ const t = useTranslations('quality');
+ const tc = useTranslations('common');
+ const tcTime = useTranslations('common.timeAgo');
+
+ const [correctId, setCorrectId] = useState(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());
+ 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 (
+
+
+
+
+ {!session ? (
+
+ ) : defects.length === 0 ? (
+
+ ) : (
+ defects.map((defect) => (
+
acknowledge.mutate({ id: defect.id })}
+ onCorrect={() => {
+ setCorrectId(defect.id);
+ setCorrectionNote('');
+ }}
+ />
+ ))
+ )}
+
+
+ {correctId && (
+
+ correct.mutate({ id: correctId, correctionNote: correctionNote.trim() || undefined })
+ }
+ onCancel={() => setCorrectId(null)}
+ />
+ )}
+
+ );
+}
diff --git a/apps/operator-pwa/app/select-operator/operator-picker.tsx b/apps/operator-pwa/app/select-operator/operator-picker.tsx
index 214a10b..864bc9f 100644
--- a/apps/operator-pwa/app/select-operator/operator-picker.tsx
+++ b/apps/operator-pwa/app/select-operator/operator-picker.tsx
@@ -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>;
tc: ReturnType>;
}) {
- const router = useRouter();
const [digits, setDigits] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState(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>;
+}) {
+ 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 (
+
+
+
{operator.email}
+
{ts('badgeInTitle')}
+
{ts('badgeInSubtitle')}
+
+
+ {isLoading ? (
+
{ts('loadingStations')}
+ ) : workstations.length === 0 ? (
+
{ts('noStations')}
+ ) : (
+
+ {workstations.map((ws) => (
+ 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"
+ >
+
+
+
+ {ws.code} — {ws.name}
+
+ {ws.area}
+
+
+ ))}
+
+ )}
+
+ {startSession.isPending && (
+
{ts('starting')}
+ )}
+
+ );
+}
+
export function OperatorPicker({ operators }: { operators: Operator[] }) {
const t = useTranslations('auth');
const tc = useTranslations('common');
+ const ts = useTranslations('session');
const [state, setState] = useState({ step: 'list' });
if (state.step === 'pin') {
@@ -192,12 +254,17 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
setState({ step: 'list' })}
+ onSuccess={() => setState({ step: 'workstation', operator: state.operator })}
t={t}
tc={tc}
/>
);
}
+ if (state.step === 'workstation') {
+ return ;
+ }
+
return (
router.refresh(),
+ });
+
+ return (
+
+
+
+
+
{ts('atStation')}
+
+ {code} — {name} · {area}
+
+
+
+
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"
+ >
+
+ {ts('badgeOut')}
+
+
+ );
+}
diff --git a/apps/operator-pwa/messages/en.json b/apps/operator-pwa/messages/en.json
index 7cf7cc6..9d2eae1 100644
--- a/apps/operator-pwa/messages/en.json
+++ b/apps/operator-pwa/messages/en.json
@@ -7,10 +7,18 @@
"common": {
"enter": "Sign in",
"entering": "Signing in…",
+ "cancel": "Cancel",
+ "confirm": "Confirm",
"status": {
"open": "Open",
"claimed": "In progress",
"resolved": "Resolved"
+ },
+ "timeAgo": {
+ "now": "just now",
+ "minutesAgo": "{mins}m ago",
+ "hoursAgo": "{hours}h ago",
+ "daysAgo": "{days}d ago"
}
},
"errors": {
@@ -32,11 +40,26 @@
"deleteDigit": "Delete",
"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": {
"operator": "Operator",
"myRequests": "My requests",
"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": {
"deadLetters": "{count, plural, one {# request failed — contact your supervisor.} other {# requests failed — contact your supervisor.}}",
@@ -51,6 +74,7 @@
"workstationRequired": "*",
"workstationLoading": "Loading workstations…",
"workstationPlaceholder": "Select a workstation…",
+ "noSession": "Badge in to a workstation before requesting maintenance.",
"photoLabel": "Photo (optional)",
"photoPreview": "Preview",
"photoButton": "Take / choose photo",
@@ -66,5 +90,31 @@
"sentMessage": "The maintenance team has been notified and will handle the issue.",
"pendingMessage": "Will be sent as soon as the connection is restored.",
"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"
+ }
}
}
diff --git a/apps/operator-pwa/messages/pt.json b/apps/operator-pwa/messages/pt.json
index cb7a349..35b6929 100644
--- a/apps/operator-pwa/messages/pt.json
+++ b/apps/operator-pwa/messages/pt.json
@@ -7,10 +7,18 @@
"common": {
"enter": "Entrar",
"entering": "A entrar…",
+ "cancel": "Cancelar",
+ "confirm": "Confirmar",
"status": {
"open": "Aberto",
"claimed": "Em curso",
"resolved": "Resolvido"
+ },
+ "timeAgo": {
+ "now": "agora",
+ "minutesAgo": "há {mins}m",
+ "hoursAgo": "há {hours}h",
+ "daysAgo": "há {days}d"
}
},
"errors": {
@@ -32,11 +40,26 @@
"deleteDigit": "Apagar",
"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": {
"operator": "Operador",
"myRequests": "Os meus pedidos",
"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": {
"deadLetters": "{count, plural, one {# pedido com erro — contacta o supervisor.} other {# pedidos com erro — contacta o supervisor.}}",
@@ -51,6 +74,7 @@
"workstationRequired": "*",
"workstationLoading": "A carregar postos…",
"workstationPlaceholder": "Seleciona um posto…",
+ "noSession": "Entra num posto antes de pedir manutenção.",
"photoLabel": "Foto (opcional)",
"photoPreview": "Pré-visualização",
"photoButton": "Tirar / escolher foto",
@@ -66,5 +90,31 @@
"sentMessage": "A equipa de manutenção foi notificada e irá tratar do problema.",
"pendingMessage": "Será enviado assim que a ligação for restabelecida.",
"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"
+ }
}
}
diff --git a/e2e/tests/mai-call.spec.ts b/e2e/tests/mai-call.spec.ts
index cb363a2..7c77b68 100644
--- a/e2e/tests/mai-call.spec.ts
+++ b/e2e/tests/mai-call.spec.ts
@@ -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.
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 ────────────────────────────────────────
- await page.goto('/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!);
+ await requestBtn.click();
+ await page.waitForURL('**/maintenance/new**');
+ // The workstation is shown read-only from the session; just describe + submit.
await page.fill('#description', desc);
+ await expect(page.locator('button[type=submit]')).toBeEnabled({ timeout: 15_000 });
await page.click('button[type=submit]');
// ── 2. Wait for the sync to complete ─────────────────────────────────────
diff --git a/packages/api/src/context.ts b/packages/api/src/context.ts
index 899a741..3bc04a9 100644
--- a/packages/api/src/context.ts
+++ b/packages/api/src/context.ts
@@ -11,7 +11,7 @@ import { logger } from './logger';
export type SessionUser = {
id: string;
email: string;
- role: 'ADMIN' | 'SUPERVISOR' | 'OPERATOR';
+ role: 'ADMIN' | 'SUPERVISOR' | 'QUALITY' | 'OPERATOR';
tenantId: string;
};
diff --git a/packages/api/src/routers/_app.ts b/packages/api/src/routers/_app.ts
index aeea47c..6dc5cac 100644
--- a/packages/api/src/routers/_app.ts
+++ b/packages/api/src/routers/_app.ts
@@ -4,6 +4,8 @@ import { workstationRouter } from './workstation';
import { userRouter } from './user';
import { storageRouter } from './storage';
import { maintenanceRequestRouter } from './maintenance-request';
+import { operatorSessionRouter } from './operator-session';
+import { qualityDefectRouter } from './quality-defect';
export const appRouter = router({
ping: pingRouter,
@@ -11,6 +13,8 @@ export const appRouter = router({
user: userRouter,
storage: storageRouter,
maintenanceRequest: maintenanceRequestRouter,
+ operatorSession: operatorSessionRouter,
+ qualityDefect: qualityDefectRouter,
});
export type AppRouter = typeof appRouter;
diff --git a/packages/api/src/routers/operator-session.ts b/packages/api/src/routers/operator-session.ts
new file mode 100644
index 0000000..11f863e
--- /dev/null
+++ b/packages/api/src/routers/operator-session.ts
@@ -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 };
+ }),
+});
diff --git a/packages/api/src/routers/quality-defect.ts b/packages/api/src/routers/quality-defect.ts
new file mode 100644
index 0000000..ed64964
--- /dev/null
+++ b/packages/api/src/routers/quality-defect.ts
@@ -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;
+ });
+ }),
+});
diff --git a/packages/api/src/routers/storage.ts b/packages/api/src/routers/storage.ts
index 038c52c..4f1b9f5 100644
--- a/packages/api/src/routers/storage.ts
+++ b/packages/api/src/routers/storage.ts
@@ -12,7 +12,7 @@ const EXT_MAP: Record<(typeof CONTENT_TYPES)[number], string> = {
const photoKeySchema = z
.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({
signPhotoUpload: protectedProcedure
@@ -20,11 +20,14 @@ export const storageRouter = router({
z.object({
contentType: z.enum(CONTENT_TYPES),
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 }) => {
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 { url: uploadUrl, expiresAt } = await storage.signPut(
photoKey,
diff --git a/packages/db/prisma/migrations/20260611141713_my_quality_sessions_defects/migration.sql b/packages/db/prisma/migrations/20260611141713_my_quality_sessions_defects/migration.sql
new file mode 100644
index 0000000..3572552
--- /dev/null
+++ b/packages/db/prisma/migrations/20260611141713_my_quality_sessions_defects/migration.sql
@@ -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;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 974424e..6bef926 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -16,6 +16,7 @@ datasource db {
enum UserRole {
ADMIN
SUPERVISOR
+ QUALITY
OPERATOR
}
@@ -25,6 +26,12 @@ enum MaintenanceRequestStatus {
RESOLVED
}
+enum QualityDefectStatus {
+ OPEN
+ ACKNOWLEDGED
+ CORRECTED
+}
+
model Tenant {
id String @id @default(cuid())
name String
@@ -34,6 +41,8 @@ model Tenant {
workstations Workstation[]
events DomainEvent[]
maintenanceRequests MaintenanceRequest[]
+ operatorSessions OperatorSession[]
+ qualityDefects QualityDefect[]
}
model User {
@@ -52,6 +61,11 @@ model User {
claimedRequests MaintenanceRequest[] @relation("claimed")
resolvedRequests MaintenanceRequest[] @relation("resolved")
+ sessions OperatorSession[]
+ createdDefects QualityDefect[] @relation("defectCreated")
+ acknowledgedDefects QualityDefect[] @relation("defectAcknowledged")
+ correctedDefects QualityDefect[] @relation("defectCorrected")
+
@@unique([tenantId, email])
@@index([tenantId])
}
@@ -65,6 +79,8 @@ model Workstation {
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
maintenanceRequests MaintenanceRequest[]
+ operatorSessions OperatorSession[]
+ qualityDefects QualityDefect[]
@@unique([tenantId, code])
@@index([tenantId])
@@ -115,3 +131,58 @@ model MaintenanceRequest {
@@index([tenantId, status, createdAt])
@@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])
+}
diff --git a/packages/db/prisma/seed.ts b/packages/db/prisma/seed.ts
index 45b17d5..efe085a 100644
--- a/packages/db/prisma/seed.ts
+++ b/packages/db/prisma/seed.ts
@@ -15,6 +15,8 @@ const prisma = new PrismaClient();
const DEMO_TENANT_NAME = 'Demo Factory';
const DEMO_ADMIN_EMAIL = 'admin@demo.local';
const DEMO_ADMIN_PASSWORD = 'admin1234';
+const DEMO_QCP_EMAIL = 'qcp@demo.local';
+const DEMO_QCP_PASSWORD = 'qcp1234';
const OPERATORS = [
{ 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) {
await prisma.user.create({
data: {
@@ -161,6 +172,77 @@ async function main() {
}
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(
@@ -169,6 +251,9 @@ async function main() {
console.warn(
` admin: ${DEMO_ADMIN_EMAIL} / ${DEMO_ADMIN_PASSWORD}`,
);
+ console.warn(
+ ` qcp: ${DEMO_QCP_EMAIL} / ${DEMO_QCP_PASSWORD}`,
+ );
console.warn(
` operadores: ${OPERATORS.map((o) => `${o.email}=${o.pin}`).join(' | ')}`,
);
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index c8aa220..9c21005 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -1,5 +1,13 @@
export { prisma, type DbClient } from './client';
export { tenantScoped, type TenantScopedClient } from './tenant-extension';
-export { Prisma, UserRole, MaintenanceRequestStatus } from '@prisma/client';
-export type { User, Tenant, Workstation, DomainEvent, MaintenanceRequest } from '@prisma/client';
+export { Prisma, UserRole, MaintenanceRequestStatus, QualityDefectStatus } from '@prisma/client';
+export type {
+ User,
+ Tenant,
+ Workstation,
+ DomainEvent,
+ MaintenanceRequest,
+ OperatorSession,
+ QualityDefect,
+} from '@prisma/client';
export { hashSecret, verifySecret } from './crypto';
diff --git a/packages/db/src/tenant-extension.ts b/packages/db/src/tenant-extension.ts
index c753fd0..319d544 100644
--- a/packages/db/src/tenant-extension.ts
+++ b/packages/db/src/tenant-extension.ts
@@ -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];
function isTenantScoped(model: string | undefined): model is TenantScopedModel {
diff --git a/packages/storage/package.json b/packages/storage/package.json
index 6afcfd3..fa8bb9f 100644
--- a/packages/storage/package.json
+++ b/packages/storage/package.json
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@repo/config": "workspace:*",
+ "@types/node": "22.19.19",
"rimraf": "^6.0.1",
"typescript": "^5.7.2"
}
diff --git a/packages/storage/tsconfig.json b/packages/storage/tsconfig.json
index 87d9126..4675098 100644
--- a/packages/storage/tsconfig.json
+++ b/packages/storage/tsconfig.json
@@ -1,6 +1,9 @@
{
- "extends": "@repo/config/tsconfig/library.json",
+ "extends": "@repo/config/tsconfig/base.json",
"compilerOptions": {
- "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
- }
+ "noEmit": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler"
+ },
+ "include": ["src/**/*.ts"]
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fcb1324..d6b5bdd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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)
next-auth:
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:
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:
specifier: ^9.5.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)
next-auth:
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:
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:
specifier: ^9.5.0
version: 9.14.0
@@ -346,6 +346,9 @@ importers:
'@repo/config':
specifier: workspace:*
version: link:../config
+ '@types/node':
+ specifier: 22.19.19
+ version: 22.19.19
rimraf:
specifier: ^6.0.1
version: 6.1.3
@@ -7142,7 +7145,7 @@ snapshots:
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:
'@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)
@@ -7150,7 +7153,7 @@ snapshots:
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:
'@formatjs/intl-localematcher': 0.8.9
'@parcel/watcher': 2.5.6
diff --git a/scripts/quality-smoke.ts b/scripts/quality-smoke.ts
new file mode 100644
index 0000000..2bcfd14
--- /dev/null
+++ b/scripts/quality-smoke.ts
@@ -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());