Comprehensive platform audit: security, UX, performance, and visual polish
Phase 1: Security - status transition validation, Zod tightening, DB indexes, transactions Phase 2: Admin UX - search/filter for awards, learning, partners pages Phase 3: Dashboard - Recent Activity feed, Pending Actions card, quick actions Phase 4: Jury - assignments progress/urgency, autosave indicator, divergence highlighting Phase 5: Portals - observer charts, mentor search, login/onboarding polish Phase 6: Messages preview dialog, CsvExportDialog with column selection Phase 7: Performance - query optimizations, loading skeletons, useDebounce hook Phase 8: Visual - AnimatedCard, hover effects, StatusBadge, empty state CTAs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
212
src/components/shared/csv-export-dialog.tsx
Normal file
212
src/components/shared/csv-export-dialog.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
'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([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`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user