Compare commits

...

5 Commits

Author SHA1 Message Date
Matt f731f96a0a Hide jury stat card in header for non-jury rounds (INTAKE, FILTERING, etc.)
Build and Push Docker Image / build (push) Failing after 8s Details
The jury selector card in the stats bar was still visible on round types
where juries don't apply. Now conditionally rendered based on hasJury,
with the grid adjusting from 4 to 3 columns accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 18:33:08 +01:00
Matt 09049d2911 Round management: tab cleanup, date pickers, advancement workflow
- Remove Document Windows tab (round dates + file requirements in
  Config are sufficient, separate SubmissionWindow was redundant)
- Restrict Jury and Awards tabs to round types that use them
  (EVALUATION, LIVE_FINAL, DELIBERATION only)
- Add Round Dates card in Config tab with DateTimePicker for
  start/end dates (supports past and future dates)
- Make Advance Projects button always visible when projects exist
  (dimmed with guidance when no projects are PASSED yet)
- Add Close & Advance combined quick action to streamline round
  progression workflow
- Add target round selector to Advance Projects dialog so admin
  can pick which round to advance projects into

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:43:23 +01:00
Matt 3fb0d128a1 Fix missing query invalidations across member management
Add utils.user.list.invalidate() after mutations that change user
status to ensure member lists refresh without manual page reload:
- Member detail page: after update and send invitation
- User mobile actions: after send invitation
- Add member dialog: after send invitation in jury group flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:16:23 +01:00
Matt 5965f7889d Platform-wide UX fixes: assignment dialog, invalidation, settings, dashboard
1. Assignment dialog overhaul: replace raw UUID inputs with searchable
   juror Combobox (shows name, email, capacity) and multi-select project
   checklist with bulk assignment support

2. Query invalidation sweep: fix missing invalidations in
   assignment-preview-sheet (roundAssignment.execute) and
   filtering-dashboard (filtering.finalizeResults) so data refreshes
   without page reload

3. Rename Submissions tab to Document Windows with descriptive
   header explaining upload window configuration

4. Connect 6 disconnected settings: storage_provider, local_storage_path,
   avatar_max_size_mb, allowed_image_types, whatsapp_enabled,
   whatsapp_provider - all now accessible in Settings UI

5. Admin dashboard redesign: branded Editorial Command Center with
   Dark Blue gradient header, colored border-l-4 stat cards, staggered
   animations, 2-column layout, action-required panel, activity timeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:05:25 +01:00
Matt b2279067e2 Add LiteLLM proxy support for ChatGPT subscription AI access
- Add ai_provider setting: 'openai' (API key) or 'litellm' (ChatGPT subscription proxy)
- Auto-strip max_tokens/max_completion_tokens for chatgpt/ prefix models
  (ChatGPT subscription backend rejects token limit fields)
- LiteLLM mode: dummy API key when none configured, base URL required
- isOpenAIConfigured() checks base URL instead of API key for LiteLLM
- listAvailableModels() returns manualEntry flag for LiteLLM (no models.list)
- Settings UI: conditional fields, info banner, manual model input with
  chatgpt/ prefix examples when LiteLLM selected
- All 7 AI services work transparently via buildCompletionParams()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:48:34 +01:00
13 changed files with 1414 additions and 704 deletions

File diff suppressed because it is too large Load Diff

View File

@ -58,6 +58,7 @@ 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 })
@ -103,6 +104,8 @@ 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) {
@ -115,6 +118,7 @@ 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,16 +78,34 @@ 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'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
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'
@ -160,6 +178,7 @@ 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()
@ -226,8 +245,16 @@ 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({
@ -349,6 +376,8 @@ 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
@ -406,7 +435,7 @@ export default function RoundDetailPage() {
action: projectCount === 0 ? poolLink : undefined,
actionLabel: 'Assign Projects',
},
...((isEvaluation || isFiltering)
...(hasJury
? [{
label: 'Jury group set',
ready: !!juryGroup,
@ -553,7 +582,7 @@ export default function RoundDetailPage() {
</motion.div>
{/* ===== STATS BAR — Accent-bordered cards ===== */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
{/* Projects */}
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
@ -576,7 +605,8 @@ export default function RoundDetailPage() {
</Card>
</AnimatedCard>
{/* Jury (with inline group selector) */}
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
{hasJury && (
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
<CardContent className="pt-4 pb-3">
@ -623,9 +653,10 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Window */}
<AnimatedCard index={2}>
<AnimatedCard index={hasJury ? 2 : 1}>
<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">
@ -658,7 +689,7 @@ export default function RoundDetailPage() {
</AnimatedCard>
{/* Advancement */}
<AnimatedCard index={3}>
<AnimatedCard index={hasJury ? 3 : 2}>
<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">
@ -694,10 +725,9 @@ export default function RoundDetailPage() {
{ value: 'projects', label: 'Projects', icon: Layers },
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
{ value: 'jury', label: 'Jury', icon: Users },
...(hasJury ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
{ value: 'config', label: 'Config', icon: Settings },
{ value: 'windows', label: 'Submissions', icon: Clock },
{ value: 'awards', label: 'Awards', icon: Trophy },
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
].map((tab) => (
<TabsTrigger
key={tab.value}
@ -936,25 +966,56 @@ export default function RoundDetailPage() {
</div>
</button>
{/* Advance projects (shown when PASSED > 0) */}
{passedCount > 0 && (
{/* Advance projects (always visible when projects exist) */}
{projectCount > 0 && (
<button
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"
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',
)}
>
<ArrowRight className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', passedCount > 0 ? 'text-emerald-600' : 'text-muted-foreground')} />
<div>
<p className="text-sm font-medium">Advance Projects</p>
<p className="text-xs text-muted-foreground mt-0.5">
Move {passedCount} passed project(s) to the next round
{passedCount > 0
? `Move ${passedCount} passed project(s) to the next round`
: 'Mark projects as "Passed" first, then advance'}
</p>
</div>
{passedCount > 0 && (
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
)}
</button>
)}
{/* Jury assignment for evaluation/filtering */}
{(isEvaluation || isFiltering) && !juryGroup && (
{/* 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 && (
<button
onClick={() => {
const el = document.querySelector('[data-jury-select]')
@ -1040,6 +1101,13 @@ 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 */}
@ -1171,6 +1239,7 @@ export default function RoundDetailPage() {
)}
{/* ═══════════ JURY TAB ═══════════ */}
{hasJury && (
<TabsContent value="jury" className="space-y-6">
{/* Jury Group Selector + Create */}
<Card>
@ -1411,6 +1480,7 @@ export default function RoundDetailPage() {
/>
)}
</TabsContent>
)}
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
{isEvaluation && (
@ -1476,7 +1546,7 @@ export default function RoundDetailPage() {
</div>
{/* Individual Assignments Table */}
<IndividualAssignmentsTable roundId={roundId} />
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
{/* Unassigned Queue */}
<RoundUnassignedQueue roundId={roundId} />
@ -1495,6 +1565,38 @@ 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">
@ -1633,12 +1735,8 @@ 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">
@ -1707,6 +1805,7 @@ export default function RoundDetailPage() {
</CardContent>
</Card>
</TabsContent>
)}
</Tabs>
</div>
)
@ -1969,10 +2068,18 @@ function ExportEvaluationsDialog({
// ── Individual Assignments Table ─────────────────────────────────────────
function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
function IndividualAssignmentsTable({
roundId,
projectStates,
}: {
roundId: string
projectStates: any[] | undefined
}) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [newUserId, setNewUserId] = useState('')
const [newProjectId, setNewProjectId] = useState('')
const [selectedJurorId, setSelectedJurorId] = useState('')
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
const utils = trpc.useUtils()
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
@ -1980,9 +2087,15 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
{ 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),
@ -1991,14 +2104,102 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
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')
setAddDialogOpen(false)
setNewUserId('')
setNewProjectId('')
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
})
}, [])
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>
<CardHeader>
@ -2071,44 +2272,220 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
</CardContent>
{/* Add Assignment Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent>
<Dialog open={addDialogOpen} onOpenChange={(open) => {
if (!open) resetDialog()
else setAddDialogOpen(true)
}}>
<DialogContent className="sm:max-w-[540px]">
<DialogHeader>
<DialogTitle>Add Assignment</DialogTitle>
<DialogDescription>
Manually assign a juror to evaluate a project
Select a juror and one or more projects to assign
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Juror Selector */}
<div className="space-y-2">
<Label className="text-sm">Juror User ID</Label>
<Input
placeholder="Enter jury member user ID..."
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
/>
</div>
<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>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
<Label className="text-sm font-medium">Juror</Label>
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
<PopoverTrigger asChild>
<Button
onClick={() => createMutation.mutate({
userId: newUserId,
projectId: newProjectId,
roundId,
})}
disabled={!newUserId || !newProjectId || createMutation.isPending}
variant="outline"
role="combobox"
aria-expanded={jurorPopoverOpen}
className="w-full justify-between font-normal"
>
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create Assignment
{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" />
<Input
placeholder="Filter projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-9 h-9"
/>
</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="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
onClick={handleCreate}
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
>
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedProjectIds.size <= 1
? 'Create Assignment'
: `Create ${selectedProjectIds.size} Assignments`
}
</Button>
</DialogFooter>
</DialogContent>
@ -2128,14 +2505,31 @@ 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[] }) => void; isPending: boolean }
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string }) => void; isPending: boolean }
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
currentSortOrder?: number
}) {
// 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])
@ -2194,14 +2588,20 @@ function AdvanceProjectsDialog({
const handleAdvance = () => {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({ roundId, projectIds: ids })
advanceMutation.mutate({
roundId,
projectIds: ids,
...(targetRoundId ? { targetRoundId } : {}),
})
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const handleClose = () => {
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const renderCategorySection = (
@ -2263,11 +2663,35 @@ function AdvanceProjectsDialog({
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
Select which passed projects to advance to the next round.
Select which passed projects to advance.
{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'])
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({

View File

@ -45,6 +45,8 @@ 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,6 +88,7 @@ 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,6 +198,8 @@ 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,6 +250,7 @@ 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 } from 'lucide-react'
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal, Info } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -67,7 +67,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
},
})
// Fetch available models from OpenAI API
const watchProvider = form.watch('ai_provider')
const isLiteLLM = watchProvider === 'litellm'
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
const {
data: modelsData,
isLoading: modelsLoading,
@ -76,6 +79,7 @@ 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({
@ -182,32 +186,50 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
</SelectContent>
</Select>
<FormDescription>
AI provider for smart assignment suggestions
{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'}
</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>API Key</FormLabel>
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
<FormControl>
<Input
type="password"
placeholder={settings.openai_api_key ? '••••••••' : 'Enter API key'}
placeholder={isLiteLLM
? 'Optional — leave blank for default'
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
{...field}
/>
</FormControl>
<FormDescription>
Your OpenAI API key. Leave blank to keep the existing key.
{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.'}
</FormDescription>
<FormMessage />
</FormItem>
@ -219,16 +241,26 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
name="openai_base_url"
render={({ field }) => (
<FormItem>
<FormLabel>API Base URL (Optional)</FormLabel>
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
<FormControl>
<Input
placeholder="https://api.openai.com/v1"
placeholder={isLiteLLM ? 'http://localhost:4000' : '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 (access Claude, Gemini, Llama, etc.)
Use <code className="text-xs bg-muted px-1 rounded">https://openrouter.ai/api/v1</code> for OpenRouter.
</>
)}
</FormDescription>
<FormMessage />
</FormItem>
@ -242,7 +274,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Model</FormLabel>
{modelsData?.success && (
{!isLiteLLM && modelsData?.success && !modelsData?.manualEntry && (
<Button
type="button"
variant="ghost"
@ -256,7 +288,13 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
)}
</div>
{modelsLoading ? (
{isLiteLLM || modelsData?.manualEntry ? (
<Input
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
placeholder="chatgpt/gpt-5.2"
/>
) : modelsLoading ? (
<Skeleton className="h-10 w-full" />
) : modelsError || !modelsData?.success ? (
<div className="space-y-2">
@ -303,7 +341,15 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</Select>
)}
<FormDescription>
{form.watch('ai_model')?.startsWith('o') ? (
{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') ? (
<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,6 +25,7 @@ import {
ShieldAlert,
Globe,
Webhook,
MessageCircle,
} from 'lucide-react'
import Link from 'next/link'
import { AnimatedCard } from '@/components/shared/animated-container'
@ -103,8 +104,12 @@ 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([
@ -147,6 +152,11 @@ 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',
@ -183,6 +193,12 @@ 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" />
@ -259,6 +275,12 @@ 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>
@ -502,6 +524,24 @@ 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>
@ -794,6 +834,29 @@ 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,6 +22,14 @@ 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)' },
@ -41,6 +49,7 @@ 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>
@ -52,6 +61,7 @@ interface StorageSettingsFormProps {
max_file_size_mb?: string
avatar_max_size_mb?: string
allowed_file_types?: string
allowed_image_types?: string
}
}
@ -68,6 +78,16 @@ 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: {
@ -76,6 +96,7 @@ 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,
},
})
@ -99,6 +120,7 @@ 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) },
],
})
}
@ -255,6 +277,57 @@ 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,6 +8,33 @@ 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 ────────────────────────────────────────────────────
/**
@ -168,6 +195,12 @@ 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
}
@ -209,8 +242,12 @@ async function getBaseURL(): Promise<string | undefined> {
*/
async function createOpenAIClient(): Promise<OpenAI | null> {
const apiKey = await getOpenAIApiKey()
const provider = await getConfiguredProvider()
if (!apiKey) {
// LiteLLM proxy may not require a real API key
const effectiveApiKey = apiKey || (provider === 'litellm' ? 'sk-litellm' : null)
if (!effectiveApiKey) {
console.warn('OpenAI API key not configured')
return null
}
@ -218,11 +255,11 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
const baseURL = await getBaseURL()
if (baseURL) {
console.log(`[OpenAI] Using custom base URL: ${baseURL}`)
console.log(`[OpenAI] Using custom base URL: ${baseURL} (provider: ${provider})`)
}
return new OpenAI({
apiKey,
apiKey: effectiveApiKey,
...(baseURL ? { baseURL } : {}),
})
}
@ -259,6 +296,12 @@ 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
}
@ -270,8 +313,20 @@ 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 or base URL changed
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model')) {
// 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')) {
const { resetOpenAIClient } = await import('@/lib/openai')
resetOpenAIClient()
}
@ -247,6 +247,15 @@ 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,