318 lines
12 KiB
TypeScript
318 lines
12 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 [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 || [];
|
||
|
|
|
||
|
|
// TODO: Add getJuryMembers endpoint if needed for participant selection
|
||
|
|
const juryMembers: any[] = [];
|
||
|
|
|
||
|
|
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;
|
||
|
|
}
|
||
|
|
|
||
|
|
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',
|
||
|
|
DELIB_VOTING: 'default',
|
||
|
|
DELIB_TALLYING: 'secondary',
|
||
|
|
DELIB_LOCKED: 'destructive'
|
||
|
|
};
|
||
|
|
return <Badge variant={variants[status] || 'outline'}>{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}
|
||
|
|
</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}</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>
|
||
|
|
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
}
|