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:
@@ -81,6 +81,7 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { truncate } from '@/lib/utils'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import {
|
||||
ProjectFiltersBar,
|
||||
@@ -256,6 +257,11 @@ export default function ProjectsPage() {
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
|
||||
// Assign to round dialog state
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
||||
const [assignRoundId, setAssignRoundId] = useState('')
|
||||
|
||||
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
||||
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
||||
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
||||
@@ -420,6 +426,19 @@ export default function ProjectsPage() {
|
||||
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
|
||||
: false
|
||||
|
||||
const assignToRound = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Project assigned to round')
|
||||
utils.project.list.invalidate()
|
||||
setAssignDialogOpen(false)
|
||||
setProjectToAssign(null)
|
||||
setAssignRoundId('')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to assign project')
|
||||
},
|
||||
})
|
||||
|
||||
const deleteProject = trpc.project.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Project deleted successfully')
|
||||
@@ -448,6 +467,12 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/projects/pool">
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
Project Pool
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
AI Tags
|
||||
@@ -600,7 +625,13 @@ export default function ProjectsPage() {
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p>{project.round?.name ?? '-'}</p>
|
||||
{project.round ? (
|
||||
<p>{project.round.name}</p>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
|
||||
Unassigned
|
||||
</Badge>
|
||||
)}
|
||||
{project.status === 'REJECTED' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Eliminated
|
||||
@@ -620,11 +651,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}
|
||||
>
|
||||
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
|
||||
</Badge>
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||
</TableCell>
|
||||
<TableCell className="relative z-10 text-right">
|
||||
<DropdownMenu>
|
||||
@@ -647,6 +674,18 @@ export default function ProjectsPage() {
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!project.round && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setProjectToAssign({ id: project.id, title: project.title })
|
||||
setAssignDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
Assign to Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
@@ -697,14 +736,10 @@ export default function ProjectsPage() {
|
||||
<CardTitle className="text-base line-clamp-2">
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant={
|
||||
statusColors[project.status ?? 'SUBMITTED'] || 'secondary'
|
||||
}
|
||||
<StatusBadge
|
||||
status={project.status ?? 'SUBMITTED'}
|
||||
className="shrink-0"
|
||||
>
|
||||
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
|
||||
</Badge>
|
||||
/>
|
||||
</div>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
</div>
|
||||
@@ -857,6 +892,59 @@ export default function ProjectsPage() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Assign to Round Dialog */}
|
||||
<Dialog open={assignDialogOpen} onOpenChange={(open) => {
|
||||
setAssignDialogOpen(open)
|
||||
if (!open) { setProjectToAssign(null); setAssignRoundId('') }
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign "{projectToAssign?.title}" to a round.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Round</Label>
|
||||
<Select value={assignRoundId} onValueChange={setAssignRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.flatMap((p) =>
|
||||
(p.rounds || []).map((r: { id: string; name: string }) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{p.name} {p.year} - {r.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (projectToAssign && assignRoundId) {
|
||||
assignToRound.mutate({
|
||||
projectIds: [projectToAssign.id],
|
||||
roundId: assignRoundId,
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={!assignRoundId || assignToRound.isPending}
|
||||
>
|
||||
{assignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* AI Tagging Dialog */}
|
||||
<Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
|
||||
Reference in New Issue
Block a user