erp-platform/ui/src/components/reports/ReportViewer.tsx
2025-08-18 21:49:25 +03:00

509 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Button } from '../ui/Button'
import {
FaArrowLeft,
FaCalendarAlt,
FaFileAlt,
FaDownload,
FaSearchPlus,
FaSearchMinus,
} from 'react-icons/fa'
import { ReportGeneratedDto, ReportTemplateDto } from '@/proxy/reports/models'
import { useReports } from '@/utils/hooks/useReports'
import { ROUTES_ENUM } from '@/routes/route.constant'
import { useLocalization } from '@/utils/hooks/useLocalization'
export const ReportViewer: React.FC = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [zoomLevel, setZoomLevel] = useState(100)
const [report, setReport] = useState<ReportGeneratedDto | null>(null)
const [template, setTemplate] = useState<ReportTemplateDto | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { translate } = useLocalization()
const { getReportById, getTemplateById } = useReports()
// İçeriği sayfalara bölen fonksiyon
const splitContentIntoPages = (content: string) => {
// Basit olarak içeriği paragraf ve tablo bazında bölelim
const tempDiv = document.createElement('div')
tempDiv.innerHTML = content
const elements = Array.from(tempDiv.children)
const pages: string[] = []
let currentPage = ''
let currentPageHeight = 0
const maxPageHeight = 257 // 297mm - 40mm padding (top+bottom)
elements.forEach((element) => {
const elementHtml = element.outerHTML
// Basit yükseklik tahmini (gerçek uygulamada daha karmaşık olabilir)
let estimatedHeight = 20 // Default height
if (element.tagName === 'TABLE') {
const rows = element.querySelectorAll('tr')
estimatedHeight = rows.length * 25 // Her satır için 25mm
} else if (element.tagName.startsWith('H')) {
estimatedHeight = 15
} else if (element.tagName === 'P') {
estimatedHeight = 10
}
if (currentPageHeight + estimatedHeight > maxPageHeight && currentPage) {
pages.push(currentPage)
currentPage = elementHtml
currentPageHeight = estimatedHeight
} else {
currentPage += elementHtml
currentPageHeight += estimatedHeight
}
})
if (currentPage) {
pages.push(currentPage)
}
return pages.length > 0 ? pages : [content]
}
const preloadPdfLibs = useCallback(() => {
// Hoverda ısıtma için (opsiyonel)
import('jspdf')
import('html2canvas')
}, [])
// YENİ: memoize edilmiş sayfalar
const memoizedPages = useMemo(() => {
return report ? splitContentIntoPages(report.generatedContent) : []
}, [report])
// Asenkron veri yükleme
useEffect(() => {
const loadReportData = async () => {
if (!id) {
setError("Rapor ID'si bulunamadı")
setIsLoading(false)
return
}
try {
setIsLoading(true)
setError(null)
// Raporu yükle
const reportData = await getReportById(id)
if (!reportData) {
setError('Rapor bulunamadı')
setIsLoading(false)
return
}
setReport(reportData)
// Şablonu yükle
if (reportData.templateId) {
const templateData = await getTemplateById(reportData.templateId)
setTemplate(templateData || null)
}
} catch (err) {
console.error('Error loading report data:', err)
setError('Rapor yüklenirken bir hata oluştu')
} finally {
setIsLoading(false)
}
}
loadReportData()
}, [id, getReportById, getTemplateById])
// Zoom fonksiyonları
const handleZoomIn = () => {
setZoomLevel((prev) => Math.min(prev + 25, 200)) // Maksimum %200
}
const handleZoomOut = () => {
setZoomLevel((prev) => Math.max(prev - 25, 50)) // Minimum %50
}
// Loading durumu
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">
{translate('::App.Reports.ReportViewer.LoadingTitle')}
</h1>
<p className="text-gray-600">{translate('::App.Reports.ReportViewer.LoadingSubtitle')}</p>
</div>
</div>
)
}
// Error durumu
if (error || !id) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{error || translate('::App.Reports.ReportViewer.ErrorNotFound')}
</h1>
<Button onClick={() => navigate(ROUTES_ENUM.protected.dashboard)}>
<FaArrowLeft className="h-4 w-4 mr-2" />
Ana Sayfaya Dön
</Button>
</div>
</div>
)
}
if (!id) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{translate('::App.Reports.ReportViewer.ErrorNotFound')}
</h1>
<Button onClick={() => navigate(ROUTES_ENUM.protected.dashboard)}>
<FaArrowLeft className="h-4 w-4 mr-2" />
Ana Sayfaya Dön
</Button>
</div>
</div>
)
}
// Report yüklenmemiş ise
if (!report) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{translate('::App.Reports.ReportViewer.ErrorNotFound')}
</h1>
<p className="text-gray-600 mb-6">
{translate('::App.Reports.ReportViewer.ErrorNotFoundDescription')}
</p>
<Button onClick={() => navigate(ROUTES_ENUM.protected.dashboard)}>
<FaArrowLeft className="h-4 w-4 mr-2" />
{translate('::App.Reports.ReportViewer.BackToDashboard')}
</Button>
</div>
</div>
)
}
const handlePrint = () => {
// Yazdırma sırasında zoom seviyesini geçici olarak %100'e ayarla
const currentZoom = zoomLevel
setZoomLevel(100)
// DOM'un güncellenmesi için kısa bir gecikme
setTimeout(() => {
window.print()
// Yazdırma işlemi tamamlandıktan sonra orijinal zoom seviyesine geri dön
// Print dialog kapandıktan sonra zoom'u eski haline getir
setTimeout(() => {
setZoomLevel(currentZoom)
}, 100)
}, 100)
}
// DEĞİŞTİR: handleDownloadPdf
const handleDownloadPdf = async () => {
// Ağır kütüphaneleri ihtiyaç anında indir
const [jspdfMod, h2cMod] = await Promise.all([import('jspdf'), import('html2canvas')])
// jsPDF bazı dağıtımlarda default, bazılarında { jsPDF } olarak gelir
const jsPDFCtor = (jspdfMod as any).default ?? (jspdfMod as any).jsPDF
const html2canvas = (h2cMod as any).default ?? (h2cMod as any)
const pages = memoizedPages // aşağıdaki 2. adımda tanımlayacağız
try {
const pdf = new jsPDFCtor({ orientation: 'portrait', unit: 'mm', format: 'a4' })
for (let i = 0; i < pages.length; i++) {
const elementId = i === 0 ? 'report-content' : `report-content-page-${i + 1}`
const element = document.getElementById(elementId)
if (!element) continue
// Yakalama öncesi zoomu etkisizleştir (transform varsa kalite düşmesin)
const container = element.parentElement as HTMLElement | null
const prevTransform = container?.style.transform
if (container) container.style.transform = 'none'
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
})
if (container) container.style.transform = prevTransform ?? ''
const imgData = canvas.toDataURL('image/png')
const imgWidth = 210
const imgHeight = 297
if (i > 0) pdf.addPage()
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight)
}
pdf.save(
`${report!.templateName}_${new Date(report!.generatedAt).toLocaleDateString('tr-TR')}.pdf`,
)
} catch (error) {
console.error('PDF oluşturma hatası:', error)
alert('PDF oluşturulurken bir hata oluştu.')
}
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header - Print edilmeyecek */}
<div className="print:hidden bg-white shadow-sm border-b border-gray-200 sticky top-0 z-10">
<div className="w-full px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-4 flex-1">
<div className="flex-1">
<h1 className="text-lg font-semibold text-gray-900">{report.templateName}</h1>
<div className="flex items-center space-x-4 text-sm text-gray-500">
<span className="flex items-center">
<FaCalendarAlt className="h-4 w-4 mr-1" />
{new Date(report.generatedAt).toLocaleDateString('tr-TR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
{template && (
<span className="flex items-center">
<FaFileAlt className="h-4 w-4 mr-1" />
{template.categoryName}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-2 flex-shrink-0">
<Button variant="solid" onClick={handleZoomOut} disabled={zoomLevel <= 50} size="sm">
<FaSearchMinus className="h-4 w-4" />
</Button>
<span className="text-sm text-gray-600 px-2 min-w-[4rem] text-center">
{zoomLevel}%
</span>
<Button variant="solid" onClick={handleZoomIn} disabled={zoomLevel >= 200} size="sm">
<FaSearchPlus className="h-4 w-4" />
</Button>
<div className="w-px h-6 bg-gray-300 mx-2"></div>
<Button
onMouseEnter={preloadPdfLibs} // ← opsiyonel prefetch
onClick={handleDownloadPdf}
className="bg-white-600 hover:bg-white-700 font-medium px-2 sm:px-3 py-1.5 rounded text-xs flex items-center gap-1"
>
<FaDownload className="h-4 w-4 mr-2" />
{translate('::App.Reports.ReportViewer.DownloadPDF')}
</Button>
<Button variant="solid" onClick={handlePrint}>
{translate('::App.Reports.ReportViewer.Print')}
</Button>
</div>
</div>
</div>
</div>
{/* Rapor İçeriği */}
<div className="flex justify-center bg-gray-200 py-8 print:bg-white print:py-0">
<div
className="space-y-8 print:space-y-0 transition-transform duration-300 ease-in-out"
style={{
transform: `scale(${zoomLevel / 100})`,
transformOrigin: 'top center',
}}
>
{memoizedPages.map((pageContent, index) => (
<div
key={index}
id={index === 0 ? 'report-content' : `report-content-page-${index + 1}`}
className="bg-white shadow-lg print:shadow-none page-container"
style={{
width: '210mm',
minHeight: '297mm',
padding: '0',
boxSizing: 'border-box',
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
{index === 0 && (
<style>
{`
/* Sayfa kırılması için CSS */
.page-container {
page-break-after: always;
margin-bottom: 20px;
}
.page-container:last-child {
page-break-after: auto;
margin-bottom: 0;
}
/* Header ve Footer stilleri */
.page-header {
height: 15mm;
width: 100%;
display: flex;
align-items: center;
justify-content: right;
background-color: transparent;
color: #666;
font-size: 10px;
padding: 0 20mm;
box-sizing: border-box;
}
.page-footer {
height: 15mm;
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
background-color: transparent;
color: #666;
font-size: 10px;
padding: 0 20mm;
box-sizing: border-box;
margin-top: auto;
}
.page-content-area {
flex: 1;
padding: 0 20mm;
min-height: 267mm; /* 297mm - 15mm header - 15mm footer */
box-sizing: border-box;
}
/* İçerik için sayfa kırılması kuralları */
.page-container h1, .page-container h2, .page-container h3 {
page-break-after: avoid;
}
.page-container table {
page-break-inside: avoid;
border-collapse: collapse;
width: 100%;
margin: 0;
}
.page-container table td,
.page-container table th {
border: 1px solid #ddd;
text-align: left;
vertical-align: top;
}
/* Print specific styles */
@media print {
body {
margin: 0 !important;
padding: 0 !important;
}
.page-container {
width: 210mm !important;
min-height: 297mm !important;
margin: 0 !important;
padding: 0 !important;
box-shadow: none !important;
page-break-after: always !important;
}
.page-container:last-child {
page-break-after: auto !important;
}
.page-header {
height: 15mm !important;
color: #666 !important;
-webkit-print-color-adjust: exact !important;
}
.page-footer {
height: 15mm !important;
color: #666 !important;
-webkit-print-color-adjust: exact !important;
}
.page-content-area {
padding: 0 20mm !important;
min-height: 267mm !important;
}
.print\\:hidden {
display: none !important;
}
/* Table print styles */
.page-container table {
border-collapse: collapse !important;
page-break-inside: avoid !important;
}
.page-container table th,
.page-container table td {
border: 1px solid #000 !important;
page-break-inside: avoid !important;
}
.page-container table th {
background-color: #f0f0f0 !important;
-webkit-print-color-adjust: exact !important;
color-adjust: exact !important;
}
.page-container table tr:nth-child(even) {
background-color: #f5f5f5 !important;
-webkit-print-color-adjust: exact !important;
color-adjust: exact !important;
}
}
`}
</style>
)}
{/* Sayfa Header - Tarih ve Saat */}
<div className="page-header">
{new Date().toLocaleDateString('tr-TR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
{/* Sayfa İçeriği */}
<div className="page-content-area">
<div dangerouslySetInnerHTML={{ __html: pageContent }} />
</div>
{/* Sayfa Footer - Sayfa Numarası */}
<div className="page-footer">
{translate('::App.Reports.ReportViewer.Page')} {index + 1} / {memoizedPages.length}
</div>
</div>
))}
</div>
</div>
</div>
)
}