diff --git a/apps/operator-pwa/app/maintenance/new/page.tsx b/apps/operator-pwa/app/maintenance/new/page.tsx new file mode 100644 index 0000000..1ebda4c --- /dev/null +++ b/apps/operator-pwa/app/maintenance/new/page.tsx @@ -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 { + 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(null); + + const [workstationId, setWorkstationId] = useState(''); + const [description, setDescription] = useState(''); + const [photoBlob, setPhotoBlob] = useState(null); + const [photoPreview, setPhotoPreview] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const { data: workstations = [], isLoading: wsLoading } = trpc.workstation.list.useQuery(); + const signUpload = trpc.storage.signPhotoUpload.useMutation(); + const createRequest = trpc.maintenanceRequest.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); + 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 ( +
+ {/* Header */} +
+ + + +

Novo pedido de manutenção

+
+ +
+ {/* Posto */} +
+ + +
+ + {/* Foto */} +
+ Foto (opcional) + {photoPreview ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Pré-visualização + +
+ ) : ( + + )} + +
+ + {/* Descrição */} +
+ +