From 5cae78fe0cf52254d28b2db0e8db82087aa8e8c9 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 10 Feb 2026 21:21:54 +0100 Subject: [PATCH] Fix first-login error, awards performance, filter animation, cache invalidation, and query fixes - 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 --- prisma/seed-candidatures.ts | 2 +- .../(admin)/admin/awards/[id]/edit/page.tsx | 8 +- src/app/(admin)/admin/awards/[id]/page.tsx | 72 +- src/app/(admin)/admin/awards/new/page.tsx | 5 +- src/app/(admin)/admin/learning/[id]/page.tsx | 12 +- src/app/(admin)/admin/learning/new/page.tsx | 5 +- src/app/(admin)/admin/page.tsx | 13 +- src/app/(admin)/admin/partners/[id]/page.tsx | 4 + src/app/(admin)/admin/partners/new/page.tsx | 2 + .../programs/[id]/apply-settings/page.tsx | 3 + .../(admin)/admin/programs/[id]/edit/page.tsx | 4 + src/app/(admin)/admin/programs/new/page.tsx | 2 + src/app/(admin)/admin/projects/page.tsx | 874 ++++++++++++------ src/app/(admin)/admin/projects/pool/page.tsx | 3 + .../admin/projects/project-filters.tsx | 25 +- .../(admin)/admin/rounds/[id]/edit/page.tsx | 9 +- src/app/(admin)/admin/rounds/[id]/page.tsx | 9 +- src/app/(admin)/admin/rounds/page.tsx | 7 +- src/app/(auth)/onboarding/page.tsx | 31 +- src/app/(jury)/jury/awards/[id]/page.tsx | 7 +- src/app/(settings)/settings/profile/page.tsx | 5 +- src/lib/countries.ts | 12 + src/server/routers/analytics.ts | 4 +- src/server/routers/message.ts | 2 +- src/server/routers/project.ts | 47 +- src/server/routers/round.ts | 4 +- 26 files changed, 830 insertions(+), 341 deletions(-) diff --git a/prisma/seed-candidatures.ts b/prisma/seed-candidatures.ts index d8f76fd..e0a22bc 100644 --- a/prisma/seed-candidatures.ts +++ b/prisma/seed-candidatures.ts @@ -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, }, }) diff --git a/src/app/(admin)/admin/awards/[id]/edit/page.tsx b/src/app/(admin)/admin/awards/[id]/edit/page.tsx index 079644a..5826cb0 100644 --- a/src/app/(admin)/admin/awards/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/edit/page.tsx @@ -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('') diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index c9cbc56..889a0bc 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -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 | null>(null) + const [selectedJurorId, setSelectedJurorId] = useState('') + const [includeSubmitted, setIncludeSubmitted] = useState(true) + const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false) + const [projectSearchQuery, setProjectSearchQuery] = useState('') + const [expandedRows, setExpandedRows] = useState>(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 | 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>(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({ {/* Tabs */} - + diff --git a/src/app/(admin)/admin/awards/new/page.tsx b/src/app/(admin)/admin/awards/new/page.tsx index 6576227..fb6b099 100644 --- a/src/app/(admin)/admin/awards/new/page.tsx +++ b/src/app/(admin)/admin/awards/new/page.tsx @@ -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 diff --git a/src/app/(admin)/admin/learning/[id]/page.tsx b/src/app/(admin)/admin/learning/[id]/page.tsx index 856647c..b4fd7ec 100644 --- a/src/app/(admin)/admin/learning/[id]/page.tsx +++ b/src/app/(admin)/admin/learning/[id]/page.tsx @@ -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 diff --git a/src/app/(admin)/admin/learning/new/page.tsx b/src/app/(admin)/admin/learning/new/page.tsx index d5148e6..a77bed4 100644 --- a/src/app/(admin)/admin/learning/new/page.tsx +++ b/src/app/(admin)/admin/learning/new/page.tsx @@ -68,7 +68,10 @@ export default function NewLearningResourcePage() { const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' }) const [programId, setProgramId] = useState(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 diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index 245ae18..521780d 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -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: {} }, }, }), diff --git a/src/app/(admin)/admin/partners/[id]/page.tsx b/src/app/(admin)/admin/partners/[id]/page.tsx index 6aca19f..46b2c4f 100644 --- a/src/app/(admin)/admin/partners/[id]/page.tsx +++ b/src/app/(admin)/admin/partners/[id]/page.tsx @@ -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') }, diff --git a/src/app/(admin)/admin/partners/new/page.tsx b/src/app/(admin)/admin/partners/new/page.tsx index b33db17..06b9401 100644 --- a/src/app/(admin)/admin/partners/new/page.tsx +++ b/src/app/(admin)/admin/partners/new/page.tsx @@ -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') }, diff --git a/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx b/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx index 0903790..f115a71 100644 --- a/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx +++ b/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx @@ -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) }, diff --git a/src/app/(admin)/admin/programs/[id]/edit/page.tsx b/src/app/(admin)/admin/programs/[id]/edit/page.tsx index 21af297..726c0de 100644 --- a/src/app/(admin)/admin/programs/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/programs/[id]/edit/page.tsx @@ -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') }, diff --git a/src/app/(admin)/admin/programs/new/page.tsx b/src/app/(admin)/admin/programs/new/page.tsx index a7946dc..94bbff2 100644 --- a/src/app/(admin)/admin/programs/new/page.tsx +++ b/src/app/(admin)/admin/programs/new/page.tsx @@ -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') }, diff --git a/src/app/(admin)/admin/projects/page.tsx b/src/app/(admin)/admin/projects/page.tsx index e5dae26..c509c10 100644 --- a/src/app/(admin)/admin/projects/page.tsx +++ b/src/app/(admin)/admin/projects/page.tsx @@ -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>(new Set()) const [bulkStatus, setBulkStatus] = useState('') 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() {

- + +
)} @@ -623,230 +672,381 @@ export default function ProjectsPage() { ) : data ? ( <> - {/* Desktop table */} - - - - - {filters.roundId && ( - - - - )} - Project - Round - Files - Assignments - Submitted - Status - Actions - - - - {data.projects.map((project) => { - const isEliminated = project.status === 'REJECTED' - return ( - - {filters.roundId && ( - + {/* Table View */} + {viewMode === 'table' ? ( + <> + {/* Desktop table */} + +
+ + + 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" /> - - )} - - + Project + Category + Round + Tags + Assignments + Status + Actions + + + + {data.projects.map((project) => { + const isEliminated = project.status === 'REJECTED' + return ( + - -
-

- {truncate(project.title, 40)} -

-

- {project.teamName} - {project.country && ( - · {project.country} - )} -

-
- - - -
-
- {project.round ? ( -

{project.round.name}

- ) : ( - - Unassigned - - )} - {project.status === 'REJECTED' && ( - - Eliminated - - )} -
-

- {project.round?.program?.name} -

-
-
- {project._count?.files ?? 0} - -
- - {project._count.assignments} -
-
- - {project.createdAt - ? new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) - : '-'} - - - - - - - - - - - - - - View Details - - - - - - Edit - - - {!project.round && ( - { - e.stopPropagation() - setProjectToAssign({ id: project.id, title: project.title }) - setAssignDialogOpen(true) - }} - > - - Assign to Round - - )} - - { - e.stopPropagation() - handleDeleteClick({ id: project.id, title: project.title }) - }} - > - - Delete - - - - -
- )})} -
-
-
- - {/* Mobile card view */} -
- {data.projects.map((project) => ( -
- {filters.roundId && ( -
- handleToggleSelect(project.id)} - aria-label={`Select ${project.title}`} - /> -
- )} - - - -
- -
-
- - {project.title} - - + handleToggleSelect(project.id)} + aria-label={`Select ${project.title}`} + onClick={(e) => e.stopPropagation()} /> -
- {project.teamName} -
+ + + + +
+

+ {truncate(project.title, 40)} +

+

+ {project.teamName} + {project.country && ( + · {project.country} + )} +

+
+ +
+ + {project.competitionCategory ? ( + + {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} + + ) : ( + - + )} + + +
+
+ {project.round ? ( +

{project.round.name}

+ ) : ( + + Unassigned + + )} +
+

+ {project.round?.program?.name} +

+
+
+ + {project.tags && project.tags.length > 0 ? ( +
+ {project.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {project.tags.length > 3 && ( + + +{project.tags.length - 3} + + )} +
+ ) : ( + - + )} +
+ +
+ + {project._count.assignments} +
+
+ + + + + + + + + + + + + View Details + + + + + + Edit + + + {!project.round && ( + { + e.stopPropagation() + setProjectToAssign({ id: project.id, title: project.title }) + setAssignDialogOpen(true) + }} + > + + Assign to Round + + )} + + { + e.stopPropagation() + handleDeleteClick({ id: project.id, title: project.title }) + }} + > + + Delete + + + + + + )})} + + + + + {/* Mobile card view (table mode fallback) */} +
+ {data.projects.map((project) => ( +
+
+ handleToggleSelect(project.id)} + aria-label={`Select ${project.title}`} + />
- - -
- Round -
- {project.round?.name ?? '-'} - {project.status === 'REJECTED' && ( - - Eliminated - - )} -
-
-
- Assignments - {project._count.assignments} jurors -
-
- Files - {project._count?.files ?? 0} -
- {project.createdAt && ( -
- Submitted - {new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })} -
- )} -
- - + + + +
+ +
+
+ + {project.title} + + +
+ {project.teamName} +
+
+
+ +
+ Round + {project.round?.name ?? 'Unassigned'} +
+ {project.competitionCategory && ( +
+ Category + + {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} + +
+ )} +
+ Assignments + {project._count.assignments} jurors +
+ {project.tags && project.tags.length > 0 && ( +
+ {project.tags.slice(0, 4).map((tag) => ( + + {tag} + + ))} + {project.tags.length > 4 && ( + + +{project.tags.length - 4} + + )} +
+ )} +
+
+ +
+ ))}
- ))} -
+ + ) : ( + /* Card View */ +
+ {data.projects.map((project) => { + const isEliminated = project.status === 'REJECTED' + return ( +
+
+ handleToggleSelect(project.id)} + aria-label={`Select ${project.title}`} + /> +
+ + + +
+ +
+
+ + {project.title} + + + + + + + + + + View Details + + + + + + Edit + + + {!project.round && ( + { + e.stopPropagation() + setProjectToAssign({ id: project.id, title: project.title }) + setAssignDialogOpen(true) + }} + > + + Assign to Round + + )} + + { + e.stopPropagation() + handleDeleteClick({ id: project.id, title: project.title }) + }} + > + + Delete + + + +
+ + {project.teamName} + {project.country && ( + · {project.country} + )} + +
+
+
+ +
+ + {project.competitionCategory && ( + + {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'} + + )} +
+
+ Round + + {project.round ? ( + <>{project.round.name} + ) : ( + + Unassigned + + )} + +
+
+ Jurors + {project._count.assignments} +
+
+ Files + {project._count?.files ?? 0} +
+ {project.createdAt && ( +
+ Submitted + {new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })} +
+ )} + {project.tags && project.tags.length > 0 && ( +
+ {project.tags.slice(0, 5).map((tag) => ( + + {tag} + + ))} + {project.tags.length > 5 && ( + + +{project.tags.length - 5} + + )} +
+ )} +
+
+ +
+ ) + })} +
+ )} {/* Pagination */} 0 && filters.roundId && ( -
+ {selectedIds.size > 0 && ( +
{selectedIds.size} selected - -
+
+ {/* Assign to Round */} + {/* Change Status (only when filtered by round) */} + {filters.roundId && ( + <> + + + + )} + {/* Delete */}
+
@@ -1026,6 +1250,98 @@ export default function ProjectsPage() { + {/* Bulk Assign to Round Dialog */} + { + setBulkAssignDialogOpen(open) + if (!open) setBulkAssignRoundId('') + }}> + + + Assign to Round + + Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to "Assigned". + + +
+
+ + +
+
+
+ + +
+
+
+ + {/* Bulk Delete Confirmation Dialog */} + + + + Delete {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''} + +
+

+ Are you sure you want to permanently delete{' '} + {selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''}? + This will remove all associated files, assignments, and evaluations. +

+
+ +

+ This action cannot be undone. All project data will be permanently lost. +

+
+
+
+
+ + Cancel + { + bulkDeleteProjects.mutate({ ids: Array.from(selectedIds) }) + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + disabled={bulkDeleteProjects.isPending} + > + {bulkDeleteProjects.isPending ? ( + + ) : null} + Delete {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''} + + +
+
+ {/* AI Tagging Dialog */} diff --git a/src/app/(admin)/admin/projects/pool/page.tsx b/src/app/(admin)/admin/projects/pool/page.tsx index 397f58e..fcbed32 100644 --- a/src/app/(admin)/admin/projects/pool/page.tsx +++ b/src/app/(admin)/admin/projects/pool/page.tsx @@ -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) diff --git a/src/app/(admin)/admin/projects/project-filters.tsx b/src/app/(admin)/admin/projects/project-filters.tsx index 17aa425..eb98caa 100644 --- a/src/app/(admin)/admin/projects/project-filters.tsx +++ b/src/app/(admin)/admin/projects/project-filters.tsx @@ -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({
- + {/* Status toggles */}
@@ -255,11 +256,21 @@ export function ProjectFiltersBar({ All countries - {filterOptions?.countries.map((c) => ( - - {c} - - ))} + {filterOptions?.countries + .map((c) => ({ + code: c, + name: getCountryName(c), + flag: getCountryFlag(c), + })) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((c) => ( + + + {c.flag} + {c.name} + + + ))}
diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 4478c04..864e242 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -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({ diff --git a/src/app/(admin)/admin/rounds/[id]/page.tsx b/src/app/(admin)/admin/rounds/[id]/page.tsx index d61afe1..58a9e69 100644 --- a/src/app/(admin)/admin/rounds/[id]/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/page.tsx @@ -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({ diff --git a/src/app/(admin)/admin/rounds/page.tsx b/src/app/(admin)/admin/rounds/page.tsx index 3431705..24d64bc 100644 --- a/src/app/(admin)/admin/rounds/page.tsx +++ b/src/app/(admin)/admin/rounds/page.tsx @@ -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(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: { diff --git a/src/app/(auth)/onboarding/page.tsx b/src/app/(auth)/onboarding/page.tsx index 1c2199a..8d9040a 100644 --- a/src/app/(auth)/onboarding/page.tsx +++ b/src/app/(auth)/onboarding/page.tsx @@ -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('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 (
diff --git a/src/app/(jury)/jury/awards/[id]/page.tsx b/src/app/(jury)/jury/awards/[id]/page.tsx index a9a59df..d0a8560 100644 --- a/src/app/(jury)/jury/awards/[id]/page.tsx +++ b/src/app/(jury)/jury/awards/[id]/page.tsx @@ -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( null diff --git a/src/app/(settings)/settings/profile/page.tsx b/src/app/(settings)/settings/profile/page.tsx index e6a2e5e..97f7d84 100644 --- a/src/app/(settings)/settings/profile/page.tsx +++ b/src/app/(settings)/settings/profile/page.tsx @@ -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() diff --git a/src/lib/countries.ts b/src/lib/countries.ts index 6de6f79..eed72e2 100644 --- a/src/lib/countries.ts +++ b/src/lib/countries.ts @@ -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 diff --git a/src/server/routers/analytics.ts b/src/server/routers/analytics.ts index b56acee..47f2f2b 100644 --- a/src/server/routers/analytics.ts +++ b/src/server/routers/analytics.ts @@ -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 }, }) diff --git a/src/server/routers/message.ts b/src/server/routers/message.ts index 8bb516d..197ddac 100644 --- a/src/server/routers/message.ts +++ b/src/server/routers/message.ts @@ -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[]) diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index f7e0088..df68c44 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -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 = { - round: { programId }, + programId, roundId: null, } diff --git a/src/server/routers/round.ts b/src/server/routers/round.ts index 0b9147a..0e3ad5f 100644 --- a/src/server/routers/round.ts +++ b/src/server/routers/round.ts @@ -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,