diff --git a/frontend/package.json b/frontend/package.json index 3b13776..037990d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,8 @@ "clsx": "^2.1.1", "i18next": "^26.3.0", "i18next-browser-languagedetector": "^8.2.1", + "jspdf": "^4.2.1", + "jspdf-autotable": "^5.0.8", "lucide-react": "^1.16.0", "mermaid": "^11.15.0", "react": "^19.2.6", diff --git a/frontend/src/lib/pdf-export.ts b/frontend/src/lib/pdf-export.ts new file mode 100644 index 0000000..29b996e --- /dev/null +++ b/frontend/src/lib/pdf-export.ts @@ -0,0 +1,418 @@ +import jsPDF from 'jspdf'; +import autoTable from 'jspdf-autotable'; +import type { ChecklistArea } from '@/data/reporting-checklist'; +import type { ValidationResult } from '@/components/reporting/checklist-panel'; + +export interface PdfLabels { + reportTitle: string; + generatedOn: string; + projectOverview: string; + projectName: string; + description: string; + riskClassification: string; + intendedPurpose: string; + intendedUsers: string; + deploymentContext: string; + framework: string; + completionSummary: string; + totalQuestions: string; + answered: string; + covered: string; + needsAttention: string; + notAnswered: string; + question: string; + response: string; + status: string; + page: string; + of: string; + confidential: string; + validationFeedback: string; + area: string; +} + +export interface PdfExportOptions { + projectName: string; + projectDescription: string; + riskClassification: string; + intendedPurpose: string; + intendedUsers: string; + deploymentContext: string; + frameworkName: string; + checklist: ChecklistArea[]; + comments: Record; + validations: Record; + language: string; + labels: PdfLabels; +} + +interface AreaStats { + title: string; + article: string; + total: number; + answered: number; + covered: number; + needsAttention: number; +} + +interface Stats { + total: number; + answered: number; + covered: number; + needsAttention: number; + perArea: AreaStats[]; +} + +const MARGIN = 20; +const HEADER_HEIGHT = 15; +const FOOTER_HEIGHT = 12; +const PAGE_WIDTH = 210; +const CONTENT_WIDTH = PAGE_WIDTH - MARGIN * 2; +const ACCENT = [37, 99, 235] as const; // blue-600 +const LIGHT_BG = [248, 250, 252] as const; // slate-50 + +function computeStats( + checklist: ChecklistArea[], + comments: Record, + validations: Record, +): Stats { + let total = 0; + let answered = 0; + let covered = 0; + let needsAttention = 0; + const perArea: AreaStats[] = []; + + for (const area of checklist) { + let aTotal = 0; + let aAnswered = 0; + let aCovered = 0; + let aNeedsAttention = 0; + + for (const item of area.items) { + for (let idx = 0; idx < item.questions.length; idx++) { + const key = `${area.id}-${item.code}-${idx}`; + aTotal++; + if (comments[key]?.trim()) { + aAnswered++; + const v = validations[key]; + if (v) { + if (v.covered) aCovered++; + else aNeedsAttention++; + } + } + } + } + + total += aTotal; + answered += aAnswered; + covered += aCovered; + needsAttention += aNeedsAttention; + perArea.push({ + title: area.title, + article: area.article, + total: aTotal, + answered: aAnswered, + covered: aCovered, + needsAttention: aNeedsAttention, + }); + } + + return { total, answered, covered, needsAttention, perArea }; +} + +function formatDate(language: string): string { + return new Intl.DateTimeFormat(language, { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(new Date()); +} + +function percent(n: number, total: number): string { + if (total === 0) return '0%'; + return `${Math.round((n / total) * 100)}%`; +} + +function addCoverPage(doc: jsPDF, options: PdfExportOptions, stats: Stats): void { + const { labels } = options; + const dateStr = formatDate(options.language); + + let y = 50; + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(28); + doc.setTextColor(ACCENT[0], ACCENT[1], ACCENT[2]); + doc.text(options.frameworkName, MARGIN, y); + y += 12; + + doc.setFontSize(18); + doc.setTextColor(100, 100, 100); + doc.text(labels.reportTitle, MARGIN, y); + y += 20; + + doc.setDrawColor(ACCENT[0], ACCENT[1], ACCENT[2]); + doc.setLineWidth(0.8); + doc.line(MARGIN, y, MARGIN + CONTENT_WIDTH, y); + y += 15; + + doc.setFontSize(11); + doc.setTextColor(60, 60, 60); + doc.setFont('helvetica', 'normal'); + + const infoLines = [ + [labels.projectName, options.projectName], + [labels.riskClassification, options.riskClassification], + [labels.generatedOn, dateStr], + ]; + + for (const [label, value] of infoLines) { + doc.setFont('helvetica', 'bold'); + doc.text(`${label}:`, MARGIN, y); + doc.setFont('helvetica', 'normal'); + doc.text(value, MARGIN + 50, y); + y += 7; + } + + y += 10; + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(14); + doc.setTextColor(30, 30, 30); + doc.text(labels.projectOverview, MARGIN, y); + y += 8; + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(60, 60, 60); + + const overviewFields = [ + [labels.description, options.projectDescription], + [labels.intendedPurpose, options.intendedPurpose], + [labels.intendedUsers, options.intendedUsers], + [labels.deploymentContext, options.deploymentContext], + ]; + + for (const [label, value] of overviewFields) { + doc.setFont('helvetica', 'bold'); + doc.text(`${label}:`, MARGIN, y); + y += 5; + doc.setFont('helvetica', 'normal'); + const lines = doc.splitTextToSize(value, CONTENT_WIDTH); + doc.text(lines, MARGIN, y); + y += lines.length * 4.5 + 4; + } + + y += 8; + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(14); + doc.setTextColor(30, 30, 30); + doc.text(labels.completionSummary, MARGIN, y); + y += 10; + + autoTable(doc, { + startY: y, + margin: { left: MARGIN, right: MARGIN }, + head: [ + [labels.area, labels.totalQuestions, labels.answered, labels.covered, labels.needsAttention], + ], + body: stats.perArea.map((a) => [ + `${a.title} (${a.article})`, + String(a.total), + `${a.answered} (${percent(a.answered, a.total)})`, + `${a.covered} (${percent(a.covered, a.total)})`, + `${a.needsAttention} (${percent(a.needsAttention, a.total)})`, + ]), + foot: [ + [ + 'Total', + String(stats.total), + `${stats.answered} (${percent(stats.answered, stats.total)})`, + `${stats.covered} (${percent(stats.covered, stats.total)})`, + `${stats.needsAttention} (${percent(stats.needsAttention, stats.total)})`, + ], + ], + headStyles: { + fillColor: [ACCENT[0], ACCENT[1], ACCENT[2]], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 8, + }, + bodyStyles: { fontSize: 8, textColor: [50, 50, 50] }, + footStyles: { + fillColor: [LIGHT_BG[0], LIGHT_BG[1], LIGHT_BG[2]], + textColor: [30, 30, 30], + fontStyle: 'bold', + fontSize: 8, + }, + alternateRowStyles: { fillColor: [LIGHT_BG[0], LIGHT_BG[1], LIGHT_BG[2]] }, + columnStyles: { + 0: { cellWidth: 60 }, + 1: { cellWidth: 22, halign: 'center' }, + 2: { cellWidth: 30, halign: 'center' }, + 3: { cellWidth: 30, halign: 'center' }, + 4: { cellWidth: 28, halign: 'center' }, + }, + }); +} + +function getStatusText( + validation: ValidationResult | undefined, + hasComment: boolean, + labels: PdfLabels, +): string { + if (!hasComment) return `— ${labels.notAnswered}`; + if (!validation) return '—'; + return validation.covered ? labels.covered : labels.needsAttention; +} + +function addAreaContent( + doc: jsPDF, + area: ChecklistArea, + comments: Record, + validations: Record, + labels: PdfLabels, +): void { + doc.addPage(); + + let y = MARGIN + HEADER_HEIGHT + 5; + + doc.setFont('helvetica', 'bold'); + doc.setFontSize(16); + doc.setTextColor(ACCENT[0], ACCENT[1], ACCENT[2]); + doc.text(`${area.title}`, MARGIN, y); + + doc.setFont('helvetica', 'normal'); + doc.setFontSize(10); + doc.setTextColor(130, 130, 130); + doc.text(area.article, MARGIN + doc.getTextWidth(area.title + ' ') + 4, y); + y += 10; + + for (const item of area.items) { + const tableBody: (string | { content: string; styles: object })[][] = []; + + for (let idx = 0; idx < item.questions.length; idx++) { + const key = `${area.id}-${item.code}-${idx}`; + const comment = comments[key]?.trim() ?? ''; + const validation = validations[key]; + const status = getStatusText(validation, !!comment, labels); + + tableBody.push([item.questions[idx], comment || '—', status]); + + if (validation?.feedback) { + tableBody.push([ + { + content: `${labels.validationFeedback}: ${validation.feedback}`, + styles: { + fontStyle: 'italic', + textColor: [100, 100, 100], + fontSize: 7, + fillColor: [LIGHT_BG[0], LIGHT_BG[1], LIGHT_BG[2]], + }, + }, + '', + '', + ]); + } + } + + autoTable(doc, { + startY: y, + margin: { left: MARGIN, right: MARGIN, bottom: MARGIN + FOOTER_HEIGHT }, + head: [[{ content: `${item.code} — ${item.title}`, colSpan: 3, styles: { halign: 'left' } }]], + body: tableBody, + headStyles: { + fillColor: [50, 55, 70], + textColor: [255, 255, 255], + fontStyle: 'bold', + fontSize: 9, + }, + bodyStyles: { fontSize: 8, textColor: [50, 50, 50], cellPadding: 3 }, + alternateRowStyles: { fillColor: [LIGHT_BG[0], LIGHT_BG[1], LIGHT_BG[2]] }, + columnStyles: { + 0: { cellWidth: 60 }, + 1: { cellWidth: 80 }, + 2: { cellWidth: 30, halign: 'center' }, + }, + didParseCell: (data) => { + if (data.section === 'body' && data.column.index === 2) { + const text = String(data.cell.raw); + if (text === labels.covered) { + data.cell.styles.textColor = [22, 163, 74]; // green-600 + data.cell.styles.fontStyle = 'bold'; + } else if (text === labels.needsAttention) { + data.cell.styles.textColor = [217, 119, 6]; // amber-600 + data.cell.styles.fontStyle = 'bold'; + } + } + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + y = (doc as any).lastAutoTable.finalY + 8; + } +} + +function addHeadersAndFooters(doc: jsPDF, options: PdfExportOptions): void { + const { labels } = options; + const dateStr = formatDate(options.language); + const totalPages = doc.getNumberOfPages(); + + for (let i = 1; i <= totalPages; i++) { + doc.setPage(i); + + if (i > 1) { + doc.setFont('helvetica', 'bold'); + doc.setFontSize(8); + doc.setTextColor(ACCENT[0], ACCENT[1], ACCENT[2]); + doc.text(`${options.frameworkName} — ${labels.reportTitle}`, MARGIN, 12); + + doc.setFont('helvetica', 'normal'); + doc.setTextColor(100, 100, 100); + doc.text(options.projectName, PAGE_WIDTH - MARGIN, 12, { align: 'right' }); + + doc.setDrawColor(220, 220, 220); + doc.setLineWidth(0.3); + doc.line(MARGIN, 15, PAGE_WIDTH - MARGIN, 15); + } + + const footerY = 297 - 8; + doc.setFont('helvetica', 'normal'); + doc.setFontSize(7); + doc.setTextColor(150, 150, 150); + + doc.text(dateStr, MARGIN, footerY); + doc.text(labels.confidential, PAGE_WIDTH / 2, footerY, { align: 'center' }); + doc.text(`${labels.page} ${i} ${labels.of} ${totalPages}`, PAGE_WIDTH - MARGIN, footerY, { + align: 'right', + }); + + doc.setDrawColor(220, 220, 220); + doc.setLineWidth(0.3); + doc.line(MARGIN, footerY - 3, PAGE_WIDTH - MARGIN, footerY - 3); + } +} + +export async function exportReportToPdf(options: PdfExportOptions): Promise { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + + doc.setProperties({ + title: `${options.frameworkName} — ${options.labels.reportTitle} — ${options.projectName}`, + subject: `Compliance report for ${options.projectName}`, + author: options.projectName, + creator: 'Norma', + }); + + const stats = computeStats(options.checklist, options.comments, options.validations); + + addCoverPage(doc, options, stats); + + for (const area of options.checklist) { + addAreaContent(doc, area, options.comments, options.validations, options.labels); + } + + addHeadersAndFooters(doc, options); + + const safeName = (s: string) => s.replace(/[^a-zA-Z0-9-_ ]/g, '').trim(); + const dateStr = new Date().toISOString().slice(0, 10); + doc.save(`${safeName(options.projectName)}_${safeName(options.frameworkName)}_${dateStr}.pdf`); +} diff --git a/frontend/src/locales/de/common.json b/frontend/src/locales/de/common.json index 07e8edb..72d2414 100644 --- a/frontend/src/locales/de/common.json +++ b/frontend/src/locales/de/common.json @@ -89,6 +89,32 @@ "failedToSave": "Speichern fehlgeschlagen", "failedToDisconnect": "Trennen fehlgeschlagen" }, + "pdfExport": { + "reportTitle": "Konformitätsbericht", + "generatedOn": "Erstellt am", + "projectOverview": "Projektübersicht", + "projectName": "Projektname", + "description": "Beschreibung", + "riskClassification": "Risikoklassifizierung", + "intendedPurpose": "Bestimmungsgemäßer Zweck", + "intendedUsers": "Vorgesehene Nutzer", + "deploymentContext": "Einsatzkontext", + "framework": "Framework", + "completionSummary": "Zusammenfassung der Konformität", + "totalQuestions": "Gesamtfragen", + "answered": "Beantwortet", + "covered": "Abgedeckt", + "needsAttention": "Aufmerksamkeit Erforderlich", + "notAnswered": "Nicht Beantwortet", + "question": "Frage", + "response": "Antwort", + "status": "Status", + "page": "Seite", + "of": "von", + "confidential": "Vertraulich", + "validationFeedback": "Rückmeldung", + "area": "Bereich" + }, "table": { "document": "Dokument", "uploaded": "Hochgeladen", diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index d47e540..ca58d16 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -89,6 +89,32 @@ "failedToSave": "Failed to save", "failedToDisconnect": "Failed to disconnect" }, + "pdfExport": { + "reportTitle": "Compliance Report", + "generatedOn": "Generated on", + "projectOverview": "Project Overview", + "projectName": "Project Name", + "description": "Description", + "riskClassification": "Risk Classification", + "intendedPurpose": "Intended Purpose", + "intendedUsers": "Intended Users", + "deploymentContext": "Deployment Context", + "framework": "Framework", + "completionSummary": "Completion Summary", + "totalQuestions": "Total Questions", + "answered": "Answered", + "covered": "Covered", + "needsAttention": "Needs Attention", + "notAnswered": "Not Answered", + "question": "Question", + "response": "Response", + "status": "Status", + "page": "Page", + "of": "of", + "confidential": "Confidential", + "validationFeedback": "Feedback", + "area": "Area" + }, "table": { "document": "Document", "uploaded": "Uploaded", diff --git a/frontend/src/locales/es/common.json b/frontend/src/locales/es/common.json index 5a57d72..a926b99 100644 --- a/frontend/src/locales/es/common.json +++ b/frontend/src/locales/es/common.json @@ -89,6 +89,32 @@ "failedToSave": "Error al guardar", "failedToDisconnect": "Error al desconectar" }, + "pdfExport": { + "reportTitle": "Informe de Cumplimiento", + "generatedOn": "Generado el", + "projectOverview": "Descripción del Proyecto", + "projectName": "Nombre del Proyecto", + "description": "Descripción", + "riskClassification": "Clasificación de Riesgo", + "intendedPurpose": "Propósito Previsto", + "intendedUsers": "Usuarios Previstos", + "deploymentContext": "Contexto de Despliegue", + "framework": "Marco Normativo", + "completionSummary": "Resumen de Cumplimiento", + "totalQuestions": "Total de Preguntas", + "answered": "Respondidas", + "covered": "Cubiertas", + "needsAttention": "Requiere Atención", + "notAnswered": "Sin Respuesta", + "question": "Pregunta", + "response": "Respuesta", + "status": "Estado", + "page": "Página", + "of": "de", + "confidential": "Confidencial", + "validationFeedback": "Comentario", + "area": "Área" + }, "table": { "document": "Documento", "uploaded": "Subido", diff --git a/frontend/src/locales/fr/common.json b/frontend/src/locales/fr/common.json index b317213..af3d97b 100644 --- a/frontend/src/locales/fr/common.json +++ b/frontend/src/locales/fr/common.json @@ -89,6 +89,32 @@ "failedToSave": "Échec de l’enregistrement", "failedToDisconnect": "Échec de la déconnexion" }, + "pdfExport": { + "reportTitle": "Rapport de Conformité", + "generatedOn": "Généré le", + "projectOverview": "Aperçu du Projet", + "projectName": "Nom du Projet", + "description": "Description", + "riskClassification": "Classification des Risques", + "intendedPurpose": "Finalité Prévue", + "intendedUsers": "Utilisateurs Prévus", + "deploymentContext": "Contexte de Déploiement", + "framework": "Référentiel", + "completionSummary": "Résumé de Conformité", + "totalQuestions": "Total des Questions", + "answered": "Répondues", + "covered": "Couvertes", + "needsAttention": "Attention Requise", + "notAnswered": "Sans Réponse", + "question": "Question", + "response": "Réponse", + "status": "Statut", + "page": "Page", + "of": "de", + "confidential": "Confidentiel", + "validationFeedback": "Commentaire", + "area": "Domaine" + }, "table": { "document": "Document", "uploaded": "Téléversé", diff --git a/frontend/src/pages/reporting.tsx b/frontend/src/pages/reporting.tsx index fa670ff..f0f9d68 100644 --- a/frontend/src/pages/reporting.tsx +++ b/frontend/src/pages/reporting.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; -import { Download } from 'lucide-react'; +import { Download, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { AskNormaButton } from '@/components/ask-norma-button'; @@ -10,9 +10,10 @@ import { ChecklistPanel, type ValidationResult } from '@/components/reporting/ch import { useProject } from '@/hooks/use-project'; import { getFrameworkChecklists } from '@/data/reporting-checklists'; import { api, type Framework, type ReportingEvidence } from '@/lib/api'; +import type { PdfLabels } from '@/lib/pdf-export'; export function ReportingPage() { - const { t } = useTranslation(['pages', 'common']); + const { t, i18n } = useTranslation(['pages', 'common']); const { currentProject } = useProject(); const { frameworkId } = useParams<{ frameworkId: string }>(); const navigate = useNavigate(); @@ -123,9 +124,64 @@ export function ReportingPage() { [currentProject, frameworkId], ); + const [isExporting, setIsExporting] = useState(false); + const currentFramework = frameworks.find((fw) => fw.id === frameworkId); const frameworkChecklists = getFrameworkChecklists(t); - const checklist = currentFramework ? (frameworkChecklists[currentFramework.name] ?? []) : []; + const checklist = useMemo( + () => (currentFramework ? (frameworkChecklists[currentFramework.name] ?? []) : []), + [currentFramework, frameworkChecklists], + ); + + const handleExport = useCallback(async () => { + if (!currentFramework || !currentProject) return; + setIsExporting(true); + try { + const { exportReportToPdf } = await import('@/lib/pdf-export'); + const labels: PdfLabels = { + reportTitle: t('common:pdfExport.reportTitle'), + generatedOn: t('common:pdfExport.generatedOn'), + projectOverview: t('common:pdfExport.projectOverview'), + projectName: t('common:pdfExport.projectName'), + description: t('common:pdfExport.description'), + riskClassification: t('common:pdfExport.riskClassification'), + intendedPurpose: t('common:pdfExport.intendedPurpose'), + intendedUsers: t('common:pdfExport.intendedUsers'), + deploymentContext: t('common:pdfExport.deploymentContext'), + framework: t('common:pdfExport.framework'), + completionSummary: t('common:pdfExport.completionSummary'), + totalQuestions: t('common:pdfExport.totalQuestions'), + answered: t('common:pdfExport.answered'), + covered: t('common:pdfExport.covered'), + needsAttention: t('common:pdfExport.needsAttention'), + notAnswered: t('common:pdfExport.notAnswered'), + question: t('common:pdfExport.question'), + response: t('common:pdfExport.response'), + status: t('common:pdfExport.status'), + page: t('common:pdfExport.page'), + of: t('common:pdfExport.of'), + confidential: t('common:pdfExport.confidential'), + validationFeedback: t('common:pdfExport.validationFeedback'), + area: t('common:pdfExport.area'), + }; + await exportReportToPdf({ + projectName: currentProject.name, + projectDescription: currentProject.description, + riskClassification: currentProject.risk_classification, + intendedPurpose: currentProject.intended_purpose, + intendedUsers: currentProject.intended_users, + deploymentContext: currentProject.deployment_context, + frameworkName: currentFramework.name, + checklist, + comments, + validations, + language: i18n.language, + labels, + }); + } finally { + setIsExporting(false); + } + }, [currentFramework, currentProject, checklist, comments, validations, t, i18n.language]); const pageTitle = currentFramework ? `${t('reporting.title')} > ${currentFramework.name}` @@ -146,8 +202,12 @@ export function ReportingPage() { {checklist.length > 0 ? ( <>
-