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() {
|
export default function MemberDetailPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const utils = trpc.useUtils()
|
|
||||||
const userId = params.id as string
|
const userId = params.id as string
|
||||||
|
|
||||||
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
|
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
|
||||||
|
|
@ -104,8 +103,6 @@ export default function MemberDetailPage() {
|
||||||
expertiseTags,
|
expertiseTags,
|
||||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
||||||
})
|
})
|
||||||
utils.user.get.invalidate({ id: userId })
|
|
||||||
utils.user.list.invalidate()
|
|
||||||
toast.success('Member updated successfully')
|
toast.success('Member updated successfully')
|
||||||
router.push('/admin/members')
|
router.push('/admin/members')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -118,7 +115,6 @@ export default function MemberDetailPage() {
|
||||||
await sendInvitation.mutateAsync({ userId })
|
await sendInvitation.mutateAsync({ userId })
|
||||||
toast.success('Invitation email sent successfully')
|
toast.success('Invitation email sent successfully')
|
||||||
refetch()
|
refetch()
|
||||||
utils.user.list.invalidate()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,34 +78,16 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
Check,
|
|
||||||
ChevronsUpDown,
|
|
||||||
Search,
|
|
||||||
} from 'lucide-react'
|
} 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 { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
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 { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
|
||||||
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
|
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
|
|
@ -178,7 +160,6 @@ export default function RoundDetailPage() {
|
||||||
const [createJuryOpen, setCreateJuryOpen] = useState(false)
|
const [createJuryOpen, setCreateJuryOpen] = useState(false)
|
||||||
const [newJuryName, setNewJuryName] = useState('')
|
const [newJuryName, setNewJuryName] = useState('')
|
||||||
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
||||||
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
|
@ -245,16 +226,8 @@ export default function RoundDetailPage() {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.round.getById.invalidate({ id: roundId })
|
utils.round.getById.invalidate({ id: roundId })
|
||||||
toast.success('Round closed')
|
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({
|
const reopenMutation = trpc.roundEngine.reopen.useMutation({
|
||||||
|
|
@ -376,8 +349,6 @@ export default function RoundDetailPage() {
|
||||||
|
|
||||||
const isFiltering = round?.roundType === 'FILTERING'
|
const isFiltering = round?.roundType === 'FILTERING'
|
||||||
const isEvaluation = round?.roundType === 'EVALUATION'
|
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
|
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||||
|
|
||||||
|
|
@ -435,7 +406,7 @@ export default function RoundDetailPage() {
|
||||||
action: projectCount === 0 ? poolLink : undefined,
|
action: projectCount === 0 ? poolLink : undefined,
|
||||||
actionLabel: 'Assign Projects',
|
actionLabel: 'Assign Projects',
|
||||||
},
|
},
|
||||||
...(hasJury
|
...((isEvaluation || isFiltering)
|
||||||
? [{
|
? [{
|
||||||
label: 'Jury group set',
|
label: 'Jury group set',
|
||||||
ready: !!juryGroup,
|
ready: !!juryGroup,
|
||||||
|
|
@ -582,7 +553,7 @@ export default function RoundDetailPage() {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* ===== STATS BAR — Accent-bordered cards ===== */}
|
{/* ===== 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 */}
|
{/* Projects */}
|
||||||
<AnimatedCard index={0}>
|
<AnimatedCard index={0}>
|
||||||
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
|
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
|
||||||
|
|
@ -605,58 +576,56 @@ export default function RoundDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
|
{/* Jury (with inline group selector) */}
|
||||||
{hasJury && (
|
<AnimatedCard index={1}>
|
||||||
<AnimatedCard index={1}>
|
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
|
||||||
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
|
<CardContent className="pt-4 pb-3">
|
||||||
<CardContent className="pt-4 pb-3">
|
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
|
||||||
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
|
<div className="rounded-full bg-purple-50 p-1.5">
|
||||||
<div className="rounded-full bg-purple-50 p-1.5">
|
<Users className="h-4 w-4 text-purple-500" />
|
||||||
<Users className="h-4 w-4 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium text-muted-foreground">Jury</span>
|
|
||||||
</div>
|
</div>
|
||||||
{juryGroups && juryGroups.length > 0 ? (
|
<span className="text-sm font-medium text-muted-foreground">Jury</span>
|
||||||
<Select
|
</div>
|
||||||
value={round.juryGroupId ?? '__none__'}
|
{juryGroups && juryGroups.length > 0 ? (
|
||||||
onValueChange={(value) => {
|
<Select
|
||||||
assignJuryMutation.mutate({
|
value={round.juryGroupId ?? '__none__'}
|
||||||
id: roundId,
|
onValueChange={(value) => {
|
||||||
juryGroupId: value === '__none__' ? null : value,
|
assignJuryMutation.mutate({
|
||||||
})
|
id: roundId,
|
||||||
}}
|
juryGroupId: value === '__none__' ? null : value,
|
||||||
disabled={assignJuryMutation.isPending}
|
})
|
||||||
>
|
}}
|
||||||
<SelectTrigger className="h-8 text-xs mt-1">
|
disabled={assignJuryMutation.isPending}
|
||||||
<SelectValue placeholder="Select jury group..." />
|
>
|
||||||
</SelectTrigger>
|
<SelectTrigger className="h-8 text-xs mt-1">
|
||||||
<SelectContent>
|
<SelectValue placeholder="Select jury group..." />
|
||||||
<SelectItem value="__none__">No jury assigned</SelectItem>
|
</SelectTrigger>
|
||||||
{juryGroups.map((jg: any) => (
|
<SelectContent>
|
||||||
<SelectItem key={jg.id} value={jg.id}>
|
<SelectItem value="__none__">No jury assigned</SelectItem>
|
||||||
{jg.name} ({jg._count?.members ?? 0} members)
|
{juryGroups.map((jg: any) => (
|
||||||
</SelectItem>
|
<SelectItem key={jg.id} value={jg.id}>
|
||||||
))}
|
{jg.name} ({jg._count?.members ?? 0} members)
|
||||||
</SelectContent>
|
</SelectItem>
|
||||||
</Select>
|
))}
|
||||||
) : juryGroup ? (
|
</SelectContent>
|
||||||
<>
|
</Select>
|
||||||
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
|
) : juryGroup ? (
|
||||||
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
|
<>
|
||||||
</>
|
<p className="text-3xl font-bold mt-2">{juryMemberCount}</p>
|
||||||
) : (
|
<p className="text-xs text-muted-foreground truncate">{juryGroup.name}</p>
|
||||||
<>
|
</>
|
||||||
<p className="text-3xl font-bold mt-2 text-muted-foreground">—</p>
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">No jury groups yet</p>
|
<>
|
||||||
</>
|
<p className="text-3xl font-bold mt-2 text-muted-foreground">—</p>
|
||||||
)}
|
<p className="text-xs text-muted-foreground">No jury groups yet</p>
|
||||||
</CardContent>
|
</>
|
||||||
</Card>
|
)}
|
||||||
</AnimatedCard>
|
</CardContent>
|
||||||
)}
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Window */}
|
{/* Window */}
|
||||||
<AnimatedCard index={hasJury ? 2 : 1}>
|
<AnimatedCard index={2}>
|
||||||
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
|
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
|
||||||
<CardContent className="pt-4 pb-3">
|
<CardContent className="pt-4 pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
|
|
@ -689,7 +658,7 @@ export default function RoundDetailPage() {
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
|
|
||||||
{/* Advancement */}
|
{/* Advancement */}
|
||||||
<AnimatedCard index={hasJury ? 3 : 2}>
|
<AnimatedCard index={3}>
|
||||||
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
|
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
|
||||||
<CardContent className="pt-4 pb-3">
|
<CardContent className="pt-4 pb-3">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
|
|
@ -725,9 +694,10 @@ export default function RoundDetailPage() {
|
||||||
{ value: 'projects', label: 'Projects', icon: Layers },
|
{ value: 'projects', label: 'Projects', icon: Layers },
|
||||||
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
||||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
|
...(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 },
|
{ 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) => (
|
].map((tab) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
|
|
@ -966,56 +936,25 @@ export default function RoundDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Advance projects (always visible when projects exist) */}
|
{/* Advance projects (shown when PASSED > 0) */}
|
||||||
{projectCount > 0 && (
|
{passedCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => passedCount > 0
|
onClick={() => setAdvanceDialogOpen(true)}
|
||||||
? 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"
|
||||||
: 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={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>
|
<div>
|
||||||
<p className="text-sm font-medium">Advance Projects</p>
|
<p className="text-sm font-medium">Advance Projects</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
{passedCount > 0
|
Move {passedCount} passed project(s) to the next round
|
||||||
? `Move ${passedCount} passed project(s) to the next round`
|
|
||||||
: 'Mark projects as "Passed" first, then advance'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{passedCount > 0 && (
|
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
|
||||||
<Badge className="ml-auto shrink-0 bg-emerald-100 text-emerald-700 text-[10px]">{passedCount}</Badge>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Close & Advance (active rounds with passed projects) */}
|
{/* Jury assignment for evaluation/filtering */}
|
||||||
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
|
{(isEvaluation || isFiltering) && !juryGroup && (
|
||||||
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const el = document.querySelector('[data-jury-select]')
|
const el = document.querySelector('[data-jury-select]')
|
||||||
|
|
@ -1101,13 +1040,6 @@ export default function RoundDetailPage() {
|
||||||
projectStates={projectStates}
|
projectStates={projectStates}
|
||||||
config={config}
|
config={config}
|
||||||
advanceMutation={advanceMutation}
|
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 */}
|
{/* AI Shortlist Confirmation Dialog */}
|
||||||
|
|
@ -1239,7 +1171,6 @@ export default function RoundDetailPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ═══════════ JURY TAB ═══════════ */}
|
{/* ═══════════ JURY TAB ═══════════ */}
|
||||||
{hasJury && (
|
|
||||||
<TabsContent value="jury" className="space-y-6">
|
<TabsContent value="jury" className="space-y-6">
|
||||||
{/* Jury Group Selector + Create */}
|
{/* Jury Group Selector + Create */}
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -1480,7 +1411,6 @@ export default function RoundDetailPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
|
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
|
||||||
{isEvaluation && (
|
{isEvaluation && (
|
||||||
|
|
@ -1546,7 +1476,7 @@ export default function RoundDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Individual Assignments Table */}
|
{/* Individual Assignments Table */}
|
||||||
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
<IndividualAssignmentsTable roundId={roundId} />
|
||||||
|
|
||||||
{/* Unassigned Queue */}
|
{/* Unassigned Queue */}
|
||||||
<RoundUnassignedQueue roundId={roundId} />
|
<RoundUnassignedQueue roundId={roundId} />
|
||||||
|
|
@ -1565,38 +1495,6 @@ export default function RoundDetailPage() {
|
||||||
|
|
||||||
{/* ═══════════ CONFIG TAB ═══════════ */}
|
{/* ═══════════ CONFIG TAB ═══════════ */}
|
||||||
<TabsContent value="config" className="space-y-6">
|
<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 */}
|
{/* General Round Settings */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
|
|
@ -1735,8 +1633,12 @@ export default function RoundDetailPage() {
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
|
||||||
|
<TabsContent value="windows" className="space-y-4">
|
||||||
|
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
{/* ═══════════ AWARDS TAB ═══════════ */}
|
{/* ═══════════ AWARDS TAB ═══════════ */}
|
||||||
{hasAwards && (
|
|
||||||
<TabsContent value="awards" className="space-y-4">
|
<TabsContent value="awards" className="space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
|
|
@ -1805,7 +1707,6 @@ export default function RoundDetailPage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -2068,18 +1969,10 @@ function ExportEvaluationsDialog({
|
||||||
|
|
||||||
// ── Individual Assignments Table ─────────────────────────────────────────
|
// ── Individual Assignments Table ─────────────────────────────────────────
|
||||||
|
|
||||||
function IndividualAssignmentsTable({
|
function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||||
roundId,
|
|
||||||
projectStates,
|
|
||||||
}: {
|
|
||||||
roundId: string
|
|
||||||
projectStates: any[] | undefined
|
|
||||||
}) {
|
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
const [newUserId, setNewUserId] = useState('')
|
||||||
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
|
const [newProjectId, setNewProjectId] = useState('')
|
||||||
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
|
||||||
const [projectSearch, setProjectSearch] = useState('')
|
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||||
|
|
@ -2087,15 +1980,9 @@ function IndividualAssignmentsTable({
|
||||||
{ refetchInterval: 15_000 },
|
{ refetchInterval: 15_000 },
|
||||||
)
|
)
|
||||||
|
|
||||||
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
|
|
||||||
{ roundId },
|
|
||||||
{ enabled: addDialogOpen },
|
|
||||||
)
|
|
||||||
|
|
||||||
const deleteMutation = trpc.assignment.delete.useMutation({
|
const deleteMutation = trpc.assignment.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.assignment.listByStage.invalidate({ roundId })
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
|
||||||
toast.success('Assignment removed')
|
toast.success('Assignment removed')
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
onError: (err) => toast.error(err.message),
|
||||||
|
|
@ -2104,102 +1991,14 @@ function IndividualAssignmentsTable({
|
||||||
const createMutation = trpc.assignment.create.useMutation({
|
const createMutation = trpc.assignment.create.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
utils.assignment.listByStage.invalidate({ roundId })
|
utils.assignment.listByStage.invalidate({ roundId })
|
||||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
|
||||||
utils.user.getJuryMembers.invalidate({ roundId })
|
|
||||||
toast.success('Assignment created')
|
toast.success('Assignment created')
|
||||||
resetDialog()
|
setAddDialogOpen(false)
|
||||||
|
setNewUserId('')
|
||||||
|
setNewProjectId('')
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(err.message),
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|
@ -2272,220 +2071,44 @@ function IndividualAssignmentsTable({
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* Add Assignment Dialog */}
|
{/* Add Assignment Dialog */}
|
||||||
<Dialog open={addDialogOpen} onOpenChange={(open) => {
|
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||||
if (!open) resetDialog()
|
<DialogContent>
|
||||||
else setAddDialogOpen(true)
|
|
||||||
}}>
|
|
||||||
<DialogContent className="sm:max-w-[540px]">
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Add Assignment</DialogTitle>
|
<DialogTitle>Add Assignment</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select a juror and one or more projects to assign
|
Manually assign a juror to evaluate a project
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Juror Selector */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium">Juror</Label>
|
<Label className="text-sm">Juror User ID</Label>
|
||||||
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
|
<Input
|
||||||
<PopoverTrigger asChild>
|
placeholder="Enter jury member user ID..."
|
||||||
<Button
|
value={newUserId}
|
||||||
variant="outline"
|
onChange={(e) => setNewUserId(e.target.value)}
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
{/* Project Multi-Select */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<Label className="text-sm">Project ID</Label>
|
||||||
<Label className="text-sm font-medium">
|
<Input
|
||||||
Projects
|
placeholder="Enter project ID..."
|
||||||
{selectedProjectIds.size > 0 && (
|
value={newProjectId}
|
||||||
<span className="ml-1.5 text-muted-foreground font-normal">
|
onChange={(e) => setNewProjectId(e.target.value)}
|
||||||
({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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={resetDialog}>
|
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreate}
|
onClick={() => createMutation.mutate({
|
||||||
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
|
userId: newUserId,
|
||||||
|
projectId: newProjectId,
|
||||||
|
roundId,
|
||||||
|
})}
|
||||||
|
disabled={!newUserId || !newProjectId || createMutation.isPending}
|
||||||
>
|
>
|
||||||
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||||
{selectedProjectIds.size <= 1
|
Create Assignment
|
||||||
? 'Create Assignment'
|
|
||||||
: `Create ${selectedProjectIds.size} Assignments`
|
|
||||||
}
|
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
@ -2505,31 +2128,14 @@ function AdvanceProjectsDialog({
|
||||||
projectStates,
|
projectStates,
|
||||||
config,
|
config,
|
||||||
advanceMutation,
|
advanceMutation,
|
||||||
competitionRounds,
|
|
||||||
currentSortOrder,
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
roundId: string
|
roundId: string
|
||||||
projectStates: any[] | undefined
|
projectStates: any[] | undefined
|
||||||
config: Record<string, unknown>
|
config: Record<string, unknown>
|
||||||
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string }) => void; isPending: boolean }
|
advanceMutation: { mutate: (input: { roundId: string; projectIds?: 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(() =>
|
const passedProjects = useMemo(() =>
|
||||||
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
|
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
|
||||||
[projectStates])
|
[projectStates])
|
||||||
|
|
@ -2588,20 +2194,14 @@ function AdvanceProjectsDialog({
|
||||||
const handleAdvance = () => {
|
const handleAdvance = () => {
|
||||||
const ids = Array.from(selected)
|
const ids = Array.from(selected)
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
advanceMutation.mutate({
|
advanceMutation.mutate({ roundId, projectIds: ids })
|
||||||
roundId,
|
|
||||||
projectIds: ids,
|
|
||||||
...(targetRoundId ? { targetRoundId } : {}),
|
|
||||||
})
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setTargetRoundId('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setSelected(new Set())
|
setSelected(new Set())
|
||||||
setTargetRoundId('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderCategorySection = (
|
const renderCategorySection = (
|
||||||
|
|
@ -2663,35 +2263,11 @@ function AdvanceProjectsDialog({
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Advance Projects</DialogTitle>
|
<DialogTitle>Advance Projects</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Select which passed projects to advance.
|
Select which passed projects to advance to the next round.
|
||||||
{selected.size} of {passedProjects.length} selected.
|
{selected.size} of {passedProjects.length} selected.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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">
|
<div className="flex-1 overflow-y-auto space-y-4 py-2">
|
||||||
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
|
||||||
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-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'
|
import { SettingsContent } from '@/components/settings/settings-content'
|
||||||
|
|
||||||
// Categories that only super admins can access
|
// 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 }) {
|
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||||
const settings = await prisma.systemSettings.findMany({
|
const settings = await prisma.systemSettings.findMany({
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,6 @@ export function AssignmentPreviewSheet({
|
||||||
toast.success(`Created ${result.created} assignments`)
|
toast.success(`Created ${result.created} assignments`)
|
||||||
utils.roundAssignment.coverageReport.invalidate({ roundId })
|
utils.roundAssignment.coverageReport.invalidate({ roundId })
|
||||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||||
utils.assignment.listByStage.invalidate({ roundId })
|
|
||||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,6 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||||
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
|
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
toast.success(`Invitation sent to ${result.email}`)
|
toast.success(`Invitation sent to ${result.email}`)
|
||||||
utils.user.list.invalidate()
|
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
// Don't block — user was created and added, just invitation failed
|
// Don't block — user was created and added, just invitation failed
|
||||||
|
|
|
||||||
|
|
@ -198,8 +198,6 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
utils.filtering.getResults.invalidate()
|
utils.filtering.getResults.invalidate()
|
||||||
utils.filtering.getResultStats.invalidate({ roundId })
|
utils.filtering.getResultStats.invalidate({ roundId })
|
||||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
|
||||||
utils.project.list.invalidate()
|
|
||||||
toast.success(
|
toast.success(
|
||||||
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
|
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
|
||||||
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
|
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,6 @@ export function UserMobileActions({
|
||||||
try {
|
try {
|
||||||
await sendInvitation.mutateAsync({ userId })
|
await sendInvitation.mutateAsync({ userId })
|
||||||
toast.success(`Invitation sent to ${userEmail}`)
|
toast.success(`Invitation sent to ${userEmail}`)
|
||||||
utils.user.list.invalidate()
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { toast } from 'sonner'
|
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 { trpc } from '@/lib/trpc/client'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
@ -67,10 +67,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const watchProvider = form.watch('ai_provider')
|
// Fetch available models from OpenAI API
|
||||||
const isLiteLLM = watchProvider === 'litellm'
|
|
||||||
|
|
||||||
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
|
|
||||||
const {
|
const {
|
||||||
data: modelsData,
|
data: modelsData,
|
||||||
isLoading: modelsLoading,
|
isLoading: modelsLoading,
|
||||||
|
|
@ -79,7 +76,6 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
} = trpc.settings.listAIModels.useQuery(undefined, {
|
} = trpc.settings.listAIModels.useQuery(undefined, {
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
retry: false,
|
retry: false,
|
||||||
enabled: !isLiteLLM,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateSettings = trpc.settings.updateMultiple.useMutation({
|
const updateSettings = trpc.settings.updateMultiple.useMutation({
|
||||||
|
|
@ -186,50 +182,32 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
|
<SelectItem value="openai">OpenAI</SelectItem>
|
||||||
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{field.value === 'litellm'
|
AI provider for smart assignment suggestions
|
||||||
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
|
|
||||||
: 'Direct OpenAI API access using your API key'}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</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
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="openai_api_key"
|
name="openai_api_key"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
|
<FormLabel>API Key</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
placeholder={isLiteLLM
|
placeholder={settings.openai_api_key ? '••••••••' : 'Enter API key'}
|
||||||
? 'Optional — leave blank for default'
|
|
||||||
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
|
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{isLiteLLM
|
Your OpenAI API key. Leave blank to keep the existing key.
|
||||||
? '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>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -241,26 +219,16 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
name="openai_base_url"
|
name="openai_base_url"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
|
<FormLabel>API Base URL (Optional)</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
|
placeholder="https://api.openai.com/v1"
|
||||||
{...field}
|
{...field}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{isLiteLLM ? (
|
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.)
|
||||||
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.
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -274,7 +242,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<FormLabel>Model</FormLabel>
|
<FormLabel>Model</FormLabel>
|
||||||
{!isLiteLLM && modelsData?.success && !modelsData?.manualEntry && (
|
{modelsData?.success && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -288,13 +256,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLiteLLM || modelsData?.manualEntry ? (
|
{modelsLoading ? (
|
||||||
<Input
|
|
||||||
value={field.value}
|
|
||||||
onChange={(e) => field.onChange(e.target.value)}
|
|
||||||
placeholder="chatgpt/gpt-5.2"
|
|
||||||
/>
|
|
||||||
) : modelsLoading ? (
|
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
) : modelsError || !modelsData?.success ? (
|
) : modelsError || !modelsData?.success ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -341,15 +303,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||||
</Select>
|
</Select>
|
||||||
)}
|
)}
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{isLiteLLM ? (
|
{form.watch('ai_model')?.startsWith('o') ? (
|
||||||
<>
|
|
||||||
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">
|
<span className="flex items-center gap-1 text-purple-600">
|
||||||
<SlidersHorizontal className="h-3 w-3" />
|
<SlidersHorizontal className="h-3 w-3" />
|
||||||
Reasoning model - optimized for complex analysis tasks
|
Reasoning model - optimized for complex analysis tasks
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import {
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Globe,
|
Globe,
|
||||||
Webhook,
|
Webhook,
|
||||||
MessageCircle,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||||
|
|
@ -104,12 +103,8 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
])
|
])
|
||||||
|
|
||||||
const storageSettings = getSettingsByKeys([
|
const storageSettings = getSettingsByKeys([
|
||||||
'storage_provider',
|
|
||||||
'local_storage_path',
|
|
||||||
'max_file_size_mb',
|
'max_file_size_mb',
|
||||||
'avatar_max_size_mb',
|
|
||||||
'allowed_file_types',
|
'allowed_file_types',
|
||||||
'allowed_image_types',
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const securitySettings = getSettingsByKeys([
|
const securitySettings = getSettingsByKeys([
|
||||||
|
|
@ -152,11 +147,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
'anomaly_off_hours_end',
|
'anomaly_off_hours_end',
|
||||||
])
|
])
|
||||||
|
|
||||||
const whatsappSettings = getSettingsByKeys([
|
|
||||||
'whatsapp_enabled',
|
|
||||||
'whatsapp_provider',
|
|
||||||
])
|
|
||||||
|
|
||||||
const localizationSettings = getSettingsByKeys([
|
const localizationSettings = getSettingsByKeys([
|
||||||
'localization_enabled_locales',
|
'localization_enabled_locales',
|
||||||
'localization_default_locale',
|
'localization_default_locale',
|
||||||
|
|
@ -193,12 +183,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<Newspaper className="h-4 w-4" />
|
<Newspaper className="h-4 w-4" />
|
||||||
Digest
|
Digest
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{isSuperAdmin && (
|
|
||||||
<TabsTrigger value="whatsapp" className="gap-2 shrink-0">
|
|
||||||
<MessageCircle className="h-4 w-4" />
|
|
||||||
WhatsApp
|
|
||||||
</TabsTrigger>
|
|
||||||
)}
|
|
||||||
{isSuperAdmin && (
|
{isSuperAdmin && (
|
||||||
<TabsTrigger value="security" className="gap-2 shrink-0">
|
<TabsTrigger value="security" className="gap-2 shrink-0">
|
||||||
<Shield className="h-4 w-4" />
|
<Shield className="h-4 w-4" />
|
||||||
|
|
@ -275,12 +259,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
<Newspaper className="h-4 w-4" />
|
<Newspaper className="h-4 w-4" />
|
||||||
Digest
|
Digest
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -524,24 +502,6 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
</TabsContent>
|
</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 content area */}
|
||||||
</div>{/* end lg:flex */}
|
</div>{/* end lg:flex */}
|
||||||
</Tabs>
|
</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> }) {
|
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||||
const mutation = useSettingsMutation()
|
const mutation = useSettingsMutation()
|
||||||
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,6 @@ import {
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
// Note: Storage provider cache is cleared server-side when settings are updated
|
// 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 = [
|
const COMMON_FILE_TYPES = [
|
||||||
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
|
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
|
||||||
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' },
|
{ 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'),
|
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
|
||||||
avatar_max_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_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>
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
@ -61,7 +52,6 @@ interface StorageSettingsFormProps {
|
||||||
max_file_size_mb?: string
|
max_file_size_mb?: string
|
||||||
avatar_max_size_mb?: string
|
avatar_max_size_mb?: string
|
||||||
allowed_file_types?: 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']
|
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>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -96,7 +76,6 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||||
max_file_size_mb: settings.max_file_size_mb || '500',
|
max_file_size_mb: settings.max_file_size_mb || '500',
|
||||||
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
|
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
|
||||||
allowed_file_types: allowedTypes,
|
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: 'max_file_size_mb', value: data.max_file_size_mb },
|
||||||
{ key: 'avatar_max_size_mb', value: data.avatar_max_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_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' && (
|
{storageProvider === 's3' && (
|
||||||
<div className="rounded-lg border border-muted bg-muted/50 p-4">
|
<div className="rounded-lg border border-muted bg-muted/50 p-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,6 @@ const globalForOpenAI = globalThis as unknown as {
|
||||||
openaiInitialized: boolean
|
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 ────────────────────────────────────────────────────
|
// ─── Model Type Detection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -195,12 +168,6 @@ export function buildCompletionParams(
|
||||||
params.response_format = { type: 'json_object' }
|
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
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,12 +209,8 @@ async function getBaseURL(): Promise<string | undefined> {
|
||||||
*/
|
*/
|
||||||
async function createOpenAIClient(): Promise<OpenAI | null> {
|
async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||||
const apiKey = await getOpenAIApiKey()
|
const apiKey = await getOpenAIApiKey()
|
||||||
const provider = await getConfiguredProvider()
|
|
||||||
|
|
||||||
// LiteLLM proxy may not require a real API key
|
if (!apiKey) {
|
||||||
const effectiveApiKey = apiKey || (provider === 'litellm' ? 'sk-litellm' : null)
|
|
||||||
|
|
||||||
if (!effectiveApiKey) {
|
|
||||||
console.warn('OpenAI API key not configured')
|
console.warn('OpenAI API key not configured')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -255,11 +218,11 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||||
const baseURL = await getBaseURL()
|
const baseURL = await getBaseURL()
|
||||||
|
|
||||||
if (baseURL) {
|
if (baseURL) {
|
||||||
console.log(`[OpenAI] Using custom base URL: ${baseURL} (provider: ${provider})`)
|
console.log(`[OpenAI] Using custom base URL: ${baseURL}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OpenAI({
|
return new OpenAI({
|
||||||
apiKey: effectiveApiKey,
|
apiKey,
|
||||||
...(baseURL ? { baseURL } : {}),
|
...(baseURL ? { baseURL } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -296,12 +259,6 @@ export function resetOpenAIClient(): void {
|
||||||
* Check if OpenAI is configured and available
|
* Check if OpenAI is configured and available
|
||||||
*/
|
*/
|
||||||
export async function isOpenAIConfigured(): Promise<boolean> {
|
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()
|
const apiKey = await getOpenAIApiKey()
|
||||||
return !!apiKey
|
return !!apiKey
|
||||||
}
|
}
|
||||||
|
|
@ -313,20 +270,8 @@ export async function listAvailableModels(): Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
models?: string[]
|
models?: string[]
|
||||||
error?: string
|
error?: string
|
||||||
manualEntry?: boolean
|
|
||||||
}> {
|
}> {
|
||||||
try {
|
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()
|
const client = await getOpenAI()
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|
|
||||||
|
|
@ -201,8 +201,8 @@ export const settingsRouter = router({
|
||||||
clearStorageProviderCache()
|
clearStorageProviderCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset OpenAI client if API key, base URL, model, or provider changed
|
// 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' || s.key === 'ai_provider')) {
|
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')
|
const { resetOpenAIClient } = await import('@/lib/openai')
|
||||||
resetOpenAIClient()
|
resetOpenAIClient()
|
||||||
}
|
}
|
||||||
|
|
@ -247,15 +247,6 @@ export const settingsRouter = router({
|
||||||
listAIModels: superAdminProcedure.query(async () => {
|
listAIModels: superAdminProcedure.query(async () => {
|
||||||
const result = await listAvailableModels()
|
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) {
|
if (!result.success || !result.models) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue