Files
MOPC-Portal/src/app/(admin)/admin/juries/[groupId]/page.tsx
Matt ef1bf24388
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s
Fix evaluation criteria, jury preferences, assignment config, and dashboard stats
- Fix criteria not showing for jurors: fetch active form independently via
  getStageForm query instead of relying on existing evaluation record
- Fix scoringMode default from 'global' to 'criteria' (matching schema)
- Parse scale string format ("1-10") into minScore/maxScore for criteria display
- Fix COI dialog dismissal: prevent outside click on evaluate page Dialog
- Fix requiredReviews hardcoded to 3: read from round configJson in 4 locations
- Add jury preferences banner for unconfirmed caps on jury dashboard
- Add updateJuryPreferences tRPC procedure for self-service cap/ratio
- Simplify onboarding: always show jury step, allow cap up to 50
- Add role/ratio/availability fields to jury member invite dialog
- Simplify jury group settings (keep only defaultMaxAssignments)
- Enforce deliberation showCollectiveRankings flag for non-admin users
- Redesign dashboard stat cards: editorial data strip on mobile,
  clean grid layout on desktop (no more generic card pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:33:20 +01:00

590 lines
20 KiB
TypeScript

'use client'
import { use, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
Plus,
Loader2,
Trash2,
Users,
Settings,
Search,
} from 'lucide-react'
const capModeLabels = {
HARD: 'Hard Cap',
SOFT: 'Soft Cap',
NONE: 'No Cap',
}
const capModeColors = {
HARD: 'bg-red-100 text-red-700',
SOFT: 'bg-amber-100 text-amber-700',
NONE: 'bg-gray-100 text-gray-600',
}
type JuryGroupDetailPageProps = {
params: Promise<{ groupId: string }>
}
export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps) {
const resolvedParams = use(params)
const groupId = resolvedParams.groupId
const router = useRouter()
const utils = trpc.useUtils()
const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false)
const [userSearch, setUserSearch] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [selectedRole, setSelectedRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [maxAssignmentsOverride, setMaxAssignmentsOverride] = useState('')
const { data: group, isLoading: loadingGroup } = trpc.juryGroup.getById.useQuery(
{ id: groupId },
{ enabled: !!groupId }
)
const { data: competition, isLoading: loadingCompetition } = trpc.competition.getById.useQuery(
{ id: group?.competitionId ?? '' },
{ enabled: !!group?.competitionId }
)
const { data: userSearchResults, isLoading: loadingUsers } = trpc.user.list.useQuery(
{
role: 'JURY_MEMBER',
search: userSearch,
page: 1,
perPage: 20,
},
{ enabled: addMemberDialogOpen }
)
const { data: selfServiceData } = trpc.juryGroup.reviewSelfServiceValues.useQuery(
{ juryGroupId: groupId },
{ enabled: !!groupId }
)
const addMemberMutation = trpc.juryGroup.addMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: groupId })
utils.juryGroup.reviewSelfServiceValues.invalidate({ juryGroupId: groupId })
toast.success('Member added')
setAddMemberDialogOpen(false)
setSelectedUserId('')
setUserSearch('')
setMaxAssignmentsOverride('')
},
onError: (err) => toast.error(err.message),
})
const removeMemberMutation = trpc.juryGroup.removeMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: groupId })
utils.juryGroup.reviewSelfServiceValues.invalidate({ juryGroupId: groupId })
toast.success('Member removed')
},
onError: (err) => toast.error(err.message),
})
const updateMemberMutation = trpc.juryGroup.updateMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: groupId })
toast.success('Member updated')
},
onError: (err) => toast.error(err.message),
})
const updateGroupMutation = trpc.juryGroup.update.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: groupId })
utils.juryGroup.list.invalidate()
toast.success('Jury group updated')
},
onError: (err) => toast.error(err.message),
})
const handleAddMember = () => {
if (!selectedUserId) {
toast.error('Please select a user')
return
}
addMemberMutation.mutate({
juryGroupId: groupId,
userId: selectedUserId,
role: selectedRole,
maxAssignmentsOverride: maxAssignmentsOverride
? parseInt(maxAssignmentsOverride, 10)
: null,
})
}
const handleRemoveMember = (memberId: string) => {
if (!confirm('Remove this member from the jury group?')) return
removeMemberMutation.mutate({ id: memberId })
}
const handleRoleChange = (memberId: string, role: 'CHAIR' | 'MEMBER' | 'OBSERVER') => {
updateMemberMutation.mutate({ id: memberId, role })
}
if (loadingGroup || loadingCompetition) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-40 w-full" />
</CardContent>
</Card>
</div>
)
}
if (!group) {
return (
<div className="space-y-6">
<h1 className="text-xl font-bold">Jury Group Not Found</h1>
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-muted-foreground">The requested jury group could not be found.</p>
<Button asChild className="mt-4" variant="outline">
<Link href={'/admin/juries' as Route}>
Back to Juries
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="space-y-3">
<Button
variant="ghost"
size="sm"
asChild
className="mb-2"
>
<Link href={'/admin/juries' as Route}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back to Juries
</Link>
</Button>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">{group.name}</h1>
<Badge
variant="secondary"
className={cn('text-xs', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
>
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{competition?.name ?? 'Loading...'}
</p>
</div>
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="members" className="space-y-4">
<TabsList>
<TabsTrigger value="members">
<Users className="h-4 w-4 mr-1" />
Members
</TabsTrigger>
<TabsTrigger value="settings">
<Settings className="h-4 w-4 mr-1" />
Settings
</TabsTrigger>
</TabsList>
{/* Members Tab */}
<TabsContent value="members" className="space-y-4">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Members</CardTitle>
<CardDescription>
{group.members.length} member{group.members.length === 1 ? '' : 's'}
</CardDescription>
</div>
<Button size="sm" onClick={() => setAddMemberDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Member
</Button>
</div>
</CardHeader>
<CardContent>
{group.members.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No members yet. Add jury members to this group.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Cap Override</TableHead>
<TableHead>Availability</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.members.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-medium">
{member.user.name || 'Unnamed'}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{member.user.email}
</TableCell>
<TableCell>
<Select
value={member.role}
onValueChange={(value) =>
handleRoleChange(member.id, value as 'CHAIR' | 'MEMBER' | 'OBSERVER')
}
disabled={updateMemberMutation.isPending}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell>
{member.maxAssignmentsOverride ?? (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{member.availabilityNotes ? (
<span className="text-xs">{member.availabilityNotes}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveMember(member.id)}
disabled={removeMemberMutation.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-4">
<SettingsForm
group={group}
onSave={(data) => updateGroupMutation.mutate({ id: groupId, ...data })}
isPending={updateGroupMutation.isPending}
/>
{/* Self-Service Review Section */}
{selfServiceData && selfServiceData.members.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Self-Service Values</CardTitle>
<CardDescription>
Members who set their own capacity or ratio during onboarding
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead>Admin Cap</TableHead>
<TableHead>Self-Service Cap</TableHead>
<TableHead>Self-Service Ratio</TableHead>
<TableHead>Preferred Ratio</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{selfServiceData.members.map((m) => (
<TableRow key={m.id}>
<TableCell>
<div className="font-medium">{m.userName}</div>
<div className="text-xs text-muted-foreground">{m.userEmail}</div>
</TableCell>
<TableCell>
<Badge variant="outline">{m.role}</Badge>
</TableCell>
<TableCell>{m.adminCap}</TableCell>
<TableCell>
{m.selfServiceCap ?? <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell>
{m.selfServiceRatio !== null ? (
<span>{(m.selfServiceRatio * 100).toFixed(0)}%</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell>
{m.preferredStartupRatio !== null ? (
<span>{(m.preferredStartupRatio * 100).toFixed(0)}%</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
{/* Add Member Dialog */}
<Dialog open={addMemberDialogOpen} onOpenChange={setAddMemberDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add Member</DialogTitle>
<DialogDescription>
Search for a jury member to add to this group
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Search Users</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by name or email..."
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
{loadingUsers ? (
<div className="space-y-2">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
) : userSearchResults?.users && userSearchResults.users.length > 0 ? (
<div className="border rounded-md max-h-64 overflow-y-auto">
{userSearchResults.users.map((user) => (
<div
key={user.id}
className={cn(
'p-3 border-b last:border-b-0 cursor-pointer hover:bg-muted/50 transition-colors',
selectedUserId === user.id && 'bg-primary/10'
)}
onClick={() => setSelectedUserId(user.id)}
>
<div className="font-medium">{user.name || 'Unnamed'}</div>
<div className="text-sm text-muted-foreground">{user.email}</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-8">
No users found. Try a different search.
</p>
)}
<div className="space-y-2">
<Label>Role</Label>
<Select
value={selectedRole}
onValueChange={(v) => setSelectedRole(v as 'CHAIR' | 'MEMBER' | 'OBSERVER')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Max Assignments Override (optional)</Label>
<Input
type="number"
min="1"
placeholder={`Default: ${group.defaultMaxAssignments}`}
value={maxAssignmentsOverride}
onChange={(e) => setMaxAssignmentsOverride(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddMemberDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleAddMember} disabled={addMemberMutation.isPending || !selectedUserId}>
{addMemberMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Adding...
</>
) : (
'Add Member'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── Settings Form Component ─────────────────────────────────────────────────
type SettingsFormProps = {
group: any
onSave: (data: any) => void
isPending: boolean
}
function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
const [formData, setFormData] = useState({
name: group.name,
description: group.description || '',
defaultMaxAssignments: group.defaultMaxAssignments,
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSave(formData)
}
return (
<Card>
<CardHeader>
<CardTitle>General Settings</CardTitle>
<CardDescription>Configure jury group defaults and permissions</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Jury group name"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Optional description"
rows={3}
/>
</div>
<div className="space-y-2">
<Label>Default Max Assignments</Label>
<Input
type="number"
min="1"
max="50"
value={formData.defaultMaxAssignments}
onChange={(e) =>
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) || 15 })
}
/>
<p className="text-xs text-muted-foreground">
Suggested cap for new members. Per-member overrides and juror self-service preferences take priority.
</p>
</div>
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
{isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
Saving...
</>
) : (
'Save Settings'
)}
</Button>
</form>
</CardContent>
</Card>
)
}