Fix first-login error, awards performance, filter animation, cache invalidation, and query fixes
Build and Push Docker Image / build (push) Successful in 11m24s
Details
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:
parent
573785e440
commit
5cae78fe0c
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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('')
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: {} },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,8 +567,9 @@ 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 items-center justify-between gap-4">
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
{Object.entries(
|
{Object.entries(
|
||||||
data.projects.reduce<Record<string, number>>((acc, p) => {
|
data.projects.reduce<Record<string, number>>((acc, p) => {
|
||||||
|
|
@ -569,6 +597,27 @@ export default function ProjectsPage() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|
@ -622,13 +671,15 @@ export default function ProjectsPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
|
<>
|
||||||
|
{/* Table View */}
|
||||||
|
{viewMode === 'table' ? (
|
||||||
<>
|
<>
|
||||||
{/* Desktop table */}
|
{/* Desktop table */}
|
||||||
<Card className="hidden md:block">
|
<Card className="hidden md:block">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{filters.roundId && (
|
|
||||||
<TableHead className="w-10">
|
<TableHead className="w-10">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
|
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
|
||||||
|
|
@ -636,12 +687,11 @@ export default function ProjectsPage() {
|
||||||
aria-label="Select all projects"
|
aria-label="Select all projects"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
|
||||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
<TableHead className="min-w-[280px]">Project</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
<TableHead>Round</TableHead>
|
<TableHead>Round</TableHead>
|
||||||
<TableHead>Files</TableHead>
|
<TableHead>Tags</TableHead>
|
||||||
<TableHead>Assignments</TableHead>
|
<TableHead>Assignments</TableHead>
|
||||||
<TableHead>Submitted</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -654,7 +704,6 @@ export default function ProjectsPage() {
|
||||||
key={project.id}
|
key={project.id}
|
||||||
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
|
className={`group relative cursor-pointer hover:bg-muted/50 ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}
|
||||||
>
|
>
|
||||||
{filters.roundId && (
|
|
||||||
<TableCell className="relative z-10">
|
<TableCell className="relative z-10">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.has(project.id)}
|
checked={selectedIds.has(project.id)}
|
||||||
|
|
@ -663,7 +712,6 @@ export default function ProjectsPage() {
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/projects/${project.id}`}
|
href={`/admin/projects/${project.id}`}
|
||||||
|
|
@ -687,6 +735,15 @@ export default function ProjectsPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -697,29 +754,43 @@ export default function ProjectsPage() {
|
||||||
Unassigned
|
Unassigned
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{project.status === 'REJECTED' && (
|
|
||||||
<Badge variant="destructive" className="text-xs">
|
|
||||||
Eliminated
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{project.round?.program?.name}
|
{project.round?.program?.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{project._count?.files ?? 0}</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>
|
<TableCell>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
{project._count.assignments}
|
{project._count.assignments}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</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>
|
<TableCell>
|
||||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -776,11 +847,10 @@ export default function ProjectsPage() {
|
||||||
</Table>
|
</Table>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Mobile card view */}
|
{/* Mobile card view (table mode fallback) */}
|
||||||
<div className="space-y-4 md:hidden">
|
<div className="space-y-4 md:hidden">
|
||||||
{data.projects.map((project) => (
|
{data.projects.map((project) => (
|
||||||
<div key={project.id} className="relative">
|
<div key={project.id} className="relative">
|
||||||
{filters.roundId && (
|
|
||||||
<div className="absolute left-3 top-4 z-10">
|
<div className="absolute left-3 top-4 z-10">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedIds.has(project.id)}
|
checked={selectedIds.has(project.id)}
|
||||||
|
|
@ -788,28 +858,17 @@ export default function ProjectsPage() {
|
||||||
aria-label={`Select ${project.title}`}
|
aria-label={`Select ${project.title}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Link href={`/admin/projects/${project.id}`} className="block">
|
||||||
<Link
|
|
||||||
href={`/admin/projects/${project.id}`}
|
|
||||||
className="block"
|
|
||||||
>
|
|
||||||
<Card className="transition-colors hover:bg-muted/50">
|
<Card className="transition-colors hover:bg-muted/50">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className={`flex items-start gap-3 ${filters.roundId ? 'pl-8' : ''}`}>
|
<div className="flex items-start gap-3 pl-8">
|
||||||
<ProjectLogo
|
<ProjectLogo project={project} size="md" fallback="initials" />
|
||||||
project={project}
|
|
||||||
size="md"
|
|
||||||
fallback="initials"
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||||
{project.title}
|
{project.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<StatusBadge
|
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
|
||||||
status={project.status ?? 'SUBMITTED'}
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>{project.teamName}</CardDescription>
|
<CardDescription>{project.teamName}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -818,27 +877,32 @@ export default function ProjectsPage() {
|
||||||
<CardContent className="space-y-3">
|
<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">Round</span>
|
<span className="text-muted-foreground">Round</span>
|
||||||
<div className="flex items-center gap-2">
|
<span>{project.round?.name ?? 'Unassigned'}</span>
|
||||||
<span>{project.round?.name ?? '-'}</span>
|
</div>
|
||||||
{project.status === 'REJECTED' && (
|
{project.competitionCategory && (
|
||||||
<Badge variant="destructive" className="text-xs">
|
<div className="flex items-center justify-between text-sm">
|
||||||
Eliminated
|
<span className="text-muted-foreground">Category</span>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">Assignments</span>
|
<span className="text-muted-foreground">Assignments</span>
|
||||||
<span>{project._count.assignments} jurors</span>
|
<span>{project._count.assignments} jurors</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
{project.tags && project.tags.length > 0 && (
|
||||||
<span className="text-muted-foreground">Files</span>
|
<div className="flex flex-wrap gap-1">
|
||||||
<span>{project._count?.files ?? 0}</span>
|
{project.tags.slice(0, 4).map((tag) => (
|
||||||
</div>
|
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
|
||||||
{project.createdAt && (
|
{tag}
|
||||||
<div className="flex items-center justify-between text-sm">
|
</Badge>
|
||||||
<span className="text-muted-foreground">Submitted</span>
|
))}
|
||||||
<span>{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
|
{project.tags.length > 4 && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
|
||||||
|
+{project.tags.length - 4}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -847,6 +911,142 @@ export default function ProjectsPage() {
|
||||||
</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,15 +1061,28 @@ 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>
|
||||||
|
<div className="flex flex-wrap gap-2 flex-1">
|
||||||
|
{/* Assign to Round */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setBulkAssignDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
|
||||||
|
Assign to Round
|
||||||
|
</Button>
|
||||||
|
{/* Change Status (only when filtered by round) */}
|
||||||
|
{filters.roundId && (
|
||||||
|
<>
|
||||||
<Select value={bulkStatus} onValueChange={setBulkStatus}>
|
<Select value={bulkStatus} onValueChange={setBulkStatus}>
|
||||||
<SelectTrigger className="w-full sm:w-[180px]">
|
<SelectTrigger className="w-[160px] h-9 text-sm">
|
||||||
<SelectValue placeholder="Set status..." />
|
<SelectValue placeholder="Set status..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|
@ -880,7 +1093,6 @@ export default function ProjectsPage() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleBulkApply}
|
onClick={handleBulkApply}
|
||||||
|
|
@ -891,6 +1103,18 @@ export default function ProjectsPage() {
|
||||||
)}
|
)}
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* Delete */}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setBulkDeleteConfirmOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -898,11 +1122,11 @@ export default function ProjectsPage() {
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
setBulkStatus('')
|
setBulkStatus('')
|
||||||
}}
|
}}
|
||||||
|
className="shrink-0"
|
||||||
>
|
>
|
||||||
<X className="mr-1 h-4 w-4" />
|
<X className="mr-1 h-4 w-4" />
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</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 "Assigned".
|
||||||
|
</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">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,9 +256,19 @@ 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,
|
||||||
|
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>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
|
||||||
|
|
@ -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>({
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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[])
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue