- 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>
216 lines
6.6 KiB
TypeScript
216 lines
6.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Download, Loader2 } from 'lucide-react'
|
|
|
|
/**
|
|
* Converts a camelCase or snake_case column name to Title Case.
|
|
* e.g. "projectTitle" -> "Project Title", "ai_meetsCriteria" -> "Ai Meets Criteria"
|
|
*/
|
|
function formatColumnName(col: string): string {
|
|
// Replace underscores with spaces
|
|
let result = col.replace(/_/g, ' ')
|
|
// Insert space before uppercase letters (camelCase -> spaced)
|
|
result = result.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
// Capitalize first letter of each word
|
|
return result
|
|
.split(' ')
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ')
|
|
}
|
|
|
|
type ExportData = {
|
|
data: Record<string, unknown>[]
|
|
columns: string[]
|
|
}
|
|
|
|
type CsvExportDialogProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
exportData: ExportData | undefined
|
|
isLoading: boolean
|
|
filename: string
|
|
onRequestData: () => Promise<ExportData | undefined>
|
|
}
|
|
|
|
export function CsvExportDialog({
|
|
open,
|
|
onOpenChange,
|
|
exportData,
|
|
isLoading,
|
|
filename,
|
|
onRequestData,
|
|
}: CsvExportDialogProps) {
|
|
const [selectedColumns, setSelectedColumns] = useState<Set<string>>(new Set())
|
|
const [dataLoaded, setDataLoaded] = useState(false)
|
|
|
|
// When dialog opens, fetch data if not already loaded
|
|
useEffect(() => {
|
|
if (open && !dataLoaded) {
|
|
onRequestData().then((result) => {
|
|
if (result?.columns) {
|
|
setSelectedColumns(new Set(result.columns))
|
|
}
|
|
setDataLoaded(true)
|
|
})
|
|
}
|
|
}, [open, dataLoaded, onRequestData])
|
|
|
|
// Sync selected columns when export data changes
|
|
useEffect(() => {
|
|
if (exportData?.columns) {
|
|
setSelectedColumns(new Set(exportData.columns))
|
|
}
|
|
}, [exportData])
|
|
|
|
// Reset state when dialog closes
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setDataLoaded(false)
|
|
}
|
|
}, [open])
|
|
|
|
const toggleColumn = (col: string, checked: boolean) => {
|
|
const next = new Set(selectedColumns)
|
|
if (checked) {
|
|
next.add(col)
|
|
} else {
|
|
next.delete(col)
|
|
}
|
|
setSelectedColumns(next)
|
|
}
|
|
|
|
const toggleAll = () => {
|
|
if (!exportData) return
|
|
if (selectedColumns.size === exportData.columns.length) {
|
|
setSelectedColumns(new Set())
|
|
} else {
|
|
setSelectedColumns(new Set(exportData.columns))
|
|
}
|
|
}
|
|
|
|
const handleDownload = () => {
|
|
if (!exportData) return
|
|
|
|
const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col))
|
|
|
|
// Build CSV header with formatted names
|
|
const csvHeader = columnsArray.map((col) => {
|
|
const formatted = formatColumnName(col)
|
|
// Escape quotes in header
|
|
if (formatted.includes(',') || formatted.includes('"')) {
|
|
return `"${formatted.replace(/"/g, '""')}"`
|
|
}
|
|
return formatted
|
|
})
|
|
|
|
const csvContent = [
|
|
csvHeader.join(','),
|
|
...exportData.data.map((row) =>
|
|
columnsArray
|
|
.map((col) => {
|
|
const value = row[col]
|
|
if (value === null || value === undefined) return ''
|
|
const str = String(value)
|
|
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
|
|
return `"${str.replace(/"/g, '""')}"`
|
|
}
|
|
return str
|
|
})
|
|
.join(',')
|
|
),
|
|
].join('\n')
|
|
|
|
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' })
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv`
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
// Delay revoking to ensure download starts before URL is invalidated
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
onOpenChange(false)
|
|
}
|
|
|
|
const allSelected = exportData ? selectedColumns.size === exportData.columns.length : false
|
|
const noneSelected = selectedColumns.size === 0
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Export CSV</DialogTitle>
|
|
<DialogDescription>
|
|
Select which columns to include in the export
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{isLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
<span className="ml-2 text-sm text-muted-foreground">Loading data...</span>
|
|
</div>
|
|
) : exportData ? (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-sm font-medium">
|
|
{selectedColumns.size} of {exportData.columns.length} columns selected
|
|
</Label>
|
|
<Button variant="ghost" size="sm" onClick={toggleAll}>
|
|
{allSelected ? 'Deselect all' : 'Select all'}
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-1.5 max-h-60 overflow-y-auto rounded-lg border p-3">
|
|
{exportData.columns.map((col) => (
|
|
<div key={col} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`col-${col}`}
|
|
checked={selectedColumns.has(col)}
|
|
onCheckedChange={(checked) => toggleColumn(col, !!checked)}
|
|
/>
|
|
<Label htmlFor={`col-${col}`} className="text-sm cursor-pointer font-normal">
|
|
{formatColumnName(col)}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{exportData.data.length} row{exportData.data.length !== 1 ? 's' : ''} will be exported
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
|
No data available for export.
|
|
</p>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleDownload}
|
|
disabled={isLoading || !exportData || noneSelected}
|
|
>
|
|
<Download className="mr-2 h-4 w-4" />
|
|
Download CSV
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|