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
|
||||
feature: a full maintenance-request loop (offline-first, operator PIN + admin
|
||||
password auth) with an **end-of-shift report** for supervisors.
|
||||
Modular industrial SaaS monorepo. Two modules are shipped:
|
||||
|
||||
- **MAI CALL** — a full maintenance-request loop (offline-first, operator PIN +
|
||||
admin password auth) with an **end-of-shift report** for supervisors.
|
||||
- **MY QUALITY** — operators **badge in** to a workstation (operator↔posto
|
||||
session); the Quality team (QCP) raises **defects** against a workstation,
|
||||
which are routed in real time to the operator bound there to acknowledge and
|
||||
correct. State machine OPEN → ACKNOWLEDGED → CORRECTED.
|
||||
|
||||
## What's here
|
||||
|
||||
@ -76,10 +81,13 @@ pnpm --filter @repo/admin-web dev
|
||||
http://localhost:3000/select-operator, tap **op1@demo.local**, then
|
||||
enter PIN **1111** on the keypad.
|
||||
(op2 = **2222**, op3 = **3333**)
|
||||
2. Tap **Pedir manutenção**.
|
||||
3. Select a workstation, optionally attach a photo, write a description,
|
||||
and tap **Enviar pedido**.
|
||||
4. The page shows **"Pedido enviado"** once the sync completes (usually
|
||||
2. **Badge in:** pick the workstation you are at. This starts your
|
||||
operator↔posto session (the future RFID badge-in). The home shows your
|
||||
current posto with a **Sair do posto** (badge-out) button.
|
||||
3. Tap **Pedir manutenção**.
|
||||
4. The posto is taken automatically from your session (no dropdown).
|
||||
Optionally attach a photo, write a description, and tap **Enviar pedido**.
|
||||
5. The page shows **"Pedido enviado"** once the sync completes (usually
|
||||
within 1–2 seconds when online).
|
||||
|
||||
**Offline test:**
|
||||
@ -97,7 +105,8 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears.
|
||||
1. Open http://localhost:3001.
|
||||
With `AUTH_DEV_AUTOLOGIN=true` you land on the maintenance queue
|
||||
automatically. Without it, you see a login form — use
|
||||
**admin@demo.local** / **admin1234**.
|
||||
**admin@demo.local** / **admin1234** (or the QCP user
|
||||
**qcp@demo.local** / **qcp1234**, who lands on the quality console).
|
||||
2. The queue refreshes every 5 s; new requests appear automatically.
|
||||
3. Click **Aceitar** to claim a request (status: Em curso).
|
||||
4. Click **Marcar resolvido**, optionally add a note, click **Confirmar**
|
||||
@ -118,6 +127,30 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears.
|
||||
After `pnpm db:seed`, the "Hoje" window already has 6 sample requests
|
||||
(3 resolved, 1 claimed, 2 open) so the report is never empty on first boot.
|
||||
|
||||
### MY QUALITY — quality defects
|
||||
|
||||
The Quality controller (QCP) raises defects; the operator at the targeted
|
||||
workstation handles them.
|
||||
|
||||
**As QCP (port 3001):** sign in with **qcp@demo.local** / **qcp1234** — you
|
||||
land on the **Defeitos de qualidade** console (QCP users are redirected there
|
||||
from `/maintenance`). Fill the **Novo defeito** form (posto, tipo, localização,
|
||||
RFS, descrição, foto opcional) and click **Lançar defeito**. The queue below
|
||||
polls every 5 s and shows each defect's lifecycle (who raised / acknowledged /
|
||||
corrected it). Admins can reach the console from the **Qualidade** link in the
|
||||
maintenance-queue header.
|
||||
|
||||
**As operator (port 3000):** once badged in (see above), open **Defeitos de
|
||||
qualidade** from the home. Defects raised at your posto appear here (polled,
|
||||
with an optional sound alert). Tap **Tomei conhecimento** (→ ACKNOWLEDGED), then
|
||||
**Marcar corrigido** with an optional note (→ CORRECTED).
|
||||
|
||||
After `pnpm db:seed`, op1 is already badged in at **CTR04** with 3 sample
|
||||
defects (1 open, 1 acknowledged, 1 corrected).
|
||||
|
||||
Smoke test (no browser): `pnpm tsx scripts/quality-smoke.ts` (18 assertions —
|
||||
the full QCP→operator loop, role guards, and state-machine conflicts).
|
||||
|
||||
---
|
||||
|
||||
## MinIO (photo storage)
|
||||
@ -275,6 +308,7 @@ key-parity and ICU validation scripts to run before shipping a new language.
|
||||
| `pnpm tsx scripts/maintenance-smoke.ts` | Verify the full create→claim→resolve cycle |
|
||||
| `pnpm tsx scripts/auth-smoke.ts` | Verify hashing, PIN/password login, and lockout |
|
||||
| `pnpm tsx scripts/report-smoke.ts` | Verify shift-report aggregation against seeded data |
|
||||
| `pnpm tsx scripts/quality-smoke.ts` | Verify the MY QUALITY loop (badge-in, defect create→acknowledge→correct, roles) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react';
|
||||
import { CheckCircle2, Clock, Loader2, Wrench, BarChart2, ClipboardList } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
@ -275,6 +275,13 @@ export function MaintenanceQueue() {
|
||||
</h1>
|
||||
</div>
|
||||
<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
|
||||
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"
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { resolveUser } from '@/lib/auth';
|
||||
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 />;
|
||||
}
|
||||
|
||||
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({
|
||||
email,
|
||||
secret: password,
|
||||
allowedRoles: ['ADMIN', 'SUPERVISOR'],
|
||||
allowedRoles: ['ADMIN', 'SUPERVISOR', 'QUALITY'],
|
||||
});
|
||||
if (!u) return null;
|
||||
return {
|
||||
|
||||
@ -51,7 +51,48 @@
|
||||
"resolveNoteLabel": "Resolution note (optional)",
|
||||
"resolveNotePlaceholder": "Describe what was done…",
|
||||
"documentTitleWithCount": "({count}) FieldOps — Maintenance",
|
||||
"documentTitle": "FieldOps — Maintenance"
|
||||
"documentTitle": "FieldOps — Maintenance",
|
||||
"qualityLink": "Quality"
|
||||
},
|
||||
"quality": {
|
||||
"consoleTitle": "Quality defects",
|
||||
"newDefect": "New defect",
|
||||
"queueTitle": "Raised defects",
|
||||
"backToMaintenance": "Maintenance",
|
||||
"filterStatus": "Status:",
|
||||
"updatesEvery": "Updates every 5s",
|
||||
"empty": "No defects match the current filters.",
|
||||
"photoAlt": "Defect photo",
|
||||
"location": "Location",
|
||||
"rfs": "RFS",
|
||||
"createdBy": "Raised by {email} · {time}",
|
||||
"acknowledgedBy": "Acknowledged by {email} · {time}",
|
||||
"correctedBy": "Corrected by {email} · {time}",
|
||||
"documentTitle": "FieldOps — Quality",
|
||||
"status": {
|
||||
"open": "Unacknowledged",
|
||||
"acknowledged": "Correcting",
|
||||
"corrected": "Corrected"
|
||||
},
|
||||
"form": {
|
||||
"workstation": "Workstation",
|
||||
"workstationPlaceholder": "Select a workstation…",
|
||||
"defectType": "Defect type",
|
||||
"defectTypePlaceholder": "e.g. Torque out of spec",
|
||||
"location": "Location (optional)",
|
||||
"locationPlaceholder": "e.g. Front-left seat",
|
||||
"rfs": "RFS code (optional)",
|
||||
"rfsPlaceholder": "e.g. RFS-1042",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Describe the detected defect…",
|
||||
"photo": "Photo (optional)",
|
||||
"photoButton": "Choose photo",
|
||||
"photoChange": "Change photo",
|
||||
"submit": "Raise defect",
|
||||
"submitting": "Raising…",
|
||||
"submitError": "Error raising defect. Please try again.",
|
||||
"photoError": "Could not process the photo."
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"pageTitle": "FieldOps — Shift report",
|
||||
|
||||
@ -51,7 +51,48 @@
|
||||
"resolveNoteLabel": "Nota de resolução (opcional)",
|
||||
"resolveNotePlaceholder": "Descreve o que foi feito…",
|
||||
"documentTitleWithCount": "({count}) FieldOps — Manutenção",
|
||||
"documentTitle": "FieldOps — Manutenção"
|
||||
"documentTitle": "FieldOps — Manutenção",
|
||||
"qualityLink": "Qualidade"
|
||||
},
|
||||
"quality": {
|
||||
"consoleTitle": "Defeitos de qualidade",
|
||||
"newDefect": "Novo defeito",
|
||||
"queueTitle": "Defeitos lançados",
|
||||
"backToMaintenance": "Manutenção",
|
||||
"filterStatus": "Estado:",
|
||||
"updatesEvery": "Atualiza a cada 5s",
|
||||
"empty": "Nenhum defeito com os filtros atuais.",
|
||||
"photoAlt": "Foto do defeito",
|
||||
"location": "Localização",
|
||||
"rfs": "RFS",
|
||||
"createdBy": "Lançado por {email} · {time}",
|
||||
"acknowledgedBy": "Reconhecido por {email} · {time}",
|
||||
"correctedBy": "Corrigido por {email} · {time}",
|
||||
"documentTitle": "FieldOps — Qualidade",
|
||||
"status": {
|
||||
"open": "Por reconhecer",
|
||||
"acknowledged": "Em correção",
|
||||
"corrected": "Corrigido"
|
||||
},
|
||||
"form": {
|
||||
"workstation": "Posto",
|
||||
"workstationPlaceholder": "Seleciona um posto…",
|
||||
"defectType": "Tipo de defeito",
|
||||
"defectTypePlaceholder": "Ex.: Aperto não conforme",
|
||||
"location": "Localização (opcional)",
|
||||
"locationPlaceholder": "Ex.: Banco dianteiro esquerdo",
|
||||
"rfs": "Código RFS (opcional)",
|
||||
"rfsPlaceholder": "Ex.: RFS-1042",
|
||||
"description": "Descrição",
|
||||
"descriptionPlaceholder": "Descreve o defeito detetado…",
|
||||
"photo": "Foto (opcional)",
|
||||
"photoButton": "Escolher foto",
|
||||
"photoChange": "Trocar foto",
|
||||
"submit": "Lançar defeito",
|
||||
"submitting": "A lançar…",
|
||||
"submitError": "Erro ao lançar defeito. Tenta de novo.",
|
||||
"photoError": "Não foi possível processar a foto."
|
||||
}
|
||||
},
|
||||
"report": {
|
||||
"pageTitle": "FieldOps — Relatório de turno",
|
||||
|
||||
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 { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Camera, X } from 'lucide-react';
|
||||
import { ArrowLeft, Camera, X, MapPin } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
import { db } from '@/lib/queue/db';
|
||||
@ -48,17 +48,17 @@ export default function NewRequestPage() {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [workstationId, setWorkstationId] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [photoBlob, setPhotoBlob] = useState<Blob | null>(null);
|
||||
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(
|
||||
undefined,
|
||||
{ staleTime: 60 * 60 * 1000 },
|
||||
);
|
||||
// Workstation is no longer chosen per-request: it comes from the operator's
|
||||
// active badge-in session. It still travels in the queued payload so offline
|
||||
// submissions remain self-contained even if the operator later changes posto.
|
||||
const { data: session, isLoading: sessionLoading } = trpc.operatorSession.current.useQuery();
|
||||
const workstationId = session?.workstationId ?? '';
|
||||
|
||||
async function handlePhotoChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
@ -121,28 +121,24 @@ export default function NewRequestPage() {
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
|
||||
{/* Workstation */}
|
||||
{/* Workstation — read-only, from the active session */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="workstation" className="text-sm font-medium">
|
||||
{t('workstationLabel')} <span className="text-destructive">{t('workstationRequired')}</span>
|
||||
</label>
|
||||
<select
|
||||
id="workstation"
|
||||
value={workstationId}
|
||||
onChange={(e) => setWorkstationId(e.target.value)}
|
||||
required
|
||||
disabled={wsLoading}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2.5 text-sm disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{wsLoading ? t('workstationLoading') : t('workstationPlaceholder')}
|
||||
</option>
|
||||
{workstations.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
{ws.code} — {ws.name} · {ws.area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-sm font-medium">{t('workstationLabel')}</span>
|
||||
{sessionLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{t('workstationLoading')}</p>
|
||||
) : session ? (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-muted/40 px-3 py-2.5 text-sm">
|
||||
<MapPin className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="font-medium">
|
||||
{session.workstation.code} — {session.workstation.name}
|
||||
<span className="text-xs text-muted-foreground"> · {session.workstation.area}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{t('noSession')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Photo */}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import Link from 'next/link';
|
||||
import { Wrench } from 'lucide-react';
|
||||
import { Wrench, ClipboardCheck, ChevronRight } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import { resolveUser } from '@/lib/auth';
|
||||
import { api } from '@/lib/trpc/server';
|
||||
@ -7,22 +7,22 @@ import { SignOutButton } from './sign-out-button';
|
||||
import { StatusBadge } from './status-badge';
|
||||
import { SyncChip } from './sync-chip';
|
||||
import { LanguageSwitcher } from './language-switcher';
|
||||
import { BadgeInPanel } from './badge-in-panel';
|
||||
import { SessionBar } from './session-bar';
|
||||
|
||||
export default async function HomePage() {
|
||||
const t = await getTranslations('home');
|
||||
const user = await resolveUser();
|
||||
|
||||
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
|
||||
let recent: RecentItem[] = [];
|
||||
// Current badge-in session (operator bound to a workstation).
|
||||
let session: Awaited<ReturnType<typeof api.operatorSession.current>> = null;
|
||||
try {
|
||||
recent = await api.maintenanceRequest.myRecent({ limit: 5 });
|
||||
session = await api.operatorSession.current();
|
||||
} catch {
|
||||
// No session or other error — show empty list without crashing.
|
||||
// No auth / error — treat as not badged in.
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
||||
{/* ── Header ── */}
|
||||
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>
|
||||
@ -35,22 +35,95 @@ export default async function HomePage() {
|
||||
<SignOutButton />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
// ── Not badged in: prompt to pick a workstation ──
|
||||
if (!session) {
|
||||
let workstations: Awaited<ReturnType<typeof api.workstation.list>> = [];
|
||||
try {
|
||||
workstations = await api.workstation.list();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
||||
{header}
|
||||
<div className="flex flex-1 flex-col gap-6 p-4">
|
||||
<SyncChip />
|
||||
<BadgeInPanel workstations={workstations} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Badged in: full home ──
|
||||
type RecentItem = Awaited<ReturnType<typeof api.maintenanceRequest.myRecent>>[number];
|
||||
let recent: RecentItem[] = [];
|
||||
try {
|
||||
recent = await api.maintenanceRequest.myRecent({ limit: 5 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let openDefects = 0;
|
||||
try {
|
||||
const defects = await api.qualityDefect.forMyStation();
|
||||
openDefects = defects.filter((d) => d.status === 'OPEN').length;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
||||
{header}
|
||||
|
||||
<div className="flex flex-1 flex-col gap-6 p-4">
|
||||
{/* ── Sync status ── */}
|
||||
<SyncChip />
|
||||
|
||||
{/* ── Primary CTA ── */}
|
||||
{/* Current workstation + badge-out */}
|
||||
<SessionBar
|
||||
code={session.workstation.code}
|
||||
name={session.workstation.name}
|
||||
area={session.workstation.area}
|
||||
/>
|
||||
|
||||
{/* 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-10 text-lg font-semibold text-primary-foreground shadow-sm transition-opacity hover:opacity-90 active:scale-[0.98]"
|
||||
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>
|
||||
|
||||
{/* ── Recent requests ── */}
|
||||
<Link
|
||||
href="/quality"
|
||||
data-testid="btn-quality-defects"
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card px-6 py-5 transition-colors hover:bg-accent active:scale-[0.98]"
|
||||
>
|
||||
<span className="flex items-center gap-3">
|
||||
<ClipboardCheck className="h-6 w-6 text-primary" />
|
||||
<span>
|
||||
<span className="block text-base font-semibold">{t('defects')}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{openDefects > 0 ? t('defectsWithCount', { count: openDefects }) : t('noDefects')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
{openDefects > 0 && (
|
||||
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-orange-500 px-1.5 text-xs font-semibold text-white">
|
||||
{openDefects}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Recent requests */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-medium text-muted-foreground">{t('myRequests')}</h2>
|
||||
|
||||
|
||||
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 { useTranslations } from 'next-intl';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { ArrowLeft, Delete } from 'lucide-react';
|
||||
import { ArrowLeft, Delete, MapPin } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
|
||||
interface Operator {
|
||||
id: string;
|
||||
@ -13,7 +14,8 @@ interface Operator {
|
||||
|
||||
type PickerState =
|
||||
| { step: 'list' }
|
||||
| { step: 'pin'; operator: Operator };
|
||||
| { step: 'pin'; operator: Operator }
|
||||
| { step: 'workstation'; operator: Operator };
|
||||
|
||||
const PIN_MIN = 4;
|
||||
const PIN_MAX = 6;
|
||||
@ -50,15 +52,16 @@ function OperatorList({
|
||||
function PinPad({
|
||||
operator,
|
||||
onBack,
|
||||
onSuccess,
|
||||
t,
|
||||
tc,
|
||||
}: {
|
||||
operator: Operator;
|
||||
onBack: () => void;
|
||||
onSuccess: () => void;
|
||||
t: ReturnType<typeof useTranslations<'auth'>>;
|
||||
tc: ReturnType<typeof useTranslations<'common'>>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [digits, setDigits] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -88,8 +91,8 @@ function PinPad({
|
||||
setDigits('');
|
||||
setError(t('invalidPin'));
|
||||
} else {
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
// Authenticated — next step is binding to a workstation (badge-in).
|
||||
onSuccess();
|
||||
}
|
||||
} catch {
|
||||
setDigits('');
|
||||
@ -182,9 +185,68 @@ function PinPad({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkstationStep({
|
||||
operator,
|
||||
ts,
|
||||
}: {
|
||||
operator: Operator;
|
||||
ts: ReturnType<typeof useTranslations<'session'>>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { data: workstations = [], isLoading } = trpc.workstation.list.useQuery(undefined, {
|
||||
staleTime: 60 * 60 * 1000,
|
||||
});
|
||||
const startSession = trpc.operatorSession.start.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push('/');
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{operator.email}</p>
|
||||
<h2 className="mt-1 text-xl font-bold tracking-tight">{ts('badgeInTitle')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{ts('badgeInSubtitle')}</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">{ts('loadingStations')}</p>
|
||||
) : workstations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{ts('noStations')}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{workstations.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => startSession.mutate({ workstationId: ws.id })}
|
||||
disabled={startSession.isPending}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-border bg-card px-6 py-5 text-left transition-colors hover:bg-accent active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
<MapPin className="h-5 w-5 shrink-0 text-primary" />
|
||||
<span>
|
||||
<span className="block text-base font-medium">
|
||||
{ws.code} — {ws.name}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">{ws.area}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{startSession.isPending && (
|
||||
<p className="text-center text-sm text-muted-foreground">{ts('starting')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
||||
const t = useTranslations('auth');
|
||||
const tc = useTranslations('common');
|
||||
const ts = useTranslations('session');
|
||||
const [state, setState] = useState<PickerState>({ step: 'list' });
|
||||
|
||||
if (state.step === 'pin') {
|
||||
@ -192,12 +254,17 @@ export function OperatorPicker({ operators }: { operators: Operator[] }) {
|
||||
<PinPad
|
||||
operator={state.operator}
|
||||
onBack={() => setState({ step: 'list' })}
|
||||
onSuccess={() => setState({ step: 'workstation', operator: state.operator })}
|
||||
t={t}
|
||||
tc={tc}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === 'workstation') {
|
||||
return <WorkstationStep operator={state.operator} ts={ts} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<OperatorList
|
||||
operators={operators}
|
||||
|
||||
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": {
|
||||
"enter": "Sign in",
|
||||
"entering": "Signing in…",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"status": {
|
||||
"open": "Open",
|
||||
"claimed": "In progress",
|
||||
"resolved": "Resolved"
|
||||
},
|
||||
"timeAgo": {
|
||||
"now": "just now",
|
||||
"minutesAgo": "{mins}m ago",
|
||||
"hoursAgo": "{hours}h ago",
|
||||
"daysAgo": "{days}d ago"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@ -32,11 +40,26 @@
|
||||
"deleteDigit": "Delete",
|
||||
"switchOperator": "Switch"
|
||||
},
|
||||
"session": {
|
||||
"badgeInTitle": "Which workstation are you at?",
|
||||
"badgeInSubtitle": "Register your workstation to start.",
|
||||
"loadingStations": "Loading workstations…",
|
||||
"noStations": "No workstations configured.",
|
||||
"starting": "Registering…",
|
||||
"atStation": "At workstation",
|
||||
"badgeOut": "Badge out",
|
||||
"badgeInPrompt": "Register your workstation to start working.",
|
||||
"badgeInButton": "Badge in",
|
||||
"endError": "Could not badge out. Please try again."
|
||||
},
|
||||
"home": {
|
||||
"operator": "Operator",
|
||||
"myRequests": "My requests",
|
||||
"requestMaintenance": "Request maintenance",
|
||||
"noRequests": "No requests yet."
|
||||
"noRequests": "No requests yet.",
|
||||
"defects": "Quality defects",
|
||||
"defectsWithCount": "{count, plural, one {# defect to handle} other {# defects to handle}}",
|
||||
"noDefects": "No defects to handle"
|
||||
},
|
||||
"sync": {
|
||||
"deadLetters": "{count, plural, one {# request failed — contact your supervisor.} other {# requests failed — contact your supervisor.}}",
|
||||
@ -51,6 +74,7 @@
|
||||
"workstationRequired": "*",
|
||||
"workstationLoading": "Loading workstations…",
|
||||
"workstationPlaceholder": "Select a workstation…",
|
||||
"noSession": "Badge in to a workstation before requesting maintenance.",
|
||||
"photoLabel": "Photo (optional)",
|
||||
"photoPreview": "Preview",
|
||||
"photoButton": "Take / choose photo",
|
||||
@ -66,5 +90,31 @@
|
||||
"sentMessage": "The maintenance team has been notified and will handle the issue.",
|
||||
"pendingMessage": "Will be sent as soon as the connection is restored.",
|
||||
"backHome": "Back to home"
|
||||
},
|
||||
"quality": {
|
||||
"title": "Defects at my station",
|
||||
"subtitle": "Workstation {code}",
|
||||
"empty": "No defects at your station. ✓",
|
||||
"noSession": "Badge in to a workstation to see quality defects.",
|
||||
"photo": "Defect photo",
|
||||
"rfs": "RFS",
|
||||
"location": "Location",
|
||||
"raised": "Raised by {email} · {time}",
|
||||
"acknowledge": "Acknowledge",
|
||||
"acknowledging": "Saving…",
|
||||
"correct": "Mark corrected",
|
||||
"correctDialogTitle": "Mark as corrected",
|
||||
"correctNoteLabel": "Correction note (optional)",
|
||||
"correctNotePlaceholder": "Describe what was corrected…",
|
||||
"acknowledgedBy": "Acknowledged · {time}",
|
||||
"backHome": "Back to home",
|
||||
"updatesEvery": "Updates every 5s",
|
||||
"soundOn": "🔔 Sound on",
|
||||
"soundOff": "🔕 Sound off",
|
||||
"status": {
|
||||
"open": "New",
|
||||
"acknowledged": "Correcting",
|
||||
"corrected": "Corrected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,10 +7,18 @@
|
||||
"common": {
|
||||
"enter": "Entrar",
|
||||
"entering": "A entrar…",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"status": {
|
||||
"open": "Aberto",
|
||||
"claimed": "Em curso",
|
||||
"resolved": "Resolvido"
|
||||
},
|
||||
"timeAgo": {
|
||||
"now": "agora",
|
||||
"minutesAgo": "há {mins}m",
|
||||
"hoursAgo": "há {hours}h",
|
||||
"daysAgo": "há {days}d"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
@ -32,11 +40,26 @@
|
||||
"deleteDigit": "Apagar",
|
||||
"switchOperator": "Trocar"
|
||||
},
|
||||
"session": {
|
||||
"badgeInTitle": "Em que posto estás?",
|
||||
"badgeInSubtitle": "Regista o teu posto para começar.",
|
||||
"loadingStations": "A carregar postos…",
|
||||
"noStations": "Nenhum posto configurado.",
|
||||
"starting": "A registar…",
|
||||
"atStation": "No posto",
|
||||
"badgeOut": "Sair do posto",
|
||||
"badgeInPrompt": "Regista o teu posto para começar a trabalhar.",
|
||||
"badgeInButton": "Entrar no posto",
|
||||
"endError": "Não foi possível sair do posto. Tenta de novo."
|
||||
},
|
||||
"home": {
|
||||
"operator": "Operador",
|
||||
"myRequests": "Os meus pedidos",
|
||||
"requestMaintenance": "Pedir manutenção",
|
||||
"noRequests": "Nenhum pedido ainda."
|
||||
"noRequests": "Nenhum pedido ainda.",
|
||||
"defects": "Defeitos de qualidade",
|
||||
"defectsWithCount": "{count, plural, one {# defeito por tratar} other {# defeitos por tratar}}",
|
||||
"noDefects": "Sem defeitos por tratar"
|
||||
},
|
||||
"sync": {
|
||||
"deadLetters": "{count, plural, one {# pedido com erro — contacta o supervisor.} other {# pedidos com erro — contacta o supervisor.}}",
|
||||
@ -51,6 +74,7 @@
|
||||
"workstationRequired": "*",
|
||||
"workstationLoading": "A carregar postos…",
|
||||
"workstationPlaceholder": "Seleciona um posto…",
|
||||
"noSession": "Entra num posto antes de pedir manutenção.",
|
||||
"photoLabel": "Foto (opcional)",
|
||||
"photoPreview": "Pré-visualização",
|
||||
"photoButton": "Tirar / escolher foto",
|
||||
@ -66,5 +90,31 @@
|
||||
"sentMessage": "A equipa de manutenção foi notificada e irá tratar do problema.",
|
||||
"pendingMessage": "Será enviado assim que a ligação for restabelecida.",
|
||||
"backHome": "Voltar ao início"
|
||||
},
|
||||
"quality": {
|
||||
"title": "Defeitos do meu posto",
|
||||
"subtitle": "Posto {code}",
|
||||
"empty": "Sem defeitos no teu posto. ✓",
|
||||
"noSession": "Entra num posto para veres os defeitos de qualidade.",
|
||||
"photo": "Foto do defeito",
|
||||
"rfs": "RFS",
|
||||
"location": "Localização",
|
||||
"raised": "Lançado por {email} · {time}",
|
||||
"acknowledge": "Tomei conhecimento",
|
||||
"acknowledging": "A registar…",
|
||||
"correct": "Marcar corrigido",
|
||||
"correctDialogTitle": "Marcar como corrigido",
|
||||
"correctNoteLabel": "Nota de correção (opcional)",
|
||||
"correctNotePlaceholder": "Descreve o que foi corrigido…",
|
||||
"acknowledgedBy": "Reconhecido · {time}",
|
||||
"backHome": "Voltar ao início",
|
||||
"updatesEvery": "Atualiza a cada 5s",
|
||||
"soundOn": "🔔 Som on",
|
||||
"soundOff": "🔕 Som off",
|
||||
"status": {
|
||||
"open": "Novo",
|
||||
"acknowledged": "Em correção",
|
||||
"corrected": "Corrigido"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,18 +14,23 @@ test('MAI CALL happy path: create → claim → resolve', async ({ page, context
|
||||
// Use a unique description so the test can find its own card in the queue.
|
||||
const desc = `E2E ${Date.now()} — ruído anormal no posto`;
|
||||
|
||||
// ── 0. Badge in to a workstation (the request posto now comes from the
|
||||
// operator's active session, not a per-request dropdown) ────────────
|
||||
await page.goto('/');
|
||||
const requestBtn = page.getByTestId('btn-request-maintenance');
|
||||
if (!(await requestBtn.isVisible().catch(() => false))) {
|
||||
// No active session yet → pick the first workstation in the badge-in panel.
|
||||
await page.getByTestId('badge-in-station').first().click();
|
||||
await expect(requestBtn).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
// ── 1. Operator creates a request ────────────────────────────────────────
|
||||
await page.goto('/maintenance/new');
|
||||
|
||||
// Wait for workstation options to load (select is disabled while loading)
|
||||
await expect(page.locator('#workstation')).toBeEnabled({ timeout: 15_000 });
|
||||
|
||||
// Select the first real workstation
|
||||
const firstOpt = page.locator('#workstation option:not([value=""])').first();
|
||||
const wsValue = await firstOpt.getAttribute('value');
|
||||
await page.selectOption('#workstation', wsValue!);
|
||||
await requestBtn.click();
|
||||
await page.waitForURL('**/maintenance/new**');
|
||||
|
||||
// The workstation is shown read-only from the session; just describe + submit.
|
||||
await page.fill('#description', desc);
|
||||
await expect(page.locator('button[type=submit]')).toBeEnabled({ timeout: 15_000 });
|
||||
await page.click('button[type=submit]');
|
||||
|
||||
// ── 2. Wait for the sync to complete ─────────────────────────────────────
|
||||
|
||||
@ -11,7 +11,7 @@ import { logger } from './logger';
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: 'ADMIN' | 'SUPERVISOR' | 'OPERATOR';
|
||||
role: 'ADMIN' | 'SUPERVISOR' | 'QUALITY' | 'OPERATOR';
|
||||
tenantId: string;
|
||||
};
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@ import { workstationRouter } from './workstation';
|
||||
import { userRouter } from './user';
|
||||
import { storageRouter } from './storage';
|
||||
import { maintenanceRequestRouter } from './maintenance-request';
|
||||
import { operatorSessionRouter } from './operator-session';
|
||||
import { qualityDefectRouter } from './quality-defect';
|
||||
|
||||
export const appRouter = router({
|
||||
ping: pingRouter,
|
||||
@ -11,6 +13,8 @@ export const appRouter = router({
|
||||
user: userRouter,
|
||||
storage: storageRouter,
|
||||
maintenanceRequest: maintenanceRequestRouter,
|
||||
operatorSession: operatorSessionRouter,
|
||||
qualityDefect: qualityDefectRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
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
|
||||
.string()
|
||||
.regex(/^tenants\/[a-z0-9-]+\/maintenance\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/);
|
||||
.regex(/^tenants\/[a-z0-9-]+\/(maintenance|quality)\/[a-z0-9-]+\.(jpg|jpeg|png|webp)$/);
|
||||
|
||||
export const storageRouter = router({
|
||||
signPhotoUpload: protectedProcedure
|
||||
@ -20,11 +20,14 @@ export const storageRouter = router({
|
||||
z.object({
|
||||
contentType: z.enum(CONTENT_TYPES),
|
||||
byteSize: z.number().int().min(1).max(10 * 1024 * 1024),
|
||||
// Logical bucket inside the tenant prefix. Defaults to 'maintenance' so
|
||||
// existing MAI CALL callers need no change; MY QUALITY uses 'quality'.
|
||||
category: z.enum(['maintenance', 'quality']).default('maintenance'),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const ext = EXT_MAP[input.contentType];
|
||||
const photoKey = `tenants/${ctx.tenantId}/maintenance/${randomUUID()}.${ext}`;
|
||||
const photoKey = `tenants/${ctx.tenantId}/${input.category}/${randomUUID()}.${ext}`;
|
||||
const storage = makeStorage();
|
||||
const { url: uploadUrl, expiresAt } = await storage.signPut(
|
||||
photoKey,
|
||||
|
||||
@ -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 {
|
||||
ADMIN
|
||||
SUPERVISOR
|
||||
QUALITY
|
||||
OPERATOR
|
||||
}
|
||||
|
||||
@ -25,6 +26,12 @@ enum MaintenanceRequestStatus {
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
enum QualityDefectStatus {
|
||||
OPEN
|
||||
ACKNOWLEDGED
|
||||
CORRECTED
|
||||
}
|
||||
|
||||
model Tenant {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@ -34,6 +41,8 @@ model Tenant {
|
||||
workstations Workstation[]
|
||||
events DomainEvent[]
|
||||
maintenanceRequests MaintenanceRequest[]
|
||||
operatorSessions OperatorSession[]
|
||||
qualityDefects QualityDefect[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@ -52,6 +61,11 @@ model User {
|
||||
claimedRequests MaintenanceRequest[] @relation("claimed")
|
||||
resolvedRequests MaintenanceRequest[] @relation("resolved")
|
||||
|
||||
sessions OperatorSession[]
|
||||
createdDefects QualityDefect[] @relation("defectCreated")
|
||||
acknowledgedDefects QualityDefect[] @relation("defectAcknowledged")
|
||||
correctedDefects QualityDefect[] @relation("defectCorrected")
|
||||
|
||||
@@unique([tenantId, email])
|
||||
@@index([tenantId])
|
||||
}
|
||||
@ -65,6 +79,8 @@ model Workstation {
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
maintenanceRequests MaintenanceRequest[]
|
||||
operatorSessions OperatorSession[]
|
||||
qualityDefects QualityDefect[]
|
||||
|
||||
@@unique([tenantId, code])
|
||||
@@index([tenantId])
|
||||
@ -115,3 +131,58 @@ model MaintenanceRequest {
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([tenantId, reportedByUserId])
|
||||
}
|
||||
|
||||
/// MY QUALITY — an operator's active binding to a workstation ("badge-in").
|
||||
/// At most one active session (endedAt == null) per user; starting a new one
|
||||
/// ends the previous. Quality defects route to whoever has the active session
|
||||
/// at the targeted workstation.
|
||||
model OperatorSession {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
userId String
|
||||
workstationId String
|
||||
startedAt DateTime @default(now())
|
||||
endedAt DateTime?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
workstation Workstation @relation(fields: [workstationId], references: [id])
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([tenantId, userId, endedAt])
|
||||
@@index([tenantId, workstationId, endedAt])
|
||||
}
|
||||
|
||||
/// MY QUALITY — a quality defect raised by QCP against a workstation, routed to
|
||||
/// the operator currently bound there. Mirrors MaintenanceRequest but in the
|
||||
/// opposite direction (quality -> operator). State: OPEN -> ACKNOWLEDGED ->
|
||||
/// CORRECTED.
|
||||
model QualityDefect {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
workstationId String
|
||||
createdByUserId String
|
||||
defectType String
|
||||
location String?
|
||||
description String
|
||||
rfsCode String?
|
||||
photoKey String?
|
||||
status QualityDefectStatus @default(OPEN)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
acknowledgedByUserId String?
|
||||
acknowledgedAt DateTime?
|
||||
|
||||
correctedByUserId String?
|
||||
correctedAt DateTime?
|
||||
correctionNote String?
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
workstation Workstation @relation(fields: [workstationId], references: [id])
|
||||
createdBy User @relation("defectCreated", fields: [createdByUserId], references: [id])
|
||||
acknowledgedBy User? @relation("defectAcknowledged", fields: [acknowledgedByUserId], references: [id])
|
||||
correctedBy User? @relation("defectCorrected", fields: [correctedByUserId], references: [id])
|
||||
|
||||
@@index([tenantId, status, createdAt])
|
||||
@@index([tenantId, workstationId, status])
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ const prisma = new PrismaClient();
|
||||
const DEMO_TENANT_NAME = 'Demo Factory';
|
||||
const DEMO_ADMIN_EMAIL = 'admin@demo.local';
|
||||
const DEMO_ADMIN_PASSWORD = 'admin1234';
|
||||
const DEMO_QCP_EMAIL = 'qcp@demo.local';
|
||||
const DEMO_QCP_PASSWORD = 'qcp1234';
|
||||
|
||||
const OPERATORS = [
|
||||
{ email: 'op1@demo.local', pin: '1111' },
|
||||
@ -49,6 +51,15 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
email: DEMO_QCP_EMAIL,
|
||||
role: UserRole.QUALITY,
|
||||
passwordHash: await hashSecret(DEMO_QCP_PASSWORD),
|
||||
},
|
||||
});
|
||||
|
||||
for (const op of OPERATORS) {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
@ -161,6 +172,77 @@ async function main() {
|
||||
}
|
||||
|
||||
console.warn(` pedidos de exemplo: ${samples.length} criados`);
|
||||
|
||||
// MY QUALITY — op1 is badged-in at the first workstation, and QCP has
|
||||
// raised a few defects there so the operator's alerts and the QCP queue
|
||||
// are non-empty on first boot.
|
||||
const station = wsList[0]!;
|
||||
const qcpUser = await prisma.user.findFirst({
|
||||
where: { tenantId: tenant.id, email: DEMO_QCP_EMAIL },
|
||||
});
|
||||
|
||||
await prisma.operatorSession.create({
|
||||
data: {
|
||||
tenantId: tenant.id,
|
||||
userId: op1User.id,
|
||||
workstationId: station.id,
|
||||
startedAt: ago(120),
|
||||
},
|
||||
});
|
||||
|
||||
if (qcpUser) {
|
||||
const defects = [
|
||||
// OPEN — operator hasn't seen it yet
|
||||
{
|
||||
tenantId: tenant.id,
|
||||
workstationId: station.id,
|
||||
createdByUserId: qcpUser.id,
|
||||
defectType: 'Aperto não conforme',
|
||||
location: 'Banco dianteiro esquerdo',
|
||||
description: 'Binário fora de especificação no parafuso da calha.',
|
||||
rfsCode: 'RFS-1042',
|
||||
status: 'OPEN' as const,
|
||||
createdAt: ago(8),
|
||||
},
|
||||
// ACKNOWLEDGED — operator saw it, correcting
|
||||
{
|
||||
tenantId: tenant.id,
|
||||
workstationId: station.id,
|
||||
createdByUserId: qcpUser.id,
|
||||
defectType: 'Clip em falta',
|
||||
location: 'Painel de porta traseira direita',
|
||||
description: 'Clip de fixação do painel ausente.',
|
||||
rfsCode: 'RFS-1043',
|
||||
status: 'ACKNOWLEDGED' as const,
|
||||
createdAt: ago(35),
|
||||
acknowledgedByUserId: op1User.id,
|
||||
acknowledgedAt: ago(30),
|
||||
},
|
||||
// CORRECTED — closed loop
|
||||
{
|
||||
tenantId: tenant.id,
|
||||
workstationId: station.id,
|
||||
createdByUserId: qcpUser.id,
|
||||
defectType: 'Risco na pintura',
|
||||
location: 'Capot',
|
||||
description: 'Risco superficial detetado no controlo visual.',
|
||||
rfsCode: 'RFS-1041',
|
||||
status: 'CORRECTED' as const,
|
||||
createdAt: ago(90),
|
||||
acknowledgedByUserId: op1User.id,
|
||||
acknowledgedAt: ago(85),
|
||||
correctedByUserId: op1User.id,
|
||||
correctedAt: ago(70),
|
||||
correctionNote: 'Polimento efetuado, defeito eliminado.',
|
||||
},
|
||||
];
|
||||
|
||||
for (const d of defects) {
|
||||
await prisma.qualityDefect.create({ data: d });
|
||||
}
|
||||
|
||||
console.warn(` defeitos de exemplo: ${defects.length} criados (op1 em ${station.code})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(
|
||||
@ -169,6 +251,9 @@ async function main() {
|
||||
console.warn(
|
||||
` admin: ${DEMO_ADMIN_EMAIL} / ${DEMO_ADMIN_PASSWORD}`,
|
||||
);
|
||||
console.warn(
|
||||
` qcp: ${DEMO_QCP_EMAIL} / ${DEMO_QCP_PASSWORD}`,
|
||||
);
|
||||
console.warn(
|
||||
` operadores: ${OPERATORS.map((o) => `${o.email}=${o.pin}`).join(' | ')}`,
|
||||
);
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
export { prisma, type DbClient } from './client';
|
||||
export { tenantScoped, type TenantScopedClient } from './tenant-extension';
|
||||
export { Prisma, UserRole, MaintenanceRequestStatus } from '@prisma/client';
|
||||
export type { User, Tenant, Workstation, DomainEvent, MaintenanceRequest } from '@prisma/client';
|
||||
export { Prisma, UserRole, MaintenanceRequestStatus, QualityDefectStatus } from '@prisma/client';
|
||||
export type {
|
||||
User,
|
||||
Tenant,
|
||||
Workstation,
|
||||
DomainEvent,
|
||||
MaintenanceRequest,
|
||||
OperatorSession,
|
||||
QualityDefect,
|
||||
} from '@prisma/client';
|
||||
export { hashSecret, verifySecret } from './crypto';
|
||||
|
||||
@ -83,7 +83,14 @@ import type { PrismaClient } from '@prisma/client';
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
const TENANT_SCOPED_MODELS = ['User', 'Workstation', 'DomainEvent', 'MaintenanceRequest'] as const;
|
||||
const TENANT_SCOPED_MODELS = [
|
||||
'User',
|
||||
'Workstation',
|
||||
'DomainEvent',
|
||||
'MaintenanceRequest',
|
||||
'OperatorSession',
|
||||
'QualityDefect',
|
||||
] as const;
|
||||
type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
||||
|
||||
function isTenantScoped(model: string | undefined): model is TenantScopedModel {
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/config": "workspace:*",
|
||||
"@types/node": "22.19.19",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/config/tsconfig/library.json",
|
||||
"extends": "@repo/config/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
}
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
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)
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.25
|
||||
version: 5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
next-intl:
|
||||
specifier: ^4.13.0
|
||||
version: 4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
version: 4.13.0(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
@ -167,10 +167,10 @@ importers:
|
||||
version: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.25
|
||||
version: 5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
version: 5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
|
||||
next-intl:
|
||||
specifier: ^4.13.0
|
||||
version: 4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
version: 4.13.0(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
pino:
|
||||
specifier: ^9.5.0
|
||||
version: 9.14.0
|
||||
@ -346,6 +346,9 @@ importers:
|
||||
'@repo/config':
|
||||
specifier: workspace:*
|
||||
version: link:../config
|
||||
'@types/node':
|
||||
specifier: 22.19.19
|
||||
version: 22.19.19
|
||||
rimraf:
|
||||
specifier: ^6.0.1
|
||||
version: 6.1.3
|
||||
@ -7142,7 +7145,7 @@ snapshots:
|
||||
|
||||
neo-async@2.6.2: {}
|
||||
|
||||
next-auth@5.0.0-beta.25(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
|
||||
next-auth@5.0.0-beta.25(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@auth/core': 0.37.2
|
||||
next: 15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@ -7150,7 +7153,7 @@ snapshots:
|
||||
|
||||
next-intl-swc-plugin-extractor@4.13.0: {}
|
||||
|
||||
next-intl@4.13.0(next@15.3.9(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
|
||||
next-intl@4.13.0(next@15.3.9(@babel/core@7.29.0)(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.8.9
|
||||
'@parcel/watcher': 2.5.6
|
||||
|
||||
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