Add dynamic apply wizard customization with admin settings UI
- Create wizard config types, utilities, and defaults (wizard-config.ts) - Add admin apply settings page with drag-and-drop step ordering, dropdown option management, feature toggles, welcome message customization, and custom field builder with select/multiselect options editor - Build dynamic apply wizard component with animated step transitions, mobile-first responsive design, and config-driven form validation - Update step components to accept dynamic config (categories, ocean issues, field visibility, feature flags) - Replace hardcoded enum validation with string-based validation for admin-configurable dropdown values, with safe enum casting at storage layer - Add wizard template system (model, router, admin UI) with built-in MOPC Classic preset - Add program wizard config CRUD procedures to program router - Update application router getConfig to return wizardConfig, submit handler to store custom field data in metadataJson - Add edition-based apply page, project pool page, and supporting routers - Fix CSS (invalid sm:fixed-none), Enter key handler (skip textarea), safe area insets for notched phones, buildStepsArray field visibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,7 @@ import {
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Step = 'input' | 'preview' | 'sending' | 'complete'
|
||||
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
type Role = 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
|
||||
interface Assignment {
|
||||
projectId: string
|
||||
@@ -99,6 +99,7 @@ interface ParsedUser {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
PROGRAM_ADMIN: 'Program Admin',
|
||||
JURY_MEMBER: 'Jury Member',
|
||||
MENTOR: 'Mentor',
|
||||
OBSERVER: 'Observer',
|
||||
@@ -265,6 +266,11 @@ export default function MemberInvitePage() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Fetch current user to check role
|
||||
const { data: currentUser } = trpc.user.me.useQuery()
|
||||
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN'
|
||||
|
||||
const bulkCreate = trpc.user.bulkCreate.useMutation({
|
||||
onSuccess: () => {
|
||||
// Invalidate user list to refresh the members table when navigating back
|
||||
@@ -393,19 +399,22 @@ export default function MemberInvitePage() {
|
||||
const name = nameKey ? row[nameKey]?.trim() : undefined
|
||||
const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : ''
|
||||
const role: Role =
|
||||
rawRole === 'MENTOR'
|
||||
rawRole === 'PROGRAM_ADMIN'
|
||||
? 'PROGRAM_ADMIN'
|
||||
: rawRole === 'MENTOR'
|
||||
? 'MENTOR'
|
||||
: rawRole === 'OBSERVER'
|
||||
? 'OBSERVER'
|
||||
: 'JURY_MEMBER'
|
||||
const isValidFormat = emailRegex.test(email)
|
||||
const isDuplicate = email ? seenEmails.has(email) : false
|
||||
const isUnauthorizedAdmin = role === 'PROGRAM_ADMIN' && !isSuperAdmin
|
||||
if (isValidFormat && !isDuplicate && email) seenEmails.add(email)
|
||||
return {
|
||||
email,
|
||||
name,
|
||||
role,
|
||||
isValid: isValidFormat && !isDuplicate,
|
||||
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
|
||||
isDuplicate,
|
||||
error: !email
|
||||
? 'No email found'
|
||||
@@ -413,6 +422,8 @@ export default function MemberInvitePage() {
|
||||
? 'Invalid email format'
|
||||
: isDuplicate
|
||||
? 'Duplicate email'
|
||||
: isUnauthorizedAdmin
|
||||
? 'Only super admins can invite program admins'
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
@@ -421,7 +432,7 @@ export default function MemberInvitePage() {
|
||||
},
|
||||
})
|
||||
},
|
||||
[]
|
||||
[isSuperAdmin]
|
||||
)
|
||||
|
||||
// --- Parse manual rows into ParsedUser format ---
|
||||
@@ -433,6 +444,7 @@ export default function MemberInvitePage() {
|
||||
const email = r.email.trim().toLowerCase()
|
||||
const isValidFormat = emailRegex.test(email)
|
||||
const isDuplicate = seenEmails.has(email)
|
||||
const isUnauthorizedAdmin = r.role === 'PROGRAM_ADMIN' && !isSuperAdmin
|
||||
if (isValidFormat && !isDuplicate) seenEmails.add(email)
|
||||
return {
|
||||
email,
|
||||
@@ -440,12 +452,14 @@ export default function MemberInvitePage() {
|
||||
role: r.role,
|
||||
expertiseTags: r.expertiseTags.length > 0 ? r.expertiseTags : undefined,
|
||||
assignments: r.assignments.length > 0 ? r.assignments : undefined,
|
||||
isValid: isValidFormat && !isDuplicate,
|
||||
isValid: isValidFormat && !isDuplicate && !isUnauthorizedAdmin,
|
||||
isDuplicate,
|
||||
error: !isValidFormat
|
||||
? 'Invalid email format'
|
||||
: isDuplicate
|
||||
? 'Duplicate email'
|
||||
: isUnauthorizedAdmin
|
||||
? 'Only super admins can invite program admins'
|
||||
: undefined,
|
||||
}
|
||||
})
|
||||
@@ -524,6 +538,11 @@ export default function MemberInvitePage() {
|
||||
<CardTitle>Invite Members</CardTitle>
|
||||
<CardDescription>
|
||||
Add members individually or upload a CSV file
|
||||
{isSuperAdmin && (
|
||||
<span className="block mt-1 text-primary font-medium">
|
||||
As a super admin, you can also invite program admins
|
||||
</span>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
@@ -627,6 +646,11 @@ export default function MemberInvitePage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{isSuperAdmin && (
|
||||
<SelectItem value="PROGRAM_ADMIN">
|
||||
Program Admin
|
||||
</SelectItem>
|
||||
)}
|
||||
<SelectItem value="JURY_MEMBER">
|
||||
Jury Member
|
||||
</SelectItem>
|
||||
|
||||
1473
src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx
Normal file
1473
src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -45,18 +45,23 @@ export default function EditProgramPage() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
status: 'DRAFT',
|
||||
applyMode: 'round' as 'edition' | 'round' | 'both',
|
||||
})
|
||||
|
||||
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
|
||||
|
||||
useEffect(() => {
|
||||
if (program) {
|
||||
const settings = (program.settingsJson as Record<string, any>) || {}
|
||||
setFormData({
|
||||
name: program.name,
|
||||
slug: program.slug || '',
|
||||
description: program.description || '',
|
||||
status: program.status,
|
||||
applyMode: settings.applyMode || 'round',
|
||||
})
|
||||
}
|
||||
}, [program])
|
||||
@@ -89,8 +94,12 @@ export default function EditProgramPage() {
|
||||
updateProgram.mutate({
|
||||
id,
|
||||
name: formData.name,
|
||||
slug: formData.slug || undefined,
|
||||
description: formData.description || undefined,
|
||||
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
|
||||
settingsJson: {
|
||||
applyMode: formData.applyMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,6 +205,41 @@ export default function EditProgramPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">Edition Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
placeholder="e.g., mopc-2026"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
URL-friendly identifier for edition-wide applications (optional)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="applyMode">Application Flow</Label>
|
||||
<Select
|
||||
value={formData.applyMode}
|
||||
onValueChange={(value) => setFormData({ ...formData, applyMode: value as 'edition' | 'round' | 'both' })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="edition">Edition-wide only</SelectItem>
|
||||
<SelectItem value="round">Round-specific only</SelectItem>
|
||||
<SelectItem value="both">Allow both</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls whether applicants apply to the program or specific rounds
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
FolderKanban,
|
||||
Eye,
|
||||
Pencil,
|
||||
Wand2,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
@@ -146,6 +147,12 @@ async function ProgramsContent() {
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}/apply-settings`}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
Apply Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
@@ -194,6 +201,12 @@ async function ProgramsContent() {
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/programs/${program.id}/apply-settings`}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
Apply
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -78,9 +78,21 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
id: projectId,
|
||||
})
|
||||
|
||||
// Fetch files
|
||||
// Fetch files (flat list for backward compatibility)
|
||||
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
||||
|
||||
// Fetch grouped files by round (if project has a roundId)
|
||||
const { data: groupedFiles } = trpc.file.listByProjectForRound.useQuery(
|
||||
{ projectId, roundId: project?.roundId || '' },
|
||||
{ enabled: !!project?.roundId }
|
||||
)
|
||||
|
||||
// Fetch available rounds for upload selector (if project has a programId)
|
||||
const { data: rounds } = trpc.round.listByProgram.useQuery(
|
||||
{ programId: project?.programId || '' },
|
||||
{ enabled: !!project?.programId }
|
||||
)
|
||||
|
||||
// Fetch assignments
|
||||
const { data: assignments } = trpc.assignment.listByProject.useQuery({
|
||||
projectId,
|
||||
@@ -492,7 +504,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{files && files.length > 0 ? (
|
||||
{groupedFiles && groupedFiles.length > 0 ? (
|
||||
<FileViewer groupedFiles={groupedFiles} />
|
||||
) : files && files.length > 0 ? (
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={files.map((f) => ({
|
||||
@@ -516,8 +530,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<p className="text-sm font-medium mb-3">Upload New Files</p>
|
||||
<FileUpload
|
||||
projectId={projectId}
|
||||
roundId={project.roundId || undefined}
|
||||
availableRounds={rounds?.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name }))}
|
||||
onUploadComplete={() => {
|
||||
utils.file.listByProject.invalidate({ projectId })
|
||||
if (project.roundId) {
|
||||
utils.file.listByProjectForRound.invalidate({ projectId, roundId: project.roundId })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
345
src/app/(admin)/admin/projects/pool/page.tsx
Normal file
345
src/app/(admin)/admin/projects/pool/page.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function ProjectPoolPage() {
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [targetRoundId, setTargetRoundId] = useState<string>('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const perPage = 50
|
||||
|
||||
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
|
||||
|
||||
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
|
||||
{
|
||||
programId: selectedProgramId,
|
||||
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
|
||||
search: searchQuery || undefined,
|
||||
page: currentPage,
|
||||
perPage,
|
||||
},
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
|
||||
const { data: rounds, isLoading: isLoadingRounds } = trpc.round.listByProgram.useQuery(
|
||||
{ programId: selectedProgramId },
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
|
||||
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
||||
setSelectedProjects([])
|
||||
setAssignDialogOpen(false)
|
||||
setTargetRoundId('')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to assign projects')
|
||||
},
|
||||
})
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (selectedProjects.length === 0 || !targetRoundId) return
|
||||
assignMutation.mutate({
|
||||
projectIds: selectedProjects,
|
||||
roundId: targetRoundId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleQuickAssign = (projectId: string, roundId: string) => {
|
||||
assignMutation.mutate({
|
||||
projectIds: [projectId],
|
||||
roundId,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (!poolData?.projects) return
|
||||
if (selectedProjects.length === poolData.projects.length) {
|
||||
setSelectedProjects([])
|
||||
} else {
|
||||
setSelectedProjects(poolData.projects.map((p) => p.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectProject = (projectId: string) => {
|
||||
if (selectedProjects.includes(projectId)) {
|
||||
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
|
||||
} else {
|
||||
setSelectedProjects([...selectedProjects, projectId])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Assign unassigned projects to evaluation rounds
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Program Selector */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Program</label>
|
||||
<Select value={selectedProgramId} onValueChange={(value) => {
|
||||
setSelectedProgramId(value)
|
||||
setSelectedProjects([])
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select program..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((program) => (
|
||||
<SelectItem key={program.id} value={program.id}>
|
||||
{program.name} {program.year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
<Select value={categoryFilter} onValueChange={(value: any) => {
|
||||
setCategoryFilter(value)
|
||||
setCurrentPage(1)
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
<label className="text-sm font-medium">Search</label>
|
||||
<Input
|
||||
placeholder="Project or team name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedProjects.length > 0 && (
|
||||
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
|
||||
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Projects Table */}
|
||||
{selectedProgramId && (
|
||||
<>
|
||||
{isLoadingPool ? (
|
||||
<Card className="p-4">
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : poolData && poolData.total > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b">
|
||||
<tr className="text-sm">
|
||||
<th className="p-3 text-left">
|
||||
<Checkbox
|
||||
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3 text-left font-medium">Project</th>
|
||||
<th className="p-3 text-left font-medium">Category</th>
|
||||
<th className="p-3 text-left font-medium">Country</th>
|
||||
<th className="p-3 text-left font-medium">Submitted</th>
|
||||
<th className="p-3 text-left font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{poolData.projects.map((project) => (
|
||||
<tr key={project.id} className="border-b hover:bg-muted/50">
|
||||
<td className="p-3">
|
||||
<Checkbox
|
||||
checked={selectedProjects.includes(project.id)}
|
||||
onCheckedChange={() => toggleSelectProject(project.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
<div className="font-medium">{project.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{project.teamName}</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<Badge variant="outline">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.country || '-'}
|
||||
</td>
|
||||
<td className="p-3 text-sm text-muted-foreground">
|
||||
{project.submittedAt
|
||||
? new Date(project.submittedAt).toLocaleDateString()
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{isLoadingRounds ? (
|
||||
<Skeleton className="h-9 w-[200px]" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
|
||||
disabled={assignMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Assign to round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{poolData.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total} projects
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
disabled={currentPage === poolData.totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
No unassigned projects found for this program
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!selectedProgramId && (
|
||||
<Card className="p-8 text-center text-muted-foreground">
|
||||
Select a program to view unassigned projects
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bulk Assignment Dialog */}
|
||||
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Projects to Round</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkAssign}
|
||||
disabled={!targetRoundId || assignMutation.isPending}
|
||||
>
|
||||
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user