Compare commits

..

No commits in common. "f731f96a0a37e5caf8cef2e76a46b37e4fbadd8c" and "014bb15890089d1601a25b7b1c857d024e37ecbc" have entirely different histories.

13 changed files with 705 additions and 1415 deletions

File diff suppressed because it is too large Load Diff

View File

@ -58,7 +58,6 @@ import {
export default function MemberDetailPage() {
const params = useParams()
const router = useRouter()
const utils = trpc.useUtils()
const userId = params.id as string
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
@ -104,8 +103,6 @@ export default function MemberDetailPage() {
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
})
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success('Member updated successfully')
router.push('/admin/members')
} catch (error) {
@ -118,7 +115,6 @@ export default function MemberDetailPage() {
await sendInvitation.mutateAsync({ userId })
toast.success('Invitation email sent successfully')
refetch()
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
}

View File

@ -78,34 +78,16 @@ import {
ArrowRight,
RotateCcw,
X,
Check,
ChevronsUpDown,
Search,
} from 'lucide-react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
import { AnimatedCard } from '@/components/shared/animated-container'
import { DateTimePicker } from '@/components/ui/datetime-picker'
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
import { motion } from 'motion/react'
@ -178,7 +160,6 @@ export default function RoundDetailPage() {
const [createJuryOpen, setCreateJuryOpen] = useState(false)
const [newJuryName, setNewJuryName] = useState('')
const [addMemberOpen, setAddMemberOpen] = useState(false)
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
const utils = trpc.useUtils()
@ -245,16 +226,8 @@ export default function RoundDetailPage() {
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round closed')
if (closeAndAdvance) {
setCloseAndAdvance(false)
// Small delay to let cache invalidation complete before opening dialog
setTimeout(() => setAdvanceDialogOpen(true), 300)
}
},
onError: (err) => {
setCloseAndAdvance(false)
toast.error(err.message)
},
onError: (err) => toast.error(err.message),
})
const reopenMutation = trpc.roundEngine.reopen.useMutation({
@ -376,8 +349,6 @@ export default function RoundDetailPage() {
const isFiltering = round?.roundType === 'FILTERING'
const isEvaluation = round?.roundType === 'EVALUATION'
const hasJury = ['EVALUATION', 'LIVE_FINAL', 'DELIBERATION'].includes(round?.roundType ?? '')
const hasAwards = hasJury
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
@ -435,7 +406,7 @@ export default function RoundDetailPage() {
action: projectCount === 0 ? poolLink : undefined,
actionLabel: 'Assign Projects',
},
...(hasJury
...((isEvaluation || isFiltering)
? [{
label: 'Jury group set',
ready: !!juryGroup,
@ -582,7 +553,7 @@ export default function RoundDetailPage() {
</motion.div>
{/* ===== STATS BAR — Accent-bordered cards ===== */}
<div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
{/* Projects */}
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
@ -605,8 +576,7 @@ export default function RoundDetailPage() {
</Card>
</AnimatedCard>
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
{hasJury && (
{/* Jury (with inline group selector) */}
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
@ -653,10 +623,9 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Window */}
<AnimatedCard index={hasJury ? 2 : 1}>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
@ -689,7 +658,7 @@ export default function RoundDetailPage() {
</AnimatedCard>
{/* Advancement */}
<AnimatedCard index={hasJury ? 3 : 2}>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2.5">
@ -725,9 +694,10 @@ export default function RoundDetailPage() {
{ value: 'projects', label: 'Projects', icon: Layers },
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
...(hasJury ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
{ value: 'jury', label: 'Jury', icon: Users },
{ value: 'config', label: 'Config', icon: Settings },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
{ value: 'windows', label: 'Submissions', icon: Clock },
{ value: 'awards', label: 'Awards', icon: Trophy },
].map((tab) => (
<TabsTrigger
key={tab.value}
@ -966,56 +936,25 @@ export default function RoundDetailPage() {
</div>
</button>
{/* Advance projects (always visible when projects exist) */}
{projectCount > 0 && (
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
<button
onClick={() => passedCount > 0
? setAdvanceDialogOpen(true)
: toast.info('Mark projects as "Passed" first in the Projects tab')}
className={cn(
'flex items-start gap-3 p-4 rounded-lg border hover:-translate-y-0.5 hover:shadow-md transition-all text-left',
passedCount > 0
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
: 'border-dashed opacity-60',
)}
onClick={() => setAdvanceDialogOpen(true)}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 bg-emerald-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', passedCount > 0 ? 'text-emerald-600' : 'text-muted-foreground')} />
<ArrowRight className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
{passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
Move {passedCount} passed project(s) to the next round
</p>
</div>
{passedCount > 0 && (
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
)}
</button>
)}
{/* Close & Advance (active rounds with passed projects) */}
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
<button
onClick={() => {
setCloseAndAdvance(true)
closeMutation.mutate({ roundId })
}}
disabled={isTransitioning}
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
>
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Close & Advance</p>
<p className="text-xs text-muted-foreground mt-0.5">
Close this round and advance {passedCount} passed project(s) to the next round
</p>
</div>
</button>
)}
{/* Jury assignment for rounds that use jury */}
{hasJury && !juryGroup && (
{/* Jury assignment for evaluation/filtering */}
{(isEvaluation || isFiltering) && !juryGroup && (
<button
onClick={() => {
const el = document.querySelector('[data-jury-select]')
@ -1101,13 +1040,6 @@ export default function RoundDetailPage() {
projectStates={projectStates}
config={config}
advanceMutation={advanceMutation}
competitionRounds={competition?.rounds?.map((r: any) => ({
id: r.id,
name: r.name,
sortOrder: r.sortOrder,
roundType: r.roundType,
}))}
currentSortOrder={round?.sortOrder}
/>
{/* AI Shortlist Confirmation Dialog */}
@ -1239,7 +1171,6 @@ export default function RoundDetailPage() {
)}
{/* ═══════════ JURY TAB ═══════════ */}
{hasJury && (
<TabsContent value="jury" className="space-y-6">
{/* Jury Group Selector + Create */}
<Card>
@ -1480,7 +1411,6 @@ export default function RoundDetailPage() {
/>
)}
</TabsContent>
)}
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
{isEvaluation && (
@ -1546,7 +1476,7 @@ export default function RoundDetailPage() {
</div>
{/* Individual Assignments Table */}
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
<IndividualAssignmentsTable roundId={roundId} />
{/* Unassigned Queue */}
<RoundUnassignedQueue roundId={roundId} />
@ -1565,38 +1495,6 @@ export default function RoundDetailPage() {
{/* ═══════════ CONFIG TAB ═══════════ */}
<TabsContent value="config" className="space-y-6">
{/* Round Dates */}
<Card>
<CardHeader className="border-b">
<CardTitle className="text-base">Round Dates</CardTitle>
<CardDescription>
When this round starts and ends. Defines the active period for document uploads and evaluations.
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Start Date</Label>
<DateTimePicker
value={round.windowOpenAt ? new Date(round.windowOpenAt) : null}
onChange={(date) => updateMutation.mutate({ id: roundId, windowOpenAt: date })}
placeholder="Select start date & time"
clearable
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<DateTimePicker
value={round.windowCloseAt ? new Date(round.windowCloseAt) : null}
onChange={(date) => updateMutation.mutate({ id: roundId, windowCloseAt: date })}
placeholder="Select end date & time"
clearable
/>
</div>
</div>
</CardContent>
</Card>
{/* General Round Settings */}
<Card>
<CardHeader className="border-b">
@ -1735,8 +1633,12 @@ export default function RoundDetailPage() {
</Card>
</TabsContent>
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
<TabsContent value="windows" className="space-y-4">
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
</TabsContent>
{/* ═══════════ AWARDS TAB ═══════════ */}
{hasAwards && (
<TabsContent value="awards" className="space-y-4">
<Card>
<CardContent className="p-6">
@ -1805,7 +1707,6 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</div>
)
@ -2068,18 +1969,10 @@ function ExportEvaluationsDialog({
// ── Individual Assignments Table ─────────────────────────────────────────
function IndividualAssignmentsTable({
roundId,
projectStates,
}: {
roundId: string
projectStates: any[] | undefined
}) {
function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [selectedJurorId, setSelectedJurorId] = useState('')
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
const [newUserId, setNewUserId] = useState('')
const [newProjectId, setNewProjectId] = useState('')
const utils = trpc.useUtils()
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
@ -2087,15 +1980,9 @@ function IndividualAssignmentsTable({
{ refetchInterval: 15_000 },
)
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
{ roundId },
{ enabled: addDialogOpen },
)
const deleteMutation = trpc.assignment.delete.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success('Assignment removed')
},
onError: (err) => toast.error(err.message),
@ -2104,101 +1991,13 @@ function IndividualAssignmentsTable({
const createMutation = trpc.assignment.create.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success('Assignment created')
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
onSuccess: (result) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success(`${result.created} assignment(s) created`)
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const resetDialog = useCallback(() => {
setAddDialogOpen(false)
setSelectedJurorId('')
setSelectedProjectIds(new Set())
setProjectSearch('')
}, [])
const selectedJuror = useMemo(
() => juryMembers?.find((j: any) => j.id === selectedJurorId),
[juryMembers, selectedJurorId],
)
// Filter projects by search term
const filteredProjects = useMemo(() => {
const items = projectStates ?? []
if (!projectSearch) return items
const q = projectSearch.toLowerCase()
return items.filter((ps: any) =>
ps.project?.title?.toLowerCase().includes(q) ||
ps.project?.teamName?.toLowerCase().includes(q) ||
ps.project?.competitionCategory?.toLowerCase().includes(q)
)
}, [projectStates, projectSearch])
// Existing assignments for the selected juror (to grey out already-assigned projects)
const jurorExistingProjectIds = useMemo(() => {
if (!selectedJurorId || !assignments) return new Set<string>()
return new Set(
assignments
.filter((a: any) => a.userId === selectedJurorId)
.map((a: any) => a.projectId)
)
}, [selectedJurorId, assignments])
const toggleProject = useCallback((projectId: string) => {
setSelectedProjectIds(prev => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
setNewUserId('')
setNewProjectId('')
},
onError: (err) => toast.error(err.message),
})
}, [])
const selectAllUnassigned = useCallback(() => {
const unassigned = filteredProjects
.filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
.map((ps: any) => ps.project?.id)
.filter(Boolean)
setSelectedProjectIds(new Set(unassigned))
}, [filteredProjects, jurorExistingProjectIds])
const handleCreate = useCallback(() => {
if (!selectedJurorId || selectedProjectIds.size === 0) return
const projectIds = Array.from(selectedProjectIds)
if (projectIds.length === 1) {
createMutation.mutate({
userId: selectedJurorId,
projectId: projectIds[0],
roundId,
})
} else {
bulkCreateMutation.mutate({
roundId,
assignments: projectIds.map(projectId => ({
userId: selectedJurorId,
projectId,
})),
})
}
}, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
return (
<Card>
@ -2272,220 +2071,44 @@ function IndividualAssignmentsTable({
</CardContent>
{/* Add Assignment Dialog */}
<Dialog open={addDialogOpen} onOpenChange={(open) => {
if (!open) resetDialog()
else setAddDialogOpen(true)
}}>
<DialogContent className="sm:max-w-[540px]">
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Assignment</DialogTitle>
<DialogDescription>
Select a juror and one or more projects to assign
Manually assign a juror to evaluate a project
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Juror Selector */}
<div className="space-y-2">
<Label className="text-sm font-medium">Juror</Label>
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={jurorPopoverOpen}
className="w-full justify-between font-normal"
>
{selectedJuror
? (
<span className="flex items-center gap-2 truncate">
<span className="truncate">{selectedJuror.name || selectedJuror.email}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">
{selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
</Badge>
</span>
)
: <span className="text-muted-foreground">Select a jury member...</span>
}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[480px] p-0" align="start">
<Command>
<CommandInput placeholder="Search by name or email..." />
<CommandList>
<CommandEmpty>No jury members found.</CommandEmpty>
<CommandGroup>
{juryMembers?.map((juror: any) => {
const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
return (
<CommandItem
key={juror.id}
value={`${juror.name ?? ''} ${juror.email}`}
disabled={atCapacity}
onSelect={() => {
setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
setSelectedProjectIds(new Set())
setJurorPopoverOpen(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedJurorId === juror.id ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{juror.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground truncate">
{juror.email}
</p>
</div>
<Badge
variant={atCapacity ? 'destructive' : 'secondary'}
className="text-[10px] ml-2 shrink-0"
>
{juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
{atCapacity ? ' full' : ''}
</Badge>
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Project Multi-Select */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Projects
{selectedProjectIds.size > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">
({selectedProjectIds.size} selected)
</span>
)}
</Label>
{selectedJurorId && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={selectAllUnassigned}
>
Select all
</Button>
{selectedProjectIds.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedProjectIds(new Set())}
>
Clear
</Button>
)}
</div>
)}
</div>
{/* Search input */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Label className="text-sm">Juror User ID</Label>
<Input
placeholder="Filter projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-9 h-9"
placeholder="Enter jury member user ID..."
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
/>
</div>
{/* Project checklist */}
<ScrollArea className="h-[240px] rounded-md border">
<div className="p-2 space-y-0.5">
{!selectedJurorId ? (
<p className="text-sm text-muted-foreground text-center py-8">
Select a juror first
</p>
) : filteredProjects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No projects found
</p>
) : (
filteredProjects.map((ps: any) => {
const project = ps.project
if (!project) return null
const alreadyAssigned = jurorExistingProjectIds.has(project.id)
const isSelected = selectedProjectIds.has(project.id)
return (
<label
key={project.id}
className={cn(
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
alreadyAssigned
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-accent'
: 'hover:bg-muted/50',
)}
>
<Checkbox
checked={isSelected}
disabled={alreadyAssigned}
onCheckedChange={() => toggleProject(project.id)}
<div className="space-y-2">
<Label className="text-sm">Project ID</Label>
<Input
placeholder="Enter project ID..."
value={newProjectId}
onChange={(e) => setNewProjectId(e.target.value)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="truncate">{project.title}</span>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px]">
{project.competitionCategory === 'STARTUP'
? 'Startup'
: project.competitionCategory === 'BUSINESS_CONCEPT'
? 'Concept'
: project.competitionCategory}
</Badge>
)}
{alreadyAssigned && (
<Badge variant="secondary" className="text-[10px]">
Assigned
</Badge>
)}
</div>
</div>
</label>
)
})
)}
</div>
</ScrollArea>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetDialog}>
Cancel
</Button>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleCreate}
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
onClick={() => createMutation.mutate({
userId: newUserId,
projectId: newProjectId,
roundId,
})}
disabled={!newUserId || !newProjectId || createMutation.isPending}
>
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedProjectIds.size <= 1
? 'Create Assignment'
: `Create ${selectedProjectIds.size} Assignments`
}
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create Assignment
</Button>
</DialogFooter>
</DialogContent>
@ -2505,31 +2128,14 @@ function AdvanceProjectsDialog({
projectStates,
config,
advanceMutation,
competitionRounds,
currentSortOrder,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
projectStates: any[] | undefined
config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string }) => void; isPending: boolean }
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
currentSortOrder?: number
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
}) {
// Target round selector
const availableTargets = useMemo(() =>
(competitionRounds ?? [])
.filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
.sort((a, b) => a.sortOrder - b.sortOrder),
[competitionRounds, currentSortOrder, roundId])
const [targetRoundId, setTargetRoundId] = useState<string>('')
// Default to first available target when dialog opens
if (open && !targetRoundId && availableTargets.length > 0) {
setTargetRoundId(availableTargets[0].id)
}
const passedProjects = useMemo(() =>
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
[projectStates])
@ -2588,20 +2194,14 @@ function AdvanceProjectsDialog({
const handleAdvance = () => {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({
roundId,
projectIds: ids,
...(targetRoundId ? { targetRoundId } : {}),
})
advanceMutation.mutate({ roundId, projectIds: ids })
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const handleClose = () => {
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const renderCategorySection = (
@ -2663,35 +2263,11 @@ function AdvanceProjectsDialog({
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select which passed projects to advance.
Select which passed projects to advance to the next round.
{selected.size} of {passedProjects.length} selected.
</DialogDescription>
</DialogHeader>
{/* Target round selector */}
{availableTargets.length > 0 && (
<div className="space-y-2 pb-2 border-b">
<Label className="text-sm">Advance to</Label>
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select target round" />
</SelectTrigger>
<SelectContent>
{availableTargets.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{availableTargets.length === 0 && (
<div className="text-sm text-amber-600 bg-amber-50 rounded-md p-3">
No subsequent rounds found. Projects will advance to the next round by sort order.
</div>
)}
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}

View File

@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
// Categories that only super admins can access
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({

View File

@ -45,8 +45,6 @@ export function AssignmentPreviewSheet({
toast.success(`Created ${result.created} assignments`)
utils.roundAssignment.coverageReport.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
onOpenChange(false)
},
onError: (err) => {

View File

@ -88,7 +88,6 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
onSuccess: (result) => {
toast.success(`Invitation sent to ${result.email}`)
utils.user.list.invalidate()
},
onError: (err) => {
// Don't block — user was created and added, just invitation failed

View File

@ -198,8 +198,6 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
onSuccess: (data) => {
utils.filtering.getResults.invalidate()
utils.filtering.getResultStats.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.project.list.invalidate()
toast.success(
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')

View File

@ -250,7 +250,6 @@ export function UserMobileActions({
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {

View File

@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal, Info } from 'lucide-react'
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -67,10 +67,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
},
})
const watchProvider = form.watch('ai_provider')
const isLiteLLM = watchProvider === 'litellm'
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
// Fetch available models from OpenAI API
const {
data: modelsData,
isLoading: modelsLoading,
@ -79,7 +76,6 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
} = trpc.settings.listAIModels.useQuery(undefined, {
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
retry: false,
enabled: !isLiteLLM,
})
const updateSettings = trpc.settings.updateMultiple.useMutation({
@ -186,50 +182,32 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
</SelectContent>
</Select>
<FormDescription>
{field.value === 'litellm'
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
: 'Direct OpenAI API access using your API key'}
AI provider for smart assignment suggestions
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{isLiteLLM && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong>LiteLLM Proxy Mode</strong> AI calls will be routed through your LiteLLM proxy
using your ChatGPT subscription. Token limits are automatically stripped (not supported by ChatGPT backend).
Make sure your LiteLLM proxy is running and accessible.
</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="openai_api_key"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder={isLiteLLM
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
placeholder={settings.openai_api_key ? '••••••••' : 'Enter API key'}
{...field}
/>
</FormControl>
<FormDescription>
{isLiteLLM
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.'
: 'Your OpenAI API key. Leave blank to keep the existing key.'}
Your OpenAI API key. Leave blank to keep the existing key.
</FormDescription>
<FormMessage />
</FormItem>
@ -241,26 +219,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
name="openai_base_url"
render={({ field }) => (
<FormItem>
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
<FormLabel>API Base URL (Optional)</FormLabel>
<FormControl>
<Input
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
placeholder="https://api.openai.com/v1"
{...field}
/>
</FormControl>
<FormDescription>
{isLiteLLM ? (
<>
URL of your LiteLLM proxy. Typically{' '}
<code className="text-xs bg-muted px-1 rounded">http://localhost:4000</code>{' '}
or your server address.
</>
) : (
<>
Custom base URL for OpenAI-compatible providers. Leave blank for OpenAI.
Use <code className="text-xs bg-muted px-1 rounded">https://openrouter.ai/api/v1</code> for OpenRouter.
</>
)}
Use <code className="text-xs bg-muted px-1 rounded">https://openrouter.ai/api/v1</code> for OpenRouter (access Claude, Gemini, Llama, etc.)
</FormDescription>
<FormMessage />
</FormItem>
@ -274,7 +242,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Model</FormLabel>
{!isLiteLLM && modelsData?.success && !modelsData?.manualEntry && (
{modelsData?.success && (
<Button
type="button"
variant="ghost"
@ -288,13 +256,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)}
</div>
{isLiteLLM || modelsData?.manualEntry ? (
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="chatgpt/gpt-5.2"
/>
) : modelsLoading ? (
{modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsError || !modelsData?.success ? (
<div className="space-y-2">
@ -341,15 +303,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Select>
)}
<FormDescription>
{isLiteLLM ? (
<>
Enter the model ID with the{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.
Examples:{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/gpt-5.2</code>,{' '}
<code className="text-xs bg-muted px-1 rounded">chatgpt/gpt-5.2-codex</code>
</>
) : form.watch('ai_model')?.startsWith('o') ? (
{form.watch('ai_model')?.startsWith('o') ? (
<span className="flex items-center gap-1 text-purple-600">
<SlidersHorizontal className="h-3 w-3" />
Reasoning model - optimized for complex analysis tasks

View File

@ -25,7 +25,6 @@ import {
ShieldAlert,
Globe,
Webhook,
MessageCircle,
} from 'lucide-react'
import Link from 'next/link'
import { AnimatedCard } from '@/components/shared/animated-container'
@ -104,12 +103,8 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
])
const storageSettings = getSettingsByKeys([
'storage_provider',
'local_storage_path',
'max_file_size_mb',
'avatar_max_size_mb',
'allowed_file_types',
'allowed_image_types',
])
const securitySettings = getSettingsByKeys([
@ -152,11 +147,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
'anomaly_off_hours_end',
])
const whatsappSettings = getSettingsByKeys([
'whatsapp_enabled',
'whatsapp_provider',
])
const localizationSettings = getSettingsByKeys([
'localization_enabled_locales',
'localization_default_locale',
@ -193,12 +183,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Newspaper className="h-4 w-4" />
Digest
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="whatsapp" className="gap-2 shrink-0">
<MessageCircle className="h-4 w-4" />
WhatsApp
</TabsTrigger>
)}
{isSuperAdmin && (
<TabsTrigger value="security" className="gap-2 shrink-0">
<Shield className="h-4 w-4" />
@ -275,12 +259,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<Newspaper className="h-4 w-4" />
Digest
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="whatsapp" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<MessageCircle className="h-4 w-4" />
WhatsApp
</TabsTrigger>
)}
</TabsList>
</div>
<div>
@ -524,24 +502,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</Card>
</AnimatedCard>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="whatsapp" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>WhatsApp Notifications</CardTitle>
<CardDescription>
Configure WhatsApp messaging for notifications
</CardDescription>
</CardHeader>
<CardContent>
<WhatsAppSettingsSection settings={whatsappSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
</div>{/* end content area */}
</div>{/* end lg:flex */}
</Tabs>
@ -834,29 +794,6 @@ function AuditSettingsSection({ settings }: { settings: Record<string, string> }
)
}
function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
return (
<div className="space-y-4">
<SettingToggle
label="Enable WhatsApp Notifications"
description="Send notifications via WhatsApp in addition to email"
settingKey="whatsapp_enabled"
value={settings.whatsapp_enabled || 'false'}
/>
<SettingSelect
label="WhatsApp Provider"
description="Select the API provider for sending WhatsApp messages"
settingKey="whatsapp_provider"
value={settings.whatsapp_provider || 'META'}
options={[
{ value: 'META', label: 'Meta (WhatsApp Business API)' },
{ value: 'TWILIO', label: 'Twilio' },
]}
/>
</div>
)
}
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
const mutation = useSettingsMutation()
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')

View File

@ -22,14 +22,6 @@ import {
} from '@/components/ui/form'
// Note: Storage provider cache is cleared server-side when settings are updated
const COMMON_IMAGE_TYPES = [
{ value: 'image/png', label: 'PNG (.png)' },
{ value: 'image/jpeg', label: 'JPEG (.jpg, .jpeg)' },
{ value: 'image/webp', label: 'WebP (.webp)' },
{ value: 'image/gif', label: 'GIF (.gif)' },
{ value: 'image/svg+xml', label: 'SVG (.svg)' },
]
const COMMON_FILE_TYPES = [
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' },
@ -49,7 +41,6 @@ const formSchema = z.object({
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'),
allowed_image_types: z.array(z.string()).min(1, 'Select at least one image type'),
})
type FormValues = z.infer<typeof formSchema>
@ -61,7 +52,6 @@ interface StorageSettingsFormProps {
max_file_size_mb?: string
avatar_max_size_mb?: string
allowed_file_types?: string
allowed_image_types?: string
}
}
@ -78,16 +68,6 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
}
// Parse allowed image types from JSON string
let allowedImageTypes: string[] = []
try {
allowedImageTypes = settings.allowed_image_types
? JSON.parse(settings.allowed_image_types)
: ['image/png', 'image/jpeg', 'image/webp']
} catch {
allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp']
}
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
@ -96,7 +76,6 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
max_file_size_mb: settings.max_file_size_mb || '500',
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
allowed_file_types: allowedTypes,
allowed_image_types: allowedImageTypes,
},
})
@ -120,7 +99,6 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
{ key: 'max_file_size_mb', value: data.max_file_size_mb },
{ key: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
{ key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) },
{ key: 'allowed_image_types', value: JSON.stringify(data.allowed_image_types) },
],
})
}
@ -277,57 +255,6 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
)}
/>
<FormField
control={form.control}
name="allowed_image_types"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel>Allowed Image Types (Avatars/Logos)</FormLabel>
<FormDescription>
Select which image formats can be used for profile pictures and project logos
</FormDescription>
</div>
<div className="grid gap-3 md:grid-cols-2">
{COMMON_IMAGE_TYPES.map((type) => (
<FormField
key={type.value}
control={form.control}
name="allowed_image_types"
render={({ field }) => {
return (
<FormItem
key={type.value}
className="flex items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(type.value)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, type.value])
: field.onChange(
field.value?.filter(
(value) => value !== type.value
)
)
}}
/>
</FormControl>
<FormLabel className="cursor-pointer text-sm font-normal">
{type.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
</div>
<FormMessage />
</FormItem>
)}
/>
{storageProvider === 's3' && (
<div className="rounded-lg border border-muted bg-muted/50 p-4">
<p className="text-sm text-muted-foreground">

View File

@ -8,33 +8,6 @@ const globalForOpenAI = globalThis as unknown as {
openaiInitialized: boolean
}
// ─── Provider Detection ─────────────────────────────────────────────────────
/**
* Get the configured AI provider from SystemSettings.
* Returns 'openai' (default) or 'litellm' (ChatGPT subscription proxy).
*/
export async function getConfiguredProvider(): Promise<'openai' | 'litellm'> {
try {
const setting = await prisma.systemSettings.findUnique({
where: { key: 'ai_provider' },
})
const value = setting?.value || 'openai'
return value === 'litellm' ? 'litellm' : 'openai'
} catch {
return 'openai'
}
}
/**
* Check if a model ID indicates LiteLLM ChatGPT subscription routing.
* Models like 'chatgpt/gpt-5.2' use the chatgpt/ prefix.
* Used by buildCompletionParams (sync) to strip unsupported token limit fields.
*/
export function isLiteLLMChatGPTModel(model: string): boolean {
return model.toLowerCase().startsWith('chatgpt/')
}
// ─── Model Type Detection ────────────────────────────────────────────────────
/**
@ -195,12 +168,6 @@ export function buildCompletionParams(
params.response_format = { type: 'json_object' }
}
// LiteLLM ChatGPT subscription models reject token limit fields
if (isLiteLLMChatGPTModel(model)) {
delete params.max_tokens
delete params.max_completion_tokens
}
return params
}
@ -242,12 +209,8 @@ async function getBaseURL(): Promise<string | undefined> {
*/
async function createOpenAIClient(): Promise<OpenAI | null> {
const apiKey = await getOpenAIApiKey()
const provider = await getConfiguredProvider()
// LiteLLM proxy may not require a real API key
const effectiveApiKey = apiKey || (provider === 'litellm' ? 'sk-litellm' : null)
if (!effectiveApiKey) {
if (!apiKey) {
console.warn('OpenAI API key not configured')
return null
}
@ -255,11 +218,11 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
const baseURL = await getBaseURL()
if (baseURL) {
console.log(`[OpenAI] Using custom base URL: ${baseURL} (provider: ${provider})`)
console.log(`[OpenAI] Using custom base URL: ${baseURL}`)
}
return new OpenAI({
apiKey: effectiveApiKey,
apiKey,
...(baseURL ? { baseURL } : {}),
})
}
@ -296,12 +259,6 @@ export function resetOpenAIClient(): void {
* Check if OpenAI is configured and available
*/
export async function isOpenAIConfigured(): Promise<boolean> {
const provider = await getConfiguredProvider()
if (provider === 'litellm') {
// LiteLLM just needs a base URL configured
const baseURL = await getBaseURL()
return !!baseURL
}
const apiKey = await getOpenAIApiKey()
return !!apiKey
}
@ -313,20 +270,8 @@ export async function listAvailableModels(): Promise<{
success: boolean
models?: string[]
error?: string
manualEntry?: boolean
}> {
try {
const provider = await getConfiguredProvider()
// LiteLLM proxy for ChatGPT subscription doesn't support models.list()
if (provider === 'litellm') {
return {
success: true,
models: [],
manualEntry: true,
}
}
const client = await getOpenAI()
if (!client) {

View File

@ -201,8 +201,8 @@ export const settingsRouter = router({
clearStorageProviderCache()
}
// Reset OpenAI client if API key, base URL, model, or provider changed
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) {
// Reset OpenAI client if API key or base URL changed
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model')) {
const { resetOpenAIClient } = await import('@/lib/openai')
resetOpenAIClient()
}
@ -247,15 +247,6 @@ export const settingsRouter = router({
listAIModels: superAdminProcedure.query(async () => {
const result = await listAvailableModels()
// LiteLLM mode: manual model entry, no listing available
if (result.manualEntry) {
return {
success: true,
models: [],
manualEntry: true,
}
}
if (!result.success || !result.models) {
return {
success: false,