Implement 15 platform features: digest, availability, templates, comparison, live voting SSE, file versioning, mentorship, messaging, analytics, drafts, webhooks, peer review, audit enhancements, i18n
Features implemented: - F1: Email digest notifications with cron endpoint and per-user frequency - F2: Jury availability windows and workload preferences in smart assignment - F3: Round templates with save-from-round and CRUD management - F4: Side-by-side project comparison view for jury members - F5: Real-time voting dashboard with Server-Sent Events (SSE) - F6: Live voting UX: QR codes, audience voting, tie-breaking, score animations - F7: File versioning, inline preview, bulk download with presigned URLs - F8: Mentor dashboard: milestones, private notes, activity tracking - F9: Communication hub with broadcasts, templates, and recipient targeting - F10: Advanced analytics: cross-round comparison, juror consistency, diversity metrics, PDF export - F11: Applicant draft saving with magic link resume and cron cleanup - F12: Webhook integration layer with HMAC signing, retry, and delivery logs - F13: Peer review discussions with anonymized scores and threaded comments - F14: Audit log enhancements: before/after diffs, session grouping, anomaly detection, retention - F15: i18n foundation with next-intl (EN/FR), cookie-based locale, language switcher Schema: 12 new models, field additions to User, Project, ProjectFile, LiveVotingSession, LiveVote, MentorAssignment, AuditLog, Program New routers: roundTemplate, message, webhook (registered in _app.ts) New services: email-digest, webhook-dispatcher New cron endpoints: /api/cron/digest, /api/cron/draft-cleanup, /api/cron/audit-cleanup New API routes: /api/live-voting/stream (SSE), /api/files/bulk-download All features are admin-configurable via SystemSettings or per-model settingsJson fields. Docker build verified successfully. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
156
src/components/admin/pdf-report.tsx
Normal file
156
src/components/admin/pdf-report.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileDown, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
const { refetch } = trpc.export.getReportData.useQuery(
|
||||
{ roundId, sections },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
setGenerating(true)
|
||||
try {
|
||||
const result = await refetch()
|
||||
if (!result.data) {
|
||||
toast.error('Failed to fetch report data')
|
||||
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
|
||||
}
|
||||
// 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')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}, [refetch])
|
||||
|
||||
return (
|
||||
<Button variant="outline" 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user