Fix first-login error, awards performance, filter animation, cache invalidation, and query fixes
Build and Push Docker Image / build (push) Successful in 11m24s Details

- Guard onboarding tRPC queries with session hydration check (fixes UNAUTHORIZED on first login)
- Defer expensive queries on awards page until UI elements are opened (dialog/tab)
- Fix perPage: 500 exceeding backend Zod max of 100 on awards eligibility query
- Add smooth open/close animation to project filters collapsible bar
- Fix seeded user status from ACTIVE to INVITED in seed-candidatures.ts
- Add router.refresh() cache invalidation across ~22 admin forms
- Fix geographic analytics query to use programId instead of round.programId
- Fix dashboard queries to scope by programId correctly
- Fix project.listPool and round queries for projects outside round context
- Add rounds page useEffect for state sync after mutations

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-10 21:21:54 +01:00
parent 573785e440
commit 5cae78fe0c
26 changed files with 830 additions and 341 deletions

View File

@ -346,7 +346,7 @@ async function main() {
email,
name: row['Full name']?.trim() || 'Unknown',
role: 'APPLICANT',
status: 'ACTIVE',
status: 'INVITED',
phoneNumber: row['Téléphone']?.trim() || null,
},
})

View File

@ -35,8 +35,14 @@ export default function EditAwardPage({
const { id: awardId } = use(params)
const router = useRouter()
const utils = trpc.useUtils()
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
const updateAward = trpc.specialAward.update.useMutation()
const updateAward = trpc.specialAward.update.useMutation({
onSuccess: () => {
utils.specialAward.get.invalidate({ id: awardId })
utils.specialAward.list.invalidate()
},
})
const [name, setName] = useState('')
const [description, setDescription] = useState('')

View File

@ -141,28 +141,39 @@ export default function AwardDetailPage({
const { id: awardId } = use(params)
const router = useRouter()
// State declarations (before queries that depend on them)
const [isPollingJob, setIsPollingJob] = useState(false)
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [selectedJurorId, setSelectedJurorId] = useState('')
const [includeSubmitted, setIncludeSubmitted] = useState(true)
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
const [projectSearchQuery, setProjectSearchQuery] = useState('')
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState('eligibility')
// Core queries
const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId })
const { data: eligibilityData, refetch: refetchEligibility } =
trpc.specialAward.listEligible.useQuery({
awardId,
page: 1,
perPage: 500,
perPage: 100,
})
const { data: jurors, refetch: refetchJurors } =
trpc.specialAward.listJurors.useQuery({ awardId })
const { data: voteResults } =
trpc.specialAward.getVoteResults.useQuery({ awardId })
const { data: allUsers } = trpc.user.list.useQuery({ role: 'JURY_MEMBER', page: 1, perPage: 100 })
// Fetch all projects in the program for manual eligibility addition
const { data: allProjects } = trpc.project.list.useQuery(
{ programId: award?.programId ?? '', perPage: 500 },
{ enabled: !!award?.programId }
// Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery(
{ role: 'JURY_MEMBER', page: 1, perPage: 100 },
{ enabled: activeTab === 'jurors' }
)
const { data: allProjects } = trpc.project.list.useQuery(
{ programId: award?.programId ?? '', perPage: 200 },
{ enabled: !!award?.programId && addProjectDialogOpen }
)
const [isPollingJob, setIsPollingJob] = useState(false)
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Eligibility job polling
const { data: jobStatus, refetch: refetchJobStatus } =
@ -208,19 +219,34 @@ export default function AwardDetailPage({
}
}, [award?.eligibilityJobStatus])
const updateStatus = trpc.specialAward.updateStatus.useMutation()
const runEligibility = trpc.specialAward.runEligibility.useMutation()
const setEligibility = trpc.specialAward.setEligibility.useMutation()
const addJuror = trpc.specialAward.addJuror.useMutation()
const removeJuror = trpc.specialAward.removeJuror.useMutation()
const setWinner = trpc.specialAward.setWinner.useMutation()
const deleteAward = trpc.specialAward.delete.useMutation()
const [selectedJurorId, setSelectedJurorId] = useState('')
const [includeSubmitted, setIncludeSubmitted] = useState(true)
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
const [projectSearchQuery, setProjectSearchQuery] = useState('')
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
const invalidateAward = () => {
utils.specialAward.get.invalidate({ id: awardId })
utils.specialAward.listEligible.invalidate({ awardId })
utils.specialAward.listJurors.invalidate({ awardId })
utils.specialAward.getVoteResults.invalidate({ awardId })
}
const updateStatus = trpc.specialAward.updateStatus.useMutation({
onSuccess: invalidateAward,
})
const runEligibility = trpc.specialAward.runEligibility.useMutation({
onSuccess: invalidateAward,
})
const setEligibility = trpc.specialAward.setEligibility.useMutation({
onSuccess: () => utils.specialAward.listEligible.invalidate({ awardId }),
})
const addJuror = trpc.specialAward.addJuror.useMutation({
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
})
const removeJuror = trpc.specialAward.removeJuror.useMutation({
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
})
const setWinner = trpc.specialAward.setWinner.useMutation({
onSuccess: invalidateAward,
})
const deleteAward = trpc.specialAward.delete.useMutation({
onSuccess: () => utils.specialAward.list.invalidate(),
})
const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
@ -569,7 +595,7 @@ export default function AwardDetailPage({
</div>
{/* Tabs */}
<Tabs defaultValue="eligibility">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="eligibility">
<CheckCircle2 className="mr-2 h-4 w-4" />

View File

@ -38,8 +38,11 @@ export default function CreateAwardPage() {
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
const [programId, setProgramId] = useState('')
const utils = trpc.useUtils()
const { data: programs } = trpc.program.list.useQuery()
const createAward = trpc.specialAward.create.useMutation()
const createAward = trpc.specialAward.create.useMutation({
onSuccess: () => utils.specialAward.list.invalidate(),
})
const handleSubmit = async () => {
if (!name.trim() || !programId) return

View File

@ -97,8 +97,16 @@ export default function EditLearningResourcePage() {
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const updateResource = trpc.learningResource.update.useMutation()
const deleteResource = trpc.learningResource.delete.useMutation()
const utils = trpc.useUtils()
const updateResource = trpc.learningResource.update.useMutation({
onSuccess: () => {
utils.learningResource.get.invalidate({ id: resourceId })
utils.learningResource.list.invalidate()
},
})
const deleteResource = trpc.learningResource.delete.useMutation({
onSuccess: () => utils.learningResource.list.invalidate(),
})
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
// Populate form when resource loads

View File

@ -68,7 +68,10 @@ export default function NewLearningResourcePage() {
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const [programId, setProgramId] = useState<string | null>(null)
const createResource = trpc.learningResource.create.useMutation()
const utils = trpc.useUtils()
const createResource = trpc.learningResource.create.useMutation({
onSuccess: () => utils.learningResource.list.invalidate(),
})
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
// Handle file upload for BlockNote

View File

@ -131,11 +131,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
where: { programId: editionId },
}),
prisma.project.count({
where: { round: { programId: editionId } },
where: { programId: editionId },
}),
prisma.project.count({
where: {
round: { programId: editionId },
programId: editionId,
createdAt: { gte: sevenDaysAgo },
},
}),
@ -186,7 +186,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
},
}),
prisma.project.findMany({
where: { round: { programId: editionId } },
where: { programId: editionId },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
@ -205,12 +205,12 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
}),
prisma.project.groupBy({
by: ['competitionCategory'],
where: { round: { programId: editionId } },
where: { programId: editionId },
_count: true,
}),
prisma.project.groupBy({
by: ['oceanIssue'],
where: { round: { programId: editionId } },
where: { programId: editionId },
_count: true,
}),
// Recent activity feed (scoped to last 7 days for performance)
@ -243,7 +243,8 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
// Projects without assignments in active rounds
prisma.project.count({
where: {
round: { programId: editionId, status: 'ACTIVE' },
programId: editionId,
round: { status: 'ACTIVE' },
assignments: { none: {} },
},
}),

View File

@ -68,8 +68,11 @@ export default function EditPartnerPage() {
}
}, [partner])
const utils = trpc.useUtils()
const updatePartner = trpc.partner.update.useMutation({
onSuccess: () => {
utils.partner.list.invalidate()
utils.partner.get.invalidate()
toast.success('Partner updated successfully')
router.push('/admin/partners')
},
@ -81,6 +84,7 @@ export default function EditPartnerPage() {
const deletePartner = trpc.partner.delete.useMutation({
onSuccess: () => {
utils.partner.list.invalidate()
toast.success('Partner deleted successfully')
router.push('/admin/partners')
},

View File

@ -31,8 +31,10 @@ export default function NewPartnerPage() {
const [partnerType, setPartnerType] = useState('PARTNER')
const [visibility, setVisibility] = useState('ADMIN_ONLY')
const utils = trpc.useUtils()
const createPartner = trpc.partner.create.useMutation({
onSuccess: () => {
utils.partner.list.invalidate()
toast.success('Partner created successfully')
router.push('/admin/partners')
},

View File

@ -254,8 +254,10 @@ export default function ApplySettingsPage() {
)
// --- Mutations ---
const utils = trpc.useUtils()
const createTemplate = trpc.wizardTemplate.create.useMutation({
onSuccess: () => {
utils.wizardTemplate.list.invalidate()
toast.success('Template saved')
setSaveTemplateOpen(false)
setSaveTemplateName('')
@ -264,6 +266,7 @@ export default function ApplySettingsPage() {
})
const updateConfig = trpc.program.updateWizardConfig.useMutation({
onSuccess: () => {
utils.program.get.invalidate({ id: programId })
toast.success('Settings saved successfully')
setIsDirty(false)
},

View File

@ -66,8 +66,11 @@ export default function EditProgramPage() {
}
}, [program])
const utils = trpc.useUtils()
const updateProgram = trpc.program.update.useMutation({
onSuccess: () => {
utils.program.list.invalidate()
utils.program.get.invalidate({ id })
toast.success('Program updated successfully')
router.push(`/admin/programs/${id}`)
},
@ -79,6 +82,7 @@ export default function EditProgramPage() {
const deleteProgram = trpc.program.delete.useMutation({
onSuccess: () => {
utils.program.list.invalidate()
toast.success('Program deleted successfully')
router.push('/admin/programs')
},

View File

@ -22,8 +22,10 @@ export default function NewProgramPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const utils = trpc.useUtils()
const createProgram = trpc.program.create.useMutation({
onSuccess: () => {
utils.program.list.invalidate()
toast.success('Program created successfully')
router.push('/admin/programs')
},

View File

@ -69,6 +69,9 @@ import {
FolderOpen,
X,
AlertTriangle,
ArrowRightCircle,
LayoutGrid,
LayoutList,
} from 'lucide-react'
import {
Select,
@ -180,6 +183,7 @@ export default function ProjectsPage() {
const [page, setPage] = useState(parsed.page)
const [perPage, setPerPage] = useState(parsed.perPage || 20)
const [searchInput, setSearchInput] = useState(parsed.search)
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
// Fetch display settings
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
@ -373,6 +377,10 @@ export default function ProjectsPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
onSuccess: (result) => {
@ -387,6 +395,31 @@ export default function ProjectsPage() {
},
})
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
setSelectedIds(new Set())
setBulkAssignRoundId('')
setBulkAssignDialogOpen(false)
utils.project.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to assign projects')
},
})
const bulkDeleteProjects = trpc.project.bulkDelete.useMutation({
onSuccess: (result) => {
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
setSelectedIds(new Set())
setBulkDeleteConfirmOpen(false)
utils.project.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to delete projects')
},
})
const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
@ -481,12 +514,6 @@ export default function ProjectsPage() {
</p>
</div>
<div className="flex flex-wrap 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
@ -540,34 +567,56 @@ export default function ProjectsPage() {
onChange={handleFiltersChange}
/>
{/* Stats Summary */}
{/* Stats Summary + View Toggle */}
{data && data.projects.length > 0 && (
<div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries(
data.projects.reduce<Record<string, number>>((acc, p) => {
const s = p.status ?? 'SUBMITTED'
acc[s] = (acc[s] || 0) + 1
return acc
}, {})
)
.sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([status, count]) => (
<Badge
key={status}
variant={statusColors[status] || 'secondary'}
className="text-xs font-normal"
>
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
))}
{data.total > data.projects.length && (
<span className="text-xs text-muted-foreground ml-1">
(page {data.page} of {data.totalPages})
</span>
)}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2 text-sm">
{Object.entries(
data.projects.reduce<Record<string, number>>((acc, p) => {
const s = p.status ?? 'SUBMITTED'
acc[s] = (acc[s] || 0) + 1
return acc
}, {})
)
.sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
})
.map(([status, count]) => (
<Badge
key={status}
variant={statusColors[status] || 'secondary'}
className="text-xs font-normal"
>
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
</Badge>
))}
{data.total > data.projects.length && (
<span className="text-xs text-muted-foreground ml-1">
(page {data.page} of {data.totalPages})
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant={viewMode === 'table' ? 'secondary' : 'ghost'}
size="icon"
className="h-8 w-8"
onClick={() => setViewMode('table')}
aria-label="Table view"
>
<LayoutList className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'card' ? 'secondary' : 'ghost'}
size="icon"
className="h-8 w-8"
onClick={() => setViewMode('card')}
aria-label="Card view"
>
<LayoutGrid className="h-4 w-4" />
</Button>
</div>
</div>
)}
@ -623,230 +672,381 @@ export default function ProjectsPage() {
</Card>
) : data ? (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
{filters.roundId && (
<TableHead className="w-10">
<Checkbox
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
aria-label="Select all projects"
/>
</TableHead>
)}
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Submitted</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
return (
<TableRow
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
{filters.roundId && (
<TableCell className="relative z-10">
{/* Table View */}
{viewMode === 'table' ? (
<>
{/* Desktop table */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
onClick={(e) => e.stopPropagation()}
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={handleSelectAll}
aria-label="Select all projects"
/>
</TableCell>
)}
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']"
</TableHead>
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Round</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
return (
<TableRow
key={project.id}
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
>
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div>
<p className={`font-medium hover:text-primary ${uppercaseNames ? 'uppercase' : ''}`}>
{truncate(project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
{project.country && (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div>
<div className="flex items-center gap-2">
{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
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{project.round?.program?.name}
</p>
</div>
</TableCell>
<TableCell>{project._count?.files ?? 0}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{project._count.assignments}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{project.createdAt
? new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
: '-'}
</TableCell>
<TableCell>
<StatusBadge status={project.status ?? 'SUBMITTED'} />
</TableCell>
<TableCell className="relative z-10 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
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"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)})}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<div key={project.id} className="relative">
{filters.roundId && (
<div className="absolute left-3 top-4 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
)}
<Link
href={`/admin/projects/${project.id}`}
className="block"
>
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
<ProjectLogo
project={project}
size="md"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title}
</CardTitle>
<StatusBadge
status={project.status ?? 'SUBMITTED'}
className="shrink-0"
<TableCell className="relative z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
onClick={(e) => e.stopPropagation()}
/>
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
</TableCell>
<TableCell>
<Link
href={`/admin/projects/${project.id}`}
className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']"
>
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div>
<p className={`font-medium hover:text-primary ${uppercaseNames ? 'uppercase' : ''}`}>
{truncate(project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
{project.country && (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
</p>
</div>
</Link>
</TableCell>
<TableCell>
{project.competitionCategory ? (
<Badge variant="outline" className="text-xs whitespace-nowrap">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div>
<div className="flex items-center gap-2">
{project.round ? (
<p>{project.round.name}</p>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{project.round?.program?.name}
</p>
</div>
</TableCell>
<TableCell>
{project.tags && project.tags.length > 0 ? (
<div className="flex flex-wrap gap-1 max-w-[200px]">
{project.tags.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-[10px] px-1.5 py-0 font-normal"
>
{tag}
</Badge>
))}
{project.tags.length > 3 && (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 font-normal"
>
+{project.tags.length - 3}
</Badge>
)}
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{project._count.assignments}
</div>
</TableCell>
<TableCell>
<StatusBadge status={project.status ?? 'SUBMITTED'} />
</TableCell>
<TableCell className="relative z-10 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
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"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)})}
</TableBody>
</Table>
</Card>
{/* Mobile card view (table mode fallback) */}
<div className="space-y-4 md:hidden">
{data.projects.map((project) => (
<div key={project.id} className="relative">
<div className="absolute left-3 top-4 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<div className="flex items-center gap-2">
<span>{project.round?.name ?? '-'}</span>
{project.status === 'REJECTED' && (
<Badge variant="destructive" className="text-xs">
Eliminated
</Badge>
)}
</div>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>{project._count.assignments} jurors</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Files</span>
<span>{project._count?.files ?? 0}</span>
</div>
{project.createdAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span>{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
)}
</CardContent>
</Card>
</Link>
<Link href={`/admin/projects/${project.id}`} className="block">
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-8">
<ProjectLogo project={project} size="md" fallback="initials" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title}
</CardTitle>
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{project.round?.name ?? 'Unassigned'}</span>
</div>
{project.competitionCategory && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Category</span>
<Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
</div>
)}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>{project._count.assignments} jurors</span>
</div>
{project.tags && project.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{project.tags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
{tag}
</Badge>
))}
{project.tags.length > 4 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
+{project.tags.length - 4}
</Badge>
)}
</div>
)}
</CardContent>
</Card>
</Link>
</div>
))}
</div>
))}
</div>
</>
) : (
/* Card View */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{data.projects.map((project) => {
const isEliminated = project.status === 'REJECTED'
return (
<div key={project.id} className="relative">
<div className="absolute left-3 top-3 z-10">
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => handleToggleSelect(project.id)}
aria-label={`Select ${project.title}`}
/>
</div>
<Link href={`/admin/projects/${project.id}`} className="block">
<Card className={`transition-colors hover:bg-muted/50 h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
<CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-7">
<ProjectLogo project={project} size="lg" fallback="initials" />
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
{project.title}
</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 relative z-10" onClick={(e) => e.preventDefault()}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
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"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardDescription className="mt-0.5">
{project.teamName}
{project.country && (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<div className="flex items-center justify-between gap-2">
<StatusBadge status={project.status ?? 'SUBMITTED'} />
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span className="text-right">
{project.round ? (
<>{project.round.name}</>
) : (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
Unassigned
</Badge>
)}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Jurors</span>
<span>{project._count.assignments}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Files</span>
<span>{project._count?.files ?? 0}</span>
</div>
{project.createdAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span>
<span className="text-xs">{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div className="flex flex-wrap gap-1 pt-1 border-t">
{project.tags.slice(0, 5).map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
{tag}
</Badge>
))}
{project.tags.length > 5 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
+{project.tags.length - 5}
</Badge>
)}
</div>
)}
</CardContent>
</Card>
</Link>
</div>
)
})}
</div>
)}
{/* Pagination */}
<Pagination
@ -861,48 +1061,72 @@ export default function ProjectsPage() {
) : null}
{/* Bulk Action Floating Toolbar */}
{selectedIds.size > 0 && filters.roundId && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
{selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 w-[95vw] max-w-xl">
<Card className="border-2 shadow-lg">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<Badge variant="secondary" className="shrink-0 text-sm">
{selectedIds.size} selected
</Badge>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-full sm:w-[180px]">
<SelectValue placeholder="Set status..." />
</SelectTrigger>
<SelectContent>
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
<SelectItem key={s} value={s}>
{s.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2 flex-1">
{/* Assign to Round */}
<Button
size="sm"
onClick={handleBulkApply}
disabled={!bulkStatus || bulkUpdateStatus.isPending}
variant="outline"
onClick={() => setBulkAssignDialogOpen(true)}
>
{bulkUpdateStatus.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Apply
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
Assign to Round
</Button>
{/* Change Status (only when filtered by round) */}
{filters.roundId && (
<>
<Select value={bulkStatus} onValueChange={setBulkStatus}>
<SelectTrigger className="w-[160px] h-9 text-sm">
<SelectValue placeholder="Set status..." />
</SelectTrigger>
<SelectContent>
{['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'REJECTED'].map((s) => (
<SelectItem key={s} value={s}>
{s.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={handleBulkApply}
disabled={!bulkStatus || bulkUpdateStatus.isPending}
>
{bulkUpdateStatus.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Apply
</Button>
</>
)}
{/* Delete */}
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
variant="destructive"
onClick={() => setBulkDeleteConfirmOpen(true)}
>
<X className="mr-1 h-4 w-4" />
Clear
<Trash2 className="mr-1.5 h-4 w-4" />
Delete
</Button>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
setSelectedIds(new Set())
setBulkStatus('')
}}
className="shrink-0"
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
</CardContent>
</Card>
</div>
@ -1026,6 +1250,98 @@ export default function ProjectsPage() {
</DialogContent>
</Dialog>
{/* Bulk Assign to Round Dialog */}
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
setBulkAssignDialogOpen(open)
if (!open) setBulkAssignRoundId('')
}}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Assign to Round</DialogTitle>
<DialogDescription>
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to &quot;Assigned&quot;.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
<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={() => setBulkAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
if (bulkAssignRoundId && selectedIds.size > 0) {
bulkAssignToRound.mutate({
projectIds: Array.from(selectedIds),
roundId: bulkAssignRoundId,
})
}
}}
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
>
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</Button>
</div>
</DialogContent>
</Dialog>
{/* Bulk Delete Confirmation Dialog */}
<AlertDialog open={bulkDeleteConfirmOpen} onOpenChange={setBulkDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
Are you sure you want to permanently delete{' '}
<strong>{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}</strong>?
This will remove all associated files, assignments, and evaluations.
</p>
<div className="flex items-start gap-2 rounded-md bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<p className="text-sm">
This action cannot be undone. All project data will be permanently lost.
</p>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkDeleteProjects.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
bulkDeleteProjects.mutate({ ids: Array.from(selectedIds) })
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={bulkDeleteProjects.isPending}
>
{bulkDeleteProjects.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* AI Tagging Dialog */}
<Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
<DialogContent className="sm:max-w-lg">

View File

@ -55,8 +55,11 @@ export default function ProjectPoolPage() {
{ enabled: !!selectedProgramId }
)
const utils = trpc.useUtils()
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.round.get.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)

View File

@ -26,6 +26,7 @@ import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { ChevronDown, Filter, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
const ALL_STATUSES = [
'SUBMITTED',
@ -140,14 +141,14 @@ export function ProjectFiltersBar({
</CardTitle>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 duration-200">
<CardContent className="space-y-4 pt-0">
{/* Status toggles */}
<div className="space-y-2">
@ -255,11 +256,21 @@ export function ProjectFiltersBar({
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All countries</SelectItem>
{filterOptions?.countries.map((c) => (
<SelectItem key={c} value={c}>
{c}
</SelectItem>
))}
{filterOptions?.countries
.map((c) => ({
code: c,
name: getCountryName(c),
flag: getCountryFlag(c),
}))
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) => (
<SelectItem key={c.code} value={c.code}>
<span className="flex items-center gap-2">
<span>{c.flag}</span>
<span>{c.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>

View File

@ -132,6 +132,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
// Mutations
const saveAsTemplate = trpc.roundTemplate.create.useMutation({
onSuccess: () => {
utils.roundTemplate.list.invalidate()
toast.success('Round saved as template')
setSaveTemplateOpen(false)
setTemplateName('')
@ -143,14 +144,18 @@ function EditRoundContent({ roundId }: { roundId: string }) {
const updateRound = trpc.round.update.useMutation({
onSuccess: () => {
// Invalidate cache to ensure fresh data
utils.round.get.invalidate({ id: roundId })
utils.round.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
router.push(`/admin/rounds/${roundId}`)
},
})
const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation()
const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
},
})
// Initialize form with existing data
const form = useForm<UpdateRoundForm>({

View File

@ -109,6 +109,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
utils.round.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
},
})
const deleteRound = trpc.round.delete.useMutation({
@ -125,7 +127,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
// Filtering mutations
const startJob = trpc.filtering.startJob.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
const finalizeResults = trpc.filtering.finalizeResults.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
utils.project.list.invalidate()
},
})
// Save as template
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({

View File

@ -1,6 +1,6 @@
'use client'
import { Suspense, useState } from 'react'
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
@ -119,6 +119,11 @@ function ProgramRounds({ program }: { program: any }) {
const utils = trpc.useUtils()
const [rounds, setRounds] = useState<RoundData[]>(program.rounds || [])
// Sync local state when query data refreshes (e.g. after status change)
useEffect(() => {
setRounds(program.rounds || [])
}, [program.rounds])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {

View File

@ -2,6 +2,7 @@
import { useState, useMemo, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -45,6 +46,8 @@ type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferenc
export default function OnboardingPage() {
const router = useRouter()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const [step, setStep] = useState<Step>('name')
const [initialized, setInitialized] = useState(false)
@ -59,9 +62,15 @@ export default function OnboardingPage() {
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL')
// Fetch current user data to get admin-preset tags
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery()
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
// Fetch current user data only after session is hydrated
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(
undefined,
{ enabled: isAuthenticated }
)
// Initialize form with user data
useEffect(() => {
@ -95,11 +104,17 @@ export default function OnboardingPage() {
}
}, [userData, initialized])
// Fetch feature flags
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
// Fetch feature flags only after session is hydrated
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const whatsappEnabled = featureFlags?.whatsappEnabled ?? false
const completeOnboarding = trpc.user.completeOnboarding.useMutation()
const utils = trpc.useUtils()
const completeOnboarding = trpc.user.completeOnboarding.useMutation({
onSuccess: () => utils.user.me.invalidate(),
})
// Dynamic steps based on WhatsApp availability
const steps: Step[] = useMemo(() => {
@ -162,8 +177,8 @@ export default function OnboardingPage() {
}
}
// Show loading while fetching user data
if (userLoading || !initialized) {
// Show loading while session hydrates or fetching user data
if (sessionStatus === 'loading' || userLoading || !initialized) {
return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-lg shadow-2xl">

View File

@ -30,9 +30,14 @@ export default function JuryAwardVotingPage({
}) {
const { id: awardId } = use(params)
const utils = trpc.useUtils()
const { data, isLoading, refetch } =
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
const submitVote = trpc.specialAward.submitVote.useMutation()
const submitVote = trpc.specialAward.submitVote.useMutation({
onSuccess: () => {
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
},
})
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null

View File

@ -53,7 +53,10 @@ export default function ProfileSettingsPage() {
const router = useRouter()
const { data: user, isLoading, refetch } = trpc.user.me.useQuery()
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
const updateProfile = trpc.user.updateProfile.useMutation()
const utils = trpc.useUtils()
const updateProfile = trpc.user.updateProfile.useMutation({
onSuccess: () => utils.user.me.invalidate(),
})
const changePassword = trpc.user.changePassword.useMutation()
const deleteAccount = trpc.user.deleteAccount.useMutation()

View File

@ -216,6 +216,18 @@ export function getCountryName(code: string): string {
return COUNTRIES[code]?.name || code
}
/**
* Convert ISO 3166-1 alpha-2 code to flag emoji.
* Uses regional indicator symbols (Unicode).
*/
export function getCountryFlag(code: string): string {
if (!code || code.length !== 2) return ''
const upper = code.toUpperCase()
return String.fromCodePoint(
...Array.from(upper).map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)
)
}
export function getCountryCoordinates(code: string): [number, number] | null {
const country = COUNTRIES[code]
if (!country) return null

View File

@ -353,11 +353,11 @@ export const analyticsRouter = router({
.query(async ({ ctx, input }) => {
const where = input.roundId
? { roundId: input.roundId }
: { round: { programId: input.programId } }
: { programId: input.programId }
const distribution = await ctx.prisma.project.groupBy({
by: ['country'],
where,
where: { ...where, country: { not: null } },
_count: { id: true },
})

View File

@ -385,7 +385,7 @@ async function resolveRecipients(
if (!programId) return []
// Get all applicants with projects in rounds of this program
const projects = await prisma.project.findMany({
where: { round: { programId } },
where: { programId },
select: { submittedByUserId: true },
})
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])

View File

@ -597,6 +597,51 @@ export const projectRouter = router({
return project
}),
/**
* Bulk delete projects (admin only)
*/
bulkDelete: adminProcedure
.input(
z.object({
ids: z.array(z.string()).min(1).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { id: { in: input.ids } },
select: { id: true, title: true },
})
if (projects.length === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No projects found to delete',
})
}
const result = await ctx.prisma.$transaction(async (tx) => {
await logAudit({
prisma: tx,
userId: ctx.user.id,
action: 'BULK_DELETE',
entityType: 'Project',
detailsJson: {
count: projects.length,
titles: projects.map((p) => p.title),
ids: projects.map((p) => p.id),
},
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
})
return tx.project.deleteMany({
where: { id: { in: projects.map((p) => p.id) } },
})
})
return { deleted: result.count }
}),
/**
* Import projects from CSV data (admin only)
* Projects belong to a program. Optionally assign to a round.
@ -887,7 +932,7 @@ export const projectRouter = router({
const skip = (page - 1) * perPage
const where: Record<string, unknown> = {
round: { programId },
programId,
roundId: null,
}

View File

@ -170,7 +170,7 @@ export const roundRouter = router({
if (input.roundType === 'FILTERING') {
await tx.project.updateMany({
where: {
round: { programId: input.programId },
programId: input.programId,
roundId: { not: created.id },
},
data: {
@ -664,7 +664,7 @@ export const roundRouter = router({
const updated = await ctx.prisma.project.updateMany({
where: {
id: { in: input.projectIds },
round: { programId: round.programId },
programId: round.programId,
},
data: {
roundId: input.roundId,