Changes
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import jsPDF from 'jspdf';
|
||||
import type { ChecklistItem, Measurement } from '@/stores/useModelStore';
|
||||
|
||||
interface ReportData {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fineTuning: { posX: number; posY: number; posZ: number; rotY: number };
|
||||
checklist: ChecklistItem[];
|
||||
measurements: Measurement[];
|
||||
screenshots: string[];
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: 'Pendente',
|
||||
approved: 'Aprovado',
|
||||
rejected: 'Reprovado',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, [number, number, number]> = {
|
||||
pending: [180, 180, 50],
|
||||
approved: [34, 197, 94],
|
||||
rejected: [239, 68, 68],
|
||||
};
|
||||
|
||||
export async function generateInspectionReport(data: ReportData) {
|
||||
const pdf = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
|
||||
const pageW = pdf.internal.pageSize.getWidth();
|
||||
const margin = 15;
|
||||
const contentW = pageW - margin * 2;
|
||||
let y = margin;
|
||||
|
||||
const addPage = () => {
|
||||
pdf.addPage();
|
||||
y = margin;
|
||||
};
|
||||
|
||||
const checkSpace = (needed: number) => {
|
||||
if (y + needed > 280) addPage();
|
||||
};
|
||||
|
||||
// ── Header ──
|
||||
pdf.setFillColor(15, 23, 42);
|
||||
pdf.rect(0, 0, pageW, 35, 'F');
|
||||
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.setFontSize(18);
|
||||
pdf.setTextColor(255, 255, 255);
|
||||
pdf.text('TrackkSteelXR', margin, 16);
|
||||
|
||||
pdf.setFontSize(10);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.setTextColor(148, 163, 184);
|
||||
pdf.text('Relatório de Inspeção', margin, 24);
|
||||
|
||||
const dateStr = new Date().toLocaleDateString('pt-BR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
pdf.text(dateStr, pageW - margin, 24, { align: 'right' });
|
||||
|
||||
y = 45;
|
||||
|
||||
// ── Model Info ──
|
||||
pdf.setFontSize(12);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.setTextColor(30, 41, 59);
|
||||
pdf.text('Informações do Modelo', margin, y);
|
||||
y += 7;
|
||||
|
||||
pdf.setFontSize(9);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.setTextColor(71, 85, 105);
|
||||
|
||||
const infoLines = [
|
||||
['Arquivo:', data.fileName],
|
||||
['Tamanho:', `${(data.fileSize / (1024 * 1024)).toFixed(2)} MB`],
|
||||
['Escala:', '1:1 (mm)'],
|
||||
];
|
||||
for (const [label, value] of infoLines) {
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.text(label, margin, y);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.text(value, margin + 25, y);
|
||||
y += 5;
|
||||
}
|
||||
|
||||
// ── Fine Tuning ──
|
||||
y += 5;
|
||||
checkSpace(30);
|
||||
pdf.setFontSize(12);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.setTextColor(30, 41, 59);
|
||||
pdf.text('Ajuste Fino (Offsets)', margin, y);
|
||||
y += 7;
|
||||
|
||||
pdf.setFontSize(9);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.setTextColor(71, 85, 105);
|
||||
|
||||
const ft = data.fineTuning;
|
||||
const ftLines = [
|
||||
['Posição X:', `${ft.posX.toFixed(3)} m`],
|
||||
['Posição Y:', `${ft.posY.toFixed(3)} m`],
|
||||
['Posição Z:', `${ft.posZ.toFixed(3)} m`],
|
||||
['Rotação Y:', `${ft.rotY.toFixed(1)}°`],
|
||||
];
|
||||
for (const [label, value] of ftLines) {
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.text(label, margin, y);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.text(value, margin + 25, y);
|
||||
y += 5;
|
||||
}
|
||||
|
||||
// ── Checklist ──
|
||||
y += 5;
|
||||
checkSpace(50);
|
||||
pdf.setFontSize(12);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.setTextColor(30, 41, 59);
|
||||
pdf.text('Checklist de Inspeção', margin, y);
|
||||
y += 3;
|
||||
|
||||
// Summary bar
|
||||
const approved = data.checklist.filter(i => i.status === 'approved').length;
|
||||
const rejected = data.checklist.filter(i => i.status === 'rejected').length;
|
||||
const pending = data.checklist.filter(i => i.status === 'pending').length;
|
||||
y += 5;
|
||||
pdf.setFontSize(8);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.setTextColor(71, 85, 105);
|
||||
pdf.text(`Aprovados: ${approved} | Reprovados: ${rejected} | Pendentes: ${pending}`, margin, y);
|
||||
y += 7;
|
||||
|
||||
for (const item of data.checklist) {
|
||||
checkSpace(20);
|
||||
const color = STATUS_COLORS[item.status] || [100, 100, 100];
|
||||
|
||||
// Status pill
|
||||
pdf.setFillColor(color[0], color[1], color[2]);
|
||||
pdf.roundedRect(margin, y - 3.5, 18, 5, 1.5, 1.5, 'F');
|
||||
pdf.setFontSize(7);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.setTextColor(255, 255, 255);
|
||||
pdf.text(STATUS_LABELS[item.status] || item.status, margin + 9, y, { align: 'center' });
|
||||
|
||||
// Label
|
||||
pdf.setFontSize(9);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.setTextColor(30, 41, 59);
|
||||
pdf.text(item.label, margin + 22, y);
|
||||
y += 5;
|
||||
|
||||
// Note
|
||||
if (item.note) {
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(100, 116, 139);
|
||||
const noteLines = pdf.splitTextToSize(`Nota: ${item.note}`, contentW - 22);
|
||||
pdf.text(noteLines, margin + 22, y);
|
||||
y += noteLines.length * 4;
|
||||
}
|
||||
|
||||
if (item.audioUrl) {
|
||||
pdf.setFontSize(7);
|
||||
pdf.setTextColor(100, 116, 139);
|
||||
pdf.text('🎙️ Áudio gravado anexado', margin + 22, y);
|
||||
y += 4;
|
||||
}
|
||||
|
||||
y += 3;
|
||||
}
|
||||
|
||||
// ── Measurements ──
|
||||
if (data.measurements.length > 0) {
|
||||
y += 3;
|
||||
checkSpace(30);
|
||||
pdf.setFontSize(12);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.setTextColor(30, 41, 59);
|
||||
pdf.text('Medições', margin, y);
|
||||
y += 7;
|
||||
|
||||
// Table header
|
||||
pdf.setFillColor(241, 245, 249);
|
||||
pdf.rect(margin, y - 4, contentW, 6, 'F');
|
||||
pdf.setFontSize(8);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.setTextColor(71, 85, 105);
|
||||
pdf.text('#', margin + 2, y);
|
||||
pdf.text('Ponto A (mm)', margin + 12, y);
|
||||
pdf.text('Ponto B (mm)', margin + 65, y);
|
||||
pdf.text('Distância', margin + 120, y);
|
||||
y += 6;
|
||||
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(30, 41, 59);
|
||||
|
||||
data.measurements.forEach((m, idx) => {
|
||||
checkSpace(8);
|
||||
if (idx % 2 === 0) {
|
||||
pdf.setFillColor(248, 250, 252);
|
||||
pdf.rect(margin, y - 4, contentW, 6, 'F');
|
||||
}
|
||||
const fmtPt = (p: { x: number; y: number; z: number }) =>
|
||||
`${(p.x * 1000).toFixed(1)}, ${(p.y * 1000).toFixed(1)}, ${(p.z * 1000).toFixed(1)}`;
|
||||
|
||||
pdf.text(`${idx + 1}`, margin + 2, y);
|
||||
pdf.text(fmtPt(m.pointA), margin + 12, y);
|
||||
pdf.text(fmtPt(m.pointB), margin + 65, y);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.text(`${m.distanceMM.toFixed(1)} mm`, margin + 120, y);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
y += 6;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Screenshots ──
|
||||
if (data.screenshots.length > 0) {
|
||||
addPage();
|
||||
pdf.setFontSize(12);
|
||||
pdf.setFont('helvetica', 'bold');
|
||||
pdf.setTextColor(30, 41, 59);
|
||||
pdf.text('Screenshots / Anotações', margin, y);
|
||||
y += 8;
|
||||
|
||||
for (let i = 0; i < data.screenshots.length; i++) {
|
||||
checkSpace(100);
|
||||
try {
|
||||
const imgW = contentW;
|
||||
const imgH = imgW * 0.56; // ~16:9
|
||||
pdf.addImage(data.screenshots[i], 'PNG', margin, y, imgW, imgH);
|
||||
y += imgH + 3;
|
||||
|
||||
pdf.setFontSize(7);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.setTextColor(148, 163, 184);
|
||||
pdf.text(`Captura ${i + 1} de ${data.screenshots.length}`, margin, y);
|
||||
y += 8;
|
||||
} catch {
|
||||
pdf.setFontSize(8);
|
||||
pdf.setTextColor(200, 50, 50);
|
||||
pdf.text(`[Erro ao inserir screenshot ${i + 1}]`, margin, y);
|
||||
y += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footer on last page ──
|
||||
const pageH = pdf.internal.pageSize.getHeight();
|
||||
pdf.setFillColor(241, 245, 249);
|
||||
pdf.rect(0, pageH - 12, pageW, 12, 'F');
|
||||
pdf.setFontSize(7);
|
||||
pdf.setFont('helvetica', 'normal');
|
||||
pdf.setTextColor(148, 163, 184);
|
||||
pdf.text('Gerado por TrackkSteelXR', margin, pageH - 5);
|
||||
pdf.text(`Página ${pdf.getNumberOfPages()}`, pageW - margin, pageH - 5, { align: 'right' });
|
||||
|
||||
// Save
|
||||
const safeName = data.fileName.replace(/\.[^.]+$/, '');
|
||||
pdf.save(`Inspecao_${safeName}_${new Date().toISOString().slice(0, 10)}.pdf`);
|
||||
}
|
||||
+32
-3
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Glasses, Box, Ruler, FileText, Columns2 } from 'lucide-react';
|
||||
import { ArrowLeft, Glasses, Box, Ruler, FileText, Columns2, Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { ModelViewerCanvas } from '@/components/three/ModelViewer';
|
||||
@@ -9,14 +9,34 @@ import { FineTuningControls } from '@/components/FineTuningControls';
|
||||
import { MeasurementsList } from '@/components/MeasurementsList';
|
||||
import { ScreenshotGallery } from '@/components/ScreenshotGallery';
|
||||
import { ComparePanel } from '@/components/ComparePanel';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { generateInspectionReport } from '@/lib/generateReport';
|
||||
|
||||
const Viewer = () => {
|
||||
const navigate = useNavigate();
|
||||
const { model, xrSupported, compareMode, setCompareMode } = useModelStore();
|
||||
const { model, xrSupported, compareMode, setCompareMode, checklist, measurements, screenshots, fineTuning } = useModelStore();
|
||||
|
||||
const handleExportPDF = useCallback(async () => {
|
||||
if (!model) return;
|
||||
toast.info('Gerando relatório PDF…');
|
||||
try {
|
||||
await generateInspectionReport({
|
||||
fileName: model.fileName,
|
||||
fileSize: model.fileSize,
|
||||
fineTuning,
|
||||
checklist,
|
||||
measurements,
|
||||
screenshots,
|
||||
});
|
||||
toast.success('Relatório exportado com sucesso!');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error('Erro ao gerar o relatório.');
|
||||
}
|
||||
}, [model, fineTuning, checklist, measurements, screenshots]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) {
|
||||
@@ -51,6 +71,15 @@ const Viewer = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={handleExportPDF}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Exportar PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant={compareMode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user