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,
|
||||
name: row['Full name']?.trim() || 'Unknown',
|
||||
role: 'APPLICANT',
|
||||
status: 'ACTIVE',
|
||||
status: 'INVITED',
|
||||
phoneNumber: row['Téléphone']?.trim() || null,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -35,8 +35,14 @@ export default function EditAwardPage({
|
|||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
|
||||
const updateAward = trpc.specialAward.update.useMutation()
|
||||
const updateAward = trpc.specialAward.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.get.invalidate({ id: awardId })
|
||||
utils.specialAward.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
|
|
|||
|
|
@ -141,28 +141,39 @@ export default function AwardDetailPage({
|
|||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
// State declarations (before queries that depend on them)
|
||||
const [isPollingJob, setIsPollingJob] = useState(false)
|
||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||
const [includeSubmitted, setIncludeSubmitted] = useState(true)
|
||||
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
const [activeTab, setActiveTab] = useState('eligibility')
|
||||
|
||||
// Core queries
|
||||
const { data: award, isLoading, refetch } =
|
||||
trpc.specialAward.get.useQuery({ id: awardId })
|
||||
const { data: eligibilityData, refetch: refetchEligibility } =
|
||||
trpc.specialAward.listEligible.useQuery({
|
||||
awardId,
|
||||
page: 1,
|
||||
perPage: 500,
|
||||
perPage: 100,
|
||||
})
|
||||
const { data: jurors, refetch: refetchJurors } =
|
||||
trpc.specialAward.listJurors.useQuery({ awardId })
|
||||
const { data: voteResults } =
|
||||
trpc.specialAward.getVoteResults.useQuery({ awardId })
|
||||
const { data: allUsers } = trpc.user.list.useQuery({ role: 'JURY_MEMBER', page: 1, perPage: 100 })
|
||||
|
||||
// Fetch all projects in the program for manual eligibility addition
|
||||
const { data: allProjects } = trpc.project.list.useQuery(
|
||||
{ programId: award?.programId ?? '', perPage: 500 },
|
||||
{ enabled: !!award?.programId }
|
||||
// Deferred queries - only load when needed
|
||||
const { data: allUsers } = trpc.user.list.useQuery(
|
||||
{ role: 'JURY_MEMBER', page: 1, perPage: 100 },
|
||||
{ enabled: activeTab === 'jurors' }
|
||||
)
|
||||
const { data: allProjects } = trpc.project.list.useQuery(
|
||||
{ programId: award?.programId ?? '', perPage: 200 },
|
||||
{ enabled: !!award?.programId && addProjectDialogOpen }
|
||||
)
|
||||
|
||||
const [isPollingJob, setIsPollingJob] = useState(false)
|
||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
// Eligibility job polling
|
||||
const { data: jobStatus, refetch: refetchJobStatus } =
|
||||
|
|
@ -208,19 +219,34 @@ export default function AwardDetailPage({
|
|||
}
|
||||
}, [award?.eligibilityJobStatus])
|
||||
|
||||
const updateStatus = trpc.specialAward.updateStatus.useMutation()
|
||||
const runEligibility = trpc.specialAward.runEligibility.useMutation()
|
||||
const setEligibility = trpc.specialAward.setEligibility.useMutation()
|
||||
const addJuror = trpc.specialAward.addJuror.useMutation()
|
||||
const removeJuror = trpc.specialAward.removeJuror.useMutation()
|
||||
const setWinner = trpc.specialAward.setWinner.useMutation()
|
||||
const deleteAward = trpc.specialAward.delete.useMutation()
|
||||
|
||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||
const [includeSubmitted, setIncludeSubmitted] = useState(true)
|
||||
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
|
||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
const utils = trpc.useUtils()
|
||||
const invalidateAward = () => {
|
||||
utils.specialAward.get.invalidate({ id: awardId })
|
||||
utils.specialAward.listEligible.invalidate({ awardId })
|
||||
utils.specialAward.listJurors.invalidate({ awardId })
|
||||
utils.specialAward.getVoteResults.invalidate({ awardId })
|
||||
}
|
||||
const updateStatus = trpc.specialAward.updateStatus.useMutation({
|
||||
onSuccess: invalidateAward,
|
||||
})
|
||||
const runEligibility = trpc.specialAward.runEligibility.useMutation({
|
||||
onSuccess: invalidateAward,
|
||||
})
|
||||
const setEligibility = trpc.specialAward.setEligibility.useMutation({
|
||||
onSuccess: () => utils.specialAward.listEligible.invalidate({ awardId }),
|
||||
})
|
||||
const addJuror = trpc.specialAward.addJuror.useMutation({
|
||||
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
|
||||
})
|
||||
const removeJuror = trpc.specialAward.removeJuror.useMutation({
|
||||
onSuccess: () => utils.specialAward.listJurors.invalidate({ awardId }),
|
||||
})
|
||||
const setWinner = trpc.specialAward.setWinner.useMutation({
|
||||
onSuccess: invalidateAward,
|
||||
})
|
||||
const deleteAward = trpc.specialAward.delete.useMutation({
|
||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||
})
|
||||
|
||||
const handleStatusChange = async (
|
||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||
|
|
@ -569,7 +595,7 @@ export default function AwardDetailPage({
|
|||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="eligibility">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="eligibility">
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -38,8 +38,11 @@ export default function CreateAwardPage() {
|
|||
const [maxRankedPicks, setMaxRankedPicks] = useState('3')
|
||||
const [programId, setProgramId] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: programs } = trpc.program.list.useQuery()
|
||||
const createAward = trpc.specialAward.create.useMutation()
|
||||
const createAward = trpc.specialAward.create.useMutation({
|
||||
onSuccess: () => utils.specialAward.list.invalidate(),
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !programId) return
|
||||
|
|
|
|||
|
|
@ -97,8 +97,16 @@ export default function EditLearningResourcePage() {
|
|||
|
||||
// API
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const updateResource = trpc.learningResource.update.useMutation()
|
||||
const deleteResource = trpc.learningResource.delete.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
const updateResource = trpc.learningResource.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.learningResource.get.invalidate({ id: resourceId })
|
||||
utils.learningResource.list.invalidate()
|
||||
},
|
||||
})
|
||||
const deleteResource = trpc.learningResource.delete.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Populate form when resource loads
|
||||
|
|
|
|||
|
|
@ -68,7 +68,10 @@ export default function NewLearningResourcePage() {
|
|||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
const [programId, setProgramId] = useState<string | null>(null)
|
||||
|
||||
const createResource = trpc.learningResource.create.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
const createResource = trpc.learningResource.create.useMutation({
|
||||
onSuccess: () => utils.learningResource.list.invalidate(),
|
||||
})
|
||||
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
|
||||
|
||||
// Handle file upload for BlockNote
|
||||
|
|
|
|||
|
|
@ -131,11 +131,11 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
where: { programId: editionId },
|
||||
}),
|
||||
prisma.project.count({
|
||||
where: { round: { programId: editionId } },
|
||||
where: { programId: editionId },
|
||||
}),
|
||||
prisma.project.count({
|
||||
where: {
|
||||
round: { programId: editionId },
|
||||
programId: editionId,
|
||||
createdAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
}),
|
||||
|
|
@ -186,7 +186,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
},
|
||||
}),
|
||||
prisma.project.findMany({
|
||||
where: { round: { programId: editionId } },
|
||||
where: { programId: editionId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 8,
|
||||
select: {
|
||||
|
|
@ -205,12 +205,12 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
}),
|
||||
prisma.project.groupBy({
|
||||
by: ['competitionCategory'],
|
||||
where: { round: { programId: editionId } },
|
||||
where: { programId: editionId },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.project.groupBy({
|
||||
by: ['oceanIssue'],
|
||||
where: { round: { programId: editionId } },
|
||||
where: { programId: editionId },
|
||||
_count: true,
|
||||
}),
|
||||
// Recent activity feed (scoped to last 7 days for performance)
|
||||
|
|
@ -243,7 +243,8 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
|
|||
// Projects without assignments in active rounds
|
||||
prisma.project.count({
|
||||
where: {
|
||||
round: { programId: editionId, status: 'ACTIVE' },
|
||||
programId: editionId,
|
||||
round: { status: 'ACTIVE' },
|
||||
assignments: { none: {} },
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -68,8 +68,11 @@ export default function EditPartnerPage() {
|
|||
}
|
||||
}, [partner])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updatePartner = trpc.partner.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
utils.partner.get.invalidate()
|
||||
toast.success('Partner updated successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
|
|
@ -81,6 +84,7 @@ export default function EditPartnerPage() {
|
|||
|
||||
const deletePartner = trpc.partner.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner deleted successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,8 +31,10 @@ export default function NewPartnerPage() {
|
|||
const [partnerType, setPartnerType] = useState('PARTNER')
|
||||
const [visibility, setVisibility] = useState('ADMIN_ONLY')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createPartner = trpc.partner.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.partner.list.invalidate()
|
||||
toast.success('Partner created successfully')
|
||||
router.push('/admin/partners')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -254,8 +254,10 @@ export default function ApplySettingsPage() {
|
|||
)
|
||||
|
||||
// --- Mutations ---
|
||||
const utils = trpc.useUtils()
|
||||
const createTemplate = trpc.wizardTemplate.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.wizardTemplate.list.invalidate()
|
||||
toast.success('Template saved')
|
||||
setSaveTemplateOpen(false)
|
||||
setSaveTemplateName('')
|
||||
|
|
@ -264,6 +266,7 @@ export default function ApplySettingsPage() {
|
|||
})
|
||||
const updateConfig = trpc.program.updateWizardConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.get.invalidate({ id: programId })
|
||||
toast.success('Settings saved successfully')
|
||||
setIsDirty(false)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -66,8 +66,11 @@ export default function EditProgramPage() {
|
|||
}
|
||||
}, [program])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updateProgram = trpc.program.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
utils.program.get.invalidate({ id })
|
||||
toast.success('Program updated successfully')
|
||||
router.push(`/admin/programs/${id}`)
|
||||
},
|
||||
|
|
@ -79,6 +82,7 @@ export default function EditProgramPage() {
|
|||
|
||||
const deleteProgram = trpc.program.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
toast.success('Program deleted successfully')
|
||||
router.push('/admin/programs')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ export default function NewProgramPage() {
|
|||
const router = useRouter()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const createProgram = trpc.program.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate()
|
||||
toast.success('Program created successfully')
|
||||
router.push('/admin/programs')
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ import {
|
|||
FolderOpen,
|
||||
X,
|
||||
AlertTriangle,
|
||||
ArrowRightCircle,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
|
|
@ -180,6 +183,7 @@ export default function ProjectsPage() {
|
|||
const [page, setPage] = useState(parsed.page)
|
||||
const [perPage, setPerPage] = useState(parsed.perPage || 20)
|
||||
const [searchInput, setSearchInput] = useState(parsed.search)
|
||||
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
|
||||
|
||||
// Fetch display settings
|
||||
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
|
||||
|
|
@ -373,6 +377,10 @@ export default function ProjectsPage() {
|
|||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [bulkStatus, setBulkStatus] = useState<string>('')
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
|
||||
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
|
||||
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
|
||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
|
||||
|
||||
const bulkUpdateStatus = trpc.project.bulkUpdateStatus.useMutation({
|
||||
onSuccess: (result) => {
|
||||
|
|
@ -387,6 +395,31 @@ export default function ProjectsPage() {
|
|||
},
|
||||
})
|
||||
|
||||
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
|
||||
setSelectedIds(new Set())
|
||||
setBulkAssignRoundId('')
|
||||
setBulkAssignDialogOpen(false)
|
||||
utils.project.list.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to assign projects')
|
||||
},
|
||||
})
|
||||
|
||||
const bulkDeleteProjects = trpc.project.bulkDelete.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`${result.deleted} project${result.deleted !== 1 ? 's' : ''} deleted`)
|
||||
setSelectedIds(new Set())
|
||||
setBulkDeleteConfirmOpen(false)
|
||||
utils.project.list.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to delete projects')
|
||||
},
|
||||
})
|
||||
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
|
|
@ -481,12 +514,6 @@ export default function ProjectsPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/projects/pool">
|
||||
<Layers className="mr-2 h-4 w-4" />
|
||||
Project Pool
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
AI Tags
|
||||
|
|
@ -540,8 +567,9 @@ export default function ProjectsPage() {
|
|||
onChange={handleFiltersChange}
|
||||
/>
|
||||
|
||||
{/* Stats Summary */}
|
||||
{/* Stats Summary + View Toggle */}
|
||||
{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">
|
||||
{Object.entries(
|
||||
data.projects.reduce<Record<string, number>>((acc, p) => {
|
||||
|
|
@ -569,6 +597,27 @@ export default function ProjectsPage() {
|
|||
</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>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
|
|
@ -622,13 +671,15 @@ export default function ProjectsPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Table View */}
|
||||
{viewMode === 'table' ? (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<Card className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{filters.roundId && (
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allVisibleSelected ? true : someVisibleSelected ? 'indeterminate' : false}
|
||||
|
|
@ -636,12 +687,11 @@ export default function ProjectsPage() {
|
|||
aria-label="Select all projects"
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Files</TableHead>
|
||||
<TableHead>Tags</TableHead>
|
||||
<TableHead>Assignments</TableHead>
|
||||
<TableHead>Submitted</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -654,7 +704,6 @@ export default function ProjectsPage() {
|
|||
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
|
||||
checked={selectedIds.has(project.id)}
|
||||
|
|
@ -663,7 +712,6 @@ export default function ProjectsPage() {
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}`}
|
||||
|
|
@ -687,6 +735,15 @@ export default function ProjectsPage() {
|
|||
</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">
|
||||
|
|
@ -697,29 +754,43 @@ export default function ProjectsPage() {
|
|||
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>
|
||||
{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 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>
|
||||
|
|
@ -776,11 +847,10 @@ export default function ProjectsPage() {
|
|||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Mobile card view */}
|
||||
{/* Mobile card view (table mode fallback) */}
|
||||
<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)}
|
||||
|
|
@ -788,28 +858,17 @@ export default function ProjectsPage() {
|
|||
aria-label={`Select ${project.title}`}
|
||||
/>
|
||||
</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">
|
||||
<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 items-start gap-3 pl-8">
|
||||
<ProjectLogo project={project} size="md" fallback="initials" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<StatusBadge
|
||||
status={project.status ?? 'SUBMITTED'}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
|
||||
</div>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
</div>
|
||||
|
|
@ -818,27 +877,32 @@ export default function ProjectsPage() {
|
|||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Round</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{project.round?.name ?? '-'}</span>
|
||||
{project.status === 'REJECTED' && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Eliminated
|
||||
<span>{project.round?.name ?? 'Unassigned'}</span>
|
||||
</div>
|
||||
{project.competitionCategory && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Category</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Assignments</span>
|
||||
<span>{project._count.assignments} jurors</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Files</span>
|
||||
<span>{project._count?.files ?? 0}</span>
|
||||
</div>
|
||||
{project.createdAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span>{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
|
||||
{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>
|
||||
|
|
@ -847,6 +911,142 @@ export default function ProjectsPage() {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Card View */
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.projects.map((project) => {
|
||||
const isEliminated = project.status === 'REJECTED'
|
||||
return (
|
||||
<div key={project.id} className="relative">
|
||||
<div className="absolute left-3 top-3 z-10">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => handleToggleSelect(project.id)}
|
||||
aria-label={`Select ${project.title}`}
|
||||
/>
|
||||
</div>
|
||||
<Link href={`/admin/projects/${project.id}`} className="block">
|
||||
<Card className={`transition-colors hover:bg-muted/50 h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3 pl-7">
|
||||
<ProjectLogo project={project} size="lg" fallback="initials" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0 relative z-10" onClick={(e) => e.preventDefault()}>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/projects/${project.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/projects/${project.id}/edit`}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!project.round && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setProjectToAssign({ id: project.id, title: project.title })
|
||||
setAssignDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
Assign to Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteClick({ id: project.id, title: project.title })
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<CardDescription className="mt-0.5">
|
||||
{project.teamName}
|
||||
{project.country && (
|
||||
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Round</span>
|
||||
<span className="text-right">
|
||||
{project.round ? (
|
||||
<>{project.round.name}</>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
|
||||
Unassigned
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Jurors</span>
|
||||
<span>{project._count.assignments}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Files</span>
|
||||
<span>{project._count?.files ?? 0}</span>
|
||||
</div>
|
||||
{project.createdAt && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Submitted</span>
|
||||
<span className="text-xs">{new Date(project.createdAt).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1 border-t">
|
||||
{project.tags.slice(0, 5).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{project.tags.length > 5 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 font-normal">
|
||||
+{project.tags.length - 5}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<Pagination
|
||||
|
|
@ -861,15 +1061,28 @@ export default function ProjectsPage() {
|
|||
) : null}
|
||||
|
||||
{/* Bulk Action Floating Toolbar */}
|
||||
{selectedIds.size > 0 && filters.roundId && (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 w-[95vw] max-w-xl">
|
||||
<Card className="border-2 shadow-lg">
|
||||
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
|
||||
<Badge variant="secondary" className="shrink-0 text-sm">
|
||||
{selectedIds.size} selected
|
||||
</Badge>
|
||||
<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}>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectTrigger className="w-[160px] h-9 text-sm">
|
||||
<SelectValue placeholder="Set status..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -880,7 +1093,6 @@ export default function ProjectsPage() {
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBulkApply}
|
||||
|
|
@ -891,6 +1103,18 @@ export default function ProjectsPage() {
|
|||
)}
|
||||
Apply
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Delete */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setBulkDeleteConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
|
|
@ -898,11 +1122,11 @@ export default function ProjectsPage() {
|
|||
setSelectedIds(new Set())
|
||||
setBulkStatus('')
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="mr-1 h-4 w-4" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -1026,6 +1250,98 @@ export default function ProjectsPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Assign to Round Dialog */}
|
||||
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
|
||||
setBulkAssignDialogOpen(open)
|
||||
if (!open) setBulkAssignRoundId('')
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to "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 */}
|
||||
<Dialog open={aiTagDialogOpen} onOpenChange={handleCloseTaggingDialog}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
|
|
|
|||
|
|
@ -55,8 +55,11 @@ export default function ProjectPoolPage() {
|
|||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate()
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
||||
setSelectedProjects([])
|
||||
setAssignDialogOpen(false)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { Switch } from '@/components/ui/switch'
|
|||
import { Label } from '@/components/ui/label'
|
||||
import { ChevronDown, Filter, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
|
||||
const ALL_STATUSES = [
|
||||
'SUBMITTED',
|
||||
|
|
@ -140,14 +141,14 @@ export function ProjectFiltersBar({
|
|||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 duration-200">
|
||||
<CardContent className="space-y-4 pt-0">
|
||||
{/* Status toggles */}
|
||||
<div className="space-y-2">
|
||||
|
|
@ -255,9 +256,19 @@ export function ProjectFiltersBar({
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All countries</SelectItem>
|
||||
{filterOptions?.countries.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
{filterOptions?.countries
|
||||
.map((c) => ({
|
||||
code: c,
|
||||
name: getCountryName(c),
|
||||
flag: getCountryFlag(c),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((c) => (
|
||||
<SelectItem key={c.code} value={c.code}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{c.flag}</span>
|
||||
<span>{c.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
// Mutations
|
||||
const saveAsTemplate = trpc.roundTemplate.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundTemplate.list.invalidate()
|
||||
toast.success('Round saved as template')
|
||||
setSaveTemplateOpen(false)
|
||||
setTemplateName('')
|
||||
|
|
@ -143,14 +144,18 @@ function EditRoundContent({ roundId }: { roundId: string }) {
|
|||
|
||||
const updateRound = trpc.round.update.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate cache to ensure fresh data
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
utils.round.list.invalidate()
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
router.push(`/admin/rounds/${roundId}`)
|
||||
},
|
||||
})
|
||||
|
||||
const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation()
|
||||
const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
},
|
||||
})
|
||||
|
||||
// Initialize form with existing data
|
||||
const form = useForm<UpdateRoundForm>({
|
||||
|
|
|
|||
|
|
@ -109,6 +109,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
utils.round.list.invalidate()
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
},
|
||||
})
|
||||
const deleteRound = trpc.round.delete.useMutation({
|
||||
|
|
@ -125,7 +127,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
|
||||
// Filtering mutations
|
||||
const startJob = trpc.filtering.startJob.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation()
|
||||
const finalizeResults = trpc.filtering.finalizeResults.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
utils.project.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
// Save as template
|
||||
const saveAsTemplate = trpc.roundTemplate.createFromRound.useMutation({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -119,6 +119,11 @@ function ProgramRounds({ program }: { program: any }) {
|
|||
const utils = trpc.useUtils()
|
||||
const [rounds, setRounds] = useState<RoundData[]>(program.rounds || [])
|
||||
|
||||
// Sync local state when query data refreshes (e.g. after status change)
|
||||
useEffect(() => {
|
||||
setRounds(program.rounds || [])
|
||||
}, [program.rounds])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -45,6 +46,8 @@ type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferenc
|
|||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter()
|
||||
const { data: session, status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
const [step, setStep] = useState<Step>('name')
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
|
||||
|
|
@ -59,9 +62,15 @@ export default function OnboardingPage() {
|
|||
'EMAIL' | 'WHATSAPP' | 'BOTH' | 'NONE'
|
||||
>('EMAIL')
|
||||
|
||||
// Fetch current user data to get admin-preset tags
|
||||
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery()
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
|
||||
// Fetch current user data only after session is hydrated
|
||||
const { data: userData, isLoading: userLoading, refetch: refetchUser } = trpc.user.me.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
|
||||
// Initialize form with user data
|
||||
useEffect(() => {
|
||||
|
|
@ -95,11 +104,17 @@ export default function OnboardingPage() {
|
|||
}
|
||||
}, [userData, initialized])
|
||||
|
||||
// Fetch feature flags
|
||||
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery()
|
||||
// Fetch feature flags only after session is hydrated
|
||||
const { data: featureFlags } = trpc.settings.getFeatureFlags.useQuery(
|
||||
undefined,
|
||||
{ enabled: isAuthenticated }
|
||||
)
|
||||
const whatsappEnabled = featureFlags?.whatsappEnabled ?? false
|
||||
|
||||
const completeOnboarding = trpc.user.completeOnboarding.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
const completeOnboarding = trpc.user.completeOnboarding.useMutation({
|
||||
onSuccess: () => utils.user.me.invalidate(),
|
||||
})
|
||||
|
||||
// Dynamic steps based on WhatsApp availability
|
||||
const steps: Step[] = useMemo(() => {
|
||||
|
|
@ -162,8 +177,8 @@ export default function OnboardingPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Show loading while fetching user data
|
||||
if (userLoading || !initialized) {
|
||||
// Show loading while session hydrates or fetching user data
|
||||
if (sessionStatus === 'loading' || userLoading || !initialized) {
|
||||
return (
|
||||
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Card className="w-full max-w-lg shadow-2xl">
|
||||
|
|
|
|||
|
|
@ -30,9 +30,14 @@ export default function JuryAwardVotingPage({
|
|||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data, isLoading, refetch } =
|
||||
trpc.specialAward.getMyAwardDetail.useQuery({ awardId })
|
||||
const submitVote = trpc.specialAward.submitVote.useMutation()
|
||||
const submitVote = trpc.specialAward.submitVote.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.specialAward.getMyAwardDetail.invalidate({ awardId })
|
||||
},
|
||||
})
|
||||
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
|
||||
null
|
||||
|
|
|
|||
|
|
@ -53,7 +53,10 @@ export default function ProfileSettingsPage() {
|
|||
const router = useRouter()
|
||||
const { data: user, isLoading, refetch } = trpc.user.me.useQuery()
|
||||
const { data: avatarUrl } = trpc.avatar.getUrl.useQuery()
|
||||
const updateProfile = trpc.user.updateProfile.useMutation()
|
||||
const utils = trpc.useUtils()
|
||||
const updateProfile = trpc.user.updateProfile.useMutation({
|
||||
onSuccess: () => utils.user.me.invalidate(),
|
||||
})
|
||||
const changePassword = trpc.user.changePassword.useMutation()
|
||||
const deleteAccount = trpc.user.deleteAccount.useMutation()
|
||||
|
||||
|
|
|
|||
|
|
@ -216,6 +216,18 @@ export function getCountryName(code: string): string {
|
|||
return COUNTRIES[code]?.name || code
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ISO 3166-1 alpha-2 code to flag emoji.
|
||||
* Uses regional indicator symbols (Unicode).
|
||||
*/
|
||||
export function getCountryFlag(code: string): string {
|
||||
if (!code || code.length !== 2) return ''
|
||||
const upper = code.toUpperCase()
|
||||
return String.fromCodePoint(
|
||||
...Array.from(upper).map((c) => 0x1f1e6 + c.charCodeAt(0) - 65)
|
||||
)
|
||||
}
|
||||
|
||||
export function getCountryCoordinates(code: string): [number, number] | null {
|
||||
const country = COUNTRIES[code]
|
||||
if (!country) return null
|
||||
|
|
|
|||
|
|
@ -353,11 +353,11 @@ export const analyticsRouter = router({
|
|||
.query(async ({ ctx, input }) => {
|
||||
const where = input.roundId
|
||||
? { roundId: input.roundId }
|
||||
: { round: { programId: input.programId } }
|
||||
: { programId: input.programId }
|
||||
|
||||
const distribution = await ctx.prisma.project.groupBy({
|
||||
by: ['country'],
|
||||
where,
|
||||
where: { ...where, country: { not: null } },
|
||||
_count: { id: true },
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -385,7 +385,7 @@ async function resolveRecipients(
|
|||
if (!programId) return []
|
||||
// Get all applicants with projects in rounds of this program
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { round: { programId } },
|
||||
where: { programId },
|
||||
select: { submittedByUserId: true },
|
||||
})
|
||||
const ids = new Set(projects.map((p) => p.submittedByUserId).filter(Boolean) as string[])
|
||||
|
|
|
|||
|
|
@ -597,6 +597,51 @@ export const projectRouter = router({
|
|||
return project
|
||||
}),
|
||||
|
||||
/**
|
||||
* Bulk delete projects (admin only)
|
||||
*/
|
||||
bulkDelete: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
ids: z.array(z.string()).min(1).max(200),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { id: { in: input.ids } },
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'No projects found to delete',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'BULK_DELETE',
|
||||
entityType: 'Project',
|
||||
detailsJson: {
|
||||
count: projects.length,
|
||||
titles: projects.map((p) => p.title),
|
||||
ids: projects.map((p) => p.id),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return tx.project.deleteMany({
|
||||
where: { id: { in: projects.map((p) => p.id) } },
|
||||
})
|
||||
})
|
||||
|
||||
return { deleted: result.count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Import projects from CSV data (admin only)
|
||||
* Projects belong to a program. Optionally assign to a round.
|
||||
|
|
@ -887,7 +932,7 @@ export const projectRouter = router({
|
|||
const skip = (page - 1) * perPage
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
round: { programId },
|
||||
programId,
|
||||
roundId: null,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ export const roundRouter = router({
|
|||
if (input.roundType === 'FILTERING') {
|
||||
await tx.project.updateMany({
|
||||
where: {
|
||||
round: { programId: input.programId },
|
||||
programId: input.programId,
|
||||
roundId: { not: created.id },
|
||||
},
|
||||
data: {
|
||||
|
|
@ -664,7 +664,7 @@ export const roundRouter = router({
|
|||
const updated = await ctx.prisma.project.updateMany({
|
||||
where: {
|
||||
id: { in: input.projectIds },
|
||||
round: { programId: round.programId },
|
||||
programId: round.programId,
|
||||
},
|
||||
data: {
|
||||
roundId: input.roundId,
|
||||
|
|
|
|||
Loading…
Reference in New Issue