All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
HIGH fixes (broken features / wrong data): - H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences) - H2: Fix deliberation results panel blank table (wrong field names) - H3: Fix deliberation participant names blank (wrong data path) - H4: Fix awards "Evaluated" stat duplicating "Eligible" count - H5: Fix cross-round comparison enabled at 1 round (backend requires 2) - H6: Fix setState during render anti-pattern (6 occurrences) - H7: Fix round detail jury member count always showing 0 - H8: Remove 4 invalid status values from observer dashboard filter - H9: Fix filtering progress bar always showing 100% MEDIUM fixes (misleading display): - M1: Filter special-award rounds from competition timeline - M2: Exclude special-award rounds from distinct project count - M3: Fix MENTORING pipeline node hardcoded "0 mentored" - M4: Fix DELIB_LOCKED badge using red for success state - M5: Add status label maps to deliberation session detail - M6: Humanize deliberation category + tie-break method displays - M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round" - M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels - M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge - M11: Fix unsent messages showing "Scheduled" instead of "Draft" - M12: Rename misleading totalEvaluations → totalAssignments - M13: Rename "Stage" column to "Program" in projects page LOW fixes (cosmetic / edge-case): - L1: Use unfiltered rounds array for active round detection - L2: Use all rounds length for new round sort order - L3: Filter special-award rounds from header count - L4: Fix single-underscore replace in award status badges - L5: Fix score bucket boundary gaps (4.99 dropped between buckets) - L6: Title-case LIVE_FINAL pipeline metric status - L7: Fix roundType.replace only replacing first underscore - L8: Remove duplicate severity sort in smart-actions component Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { use, useState } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { trpc } from '@/lib/trpc/client';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle
|
|
} from '@/components/ui/dialog';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ArrowLeft, Plus } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import type { Route } from 'next';
|
|
|
|
export default function DeliberationListPage({
|
|
params: paramsPromise
|
|
}: {
|
|
params: Promise<{ competitionId: string }>;
|
|
}) {
|
|
const params = use(paramsPromise);
|
|
const router = useRouter();
|
|
const utils = trpc.useUtils();
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
const [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
|
|
const [formData, setFormData] = useState({
|
|
roundId: '',
|
|
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
|
|
mode: 'SINGLE_WINNER_VOTE' as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING',
|
|
tieBreakMethod: 'TIE_RUNOFF' as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK',
|
|
showCollectiveRankings: false,
|
|
showPriorJuryData: false,
|
|
participantUserIds: [] as string[]
|
|
});
|
|
|
|
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
|
|
{ competitionId: params.competitionId },
|
|
{ enabled: !!params.competitionId }
|
|
);
|
|
|
|
// Get rounds for this competition
|
|
const { data: competition } = trpc.competition.getById.useQuery(
|
|
{ id: params.competitionId },
|
|
{ enabled: !!params.competitionId }
|
|
);
|
|
const rounds = competition?.rounds || [];
|
|
|
|
// Jury groups & members for participant selection
|
|
const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
|
|
{ competitionId: params.competitionId },
|
|
{ enabled: !!params.competitionId }
|
|
);
|
|
|
|
const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
|
|
{ id: selectedJuryGroupId },
|
|
{ enabled: !!selectedJuryGroupId }
|
|
);
|
|
const juryMembers = selectedJuryGroup?.members ?? [];
|
|
|
|
const createSessionMutation = trpc.deliberation.createSession.useMutation({
|
|
onSuccess: (data) => {
|
|
utils.deliberation.listSessions.invalidate({ competitionId: params.competitionId });
|
|
toast.success('Deliberation session created');
|
|
setCreateDialogOpen(false);
|
|
router.push(
|
|
`/admin/competitions/${params.competitionId}/deliberation/${data.id}` as Route
|
|
);
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err.message);
|
|
}
|
|
});
|
|
|
|
const handleCreateSession = () => {
|
|
if (!formData.roundId) {
|
|
toast.error('Please select a round');
|
|
return;
|
|
}
|
|
if (formData.participantUserIds.length === 0) {
|
|
toast.error('Please select at least one participant');
|
|
return;
|
|
}
|
|
|
|
createSessionMutation.mutate({
|
|
competitionId: params.competitionId,
|
|
roundId: formData.roundId,
|
|
category: formData.category,
|
|
mode: formData.mode,
|
|
tieBreakMethod: formData.tieBreakMethod,
|
|
showCollectiveRankings: formData.showCollectiveRankings,
|
|
showPriorJuryData: formData.showPriorJuryData,
|
|
participantUserIds: formData.participantUserIds
|
|
});
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
|
DELIB_OPEN: 'outline',
|
|
VOTING: 'default',
|
|
TALLYING: 'secondary',
|
|
RUNOFF: 'secondary',
|
|
DELIB_LOCKED: 'secondary',
|
|
};
|
|
const labels: Record<string, string> = {
|
|
DELIB_OPEN: 'Open',
|
|
VOTING: 'Voting',
|
|
TALLYING: 'Tallying',
|
|
RUNOFF: 'Runoff',
|
|
DELIB_LOCKED: 'Locked',
|
|
};
|
|
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="space-y-6 p-4 sm:p-6">
|
|
<div className="flex items-center gap-4">
|
|
<Skeleton className="h-8 w-8 shrink-0" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-8 w-64" />
|
|
<Skeleton className="h-4 w-96 max-w-full" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-32 w-full" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 p-4 sm:p-6">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Back to competition details">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Deliberation Sessions</h1>
|
|
<p className="text-muted-foreground">
|
|
Manage final jury deliberations and winner selection
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
New Session
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{sessions?.map((session: any) => (
|
|
<Link
|
|
key={session.id}
|
|
href={
|
|
`/admin/competitions/${params.competitionId}/deliberation/${session.id}` as Route
|
|
}
|
|
>
|
|
<Card className="cursor-pointer transition-shadow hover:shadow-md">
|
|
<CardHeader>
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<CardTitle>
|
|
{session.round?.name} - {session.category === 'BUSINESS_CONCEPT' ? 'Business Concept' : session.category === 'STARTUP' ? 'Startup' : session.category}
|
|
</CardTitle>
|
|
<CardDescription className="mt-1">
|
|
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
|
|
</CardDescription>
|
|
</div>
|
|
{getStatusBadge(session.status)}
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
|
|
<span>{session.participants?.length || 0} participants</span>
|
|
<span>•</span>
|
|
<span>Tie break: {session.tieBreakMethod === 'TIE_RUNOFF' ? 'Runoff Vote' : session.tieBreakMethod === 'TIE_ADMIN_DECIDES' ? 'Admin Decides' : session.tieBreakMethod === 'SCORE_FALLBACK' ? 'Score Fallback' : session.tieBreakMethod}</span>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
{sessions?.length === 0 && (
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
<p className="text-muted-foreground">No deliberation sessions yet</p>
|
|
<Button variant="link" onClick={() => setCreateDialogOpen(true)}>
|
|
Create your first session
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Create Session Dialog */}
|
|
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle>Create Deliberation Session</DialogTitle>
|
|
<DialogDescription>
|
|
Set up a new deliberation session for final winner selection
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="round">Round *</Label>
|
|
<Select value={formData.roundId} onValueChange={(value) => setFormData({ ...formData, roundId: value })}>
|
|
<SelectTrigger id="round">
|
|
<SelectValue placeholder="Select round" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{rounds?.map((round: any) => (
|
|
<SelectItem key={round.id} value={round.id}>
|
|
{round.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="category">Category *</Label>
|
|
<Select
|
|
value={formData.category}
|
|
onValueChange={(value) =>
|
|
setFormData({ ...formData, category: value as 'STARTUP' | 'BUSINESS_CONCEPT' })
|
|
}
|
|
>
|
|
<SelectTrigger id="category">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="STARTUP">Startup</SelectItem>
|
|
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="mode">Voting Mode *</Label>
|
|
<Select
|
|
value={formData.mode}
|
|
onValueChange={(value) =>
|
|
setFormData({
|
|
...formData,
|
|
mode: value as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING'
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger id="mode">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
|
|
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tieBreak">Tie Break Method *</Label>
|
|
<Select
|
|
value={formData.tieBreakMethod}
|
|
onValueChange={(value) =>
|
|
setFormData({
|
|
...formData,
|
|
tieBreakMethod: value as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK'
|
|
})
|
|
}
|
|
>
|
|
<SelectTrigger id="tieBreak">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="TIE_RUNOFF">Runoff Vote</SelectItem>
|
|
<SelectItem value="TIE_ADMIN_DECIDES">Admin Decides</SelectItem>
|
|
<SelectItem value="SCORE_FALLBACK">Score Fallback</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Participant Selection */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="juryGroup">Jury Group *</Label>
|
|
<Select
|
|
value={selectedJuryGroupId}
|
|
onValueChange={(value) => {
|
|
setSelectedJuryGroupId(value);
|
|
setFormData({ ...formData, participantUserIds: [] });
|
|
}}
|
|
>
|
|
<SelectTrigger id="juryGroup">
|
|
<SelectValue placeholder="Select jury group" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{juryGroups.map((group: any) => (
|
|
<SelectItem key={group.id} value={group.id}>
|
|
{group.name} ({group._count?.members ?? 0} members)
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{juryMembers.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label>Participants ({formData.participantUserIds.length}/{juryMembers.length})</Label>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const allIds = juryMembers.map((m: any) => m.user.id);
|
|
const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
|
|
setFormData({
|
|
...formData,
|
|
participantUserIds: allSelected ? [] : allIds,
|
|
});
|
|
}}
|
|
>
|
|
{juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
|
|
? 'Deselect All'
|
|
: 'Select All'}
|
|
</Button>
|
|
</div>
|
|
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3">
|
|
{juryMembers.map((member: any) => (
|
|
<div key={member.id} className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id={`member-${member.user.id}`}
|
|
checked={formData.participantUserIds.includes(member.user.id)}
|
|
onCheckedChange={(checked) => {
|
|
setFormData({
|
|
...formData,
|
|
participantUserIds: checked
|
|
? [...formData.participantUserIds, member.user.id]
|
|
: formData.participantUserIds.filter((id: string) => id !== member.user.id),
|
|
});
|
|
}}
|
|
/>
|
|
<Label htmlFor={`member-${member.user.id}`} className="flex-1 font-normal">
|
|
{member.user.name || member.user.email}
|
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
{member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
|
|
</span>
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="showCollective"
|
|
checked={formData.showCollectiveRankings}
|
|
onCheckedChange={(checked) =>
|
|
setFormData({ ...formData, showCollectiveRankings: checked as boolean })
|
|
}
|
|
/>
|
|
<Label htmlFor="showCollective" className="font-normal">
|
|
Show collective rankings during voting
|
|
</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Checkbox
|
|
id="showPrior"
|
|
checked={formData.showPriorJuryData}
|
|
onCheckedChange={(checked) =>
|
|
setFormData({ ...formData, showPriorJuryData: checked as boolean })
|
|
}
|
|
/>
|
|
<Label htmlFor="showPrior" className="font-normal">
|
|
Show prior jury evaluation data
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleCreateSession} disabled={createSessionMutation.isPending}>
|
|
{createSessionMutation.isPending ? 'Creating...' : 'Create Session'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|