feat: add jury group import to special awards and fix juror dropdown
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m43s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m43s
The juror dropdown was always empty because the page requested perPage: 200 but the user.list API caps at 100 (Zod validation). Fixed to perPage: 100 with role filter for JURY_MEMBER/AWARD_MASTER. Added "Import from Jury Group" section to the awards juror tab: select a jury group, see members with checkboxes (already-assigned shown as disabled), bulk-add selected members via bulkAddJurors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -365,6 +366,8 @@ export default function AwardDetailPage({
|
|||||||
const [isPollingJob, setIsPollingJob] = useState(false)
|
const [isPollingJob, setIsPollingJob] = useState(false)
|
||||||
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState('')
|
||||||
|
const [selectedGroupMembers, setSelectedGroupMembers] = useState<Set<string>>(new Set())
|
||||||
const [includeSubmitted, setIncludeSubmitted] = useState(true)
|
const [includeSubmitted, setIncludeSubmitted] = useState(true)
|
||||||
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
|
const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false)
|
||||||
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
const [projectSearchQuery, setProjectSearchQuery] = useState('')
|
||||||
@@ -405,9 +408,17 @@ export default function AwardDetailPage({
|
|||||||
|
|
||||||
// Deferred queries - only load when needed
|
// Deferred queries - only load when needed
|
||||||
const { data: allUsers } = trpc.user.list.useQuery(
|
const { data: allUsers } = trpc.user.list.useQuery(
|
||||||
{ page: 1, perPage: 200 },
|
{ page: 1, perPage: 100, roles: ['JURY_MEMBER', 'AWARD_MASTER'] },
|
||||||
{ enabled: activeTab === 'jurors' }
|
{ enabled: activeTab === 'jurors' }
|
||||||
)
|
)
|
||||||
|
const { data: juryGroups } = trpc.juryGroup.list.useQuery(
|
||||||
|
{ competitionId: award?.competition?.id ?? '' },
|
||||||
|
{ enabled: activeTab === 'jurors' && !!award?.competition?.id }
|
||||||
|
)
|
||||||
|
const { data: selectedGroupDetail } = trpc.juryGroup.getById.useQuery(
|
||||||
|
{ id: selectedGroupId },
|
||||||
|
{ enabled: !!selectedGroupId }
|
||||||
|
)
|
||||||
const { data: allProjects } = trpc.project.list.useQuery(
|
const { data: allProjects } = trpc.project.list.useQuery(
|
||||||
{ programId: award?.programId ?? '', perPage: 200 },
|
{ programId: award?.programId ?? '', perPage: 200 },
|
||||||
{ enabled: !!award?.programId && addProjectDialogOpen }
|
{ enabled: !!award?.programId && addProjectDialogOpen }
|
||||||
@@ -486,6 +497,15 @@ export default function AwardDetailPage({
|
|||||||
},
|
},
|
||||||
onError: () => toast.error('Failed to update chair status'),
|
onError: () => toast.error('Failed to update chair status'),
|
||||||
})
|
})
|
||||||
|
const bulkAddJurors = trpc.specialAward.bulkAddJurors.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
utils.specialAward.listJurors.invalidate({ awardId })
|
||||||
|
toast.success(`${data.added} juror(s) added from jury group`)
|
||||||
|
setSelectedGroupId('')
|
||||||
|
setSelectedGroupMembers(new Set())
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
const bulkInvite = trpc.specialAward.bulkInviteJurors.useMutation({
|
const bulkInvite = trpc.specialAward.bulkInviteJurors.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
utils.specialAward.listJurors.invalidate({ awardId })
|
utils.specialAward.listJurors.invalidate({ awardId })
|
||||||
@@ -1337,6 +1357,137 @@ export default function AwardDetailPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Import from Jury Group */}
|
||||||
|
{juryGroups && juryGroups.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
Import from Jury Group
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select a jury group and pick which members to add as jurors for this award.
|
||||||
|
</p>
|
||||||
|
<Select
|
||||||
|
value={selectedGroupId}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
setSelectedGroupId(val)
|
||||||
|
setSelectedGroupMembers(new Set())
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-80">
|
||||||
|
<SelectValue placeholder="Select a jury group..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{juryGroups.map((g) => (
|
||||||
|
<SelectItem key={g.id} value={g.id}>
|
||||||
|
{g.name} ({g._count.members} members)
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{selectedGroupDetail && selectedGroupDetail.members.length > 0 && (() => {
|
||||||
|
const jurorUserIds = new Set(jurors?.map((j) => j.userId) || [])
|
||||||
|
const addableMembers = selectedGroupDetail.members.filter(
|
||||||
|
(m) => !jurorUserIds.has(m.user.id)
|
||||||
|
)
|
||||||
|
const alreadyAdded = selectedGroupDetail.members.filter(
|
||||||
|
(m) => jurorUserIds.has(m.user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardDescription>
|
||||||
|
{addableMembers.length} available to add
|
||||||
|
{alreadyAdded.length > 0 && ` · ${alreadyAdded.length} already assigned`}
|
||||||
|
</CardDescription>
|
||||||
|
{addableMembers.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedGroupMembers.size === addableMembers.length) {
|
||||||
|
setSelectedGroupMembers(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedGroupMembers(new Set(addableMembers.map((m) => m.user.id)))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedGroupMembers.size === addableMembers.length ? 'Deselect All' : 'Select All'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-3 space-y-2">
|
||||||
|
{addableMembers.map((m) => (
|
||||||
|
<label
|
||||||
|
key={m.user.id}
|
||||||
|
className="flex items-center gap-3 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedGroupMembers.has(m.user.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const next = new Set(selectedGroupMembers)
|
||||||
|
if (checked) next.add(m.user.id)
|
||||||
|
else next.delete(m.user.id)
|
||||||
|
setSelectedGroupMembers(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{m.user.name || 'Unnamed'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{m.user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="ml-auto text-xs">
|
||||||
|
{m.role}
|
||||||
|
</Badge>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{alreadyAdded.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.user.id}
|
||||||
|
className="flex items-center gap-3 rounded-md p-2 opacity-50"
|
||||||
|
>
|
||||||
|
<Checkbox checked disabled />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium">{m.user.name || 'Unnamed'}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{m.user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="ml-auto text-xs">
|
||||||
|
Already added
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{addableMembers.length > 0 && (
|
||||||
|
<Button
|
||||||
|
className="w-full mt-2"
|
||||||
|
disabled={selectedGroupMembers.size === 0 || bulkAddJurors.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
bulkAddJurors.mutate({
|
||||||
|
awardId,
|
||||||
|
userIds: Array.from(selectedGroupMembers),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bulkAddJurors.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Add {selectedGroupMembers.size} Selected Juror{selectedGroupMembers.size !== 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium">Invite New Jurors by Email</h3>
|
<h3 className="text-sm font-medium">Invite New Jurors by Email</h3>
|
||||||
|
|||||||
Reference in New Issue
Block a user