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:
2026-02-08 22:05:01 +01:00
parent e0e4cb2a32
commit e73a676412
33 changed files with 3193 additions and 977 deletions

View File

@@ -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>

View File

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