MY QUALITY - First iteration
This commit is contained in:
parent
4f8996712e
commit
1fdb9536fa
52
README.md
52
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
|
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 1–2 seconds when online).
|
within 1–2 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/admin-web/app/quality/page.tsx
Normal file
22
apps/admin-web/app/quality/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
438
apps/admin-web/app/quality/quality-console.tsx
Normal file
438
apps/admin-web/app/quality/quality-console.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
60
apps/operator-pwa/app/badge-in-panel.tsx
Normal file
60
apps/operator-pwa/app/badge-in-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 */}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
289
apps/operator-pwa/app/quality/page.tsx
Normal file
289
apps/operator-pwa/app/quality/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
|||||||
37
apps/operator-pwa/app/session-bar.tsx
Normal file
37
apps/operator-pwa/app/session-bar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ─────────────────────────────────────
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
53
packages/api/src/routers/operator-session.ts
Normal file
53
packages/api/src/routers/operator-session.ts
Normal 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 };
|
||||||
|
}),
|
||||||
|
});
|
||||||
179
packages/api/src/routers/quality-defect.ts
Normal file
179
packages/api/src/routers/quality-defect.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@ -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(' | ')}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
15
pnpm-lock.yaml
generated
@ -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
155
scripts/quality-smoke.ts
Normal 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());
|
||||||
Loading…
x
Reference in New Issue
Block a user