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:
@@ -32,7 +32,17 @@ import {
|
||||
type Criterion,
|
||||
} from '@/components/forms/evaluation-form-builder'
|
||||
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar } from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Label } from '@/components/ui/label'
|
||||
@@ -113,9 +123,23 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
roundId,
|
||||
})
|
||||
|
||||
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false)
|
||||
const [templateName, setTemplateName] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Mutations
|
||||
const saveAsTemplate = trpc.roundTemplate.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Round saved as template')
|
||||
setSaveTemplateOpen(false)
|
||||
setTemplateName('')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
const updateRound = trpc.round.update.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate cache to ensure fresh data
|
||||
@@ -825,6 +849,58 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Dialog open={saveTemplateOpen} onOpenChange={setSaveTemplateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline">
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
Save as Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save as Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save the current round configuration as a reusable template.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateName">Template Name</Label>
|
||||
<Input
|
||||
id="templateName"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder="e.g., Standard Evaluation Round"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSaveTemplateOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!templateName.trim() || saveAsTemplate.isPending}
|
||||
onClick={() => {
|
||||
saveAsTemplate.mutate({
|
||||
name: templateName.trim(),
|
||||
roundType: roundType,
|
||||
criteriaJson: criteria,
|
||||
settingsJson: roundSettings,
|
||||
programId: round?.programId,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{saveAsTemplate.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Save Template
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link href={`/admin/rounds/${roundId}`}>Cancel</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import { use, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -114,37 +115,17 @@ export default function FilteringResultsPage({
|
||||
{ roundId },
|
||||
{ enabled: false }
|
||||
)
|
||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
|
||||
const handleExport = async () => {
|
||||
const result = await exportResults.refetch()
|
||||
if (result.data) {
|
||||
const { data: rows, columns } = result.data
|
||||
|
||||
const csvContent = [
|
||||
columns.join(','),
|
||||
...rows.map((row) =>
|
||||
columns
|
||||
.map((col) => {
|
||||
const value = row[col as keyof typeof row]
|
||||
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
return value ?? ''
|
||||
})
|
||||
.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 = `filtering-results-${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
const handleExport = () => {
|
||||
setShowExportDialog(true)
|
||||
}
|
||||
|
||||
const handleRequestExportData = useCallback(async () => {
|
||||
const result = await exportResults.refetch()
|
||||
return result.data ?? undefined
|
||||
}, [exportResults])
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
const next = new Set(expandedRows)
|
||||
if (next.has(id)) next.delete(id)
|
||||
@@ -601,6 +582,16 @@ export default function FilteringResultsPage({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* CSV Export Dialog with Column Selection */}
|
||||
<CsvExportDialog
|
||||
open={showExportDialog}
|
||||
onOpenChange={setShowExportDialog}
|
||||
exportData={exportResults.data ?? undefined}
|
||||
isLoading={exportResults.isFetching}
|
||||
filename="filtering-results"
|
||||
onRequestData={handleRequestExportData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user