192 lines
5.8 KiB
TypeScript
192 lines
5.8 KiB
TypeScript
|
|
'use client'
|
||
|
|
|
||
|
|
import { useState, useCallback, type RefObject } from 'react'
|
||
|
|
import { trpc } from '@/lib/trpc/client'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { FileDown, Loader2 } from 'lucide-react'
|
||
|
|
import { toast } from 'sonner'
|
||
|
|
import {
|
||
|
|
createReportDocument,
|
||
|
|
addCoverPage,
|
||
|
|
addPageBreak,
|
||
|
|
addHeader,
|
||
|
|
addSectionTitle,
|
||
|
|
addStatCards,
|
||
|
|
addTable,
|
||
|
|
addChartImage,
|
||
|
|
addAllPageFooters,
|
||
|
|
savePdf,
|
||
|
|
} from '@/lib/pdf-generator'
|
||
|
|
|
||
|
|
interface ExportPdfButtonProps {
|
||
|
|
roundId: string
|
||
|
|
roundName?: string
|
||
|
|
programName?: string
|
||
|
|
chartRefs?: Record<string, RefObject<HTMLDivElement | null>>
|
||
|
|
variant?: 'default' | 'outline' | 'secondary' | 'ghost'
|
||
|
|
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||
|
|
}
|
||
|
|
|
||
|
|
export function ExportPdfButton({
|
||
|
|
roundId,
|
||
|
|
roundName,
|
||
|
|
programName,
|
||
|
|
chartRefs,
|
||
|
|
variant = 'outline',
|
||
|
|
size = 'sm',
|
||
|
|
}: ExportPdfButtonProps) {
|
||
|
|
const [generating, setGenerating] = useState(false)
|
||
|
|
|
||
|
|
const { refetch } = trpc.export.getReportData.useQuery(
|
||
|
|
{ roundId, sections: [] },
|
||
|
|
{ enabled: false }
|
||
|
|
)
|
||
|
|
|
||
|
|
const handleGenerate = useCallback(async () => {
|
||
|
|
setGenerating(true)
|
||
|
|
toast.info('Generating PDF report...')
|
||
|
|
|
||
|
|
try {
|
||
|
|
const result = await refetch()
|
||
|
|
if (!result.data) {
|
||
|
|
toast.error('Failed to fetch report data')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = result.data as Record<string, unknown>
|
||
|
|
const rName = roundName || String(data.roundName || 'Report')
|
||
|
|
const pName = programName || String(data.programName || '')
|
||
|
|
|
||
|
|
// 1. Create document
|
||
|
|
const doc = await createReportDocument()
|
||
|
|
|
||
|
|
// 2. Cover page
|
||
|
|
await addCoverPage(doc, {
|
||
|
|
title: 'Round Report',
|
||
|
|
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
|
||
|
|
roundName: rName,
|
||
|
|
programName: pName,
|
||
|
|
})
|
||
|
|
|
||
|
|
// 3. Summary section
|
||
|
|
const summary = data.summary as Record<string, unknown> | undefined
|
||
|
|
if (summary) {
|
||
|
|
addPageBreak(doc)
|
||
|
|
await addHeader(doc, rName)
|
||
|
|
let y = addSectionTitle(doc, 'Summary', 28)
|
||
|
|
|
||
|
|
y = addStatCards(doc, [
|
||
|
|
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
|
||
|
|
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
|
||
|
|
{
|
||
|
|
label: 'Avg Score',
|
||
|
|
value: summary.averageScore != null
|
||
|
|
? Number(summary.averageScore).toFixed(1)
|
||
|
|
: '--',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
label: 'Completion',
|
||
|
|
value: summary.completionRate != null
|
||
|
|
? `${Number(summary.completionRate).toFixed(0)}%`
|
||
|
|
: '--',
|
||
|
|
},
|
||
|
|
], y)
|
||
|
|
|
||
|
|
// Capture chart images if refs provided
|
||
|
|
if (chartRefs) {
|
||
|
|
for (const [, ref] of Object.entries(chartRefs)) {
|
||
|
|
if (ref.current) {
|
||
|
|
try {
|
||
|
|
y = await addChartImage(doc, ref.current, y, { maxHeight: 90 })
|
||
|
|
} catch {
|
||
|
|
// Skip chart if capture fails
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. Rankings section
|
||
|
|
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
|
||
|
|
if (rankings && rankings.length > 0) {
|
||
|
|
addPageBreak(doc)
|
||
|
|
await addHeader(doc, rName)
|
||
|
|
let y = addSectionTitle(doc, 'Project Rankings', 28)
|
||
|
|
|
||
|
|
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
|
||
|
|
const rows = rankings.map((r, i) => [
|
||
|
|
i + 1,
|
||
|
|
String(r.title ?? ''),
|
||
|
|
String(r.teamName ?? ''),
|
||
|
|
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
|
||
|
|
String(r.evaluationCount ?? 0),
|
||
|
|
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
|
||
|
|
])
|
||
|
|
|
||
|
|
y = addTable(doc, headers, rows, y)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. Juror stats section
|
||
|
|
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
|
||
|
|
if (jurorStats && jurorStats.length > 0) {
|
||
|
|
addPageBreak(doc)
|
||
|
|
await addHeader(doc, rName)
|
||
|
|
let y = addSectionTitle(doc, 'Juror Statistics', 28)
|
||
|
|
|
||
|
|
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
|
||
|
|
const rows = jurorStats.map((j) => [
|
||
|
|
String(j.name ?? ''),
|
||
|
|
String(j.assigned ?? 0),
|
||
|
|
String(j.completed ?? 0),
|
||
|
|
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
|
||
|
|
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
|
||
|
|
])
|
||
|
|
|
||
|
|
y = addTable(doc, headers, rows, y)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 6. Criteria breakdown
|
||
|
|
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
|
||
|
|
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
|
||
|
|
addPageBreak(doc)
|
||
|
|
await addHeader(doc, rName)
|
||
|
|
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
|
||
|
|
|
||
|
|
const headers = ['Criterion', 'Avg Score', 'Responses']
|
||
|
|
const rows = criteriaBreakdown.map((c) => [
|
||
|
|
String(c.label ?? ''),
|
||
|
|
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
|
||
|
|
String(c.count ?? 0),
|
||
|
|
])
|
||
|
|
|
||
|
|
y = addTable(doc, headers, rows, y)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 7. Footer on all pages
|
||
|
|
addAllPageFooters(doc)
|
||
|
|
|
||
|
|
// 8. Save
|
||
|
|
const dateStr = new Date().toISOString().split('T')[0]
|
||
|
|
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
|
||
|
|
|
||
|
|
toast.success('PDF report downloaded successfully')
|
||
|
|
} catch (err) {
|
||
|
|
console.error('PDF generation error:', err)
|
||
|
|
toast.error('Failed to generate PDF report')
|
||
|
|
} finally {
|
||
|
|
setGenerating(false)
|
||
|
|
}
|
||
|
|
}, [refetch, roundName, programName, chartRefs])
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Button variant={variant} size={size} onClick={handleGenerate} disabled={generating}>
|
||
|
|
{generating ? (
|
||
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<FileDown className="mr-2 h-4 w-4" />
|
||
|
|
)}
|
||
|
|
{generating ? 'Generating...' : 'Export PDF Report'}
|
||
|
|
</Button>
|
||
|
|
)
|
||
|
|
}
|