Observer dashboard extraction, PDF reports, jury UX overhaul, and miscellaneous improvements

- Extract observer dashboard to client component, add PDF export button
- Add PDF report generator with jsPDF for analytics reports
- Overhaul jury evaluation page with improved layout and UX
- Add new analytics endpoints for observer/admin reports
- Improve round creation/edit forms with better settings
- Fix filtering rules page, CSV export dialog, notification bell
- Update auth, prisma schema, and various type fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 23:08:00 +01:00
parent 5c8d22ac11
commit d787a24921
31 changed files with 2565 additions and 930 deletions

View File

@@ -5,108 +5,23 @@ 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,
addAllPageFooters,
savePdf,
} from '@/lib/pdf-generator'
interface PdfReportProps {
roundId: string
sections: string[]
}
function buildReportHtml(reportData: Record<string, unknown>): string {
const parts: string[] = []
parts.push(`<!DOCTYPE html><html><head>
<title>Round Report - ${String(reportData.roundName || 'Report')}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;600;700&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Montserrat', sans-serif; color: #1a1a1a; padding: 40px; max-width: 1000px; margin: 0 auto; }
h1 { color: #053d57; font-size: 24px; font-weight: 700; margin-bottom: 8px; }
h2 { color: #053d57; font-size: 18px; font-weight: 600; margin: 24px 0 12px; border-bottom: 2px solid #053d57; padding-bottom: 4px; }
p { font-size: 12px; line-height: 1.6; margin-bottom: 8px; }
.subtitle { color: #557f8c; font-size: 14px; margin-bottom: 24px; }
.generated { color: #888; font-size: 10px; margin-bottom: 32px; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 11px; }
th { background: #053d57; color: white; text-align: left; padding: 8px 12px; font-weight: 600; }
td { padding: 6px 12px; border-bottom: 1px solid #e0e0e0; }
tr:nth-child(even) td { background: #f8f8f8; }
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin: 16px 0; }
.stat-card { background: #f0f4f8; border-radius: 8px; padding: 16px; text-align: center; }
.stat-value { font-size: 28px; font-weight: 700; color: #053d57; }
.stat-label { font-size: 11px; color: #557f8c; margin-top: 4px; }
@media print { body { padding: 20px; } .no-print { display: none; } }
</style>
</head><body>`)
parts.push(`<div class="no-print" style="margin-bottom: 20px;">
<button onclick="window.print()" style="background: #053d57; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-family: Montserrat; font-weight: 600;">
Print / Save as PDF
</button>
</div>`)
parts.push(`<h1>${escapeHtml(String(reportData.roundName || 'Round Report'))}</h1>`)
parts.push(`<p class="subtitle">${escapeHtml(String(reportData.programName || ''))}</p>`)
parts.push(`<p class="generated">Generated on ${new Date().toLocaleString()}</p>`)
const summary = reportData.summary as Record<string, unknown> | undefined
if (summary) {
parts.push(`<h2>Summary</h2><div class="stat-grid">`)
parts.push(statCard(summary.totalProjects, 'Projects'))
parts.push(statCard(summary.totalEvaluations, 'Evaluations'))
parts.push(statCard(summary.averageScore != null ? Number(summary.averageScore).toFixed(1) : '--', 'Avg Score'))
parts.push(statCard(summary.completionRate != null ? Number(summary.completionRate).toFixed(0) + '%' : '--', 'Completion'))
parts.push(`</div>`)
}
const rankings = reportData.rankings as Array<Record<string, unknown>> | undefined
if (rankings && rankings.length > 0) {
parts.push(`<h2>Project Rankings</h2><table><thead><tr>
<th>#</th><th>Project</th><th>Team</th><th>Avg Score</th><th>Evaluations</th>
</tr></thead><tbody>`)
for (const p of rankings) {
parts.push(`<tr>
<td>${escapeHtml(String(p.rank ?? ''))}</td>
<td>${escapeHtml(String(p.title ?? ''))}</td>
<td>${escapeHtml(String(p.team ?? ''))}</td>
<td>${Number(p.avgScore ?? 0).toFixed(2)}</td>
<td>${String(p.evalCount ?? 0)}</td>
</tr>`)
}
parts.push(`</tbody></table>`)
}
const jurorStats = reportData.jurorStats as Array<Record<string, unknown>> | undefined
if (jurorStats && jurorStats.length > 0) {
parts.push(`<h2>Juror Statistics</h2><table><thead><tr>
<th>Juror</th><th>Assigned</th><th>Completed</th><th>Completion %</th><th>Avg Score Given</th>
</tr></thead><tbody>`)
for (const j of jurorStats) {
parts.push(`<tr>
<td>${escapeHtml(String(j.name ?? ''))}</td>
<td>${String(j.assigned ?? 0)}</td>
<td>${String(j.completed ?? 0)}</td>
<td>${Number(j.completionRate ?? 0).toFixed(0)}%</td>
<td>${Number(j.avgScore ?? 0).toFixed(2)}</td>
</tr>`)
}
parts.push(`</tbody></table>`)
}
parts.push(`</body></html>`)
return parts.join('')
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function statCard(value: unknown, label: string): string {
return `<div class="stat-card"><div class="stat-value">${escapeHtml(String(value ?? 0))}</div><div class="stat-label">${escapeHtml(label)}</div></div>`
}
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
@@ -117,6 +32,8 @@ export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
const handleGenerate = useCallback(async () => {
setGenerating(true)
toast.info('Generating PDF report...')
try {
const result = await refetch()
if (!result.data) {
@@ -124,20 +41,113 @@ export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
return
}
const html = buildReportHtml(result.data as Record<string, unknown>)
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
const url = URL.createObjectURL(blob)
const newWindow = window.open(url, '_blank')
if (!newWindow) {
toast.error('Pop-up blocked. Please allow pop-ups and try again.')
URL.revokeObjectURL(url)
return
const data = result.data as Record<string, unknown>
const rName = String(data.roundName || 'Report')
const pName = 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
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)
}
// Clean up after a delay
setTimeout(() => URL.revokeObjectURL(url), 5000)
toast.success('Report generated. Use the Print button or Ctrl+P to save as PDF.')
} catch {
toast.error('Failed to generate report')
// 4. Rankings
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
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. Footers
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)
}