From 1fdb9536faf6c62330d776f0334b46257c0ec6c6 Mon Sep 17 00:00:00 2001 From: Pedro Gomes Date: Thu, 11 Jun 2026 15:43:35 +0100 Subject: [PATCH] MY QUALITY - First iteration --- README.md | 52 ++- .../app/maintenance/maintenance-queue.tsx | 9 +- apps/admin-web/app/maintenance/page.tsx | 9 +- apps/admin-web/app/quality/page.tsx | 22 + .../admin-web/app/quality/quality-console.tsx | 438 ++++++++++++++++++ apps/admin-web/lib/auth.ts | 2 +- apps/admin-web/messages/en.json | 43 +- apps/admin-web/messages/pt.json | 43 +- apps/operator-pwa/app/badge-in-panel.tsx | 60 +++ .../operator-pwa/app/maintenance/new/page.tsx | 50 +- apps/operator-pwa/app/page.tsx | 125 +++-- apps/operator-pwa/app/quality/page.tsx | 289 ++++++++++++ .../app/select-operator/operator-picker.tsx | 77 ++- apps/operator-pwa/app/session-bar.tsx | 37 ++ apps/operator-pwa/messages/en.json | 52 ++- apps/operator-pwa/messages/pt.json | 52 ++- e2e/tests/mai-call.spec.ts | 23 +- packages/api/src/context.ts | 2 +- packages/api/src/routers/_app.ts | 4 + packages/api/src/routers/operator-session.ts | 53 +++ packages/api/src/routers/quality-defect.ts | 179 +++++++ packages/api/src/routers/storage.ts | 7 +- .../migration.sql | 78 ++++ packages/db/prisma/schema.prisma | 71 +++ packages/db/prisma/seed.ts | 85 ++++ packages/db/src/index.ts | 12 +- packages/db/src/tenant-extension.ts | 9 +- packages/storage/package.json | 1 + packages/storage/tsconfig.json | 9 +- pnpm-lock.yaml | 15 +- scripts/quality-smoke.ts | 155 +++++++ 31 files changed, 1965 insertions(+), 98 deletions(-) create mode 100644 apps/admin-web/app/quality/page.tsx create mode 100644 apps/admin-web/app/quality/quality-console.tsx create mode 100644 apps/operator-pwa/app/badge-in-panel.tsx create mode 100644 apps/operator-pwa/app/quality/page.tsx create mode 100644 apps/operator-pwa/app/session-bar.tsx create mode 100644 packages/api/src/routers/operator-session.ts create mode 100644 packages/api/src/routers/quality-defect.ts create mode 100644 packages/db/prisma/migrations/20260611141713_my_quality_sessions_defects/migration.sql create mode 100644 scripts/quality-smoke.ts diff --git a/README.md b/README.md index 8fa4779..5161d27 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ -# FieldOps — MAI CALL v0.3 +# FieldOps — MAI CALL + MY QUALITY -Modular industrial SaaS monorepo. **MAI CALL v0.3** is the latest shipped -feature: a full maintenance-request loop (offline-first, operator PIN + admin -password auth) with an **end-of-shift report** for supervisors. +Modular industrial SaaS monorepo. Two modules are shipped: + +- **MAI CALL** — a full maintenance-request loop (offline-first, operator PIN + + admin password auth) with an **end-of-shift report** for supervisors. +- **MY QUALITY** — operators **badge in** to a workstation (operator↔posto + session); the Quality team (QCP) raises **defects** against a workstation, + which are routed in real time to the operator bound there to acknowledge and + correct. State machine OPEN → ACKNOWLEDGED → CORRECTED. ## What's here @@ -76,10 +81,13 @@ pnpm --filter @repo/admin-web dev http://localhost:3000/select-operator, tap **op1@demo.local**, then enter PIN **1111** on the keypad. (op2 = **2222**, op3 = **3333**) -2. Tap **Pedir manutenção**. -3. Select a workstation, optionally attach a photo, write a description, - and tap **Enviar pedido**. -4. The page shows **"Pedido enviado"** once the sync completes (usually +2. **Badge in:** pick the workstation you are at. This starts your + operator↔posto session (the future RFID badge-in). The home shows your + current posto with a **Sair do posto** (badge-out) button. +3. Tap **Pedir manutenção**. +4. The posto is taken automatically from your session (no dropdown). + Optionally attach a photo, write a description, and tap **Enviar pedido**. +5. The page shows **"Pedido enviado"** once the sync completes (usually within 1–2 seconds when online). **Offline test:** @@ -97,7 +105,8 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears. 1. Open http://localhost:3001. With `AUTH_DEV_AUTOLOGIN=true` you land on the maintenance queue automatically. Without it, you see a login form — use - **admin@demo.local** / **admin1234**. + **admin@demo.local** / **admin1234** (or the QCP user + **qcp@demo.local** / **qcp1234**, who lands on the quality console). 2. The queue refreshes every 5 s; new requests appear automatically. 3. Click **Aceitar** to claim a request (status: Em curso). 4. Click **Marcar resolvido**, optionally add a note, click **Confirmar** @@ -118,6 +127,30 @@ The requests sync automatically within ~10 s; "Tudo sincronizado" appears. After `pnpm db:seed`, the "Hoje" window already has 6 sample requests (3 resolved, 1 claimed, 2 open) so the report is never empty on first boot. +### MY QUALITY — quality defects + +The Quality controller (QCP) raises defects; the operator at the targeted +workstation handles them. + +**As QCP (port 3001):** sign in with **qcp@demo.local** / **qcp1234** — you +land on the **Defeitos de qualidade** console (QCP users are redirected there +from `/maintenance`). Fill the **Novo defeito** form (posto, tipo, localização, +RFS, descrição, foto opcional) and click **Lançar defeito**. The queue below +polls every 5 s and shows each defect's lifecycle (who raised / acknowledged / +corrected it). Admins can reach the console from the **Qualidade** link in the +maintenance-queue header. + +**As operator (port 3000):** once badged in (see above), open **Defeitos de +qualidade** from the home. Defects raised at your posto appear here (polled, +with an optional sound alert). Tap **Tomei conhecimento** (→ ACKNOWLEDGED), then +**Marcar corrigido** with an optional note (→ CORRECTED). + +After `pnpm db:seed`, op1 is already badged in at **CTR04** with 3 sample +defects (1 open, 1 acknowledged, 1 corrected). + +Smoke test (no browser): `pnpm tsx scripts/quality-smoke.ts` (18 assertions — +the full QCP→operator loop, role guards, and state-machine conflicts). + --- ## MinIO (photo storage) @@ -275,6 +308,7 @@ key-parity and ICU validation scripts to run before shipping a new language. | `pnpm tsx scripts/maintenance-smoke.ts` | Verify the full create→claim→resolve cycle | | `pnpm tsx scripts/auth-smoke.ts` | Verify hashing, PIN/password login, and lockout | | `pnpm tsx scripts/report-smoke.ts` | Verify shift-report aggregation against seeded data | +| `pnpm tsx scripts/quality-smoke.ts` | Verify the MY QUALITY loop (badge-in, defect create→acknowledge→correct, roles) | --- diff --git a/apps/admin-web/app/maintenance/maintenance-queue.tsx b/apps/admin-web/app/maintenance/maintenance-queue.tsx index bdd4ea3..14592ed 100644 --- a/apps/admin-web/app/maintenance/maintenance-queue.tsx +++ b/apps/admin-web/app/maintenance/maintenance-queue.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useRef } from 'react'; -import { CheckCircle2, Clock, Loader2, Wrench, BarChart2 } from 'lucide-react'; +import { CheckCircle2, Clock, Loader2, Wrench, BarChart2, ClipboardList } from 'lucide-react'; import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { trpc } from '@/lib/trpc/client'; @@ -275,6 +275,13 @@ export function MaintenanceQueue() {
+ + + {t('qualityLink')} + ; } diff --git a/apps/admin-web/app/quality/page.tsx b/apps/admin-web/app/quality/page.tsx new file mode 100644 index 0000000..2457473 --- /dev/null +++ b/apps/admin-web/app/quality/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; +import type { Metadata } from 'next'; +import { resolveUser } from '@/lib/auth'; +import { QualityConsole } from './quality-console'; + +export async function generateMetadata(): Promise { + const t = await getTranslations('quality'); + return { title: t('documentTitle') }; +} + +export default async function QualityPage() { + const user = await resolveUser(); + // Only quality / admin / supervisor may view the console; everyone else out. + if (user && !['QUALITY', 'ADMIN', 'SUPERVISOR'].includes(user.role)) { + redirect('/maintenance'); + } + // Defects are raised by QCP and admins; supervisors get a read-only view. + const canCreate = user?.role === 'QUALITY' || user?.role === 'ADMIN'; + + return ; +} diff --git a/apps/admin-web/app/quality/quality-console.tsx b/apps/admin-web/app/quality/quality-console.tsx new file mode 100644 index 0000000..9fa410e --- /dev/null +++ b/apps/admin-web/app/quality/quality-console.tsx @@ -0,0 +1,438 @@ +'use client'; + +import { useState, useRef } from 'react'; +import Link from 'next/link'; +import { Wrench, Camera, X, Loader2, ClipboardList, AlertTriangle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { trpc } from '@/lib/trpc/client'; +import type { RouterOutputs } from '@/lib/trpc/server'; +import { LanguageSwitcher } from '../language-switcher'; + +type Defect = RouterOutputs['qualityDefect']['queue'][number]; +type DefectStatus = 'OPEN' | 'ACKNOWLEDGED' | 'CORRECTED'; +type TFn = (key: string, values?: Record) => string; + +function timeAgo(date: Date | string, t: TFn): string { + const diffMs = Date.now() - new Date(date).getTime(); + const mins = Math.floor(diffMs / 60_000); + if (mins < 1) return t('now'); + if (mins < 60) return t('minutesAgo', { mins }); + const hours = Math.floor(mins / 60); + if (hours < 24) return t('hoursAgo', { hours }); + return t('daysAgo', { days: Math.floor(hours / 24) }); +} + +function compressImage(file: File): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + img.onload = () => { + URL.revokeObjectURL(url); + const MAX = 1600; + let { width, height } = img; + if (width > MAX || height > MAX) { + if (width >= height) { + height = Math.round((height * MAX) / width); + width = MAX; + } else { + width = Math.round((width * MAX) / height); + height = MAX; + } + } + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) return reject(new Error('Canvas context unavailable')); + ctx.drawImage(img, 0, 0, width, height); + canvas.toBlob( + (blob) => (blob ? resolve(blob) : reject(new Error('Canvas toBlob failed'))), + 'image/jpeg', + 0.8, + ); + }; + img.onerror = () => reject(new Error('Image load failed')); + img.src = url; + }); +} + +const STATUS_CLASS: Record = { + OPEN: 'bg-orange-100 text-orange-700', + ACKNOWLEDGED: 'bg-blue-100 text-blue-700', + CORRECTED: 'bg-green-100 text-green-700', +}; + +function Thumbnail({ photoKey, alt }: { photoKey: string | null; alt: string }) { + const { data } = trpc.storage.signPhotoDownload.useQuery( + { photoKey: photoKey! }, + { enabled: !!photoKey, staleTime: 50_000 }, + ); + if (!photoKey) return null; + if (!data?.url) return
; + return ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ); +} + +// ── New-defect form ─────────────────────────────────────────────────────────── + +function NewDefectForm({ + onCreated, + t, +}: { + onCreated: () => void; + t: ReturnType>; +}) { + const fileRef = useRef(null); + const [workstationId, setWorkstationId] = useState(''); + const [defectType, setDefectType] = useState(''); + const [location, setLocation] = useState(''); + const [rfsCode, setRfsCode] = useState(''); + const [description, setDescription] = useState(''); + const [photoBlob, setPhotoBlob] = useState(null); + const [photoPreview, setPhotoPreview] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const { data: workstations = [] } = trpc.workstation.list.useQuery(undefined, { + staleTime: 60 * 60 * 1000, + }); + const signUpload = trpc.storage.signPhotoUpload.useMutation(); + const create = trpc.qualityDefect.create.useMutation(); + + async function handlePhotoChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + try { + const compressed = await compressImage(file); + if (photoPreview) URL.revokeObjectURL(photoPreview); + setPhotoBlob(compressed); + setPhotoPreview(URL.createObjectURL(compressed)); + } catch { + setError(t('form.photoError')); + } + } + + function removePhoto() { + if (photoPreview) URL.revokeObjectURL(photoPreview); + setPhotoBlob(null); + setPhotoPreview(null); + if (fileRef.current) fileRef.current.value = ''; + } + + function reset() { + setWorkstationId(''); + setDefectType(''); + setLocation(''); + setRfsCode(''); + setDescription(''); + removePhoto(); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!workstationId || defectType.trim().length < 1 || description.trim().length < 3) return; + setSubmitting(true); + setError(null); + try { + let photoKey: string | undefined; + if (photoBlob) { + const signed = await signUpload.mutateAsync({ + contentType: 'image/jpeg', + byteSize: photoBlob.size, + category: 'quality', + }); + const res = await fetch(signed.uploadUrl, { + method: 'PUT', + body: photoBlob, + headers: { 'Content-Type': 'image/jpeg' }, + }); + if (!res.ok) throw new Error(`Photo PUT ${res.status}`); + photoKey = signed.photoKey; + } + await create.mutateAsync({ + workstationId, + defectType: defectType.trim(), + location: location.trim() || undefined, + rfsCode: rfsCode.trim() || undefined, + description: description.trim(), + photoKey, + }); + reset(); + onCreated(); + } catch { + setError(t('form.submitError')); + } finally { + setSubmitting(false); + } + } + + const canSubmit = + workstationId !== '' && + defectType.trim().length >= 1 && + description.trim().length >= 3 && + !submitting; + + return ( +
+

{t('newDefect')}

+ +
+ + + + + + + +
+ +