Files
MOPC-Portal/src/components/shared/csv-export-dialog.tsx
Matt d787a24921 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>
2026-02-10 23:08:00 +01:00

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>
)
}