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, email,
name: row['Full name']?.trim() || 'Unknown', name: row['Full name']?.trim() || 'Unknown',
role: 'APPLICANT', role: 'APPLICANT',
status: 'ACTIVE', status: 'INVITED',
phoneNumber: row['Téléphone']?.trim() || null, phoneNumber: row['Téléphone']?.trim() || null,
}, },
}) })

View File

@ -35,8 +35,14 @@ export default function EditAwardPage({
const { id: awardId } = use(params) const { id: awardId } = use(params)
const router = useRouter() const router = useRouter()
const utils = trpc.useUtils()
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId }) 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 [name, setName] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')

View File

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

View File

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

View File

@ -97,8 +97,16 @@ export default function EditLearningResourcePage() {
// API // API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' }) const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const updateResource = trpc.learningResource.update.useMutation() const utils = trpc.useUtils()
const deleteResource = trpc.learningResource.delete.useMutation() 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() const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
// Populate form when resource loads // 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 { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const [programId, setProgramId] = useState<string | null>(null) 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() const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
// Handle file upload for BlockNote // Handle file upload for BlockNote

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -69,6 +69,9 @@ import {
FolderOpen, FolderOpen,
X, X,
AlertTriangle, AlertTriangle,
ArrowRightCircle,
LayoutGrid,
LayoutList,
} from 'lucide-react' } from 'lucide-react'
import { import {
Select, Select,
@ -180,6 +183,7 @@ export default function ProjectsPage() {
const [page, setPage] = useState(parsed.page) const [page, setPage] = useState(parsed.page)
const [perPage, setPerPage] = useState(parsed.perPage || 20) const [perPage, setPerPage] = useState(parsed.perPage || 20)
const [searchInput, setSearchInput] = useState(parsed.search) const [searchInput, setSearchInput] = useState(parsed.search)
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
// Fetch display settings // Fetch display settings
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({ const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
@ -373,6 +377,10 @@ export default function ProjectsPage() {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [bulkStatus, setBulkStatus] = useState<string>('') const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false) 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({ const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
onSuccess: (result) => { 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) => { const handleToggleSelect = (id: string) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev) const next = new Set(prev)
@ -481,12 +514,6 @@ export default function ProjectsPage() {
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <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)}> <Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" /> <Sparkles className="mr-2 h-4 w-4" />
AI Tags AI Tags
@ -540,34 +567,56 @@ export default function ProjectsPage() {
onChange={handleFiltersChange} onChange={handleFiltersChange}
/> />
{/* Stats Summary */} {/* Stats Summary + View Toggle */}
{data && data.projects.length > 0 && ( {data && data.projects.length > 0 && (
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex items-center justify-between gap-4">
{Object.entries( <div className="flex flex-wrap items-center gap-2 text-sm">
data.projects.reduce<Record<string, number>>((acc, p) => { {Object.entries(
const s = p.status ?? 'SUBMITTED' data.projects.reduce<Record<string, number>>((acc, p) => {
acc[s] = (acc[s] || 0) + 1 const s = p.status ?? 'SUBMITTED'
return acc acc[s] = (acc[s] || 0) + 1
}, {}) return acc
) }, {})
.sort(([a], [b]) => { )
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN'] .sort(([a], [b]) => {
return order.indexOf(a) - order.indexOf(b) const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
}) return order.indexOf(a) - order.indexOf(b)
.map(([status, count]) => ( })
<Badge .map(([status, count]) => (
key={status} <Badge
variant={statusColors[status] || 'secondary'} key={status}
className="text-xs font-normal" variant={statusColors[status] || 'secondary'}
> className="text-xs font-normal"
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')} >
</Badge> {count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
))} </Badge>
{data.total > data.projects.length && ( ))}
<span className="text-xs text-muted-foreground ml-1"> {data.total > data.projects.length && (
(page {data.page} of {data.totalPages}) <span className="text-xs text-muted-foreground ml-1">
</span> (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> </div>
)} )}
@ -623,230 +672,381 @@ export default function ProjectsPage() {
</Card> </Card>
) : data ? ( ) : data ? (
<> <>
{/* Desktop table */} {/* Table View */}
<Card className="hidden md:block"> {viewMode === 'table' ? (
<Table> <>
<TableHeader> {/* Desktop table */}
<TableRow> <Card className="hidden md:block">
{filters.roundId && ( <Table>
<TableHead className="w-10"> <TableHeader>
<Checkbox <TableRow>
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false} <TableHead className="w-10">
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">
<Checkbox <Checkbox
checked={selectedIds.has(project.id)} checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
onCheckedChange={() => handleToggleSelect(project.id)} onCheckedChange={handleSelectAll}
aria-label={`Select ${project.title}`} aria-label="Select all projects"
onClick={(e) => e.stopPropagation()}
/> />
</TableCell> </TableHead>
)} <TableHead className="min-w-[280px]">Project</TableHead>
<TableCell> <TableHead>Category</TableHead>
<Link <TableHead>Round</TableHead>
href={`/admin/projects/${project.id}`} <TableHead>Tags</TableHead>
className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']" <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 <TableCell className="relative z-10">
project={project} <Checkbox
size="sm" checked={selectedIds.has(project.id)}
fallback="initials" onCheckedChange={() => handleToggleSelect(project.id)}
/> aria-label={`Select ${project.title}`}
<div> onClick={(e) => e.stopPropagation()}
<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"
/> />
</div> </TableCell>
<CardDescription>{project.teamName}</CardDescription> <TableCell>
</div> <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> </div>
</CardHeader> <Link href={`/admin/projects/${project.id}`} className="block">
<CardContent className="space-y-3"> <Card className="transition-colors hover:bg-muted/50">
<div className="flex items-center justify-between text-sm"> <CardHeader className="pb-3">
<span className="text-muted-foreground">Round</span> <div className="flex items-start gap-3 pl-8">
<div className="flex items-center gap-2"> <ProjectLogo project={project} size="md" fallback="initials" />
<span>{project.round?.name ?? '-'}</span> <div className="flex-1 min-w-0">
{project.status === 'REJECTED' && ( <div className="flex items-start justify-between gap-2">
<Badge variant="destructive" className="text-xs"> <CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
Eliminated {project.title}
</Badge> </CardTitle>
)} <StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
</div> </div>
</div> <CardDescription>{project.teamName}</CardDescription>
<div className="flex items-center justify-between text-sm"> </div>
<span className="text-muted-foreground">Assignments</span> </div>
<span>{project._count.assignments} jurors</span> </CardHeader>
</div> <CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Files</span> <span className="text-muted-foreground">Round</span>
<span>{project._count?.files ?? 0}</span> <span>{project.round?.name ?? 'Unassigned'}</span>
</div> </div>
{project.createdAt && ( {project.competitionCategory && (
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Submitted</span> <span className="text-muted-foreground">Category</span>
<span>{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span> <Badge variant="outline" className="text-xs">
</div> {project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
)} </Badge>
</CardContent> </div>
</Card> )}
</Link> <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>
))} </>
</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 */}
<Pagination <Pagination
@ -861,48 +1061,72 @@ export default function ProjectsPage() {
) : null} ) : null}
{/* Bulk Action Floating Toolbar */} {/* Bulk Action Floating Toolbar */}
{selectedIds.size > 0 && filters.roundId && ( {selectedIds.size > 0 && (
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2"> <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"> <Card className="border-2 shadow-lg">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center"> <CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
<Badge variant="secondary" className="shrink-0 text-sm"> <Badge variant="secondary" className="shrink-0 text-sm">
{selectedIds.size} selected {selectedIds.size} selected
</Badge> </Badge>
<Select value={bulkStatus} onValueChange={setBulkStatus}> <div className="flex flex-wrap gap-2 flex-1">
<SelectTrigger className="w-full sm:w-[180px]"> {/* Assign to Round */}
<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">
<Button <Button
size="sm" size="sm"
onClick={handleBulkApply} variant="outline"
disabled={!bulkStatus || bulkUpdateStatus.isPending} onClick={() => setBulkAssignDialogOpen(true)}
> >
{bulkUpdateStatus.isPending && ( <ArrowRightCircle className="mr-1.5 h-4 w-4" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Assign to Round
)}
Apply
</Button> </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 <Button
size="sm" size="sm"
variant="ghost" variant="destructive"
onClick={() => { onClick={() => setBulkDeleteConfirmOpen(true)}
setSelectedIds(new Set())
setBulkStatus('')
}}
> >
<X className="mr-1 h-4 w-4" /> <Trash2 className="mr-1.5 h-4 w-4" />
Clear Delete
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>
</div> </div>
@ -1026,6 +1250,98 @@ export default function ProjectsPage() {
</DialogContent> </DialogContent>
</Dialog> </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 */} {/* AI Tagging Dialog */}
<Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}> <Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">

View File

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

View File

@ -26,6 +26,7 @@ import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { ChevronDown, Filter, X } from 'lucide-react' import { ChevronDown, Filter, X } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
const ALL_STATUSES = [ const ALL_STATUSES = [
'SUBMITTED', 'SUBMITTED',
@ -140,14 +141,14 @@ export function ProjectFiltersBar({
</CardTitle> </CardTitle>
<ChevronDown <ChevronDown
className={cn( 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' isOpen && 'rotate-180'
)} )}
/> />
</div> </div>
</CardHeader> </CardHeader>
</CollapsibleTrigger> </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"> <CardContent className="space-y-4 pt-0">
{/* Status toggles */} {/* Status toggles */}
<div className="space-y-2"> <div className="space-y-2">
@ -255,11 +256,21 @@ export function ProjectFiltersBar({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="_all">All countries</SelectItem> <SelectItem value="_all">All countries</SelectItem>
{filterOptions?.countries.map((c) => ( {filterOptions?.countries
<SelectItem key={c} value={c}> .map((c) => ({
{c} code: c,
</SelectItem> 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> </SelectContent>
</Select> </Select>
</div> </div>

View File

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

View File

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

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import { Suspense, useState } from 'react' import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -119,6 +119,11 @@ function ProgramRounds({ program }: { program: any }) {
const utils = trpc.useUtils() const utils = trpc.useUtils()
const [rounds, setRounds] = useState<RoundData[]>(program.rounds || []) 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( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { activationConstraint: {

View File

@ -2,6 +2,7 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@ -45,6 +46,8 @@ type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferenc
export default function OnboardingPage() { export default function OnboardingPage() {
const router = useRouter() const router = useRouter()
const { data: session, status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
const [step, setStep] = useState<Step>('name') const [step, setStep] = useState<Step>('name')
const [initialized, setInitialized] = useState(false) const [initialized, setInitialized] = useState(false)
@ -59,9 +62,15 @@ export default function OnboardingPage() {
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE' 'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
>('EMAIL') >('EMAIL')
// Fetch current user data to get admin-preset tags // Fetch current user data only after session is hydrated
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery() const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery(
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery() undefined,
{ enabled: isAuthenticated }
)
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(
undefined,
{ enabled: isAuthenticated }
)
// Initialize form with user data // Initialize form with user data
useEffect(() => { useEffect(() => {
@ -95,11 +104,17 @@ export default function OnboardingPage() {
} }
}, [userData, initialized]) }, [userData, initialized])
// Fetch feature flags // Fetch feature flags only after session is hydrated
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery() const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(
undefined,
{ enabled: isAuthenticated }
)
const whatsappEnabled = featureFlags?.whatsappEnabled ?? false 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 // Dynamic steps based on WhatsApp availability
const steps: Step[] = useMemo(() => { const steps: Step[] = useMemo(() => {
@ -162,8 +177,8 @@ export default function OnboardingPage() {
} }
} }
// Show loading while fetching user data // Show loading while session hydrates or fetching user data
if (userLoading || !initialized) { if (sessionStatus === 'loading' || userLoading || !initialized) {
return ( 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]"> <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"> <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 { id: awardId } = use(params)
const utils = trpc.useUtils()
const { data, isLoading, refetch } = const { data, isLoading, refetch } =
trpc.specialAward.getMyAwardDetail.useQuery({ awardId }) 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>( const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null null

View File

@ -53,7 +53,10 @@ export default function ProfileSettingsPage() {
const router = useRouter() const router = useRouter()
const { data: user, isLoading, refetch } = trpc.user.me.useQuery() const { data: user, isLoading, refetch } = trpc.user.me.useQuery()
const { data: avatarUrl } = trpc.avatar.getUrl.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 changePassword = trpc.user.changePassword.useMutation()
const deleteAccount = trpc.user.deleteAccount.useMutation() const deleteAccount = trpc.user.deleteAccount.useMutation()

View File

@ -216,6 +216,18 @@ export function getCountryName(code: string): string {
return COUNTRIES[code]?.name || code 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 { export function getCountryCoordinates(code: string): [number, number] | null {
const country = COUNTRIES[code] const country = COUNTRIES[code]
if (!country) return null if (!country) return null

View File

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

View File

@ -385,7 +385,7 @@ async function resolveRecipients(
if (!programId) return [] if (!programId) return []
// Get all applicants with projects in rounds of this program // Get all applicants with projects in rounds of this program
const projects = await prisma.project.findMany({ const projects = await prisma.project.findMany({
where: { round: { programId } }, where: { programId },
select: { submittedByUserId: true }, select: { submittedByUserId: true },
}) })
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[]) 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 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) * Import projects from CSV data (admin only)
* Projects belong to a program. Optionally assign to a round. * Projects belong to a program. Optionally assign to a round.
@ -887,7 +932,7 @@ export const projectRouter = router({
const skip = (page - 1) * perPage const skip = (page - 1) * perPage
const where: Record<string, unknown> = { const where: Record<string, unknown> = {
round: { programId }, programId,
roundId: null, roundId: null,
} }

View File

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