erp-platform/ui/src/components/reports/ReportViewer.tsx

510 lines
18 KiB
TypeScript
Raw Normal View History

import React, { useState, useEffect, useMemo, useCallback } from 'react'
2025-08-15 08:07:51 +00:00
import { useParams, useNavigate } from 'react-router-dom'
import { Button } from '../ui/Button'
2025-08-16 19:47:24 +00:00
import {
FaArrowLeft,
FaCalendarAlt,
FaFileAlt,
FaDownload,
FaSearchPlus,
FaSearchMinus,
} from 'react-icons/fa'
2025-08-15 11:52:30 +00:00
import { ReportGeneratedDto, ReportTemplateDto } from '@/proxy/reports/models'
2025-08-15 08:07:51 +00:00
import { useReports } from '@/utils/hooks/useReports'
import { ROUTES_ENUM } from '@/routes/route.constant'
2025-08-17 12:51:31 +00:00
import { useLocalization } from '@/utils/hooks/useLocalization'
2025-08-15 08:07:51 +00:00
export const ReportViewer: React.FC = () => {
2025-08-15 09:19:20 +00:00
const { id } = useParams<{ id: string }>()
2025-08-15 08:07:51 +00:00
const navigate = useNavigate()
const [zoomLevel, setZoomLevel] = useState(100)
2025-08-15 11:52:30 +00:00
const [report, setReport] = useState<ReportGeneratedDto | null>(null)
2025-08-15 08:07:51 +00:00
const [template, setTemplate] = useState<ReportTemplateDto | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
2025-08-17 12:51:31 +00:00
const { translate } = useLocalization()
2025-08-15 08:07:51 +00:00
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])
2025-08-15 08:07:51 +00:00
// Asenkron veri yükleme
useEffect(() => {
const loadReportData = async () => {
2025-08-15 09:19:20 +00:00
if (!id) {
2025-08-15 08:07:51 +00:00
setError("Rapor ID'si bulunamadı")
setIsLoading(false)
return
}
try {
setIsLoading(true)
setError(null)
// Raporu yükle
2025-08-15 09:19:20 +00:00
const reportData = await getReportById(id)
2025-08-15 08:07:51 +00:00
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()
2025-08-15 09:19:20 +00:00
}, [id, getReportById, getTemplateById])
2025-08-15 08:07:51 +00:00
// 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>
2025-08-17 12:51:31 +00:00
<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>
2025-08-15 08:07:51 +00:00
</div>
</div>
)
}
// Error durumu
2025-08-15 09:19:20 +00:00
if (error || !id) {
2025-08-15 08:07:51 +00:00
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
2025-08-17 12:51:31 +00:00
<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)}>
2025-08-16 19:47:24 +00:00
<FaArrowLeft className="h-4 w-4 mr-2" />
2025-08-15 08:07:51 +00:00
Ana Sayfaya Dön
</Button>
</div>
</div>
)
}
2025-08-15 09:19:20 +00:00
if (!id) {
2025-08-15 08:07:51 +00:00
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
2025-08-17 12:51:31 +00:00
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{translate('::App.Reports.ReportViewer.ErrorNotFound')}
</h1>
<Button onClick={() => navigate(ROUTES_ENUM.protected.dashboard)}>
2025-08-16 19:47:24 +00:00
<FaArrowLeft className="h-4 w-4 mr-2" />
2025-08-15 08:07:51 +00:00
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">
2025-08-17 12:51:31 +00:00
<h1 className="text-2xl font-bold text-gray-900 mb-4">
{translate('::App.Reports.ReportViewer.ErrorNotFound')}
</h1>
2025-08-15 08:07:51 +00:00
<p className="text-gray-600 mb-6">
2025-08-17 12:51:31 +00:00
{translate('::App.Reports.ReportViewer.ErrorNotFoundDescription')}
2025-08-15 08:07:51 +00:00
</p>
<Button onClick={() => navigate(ROUTES_ENUM.protected.dashboard)}>
2025-08-16 19:47:24 +00:00
<FaArrowLeft className="h-4 w-4 mr-2" />
2025-08-17 12:51:31 +00:00
{translate('::App.Reports.ReportViewer.BackToDashboard')}
2025-08-15 08:07:51 +00:00
</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
2025-08-15 08:07:51 +00:00
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
2025-08-15 08:07:51 +00:00
try {
const pdf = new jsPDFCtor({ orientation: 'portrait', unit: 'mm', format: 'a4' })
2025-08-15 08:07:51 +00:00
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'
2025-08-15 08:07:51 +00:00
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff',
})
if (container) container.style.transform = prevTransform ?? ''
2025-08-15 08:07:51 +00:00
const imgData = canvas.toDataURL('image/png')
const imgWidth = 210
const imgHeight = 297
if (i > 0) pdf.addPage()
2025-08-15 08:07:51 +00:00
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth, imgHeight)
}
pdf.save(
`${report!.templateName}_${new Date(report!.generatedAt).toLocaleDateString('tr-TR')}.pdf`,
2025-08-15 08:07:51 +00:00
)
} 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">
2025-08-15 08:07:51 +00:00
<div className="flex items-center justify-between h-16">
2025-08-15 19:39:11 +00:00
<div className="flex items-center space-x-4 flex-1">
<div className="flex-1">
2025-08-15 08:07:51 +00:00
<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">
2025-08-16 19:47:24 +00:00
<FaCalendarAlt className="h-4 w-4 mr-1" />
2025-08-15 08:07:51 +00:00
{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">
2025-08-16 19:47:24 +00:00
<FaFileAlt className="h-4 w-4 mr-1" />
2025-08-15 19:39:11 +00:00
{template.categoryName}
2025-08-15 08:07:51 +00:00
</span>
)}
</div>
</div>
</div>
2025-08-15 19:39:11 +00:00
<div className="flex items-center space-x-2 flex-shrink-0">
2025-08-15 08:07:51 +00:00
<Button variant="solid" onClick={handleZoomOut} disabled={zoomLevel <= 50} size="sm">
2025-08-16 19:47:24 +00:00
<FaSearchMinus className="h-4 w-4" />
2025-08-15 08:07:51 +00:00
</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">
2025-08-16 19:47:24 +00:00
<FaSearchPlus className="h-4 w-4" />
2025-08-15 08:07:51 +00:00
</Button>
<div className="w-px h-6 bg-gray-300 mx-2"></div>
2025-08-15 19:39:11 +00:00
<Button
onMouseEnter={preloadPdfLibs} // ← opsiyonel prefetch
2025-08-15 19:39:11 +00:00
onClick={handleDownloadPdf}
2025-08-16 12:22:36 +00:00
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"
2025-08-15 19:39:11 +00:00
>
2025-08-16 19:47:24 +00:00
<FaDownload className="h-4 w-4 mr-2" />
2025-08-17 12:51:31 +00:00
{translate('::App.Reports.ReportViewer.DownloadPDF')}
2025-08-15 08:07:51 +00:00
</Button>
2025-08-16 19:47:24 +00:00
<Button variant="solid" onClick={handlePrint}>
2025-08-17 12:51:31 +00:00
{translate('::App.Reports.ReportViewer.Print')}
2025-08-16 19:47:24 +00:00
</Button>
2025-08-15 08:07:51 +00:00
</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) => (
2025-08-15 08:07:51 +00:00
<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}
2025-08-15 08:07:51 +00:00
</div>
</div>
))}
</div>
</div>
</div>
)
}