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,
|
||||
Info,
|
||||
Mail,
|
||||
GripVertical,
|
||||
ArrowRight,
|
||||
} 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'> = {
|
||||
DRAFT: 'secondary',
|
||||
@@ -116,6 +135,199 @@ function getStepIndex(status: string): number {
|
||||
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 }) {
|
||||
if (confidence > 0.8) {
|
||||
return (
|
||||
@@ -286,6 +498,18 @@ export default function AwardDetailPage({
|
||||
},
|
||||
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(
|
||||
{ awardId, customMessage: notifyCustomMessage },
|
||||
@@ -502,13 +726,26 @@ export default function AwardDetailPage({
|
||||
isSending={notifyEligible.isPending}
|
||||
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Open Voting
|
||||
</Button>
|
||||
{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
|
||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Open Voting
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{award.status === 'VOTING_OPEN' && (
|
||||
@@ -1243,99 +1480,13 @@ export default function AwardDetailPage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{awardRounds.map((round: any, index: number) => {
|
||||
const projectCount = round._count?.projectRoundStates ?? 0
|
||||
const assignmentCount = round._count?.assignments ?? 0
|
||||
const statusLabel = round.status.replace('ROUND_', '')
|
||||
const statusColors: 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',
|
||||
}
|
||||
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>
|
||||
<RoundsDndGrid
|
||||
rounds={awardRounds}
|
||||
awardId={awardId}
|
||||
onReorder={(roundIds) => reorderRounds.mutate({ awardId, roundIds })}
|
||||
onDelete={(roundId) => deleteRound.mutate({ roundId })}
|
||||
isDeleting={deleteRound.isPending}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user