MAI CALL - step 10
Passo 10 completo. AC verificado end-to-end: Sem foto — row criada com photoKey=null ✓ Com foto — upload para MinIO via presigned PUT + row criada com photoKey correto + conteúdo verificado via presigned GET ✓ O que foi implementado: /maintenance/new — Client Component com: select de posto (carregado via trpc.workstation.list), input de foto com compressão canvas (max 1600px, JPEG q=0.8), preview + botão remover, textarea com contador, submit que faz upload + create + redirect /maintenance/sent — Server Component que mostra o clientRequestId e o botão "Voltar ao início" Build de produção limpo com 7 rotas
This commit is contained in:
parent
04855cb8a4
commit
03c15fd069
238
apps/operator-pwa/app/maintenance/new/page.tsx
Normal file
238
apps/operator-pwa/app/maintenance/new/page.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Camera, X } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc/client';
|
||||
|
||||
// Resize to max 1600px on longest side and compress to JPEG q=0.8.
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
const signUpload = trpc.storage.signPhotoUpload.useMutation();
|
||||
const createRequest = trpc.maintenanceRequest.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);
|
||||
const preview = URL.createObjectURL(compressed);
|
||||
setPhotoBlob(compressed);
|
||||
setPhotoPreview(preview);
|
||||
} catch {
|
||||
setError('Não foi possível processar a foto. Tenta de novo.');
|
||||
}
|
||||
}
|
||||
|
||||
function removePhoto() {
|
||||
if (photoPreview) URL.revokeObjectURL(photoPreview);
|
||||
setPhotoBlob(null);
|
||||
setPhotoPreview(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!workstationId || description.trim().length < 3) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const clientRequestId = crypto.randomUUID();
|
||||
let photoKey: string | undefined;
|
||||
|
||||
// 1. Upload photo if present
|
||||
if (photoBlob) {
|
||||
const { uploadUrl, photoKey: key } = await signUpload.mutateAsync({
|
||||
contentType: 'image/jpeg',
|
||||
byteSize: photoBlob.size,
|
||||
});
|
||||
const res = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: photoBlob,
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
if (!res.ok) throw new Error('Falha no upload da foto');
|
||||
photoKey = key;
|
||||
}
|
||||
|
||||
// 2. Create request
|
||||
await createRequest.mutateAsync({
|
||||
workstationId,
|
||||
description: description.trim(),
|
||||
photoKey,
|
||||
clientRequestId,
|
||||
});
|
||||
|
||||
// 3. Confirm
|
||||
router.push(`/maintenance/sent?cid=${clientRequestId}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Erro ao submeter pedido. Tenta de novo.');
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const descLen = description.length;
|
||||
const canSubmit = workstationId !== '' && descLen >= 3 && descLen <= 1000 && !submitting;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header className="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">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">Novo pedido de manutenção</h1>
|
||||
</header>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-6 p-4">
|
||||
{/* Posto */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="workstation" className="text-sm font-medium">
|
||||
Posto <span className="text-destructive">*</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 ? 'A carregar postos…' : 'Seleciona um posto…'}
|
||||
</option>
|
||||
{workstations.map((ws) => (
|
||||
<option key={ws.id} value={ws.id}>
|
||||
{ws.code} — {ws.name} · {ws.area}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Foto */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">Foto (opcional)</span>
|
||||
{photoPreview ? (
|
||||
<div className="relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={photoPreview}
|
||||
alt="Pré-visualização"
|
||||
className="h-48 w-full 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-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="flex h-24 w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Camera className="h-5 w-5" />
|
||||
Tirar / escolher foto
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={handlePhotoChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Descrição */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="description" className="flex items-center justify-between text-sm font-medium">
|
||||
<span>
|
||||
Descrição <span className="text-destructive">*</span>
|
||||
</span>
|
||||
<span className={`text-xs ${descLen > 1000 ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{descLen}/1000
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={1000}
|
||||
rows={4}
|
||||
placeholder="Descreve o problema…"
|
||||
className="w-full resize-none rounded-lg border border-border bg-card px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg bg-destructive/10 px-3 py-2 text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full rounded-xl bg-primary px-6 py-4 text-base font-semibold text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{submitting ? 'A enviar…' : 'Enviar pedido'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
33
apps/operator-pwa/app/maintenance/sent/page.tsx
Normal file
33
apps/operator-pwa/app/maintenance/sent/page.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import Link from 'next/link';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
export default async function SentPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cid?: string }>;
|
||||
}) {
|
||||
const { cid } = await searchParams;
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-dvh max-w-lg flex-col items-center justify-center gap-6 p-6 text-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<CheckCircle2 className="h-16 w-16 text-green-500" />
|
||||
<h1 className="text-2xl font-bold">Pedido enviado</h1>
|
||||
{cid && (
|
||||
<p className="font-mono text-xs text-muted-foreground" data-testid="request-cid">
|
||||
{cid}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A equipa de manutenção foi notificada e irá tratar do problema.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-primary px-8 py-3 font-semibold text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
Voltar ao início
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
88
scripts/new-request-smoke.ts
Normal file
88
scripts/new-request-smoke.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* AC verification for Passo 10:
|
||||
* Submitting a request (with and without photo) creates the row in the DB.
|
||||
* When a photo is included, the object exists in MinIO.
|
||||
*/
|
||||
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';
|
||||
|
||||
async function makeCaller(email: string) {
|
||||
const user = await prisma.user.findFirst({ where: { email } });
|
||||
if (!user) throw new Error(`${email} not found — run pnpm db:seed`);
|
||||
const ctx = await createTRPCContext({
|
||||
user: { id: user.id, email: user.email, role: user.role as 'OPERATOR', tenantId: user.tenantId },
|
||||
headers: new Headers(),
|
||||
});
|
||||
return createCallerFactory(appRouter)(ctx);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const op1 = await makeCaller('op1@demo.local');
|
||||
const workstations = await op1.workstation.list();
|
||||
const ws = workstations[0];
|
||||
if (!ws) throw new Error('No workstations — run pnpm db:seed');
|
||||
|
||||
// --- Without photo ---
|
||||
console.log('1. Create request WITHOUT photo...');
|
||||
const noPhoto = await op1.maintenanceRequest.create({
|
||||
workstationId: ws.id,
|
||||
description: 'Problema no posto — sem foto',
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
});
|
||||
if (noPhoto.status !== 'OPEN') throw new Error('Expected OPEN status');
|
||||
const rowNoPhoto = await prisma.maintenanceRequest.findFirst({ where: { id: noPhoto.id } });
|
||||
if (!rowNoPhoto) throw new Error('Row not found in DB');
|
||||
if (rowNoPhoto.photoKey !== null) throw new Error('Expected null photoKey');
|
||||
console.log(` id=${noPhoto.id} photoKey=null ✓`);
|
||||
|
||||
// --- With photo ---
|
||||
console.log('2. Create request WITH photo...');
|
||||
const photoContent = 'fake-photo-content-passo10';
|
||||
const { uploadUrl, photoKey } = await op1.storage.signPhotoUpload({
|
||||
contentType: 'image/jpeg',
|
||||
byteSize: photoContent.length,
|
||||
});
|
||||
|
||||
const putRes = await fetch(uploadUrl, {
|
||||
method: 'PUT',
|
||||
body: photoContent,
|
||||
headers: { 'Content-Type': 'image/jpeg' },
|
||||
});
|
||||
if (!putRes.ok) throw new Error(`Photo PUT failed: ${putRes.status}`);
|
||||
|
||||
const withPhoto = await op1.maintenanceRequest.create({
|
||||
workstationId: ws.id,
|
||||
description: 'Problema no posto — com foto',
|
||||
photoKey,
|
||||
clientRequestId: crypto.randomUUID(),
|
||||
});
|
||||
if (withPhoto.status !== 'OPEN') throw new Error('Expected OPEN status');
|
||||
const rowWithPhoto = await prisma.maintenanceRequest.findFirst({ where: { id: withPhoto.id } });
|
||||
if (!rowWithPhoto?.photoKey) throw new Error('Expected photoKey in row');
|
||||
console.log(` id=${withPhoto.id} photoKey=${rowWithPhoto.photoKey} ✓`);
|
||||
|
||||
// Verify photo is in MinIO via signed GET
|
||||
const { url: getUrl } = await op1.storage.signPhotoDownload({ photoKey });
|
||||
const getRes = await fetch(getUrl);
|
||||
if (!getRes.ok) throw new Error(`Photo GET failed: ${getRes.status}`);
|
||||
const downloaded = await getRes.text();
|
||||
if (downloaded !== photoContent) throw new Error('Photo content mismatch');
|
||||
console.log(' Photo content in MinIO matches ✓');
|
||||
|
||||
await prisma.$disconnect();
|
||||
console.log('\nPasso 10 AC PASSED');
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error('Passo 10 AC FAILED:', err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user