feat: award round reordering, assign-to-first-round, and applicant timeline for award tracks
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
- Add drag-and-drop round reordering on award detail Rounds tab (dnd-kit) - Replace "Open Voting" with "Assign to First Round" for SEPARATE_POOL awards - Add reorderAwardRounds mutation (two-phase transaction for unique constraint) - Add assignToFirstRound mutation (re-runnable, moves/creates ProjectRoundState) - Extend applicant timeline to show award-specific rounds for SEPARATE_POOL projects - Hide irrelevant main competition rounds when project is in award track - Prefix award round labels with award name in timeline Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -93,7 +93,26 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Info,
|
Info,
|
||||||
Mail,
|
Mail,
|
||||||
|
GripVertical,
|
||||||
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
type DragEndEvent,
|
||||||
|
} from '@dnd-kit/core'
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable'
|
||||||
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||||
DRAFT: 'secondary',
|
DRAFT: 'secondary',
|
||||||
@@ -116,6 +135,199 @@ function getStepIndex(status: string): number {
|
|||||||
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
|
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROUND_TYPE_COLORS: Record<string, string> = {
|
||||||
|
EVALUATION: 'bg-violet-100 text-violet-700',
|
||||||
|
FILTERING: 'bg-amber-100 text-amber-700',
|
||||||
|
SUBMISSION: 'bg-blue-100 text-blue-700',
|
||||||
|
MENTORING: 'bg-teal-100 text-teal-700',
|
||||||
|
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
||||||
|
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||||
|
}
|
||||||
|
const ROUND_STATUS_COLORS: Record<string, string> = {
|
||||||
|
DRAFT: 'bg-gray-100 text-gray-600',
|
||||||
|
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||||
|
CLOSED: 'bg-blue-100 text-blue-700',
|
||||||
|
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||||
|
}
|
||||||
|
|
||||||
|
function SortableRoundCard({
|
||||||
|
round,
|
||||||
|
index,
|
||||||
|
isFirst,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
round: any
|
||||||
|
index: number
|
||||||
|
isFirst: boolean
|
||||||
|
onDelete: (roundId: string) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: round.id })
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCount = round._count?.projectRoundStates ?? 0
|
||||||
|
const assignmentCount = round._count?.assignments ?? 0
|
||||||
|
const statusLabel = round.status.replace('ROUND_', '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`hover:shadow-md transition-shadow ${isDragging ? 'opacity-50 shadow-lg z-50' : ''}`}
|
||||||
|
>
|
||||||
|
<CardContent className="pt-4 pb-3 space-y-3">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<button
|
||||||
|
className="cursor-grab touch-none text-muted-foreground hover:text-foreground mt-1 shrink-0"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
||||||
|
{round.name}
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||||
|
<Badge variant="secondary" className={`text-[10px] ${ROUND_TYPE_COLORS[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||||
|
{round.roundType.replace('_', ' ')}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className={`text-[10px] ${ROUND_STATUS_COLORS[statusLabel]}`}>
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
{isFirst && (
|
||||||
|
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
||||||
|
Entry point
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Layers className="h-3.5 w-3.5" />
|
||||||
|
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
{assignmentCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||||
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{round.status === 'ROUND_DRAFT' && (
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete "{round.name}". This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => onDelete(round.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoundsDndGrid({
|
||||||
|
rounds,
|
||||||
|
awardId,
|
||||||
|
onReorder,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
|
}: {
|
||||||
|
rounds: any[]
|
||||||
|
awardId: string
|
||||||
|
onReorder: (roundIds: string[]) => void
|
||||||
|
onDelete: (roundId: string) => void
|
||||||
|
isDeleting: boolean
|
||||||
|
}) {
|
||||||
|
const [items, setItems] = useState(rounds.map((r: any) => r.id))
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sync if server data changes
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(rounds.map((r: any) => r.id))
|
||||||
|
}, [rounds])
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event
|
||||||
|
if (!over || active.id === over.id) return
|
||||||
|
|
||||||
|
const oldIndex = items.indexOf(active.id as string)
|
||||||
|
const newIndex = items.indexOf(over.id as string)
|
||||||
|
const newItems = arrayMove(items, oldIndex, newIndex)
|
||||||
|
setItems(newItems)
|
||||||
|
onReorder(newItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundMap = new Map(rounds.map((r: any) => [r.id, r]))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{items.map((id, index) => {
|
||||||
|
const round = roundMap.get(id)
|
||||||
|
if (!round) return null
|
||||||
|
return (
|
||||||
|
<SortableRoundCard
|
||||||
|
key={id}
|
||||||
|
round={round}
|
||||||
|
index={index}
|
||||||
|
isFirst={index === 0}
|
||||||
|
onDelete={onDelete}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||||
if (confidence > 0.8) {
|
if (confidence > 0.8) {
|
||||||
return (
|
return (
|
||||||
@@ -286,6 +498,18 @@ export default function AwardDetailPage({
|
|||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
})
|
})
|
||||||
|
const reorderRounds = trpc.specialAward.reorderAwardRounds.useMutation({
|
||||||
|
onSuccess: () => refetchRounds(),
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
const assignToFirstRound = trpc.specialAward.assignToFirstRound.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
toast.success(`Assigned ${result.totalAssigned} projects to first round (${result.createdCount} new, ${result.movedCount} moved)`)
|
||||||
|
refetchRounds()
|
||||||
|
refetch()
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
|
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
|
||||||
{ awardId, customMessage: notifyCustomMessage },
|
{ awardId, customMessage: notifyCustomMessage },
|
||||||
@@ -502,6 +726,18 @@ export default function AwardDetailPage({
|
|||||||
isSending={notifyEligible.isPending}
|
isSending={notifyEligible.isPending}
|
||||||
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
|
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
|
||||||
/>
|
/>
|
||||||
|
{award.eligibilityMode === 'SEPARATE_POOL' ? (
|
||||||
|
<Button
|
||||||
|
onClick={() => assignToFirstRound.mutate({ awardId })}
|
||||||
|
disabled={assignToFirstRound.isPending || award.eligibleCount === 0}
|
||||||
|
>
|
||||||
|
{assignToFirstRound.isPending ? (
|
||||||
|
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Assigning...</>
|
||||||
|
) : (
|
||||||
|
<><ArrowRight className="mr-2 h-4 w-4" />Assign to First Round</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||||
disabled={updateStatus.isPending}
|
disabled={updateStatus.isPending}
|
||||||
@@ -509,6 +745,7 @@ export default function AwardDetailPage({
|
|||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Open Voting
|
Open Voting
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{award.status === 'VOTING_OPEN' && (
|
{award.status === 'VOTING_OPEN' && (
|
||||||
@@ -1243,99 +1480,13 @@ export default function AwardDetailPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<RoundsDndGrid
|
||||||
{awardRounds.map((round: any, index: number) => {
|
rounds={awardRounds}
|
||||||
const projectCount = round._count?.projectRoundStates ?? 0
|
awardId={awardId}
|
||||||
const assignmentCount = round._count?.assignments ?? 0
|
onReorder={(roundIds) => reorderRounds.mutate({ awardId, roundIds })}
|
||||||
const statusLabel = round.status.replace('ROUND_', '')
|
onDelete={(roundId) => deleteRound.mutate({ roundId })}
|
||||||
const statusColors: Record<string, string> = {
|
isDeleting={deleteRound.isPending}
|
||||||
DRAFT: 'bg-gray-100 text-gray-600',
|
/>
|
||||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
|
||||||
CLOSED: 'bg-blue-100 text-blue-700',
|
|
||||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
|
||||||
}
|
|
||||||
const roundTypeColors: Record<string, string> = {
|
|
||||||
EVALUATION: 'bg-violet-100 text-violet-700',
|
|
||||||
FILTERING: 'bg-amber-100 text-amber-700',
|
|
||||||
SUBMISSION: 'bg-blue-100 text-blue-700',
|
|
||||||
MENTORING: 'bg-teal-100 text-teal-700',
|
|
||||||
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
|
||||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
|
|
||||||
<CardContent className="pt-4 pb-3 space-y-3">
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
|
||||||
{round.name}
|
|
||||||
</Link>
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
|
||||||
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
|
||||||
{round.roundType.replace('_', ' ')}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
|
|
||||||
{statusLabel}
|
|
||||||
</Badge>
|
|
||||||
{index === 0 && (
|
|
||||||
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
|
||||||
Entry point
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Layers className="h-3.5 w-3.5" />
|
|
||||||
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
{assignmentCount > 0 && (
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
|
||||||
<ListChecks className="h-3.5 w-3.5" />
|
|
||||||
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{round.status === 'ROUND_DRAFT' && (
|
|
||||||
<div className="flex justify-end pt-1">
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
|
||||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This will permanently delete "{round.name}". This cannot be undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => deleteRound.mutate({ roundId: round.id })}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -1443,7 +1443,7 @@ export const applicantRouter = router({
|
|||||||
return { competitionName: null, entries: [] }
|
return { competitionName: null, entries: [] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all rounds ordered by sortOrder
|
// Get all rounds ordered by sortOrder (including award rounds in same competition)
|
||||||
const rounds = await ctx.prisma.round.findMany({
|
const rounds = await ctx.prisma.round.findMany({
|
||||||
where: { competitionId: competition.id },
|
where: { competitionId: competition.id },
|
||||||
orderBy: { sortOrder: 'asc' },
|
orderBy: { sortOrder: 'asc' },
|
||||||
@@ -1454,6 +1454,8 @@ export const applicantRouter = router({
|
|||||||
status: true,
|
status: true,
|
||||||
windowOpenAt: true,
|
windowOpenAt: true,
|
||||||
windowCloseAt: true,
|
windowCloseAt: true,
|
||||||
|
specialAwardId: true,
|
||||||
|
specialAward: { select: { name: true } },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1482,12 +1484,29 @@ export const applicantRouter = router({
|
|||||||
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
|
const liveFinalRounds = rounds.filter((r) => r.roundType === 'LIVE_FINAL')
|
||||||
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
|
const deliberationRounds = rounds.filter((r) => r.roundType === 'DELIBERATION')
|
||||||
|
|
||||||
|
// Check if this project is in any SEPARATE_POOL award track
|
||||||
|
const projectAwardRoundIds = new Set(
|
||||||
|
rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.id)
|
||||||
|
)
|
||||||
|
const projectAwardIds = new Set(
|
||||||
|
rounds.filter((r) => r.specialAwardId && stateMap.has(r.id)).map((r) => r.specialAwardId!)
|
||||||
|
)
|
||||||
|
const isInAwardTrack = projectAwardRoundIds.size > 0
|
||||||
|
|
||||||
// Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always.
|
// Process visible rounds: hide FILTERING, LIVE_FINAL, DELIBERATION always.
|
||||||
// Also hide MENTORING unless the project is actually participating in it.
|
// Also hide MENTORING unless the project is actually participating in it.
|
||||||
|
// For award rounds: only show ones the project is in. For main rounds after
|
||||||
|
// the split point: hide if project isn't in them and is in an award track.
|
||||||
const visibleRounds = rounds.filter(
|
const visibleRounds = rounds.filter(
|
||||||
(r) => {
|
(r) => {
|
||||||
if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false
|
if (r.roundType === 'FILTERING' || r.roundType === 'LIVE_FINAL' || r.roundType === 'DELIBERATION') return false
|
||||||
if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false
|
if (r.roundType === 'MENTORING' && !stateMap.has(r.id)) return false
|
||||||
|
// Award round that project is NOT in → hide
|
||||||
|
if (r.specialAwardId && !stateMap.has(r.id)) return false
|
||||||
|
// Award round for a different award → hide
|
||||||
|
if (r.specialAwardId && !projectAwardIds.has(r.specialAwardId)) return false
|
||||||
|
// Main competition round where project has no state AND project is in award track → hide
|
||||||
|
if (!r.specialAwardId && isInAwardTrack && !stateMap.has(r.id)) return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1520,7 +1539,7 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
entries.push({
|
entries.push({
|
||||||
id: round.id,
|
id: round.id,
|
||||||
label: round.name,
|
label: round.specialAward ? `${round.specialAward.name}: ${round.name}` : round.name,
|
||||||
roundType: round.roundType,
|
roundType: round.roundType,
|
||||||
status: round.status,
|
status: round.status,
|
||||||
windowOpenAt: round.windowOpenAt,
|
windowOpenAt: round.windowOpenAt,
|
||||||
|
|||||||
@@ -1419,4 +1419,172 @@ export const specialAwardRouter = router({
|
|||||||
detailsJson: { awardId: round.specialAwardId },
|
detailsJson: { awardId: round.specialAwardId },
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder award rounds via drag-and-drop.
|
||||||
|
* Uses a two-phase transaction: first set all to negative temps (avoid unique constraint),
|
||||||
|
* then set to final values.
|
||||||
|
*/
|
||||||
|
reorderAwardRounds: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
awardId: z.string(),
|
||||||
|
roundIds: z.array(z.string()).min(1),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const existingRounds = await ctx.prisma.round.findMany({
|
||||||
|
where: { specialAwardId: input.awardId },
|
||||||
|
select: { id: true, competitionId: true, sortOrder: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingRounds.length !== input.roundIds.length) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Round list does not match existing award rounds',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIds = new Set(existingRounds.map((r) => r.id))
|
||||||
|
for (const id of input.roundIds) {
|
||||||
|
if (!existingIds.has(id)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: `Round ${id} does not belong to this award`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the existing sortOrder values (in ascending order) and reassign them
|
||||||
|
// to the new ordering. This keeps the same sortOrder slots, just remapped.
|
||||||
|
const sortSlots = existingRounds.map((r) => r.sortOrder).sort((a, b) => a - b)
|
||||||
|
const competitionId = existingRounds[0].competitionId
|
||||||
|
|
||||||
|
await ctx.prisma.$transaction(async (tx) => {
|
||||||
|
// Phase 1: set all to negative temps to avoid unique constraint
|
||||||
|
for (let i = 0; i < existingRounds.length; i++) {
|
||||||
|
await tx.round.update({
|
||||||
|
where: { id: existingRounds[i].id },
|
||||||
|
data: { sortOrder: -(i + 1000) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: assign final sort orders based on new ordering
|
||||||
|
for (let i = 0; i < input.roundIds.length; i++) {
|
||||||
|
await tx.round.update({
|
||||||
|
where: { id: input.roundIds[i] },
|
||||||
|
data: { sortOrder: sortSlots[i] },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: input.awardId,
|
||||||
|
detailsJson: { action: 'REORDER_ROUNDS', newOrder: input.roundIds },
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign (or reassign) eligible projects to the first award round.
|
||||||
|
* Re-runnable: moves existing ProjectRoundState entries from other award rounds
|
||||||
|
* to the first, and creates new PENDING entries for unassigned projects.
|
||||||
|
*/
|
||||||
|
assignToFirstRound: adminProcedure
|
||||||
|
.input(z.object({ awardId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||||
|
where: { id: input.awardId },
|
||||||
|
select: { eligibilityMode: true, name: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (award.eligibilityMode !== 'SEPARATE_POOL') {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Assign to first round is only available for Separate Pool awards',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const awardRounds = await ctx.prisma.round.findMany({
|
||||||
|
where: { specialAwardId: input.awardId },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (awardRounds.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'Create at least one round before assigning projects',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRound = awardRounds[0]
|
||||||
|
const otherRoundIds = awardRounds.slice(1).map((r) => r.id)
|
||||||
|
|
||||||
|
// Get all eligible projects (confirmed or not — any eligible project)
|
||||||
|
const eligible = await ctx.prisma.awardEligibility.findMany({
|
||||||
|
where: { awardId: input.awardId, eligible: true },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (eligible.length === 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: 'No eligible projects to assign',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectIds = eligible.map((e) => e.projectId)
|
||||||
|
|
||||||
|
// Move existing entries from other award rounds to the first round
|
||||||
|
let movedCount = 0
|
||||||
|
if (otherRoundIds.length > 0) {
|
||||||
|
const moved = await ctx.prisma.projectRoundState.updateMany({
|
||||||
|
where: {
|
||||||
|
roundId: { in: otherRoundIds },
|
||||||
|
projectId: { in: projectIds },
|
||||||
|
},
|
||||||
|
data: { roundId: firstRound.id, state: 'PENDING' },
|
||||||
|
})
|
||||||
|
movedCount = moved.count
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create PENDING entries for projects not yet in the first round
|
||||||
|
const existing = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: firstRound.id, projectId: { in: projectIds } },
|
||||||
|
select: { projectId: true },
|
||||||
|
})
|
||||||
|
const existingSet = new Set(existing.map((e) => e.projectId))
|
||||||
|
const newProjectIds = projectIds.filter((id) => !existingSet.has(id))
|
||||||
|
|
||||||
|
let createdCount = 0
|
||||||
|
if (newProjectIds.length > 0) {
|
||||||
|
await ctx.prisma.projectRoundState.createMany({
|
||||||
|
data: newProjectIds.map((projectId) => ({
|
||||||
|
projectId,
|
||||||
|
roundId: firstRound.id,
|
||||||
|
state: 'PENDING' as const,
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
})
|
||||||
|
createdCount = newProjectIds.length
|
||||||
|
}
|
||||||
|
|
||||||
|
await logAudit({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'SpecialAward',
|
||||||
|
entityId: input.awardId,
|
||||||
|
detailsJson: {
|
||||||
|
action: 'ASSIGN_TO_FIRST_ROUND',
|
||||||
|
firstRoundId: firstRound.id,
|
||||||
|
movedCount,
|
||||||
|
createdCount,
|
||||||
|
totalEligible: projectIds.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { movedCount, createdCount, totalAssigned: existingSet.size + createdCount }
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user