Compare commits
No commits in common. "f731f96a0a37e5caf8cef2e76a46b37e4fbadd8c" and "014bb15890089d1601a25b7b1c857d024e37ecbc" have entirely different histories.
f731f96a0a
...
014bb15890
File diff suppressed because it is too large
Load Diff
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}` : '')
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(',')
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue