All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s
- 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>
590 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|