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

@@ -41,6 +41,14 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Send,
Mail,
@@ -51,6 +59,7 @@ import {
AlertCircle,
Inbox,
CheckCircle2,
Eye,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
@@ -79,6 +88,7 @@ export default function MessagesPage() {
const [deliveryChannels, setDeliveryChannels] = useState<string[]>(['EMAIL', 'IN_APP'])
const [isScheduled, setIsScheduled] = useState(false)
const [scheduledAt, setScheduledAt] = useState('')
const [showPreview, setShowPreview] = useState(false)
const utils = trpc.useUtils()
@@ -152,7 +162,42 @@ export default function MessagesPage() {
}
}
const handleSend = () => {
const getRecipientDescription = (): string => {
switch (recipientType) {
case 'ALL':
return 'All platform users'
case 'ROLE': {
const roleLabel = selectedRole ? selectedRole.replace(/_/g, ' ') : ''
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
}
case 'ROUND_JURY': {
if (!roundId) return 'Round Jury (none selected)'
const round = (rounds as Array<{ id: string; name: string; program?: { name: string } }> | undefined)?.find(
(r) => r.id === roundId
)
return round
? `Jury of ${round.program ? `${round.program.name} - ` : ''}${round.name}`
: 'Round Jury'
}
case 'PROGRAM_TEAM': {
if (!selectedProgramId) return 'Program Team (none selected)'
const program = (programs as Array<{ id: string; name: string }> | undefined)?.find(
(p) => p.id === selectedProgramId
)
return program ? `Team of ${program.name}` : 'Program Team'
}
case 'USER': {
if (!selectedUserId) return 'Specific User (none selected)'
const userList = (users as { users: Array<{ id: string; name: string | null; email: string }> } | undefined)?.users
const user = userList?.find((u) => u.id === selectedUserId)
return user ? (user.name || user.email) : 'Specific User'
}
default:
return 'Unknown'
}
}
const handlePreview = () => {
if (!subject.trim()) {
toast.error('Subject is required')
return
@@ -182,6 +227,10 @@ export default function MessagesPage() {
return
}
setShowPreview(true)
}
const handleActualSend = () => {
sendMutation.mutate({
recipientType,
recipientFilter: buildRecipientFilter(),
@@ -192,6 +241,7 @@ export default function MessagesPage() {
scheduledAt: isScheduled && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
templateId: selectedTemplateId && selectedTemplateId !== '__none__' ? selectedTemplateId : undefined,
})
setShowPreview(false)
}
return (
@@ -474,13 +524,13 @@ export default function MessagesPage() {
{/* Send button */}
<div className="flex justify-end">
<Button onClick={handleSend} disabled={sendMutation.isPending}>
<Button onClick={handlePreview} disabled={sendMutation.isPending}>
{sendMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
<Eye className="mr-2 h-4 w-4" />
)}
{isScheduled ? 'Schedule' : 'Send Message'}
{isScheduled ? 'Preview & Schedule' : 'Preview & Send'}
</Button>
</div>
</CardContent>
@@ -581,6 +631,68 @@ export default function MessagesPage() {
</Card>
</TabsContent>
</Tabs>
{/* Preview Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Preview Message</DialogTitle>
<DialogDescription>Review your message before sending</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Recipients</p>
<p className="text-sm mt-1">{getRecipientDescription()}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Subject</p>
<p className="text-sm font-medium mt-1">{subject}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Message</p>
<div className="mt-1 rounded-lg border bg-muted/30 p-4">
<p className="text-sm whitespace-pre-wrap">{body}</p>
</div>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Delivery Channels</p>
<div className="flex items-center gap-2 mt-1">
{deliveryChannels.includes('EMAIL') && (
<Badge variant="outline" className="text-xs">
<Mail className="mr-1 h-3 w-3" />
Email
</Badge>
)}
{deliveryChannels.includes('IN_APP') && (
<Badge variant="outline" className="text-xs">
<Bell className="mr-1 h-3 w-3" />
In-App
</Badge>
)}
</div>
</div>
{isScheduled && scheduledAt && (
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Scheduled For</p>
<p className="text-sm mt-1">{formatDate(new Date(scheduledAt))}</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPreview(false)}>
Edit
</Button>
<Button onClick={handleActualSend} disabled={sendMutation.isPending}>
{sendMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{isScheduled ? 'Confirm & Schedule' : 'Confirm & Send'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}