Admin system overhaul: full round config UI, flattened navigation, juries, awards integration, evaluation rewrite
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m23s
- Phase 1: 7 round config sub-components covering all ~65 Zod schema fields across INTAKE, FILTERING, EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION - Phase 2: Replace Competitions nav with Rounds + add Juries; new /admin/rounds and /admin/rounds/[roundId] pages with tabbed detail (Config, Projects, Windows, Documents, Awards) - Phase 3: Top-level /admin/juries with list + detail pages (members table, settings panel, self-service review) - Phase 4: File requirements editor in round config; project detail per-requirement upload slots replacing generic drop zone - Phase 5: Awards edit page with source round dropdown, eligibility mode, auto-tag rules builder; round detail Awards tab; specialAward router enhanced with evaluationRoundId/eligibilityMode fields - Phase 6: Evaluation page rewrite supporting all 3 scoring modes (criteria/global/binary) with config-driven behavior; live voting UI polish - Phase 7: UI design polish across admin pages — consistent headers, cards, hover transitions, empty states, brand colors - Bulk upload page for admin project imports - File router enhanced with admin upload and submission window procedures Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
674
src/app/(admin)/admin/juries/[groupId]/page.tsx
Normal file
674
src/app/(admin)/admin/juries/[groupId]/page.tsx
Normal file
@@ -0,0 +1,674 @@
|
||||
'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 { Switch } from '@/components/ui/switch'
|
||||
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,
|
||||
defaultCapMode: group.defaultCapMode,
|
||||
softCapBuffer: group.softCapBuffer,
|
||||
categoryQuotasEnabled: group.categoryQuotasEnabled,
|
||||
allowJurorCapAdjustment: group.allowJurorCapAdjustment,
|
||||
allowJurorRatioAdjustment: group.allowJurorRatioAdjustment,
|
||||
})
|
||||
|
||||
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="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Default Max Assignments</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.defaultMaxAssignments}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Cap Mode</Label>
|
||||
<Select
|
||||
value={formData.defaultCapMode}
|
||||
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||
<SelectItem value="NONE">No Cap</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.defaultCapMode === 'SOFT' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Soft Cap Buffer</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.softCapBuffer}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Number of assignments allowed above the cap when in soft mode
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Category Quotas Enabled</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable category-based assignment quotas
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.categoryQuotasEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, categoryQuotasEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Allow Juror Cap Adjustment</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jurors to set their own assignment cap during onboarding
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.allowJurorCapAdjustment}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, allowJurorCapAdjustment: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Allow Juror Ratio Adjustment</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jurors to set their own startup/concept ratio during onboarding
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.allowJurorRatioAdjustment}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user