Compare commits
10 Commits
014bb15890
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b7cdf670 | ||
|
|
90f36ac9b2 | ||
|
|
a921731c52 | ||
| fc8e58f985 | |||
|
|
e547d2bd03 | ||
|
|
f731f96a0a | ||
|
|
09049d2911 | ||
|
|
3fb0d128a1 | ||
|
|
5965f7889d | ||
|
|
b2279067e2 |
48
package-lock.json
generated
48
package-lock.json
generated
@@ -49,6 +49,7 @@
|
||||
"cmdk": "^1.0.4",
|
||||
"csv-parse": "^6.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"franc": "^6.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.1.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
@@ -6147,6 +6148,16 @@
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/collapse-white-space": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz",
|
||||
"integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -7736,6 +7747,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/franc": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/franc/-/franc-6.2.0.tgz",
|
||||
"integrity": "sha512-rcAewP7PSHvjq7Kgd7dhj82zE071kX5B4W1M4ewYMf/P+i6YsDQmj62Xz3VQm9zyUzUXwhIde/wHLGCMrM+yGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"trigram-utils": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
@@ -10441,6 +10465,16 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/n-gram": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/n-gram/-/n-gram-2.0.2.tgz",
|
||||
"integrity": "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@@ -13110,6 +13144,20 @@
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/trigram-utils": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trigram-utils/-/trigram-utils-2.0.1.tgz",
|
||||
"integrity": "sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"collapse-white-space": "^2.0.0",
|
||||
"n-gram": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"cmdk": "^1.0.4",
|
||||
"csv-parse": "^6.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"franc": "^6.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.1.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProjectFile" ADD COLUMN "textPreview" TEXT;
|
||||
ALTER TABLE "ProjectFile" ADD COLUMN "detectedLang" TEXT;
|
||||
ALTER TABLE "ProjectFile" ADD COLUMN "langConfidence" DOUBLE PRECISION;
|
||||
ALTER TABLE "ProjectFile" ADD COLUMN "analyzedAt" TIMESTAMP(3);
|
||||
@@ -689,6 +689,12 @@ model ProjectFile {
|
||||
size Int // bytes
|
||||
pageCount Int? // Number of pages (PDFs, presentations, etc.)
|
||||
|
||||
// Document analysis (optional, populated by document-analyzer service)
|
||||
textPreview String? @db.Text // First ~2000 chars of extracted text
|
||||
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
|
||||
langConfidence Float? // 0.0–1.0 confidence
|
||||
analyzedAt DateTime? // When analysis last ran
|
||||
|
||||
// MinIO location
|
||||
bucket String
|
||||
objectKey String
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@ import {
|
||||
export default function MemberDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const userId = params.id as string
|
||||
|
||||
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
|
||||
@@ -103,6 +104,8 @@ export default function MemberDetailPage() {
|
||||
expertiseTags,
|
||||
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
|
||||
})
|
||||
utils.user.get.invalidate({ id: userId })
|
||||
utils.user.list.invalidate()
|
||||
toast.success('Member updated successfully')
|
||||
router.push('/admin/members')
|
||||
} catch (error) {
|
||||
@@ -115,6 +118,7 @@ export default function MemberDetailPage() {
|
||||
await sendInvitation.mutateAsync({ userId })
|
||||
toast.success('Invitation email sent successfully')
|
||||
refetch()
|
||||
utils.user.list.invalidate()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
||||
}
|
||||
|
||||
@@ -49,7 +49,10 @@ import {
|
||||
Heart,
|
||||
Crown,
|
||||
UserPlus,
|
||||
Loader2,
|
||||
ScanSearch,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
||||
|
||||
interface PageProps {
|
||||
@@ -529,15 +532,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-rose-500" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
Files
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Project documents and materials organized by competition round
|
||||
</CardDescription>
|
||||
</div>
|
||||
Files
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Project documents and materials organized by competition round
|
||||
</CardDescription>
|
||||
<AnalyzeDocumentsButton projectId={projectId} onComplete={() => utils.file.listByProject.invalidate({ projectId })} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Requirements organized by round */}
|
||||
@@ -664,6 +672,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
@@ -847,6 +860,36 @@ function ProjectDetailSkeleton() {
|
||||
)
|
||||
}
|
||||
|
||||
function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string; onComplete: () => void }) {
|
||||
const analyzeMutation = trpc.file.analyzeProjectFiles.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
`Analyzed ${result.analyzed} file${result.analyzed !== 1 ? 's' : ''}${result.failed > 0 ? ` (${result.failed} failed)` : ''}`
|
||||
)
|
||||
onComplete()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Analysis failed')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => analyzeMutation.mutate({ projectId })}
|
||||
disabled={analyzeMutation.isPending}
|
||||
>
|
||||
{analyzeMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ScanSearch className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{analyzeMutation.isPending ? 'Analyzing...' : 'Analyze Documents'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
|
||||
@@ -78,16 +78,34 @@ import {
|
||||
ArrowRight,
|
||||
RotateCcw,
|
||||
X,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Search,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
|
||||
// SubmissionWindowManager removed — round dates + file requirements in Config are sufficient
|
||||
import { FileRequirementsEditor } from '@/components/admin/round/file-requirements-editor'
|
||||
import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'
|
||||
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
|
||||
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { DateTimePicker } from '@/components/ui/datetime-picker'
|
||||
import { AddMemberDialog } from '@/components/admin/jury/add-member-dialog'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
@@ -160,6 +178,7 @@ export default function RoundDetailPage() {
|
||||
const [createJuryOpen, setCreateJuryOpen] = useState(false)
|
||||
const [newJuryName, setNewJuryName] = useState('')
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
||||
const [closeAndAdvance, setCloseAndAdvance] = useState(false)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -226,8 +245,16 @@ export default function RoundDetailPage() {
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round closed')
|
||||
if (closeAndAdvance) {
|
||||
setCloseAndAdvance(false)
|
||||
// Small delay to let cache invalidation complete before opening dialog
|
||||
setTimeout(() => setAdvanceDialogOpen(true), 300)
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setCloseAndAdvance(false)
|
||||
toast.error(err.message)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const reopenMutation = trpc.roundEngine.reopen.useMutation({
|
||||
@@ -299,7 +326,10 @@ export default function RoundDetailPage() {
|
||||
onSuccess: (data) => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
|
||||
const msg = data.autoPassedCount
|
||||
? `Passed ${data.autoPassedCount} and advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
|
||||
: `Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`
|
||||
toast.success(msg)
|
||||
setAdvanceDialogOpen(false)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
@@ -349,6 +379,9 @@ 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 isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(round?.roundType ?? '')
|
||||
|
||||
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
|
||||
|
||||
@@ -406,7 +439,7 @@ export default function RoundDetailPage() {
|
||||
action: projectCount === 0 ? poolLink : undefined,
|
||||
actionLabel: 'Assign Projects',
|
||||
},
|
||||
...((isEvaluation || isFiltering)
|
||||
...(hasJury
|
||||
? [{
|
||||
label: 'Jury group set',
|
||||
ready: !!juryGroup,
|
||||
@@ -553,7 +586,7 @@ export default function RoundDetailPage() {
|
||||
</motion.div>
|
||||
|
||||
{/* ===== STATS BAR — Accent-bordered cards ===== */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
<div className={cn("grid gap-3 grid-cols-2", hasJury ? "sm:grid-cols-4" : "sm:grid-cols-3")}>
|
||||
{/* Projects */}
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-[#557f8c] hover:shadow-md transition-shadow">
|
||||
@@ -576,56 +609,58 @@ export default function RoundDetailPage() {
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* 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">
|
||||
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
|
||||
<div className="rounded-full bg-purple-50 p-1.5">
|
||||
<Users className="h-4 w-4 text-purple-500" />
|
||||
{/* Jury (with inline group selector) — only for jury-relevant rounds */}
|
||||
{hasJury && (
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-purple-500 hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2.5 mb-1" data-jury-select>
|
||||
<div className="rounded-full bg-purple-50 p-1.5">
|
||||
<Users className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Jury</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Jury</span>
|
||||
</div>
|
||||
{juryGroups && juryGroups.length > 0 ? (
|
||||
<Select
|
||||
value={round.juryGroupId ?? '__none__'}
|
||||
onValueChange={(value) => {
|
||||
assignJuryMutation.mutate({
|
||||
id: roundId,
|
||||
juryGroupId: value === '__none__' ? null : value,
|
||||
})
|
||||
}}
|
||||
disabled={assignJuryMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs mt-1">
|
||||
<SelectValue placeholder="Select jury group..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No jury assigned</SelectItem>
|
||||
{juryGroups.map((jg: any) => (
|
||||
<SelectItem key={jg.id} value={jg.id}>
|
||||
{jg.name} ({jg._count?.members ?? 0} members)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : juryGroup ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
{juryGroups && juryGroups.length > 0 ? (
|
||||
<Select
|
||||
value={round.juryGroupId ?? '__none__'}
|
||||
onValueChange={(value) => {
|
||||
assignJuryMutation.mutate({
|
||||
id: roundId,
|
||||
juryGroupId: value === '__none__' ? null : value,
|
||||
})
|
||||
}}
|
||||
disabled={assignJuryMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs mt-1">
|
||||
<SelectValue placeholder="Select jury group..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No jury assigned</SelectItem>
|
||||
{juryGroups.map((jg: any) => (
|
||||
<SelectItem key={jg.id} value={jg.id}>
|
||||
{jg.name} ({jg._count?.members ?? 0} members)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : juryGroup ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Window */}
|
||||
<AnimatedCard index={2}>
|
||||
<AnimatedCard index={hasJury ? 2 : 1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -658,7 +693,7 @@ export default function RoundDetailPage() {
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Advancement */}
|
||||
<AnimatedCard index={3}>
|
||||
<AnimatedCard index={hasJury ? 3 : 2}>
|
||||
<Card className="border-l-4 border-l-amber-500 hover:shadow-md transition-shadow">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -694,10 +729,9 @@ export default function RoundDetailPage() {
|
||||
{ value: 'projects', label: 'Projects', icon: Layers },
|
||||
...(isFiltering ? [{ value: 'filtering', label: 'Filtering', icon: Shield }] : []),
|
||||
...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []),
|
||||
{ value: 'jury', label: 'Jury', icon: Users },
|
||||
...(hasJury ? [{ value: 'jury', label: 'Jury', icon: Users }] : []),
|
||||
{ value: 'config', label: 'Config', icon: Settings },
|
||||
{ value: 'windows', label: 'Submissions', icon: Clock },
|
||||
{ value: 'awards', label: 'Awards', icon: Trophy },
|
||||
...(hasAwards ? [{ value: 'awards', label: 'Awards', icon: Trophy }] : []),
|
||||
].map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
@@ -936,25 +970,56 @@ export default function RoundDetailPage() {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Advance projects (shown when PASSED > 0) */}
|
||||
{passedCount > 0 && (
|
||||
{/* Advance projects (always visible when projects exist) */}
|
||||
{projectCount > 0 && (
|
||||
<button
|
||||
onClick={() => setAdvanceDialogOpen(true)}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-emerald-500 bg-emerald-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
|
||||
onClick={() => (isSimpleAdvance || 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',
|
||||
(isSimpleAdvance || passedCount > 0)
|
||||
? 'border-l-4 border-l-emerald-500 bg-emerald-50/30'
|
||||
: 'border-dashed opacity-60',
|
||||
)}
|
||||
>
|
||||
<ArrowRight className="h-5 w-5 text-emerald-600 mt-0.5 shrink-0" />
|
||||
<ArrowRight className={cn('h-5 w-5 mt-0.5 shrink-0', (isSimpleAdvance || passedCount > 0) ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Advance Projects</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Move {passedCount} passed project(s) to the next round
|
||||
{isSimpleAdvance
|
||||
? `Advance all ${projectCount} project(s) to the next round`
|
||||
: passedCount > 0
|
||||
? `Move ${passedCount} passed project(s) to the next round`
|
||||
: 'Mark projects as "Passed" first, then advance'}
|
||||
</p>
|
||||
</div>
|
||||
<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]">{isSimpleAdvance ? projectCount : passedCount}</Badge>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Jury assignment for evaluation/filtering */}
|
||||
{(isEvaluation || isFiltering) && !juryGroup && (
|
||||
{/* Close & Advance (active rounds with passed projects) */}
|
||||
{status === 'ROUND_ACTIVE' && passedCount > 0 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setCloseAndAdvance(true)
|
||||
closeMutation.mutate({ roundId })
|
||||
}}
|
||||
disabled={isTransitioning}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 hover:-translate-y-0.5 hover:shadow-md transition-all text-left"
|
||||
>
|
||||
<Square className="h-5 w-5 text-purple-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Close & Advance</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Close this round and advance {passedCount} passed project(s) to the next round
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Jury assignment for rounds that use jury */}
|
||||
{hasJury && !juryGroup && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.querySelector('[data-jury-select]')
|
||||
@@ -1037,9 +1102,17 @@ export default function RoundDetailPage() {
|
||||
open={advanceDialogOpen}
|
||||
onOpenChange={setAdvanceDialogOpen}
|
||||
roundId={roundId}
|
||||
roundType={round?.roundType}
|
||||
projectStates={projectStates}
|
||||
config={config}
|
||||
advanceMutation={advanceMutation}
|
||||
competitionRounds={competition?.rounds?.map((r: any) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
sortOrder: r.sortOrder,
|
||||
roundType: r.roundType,
|
||||
}))}
|
||||
currentSortOrder={round?.sortOrder}
|
||||
/>
|
||||
|
||||
{/* AI Shortlist Confirmation Dialog */}
|
||||
@@ -1171,6 +1244,7 @@ export default function RoundDetailPage() {
|
||||
)}
|
||||
|
||||
{/* ═══════════ JURY TAB ═══════════ */}
|
||||
{hasJury && (
|
||||
<TabsContent value="jury" className="space-y-6">
|
||||
{/* Jury Group Selector + Create */}
|
||||
<Card>
|
||||
@@ -1411,6 +1485,7 @@ export default function RoundDetailPage() {
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* ═══════════ ASSIGNMENTS TAB (Evaluation rounds) ═══════════ */}
|
||||
{isEvaluation && (
|
||||
@@ -1476,7 +1551,7 @@ export default function RoundDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Individual Assignments Table */}
|
||||
<IndividualAssignmentsTable roundId={roundId} />
|
||||
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
||||
|
||||
{/* Unassigned Queue */}
|
||||
<RoundUnassignedQueue roundId={roundId} />
|
||||
@@ -1495,6 +1570,38 @@ export default function RoundDetailPage() {
|
||||
|
||||
{/* ═══════════ CONFIG TAB ═══════════ */}
|
||||
<TabsContent value="config" className="space-y-6">
|
||||
{/* Round Dates */}
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-base">Round Dates</CardTitle>
|
||||
<CardDescription>
|
||||
When this round starts and ends. Defines the active period for document uploads and evaluations.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Start Date</Label>
|
||||
<DateTimePicker
|
||||
value={round.windowOpenAt ? new Date(round.windowOpenAt) : null}
|
||||
onChange={(date) => updateMutation.mutate({ id: roundId, windowOpenAt: date })}
|
||||
placeholder="Select start date & time"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>End Date</Label>
|
||||
<DateTimePicker
|
||||
value={round.windowCloseAt ? new Date(round.windowCloseAt) : null}
|
||||
onChange={(date) => updateMutation.mutate({ id: roundId, windowCloseAt: date })}
|
||||
placeholder="Select end date & time"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* General Round Settings */}
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
@@ -1633,12 +1740,8 @@ export default function RoundDetailPage() {
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */}
|
||||
<TabsContent value="windows" className="space-y-4">
|
||||
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
|
||||
</TabsContent>
|
||||
|
||||
{/* ═══════════ AWARDS TAB ═══════════ */}
|
||||
{hasAwards && (
|
||||
<TabsContent value="awards" className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
@@ -1707,6 +1810,7 @@ export default function RoundDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
@@ -1969,10 +2073,18 @@ function ExportEvaluationsDialog({
|
||||
|
||||
// ── Individual Assignments Table ─────────────────────────────────────────
|
||||
|
||||
function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||
function IndividualAssignmentsTable({
|
||||
roundId,
|
||||
projectStates,
|
||||
}: {
|
||||
roundId: string
|
||||
projectStates: any[] | undefined
|
||||
}) {
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false)
|
||||
const [newUserId, setNewUserId] = useState('')
|
||||
const [newProjectId, setNewProjectId] = useState('')
|
||||
const [selectedJurorId, setSelectedJurorId] = useState('')
|
||||
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
|
||||
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
||||
const [projectSearch, setProjectSearch] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
|
||||
@@ -1980,9 +2092,15 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: addDialogOpen },
|
||||
)
|
||||
|
||||
const deleteMutation = trpc.assignment.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
toast.success('Assignment removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
@@ -1991,14 +2109,102 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||
const createMutation = trpc.assignment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.user.getJuryMembers.invalidate({ roundId })
|
||||
toast.success('Assignment created')
|
||||
setAddDialogOpen(false)
|
||||
setNewUserId('')
|
||||
setNewProjectId('')
|
||||
resetDialog()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.user.getJuryMembers.invalidate({ roundId })
|
||||
toast.success(`${result.created} assignment(s) created`)
|
||||
resetDialog()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const resetDialog = useCallback(() => {
|
||||
setAddDialogOpen(false)
|
||||
setSelectedJurorId('')
|
||||
setSelectedProjectIds(new Set())
|
||||
setProjectSearch('')
|
||||
}, [])
|
||||
|
||||
const selectedJuror = useMemo(
|
||||
() => juryMembers?.find((j: any) => j.id === selectedJurorId),
|
||||
[juryMembers, selectedJurorId],
|
||||
)
|
||||
|
||||
// Filter projects by search term
|
||||
const filteredProjects = useMemo(() => {
|
||||
const items = projectStates ?? []
|
||||
if (!projectSearch) return items
|
||||
const q = projectSearch.toLowerCase()
|
||||
return items.filter((ps: any) =>
|
||||
ps.project?.title?.toLowerCase().includes(q) ||
|
||||
ps.project?.teamName?.toLowerCase().includes(q) ||
|
||||
ps.project?.competitionCategory?.toLowerCase().includes(q)
|
||||
)
|
||||
}, [projectStates, projectSearch])
|
||||
|
||||
// Existing assignments for the selected juror (to grey out already-assigned projects)
|
||||
const jurorExistingProjectIds = useMemo(() => {
|
||||
if (!selectedJurorId || !assignments) return new Set<string>()
|
||||
return new Set(
|
||||
assignments
|
||||
.filter((a: any) => a.userId === selectedJurorId)
|
||||
.map((a: any) => a.projectId)
|
||||
)
|
||||
}, [selectedJurorId, assignments])
|
||||
|
||||
const toggleProject = useCallback((projectId: string) => {
|
||||
setSelectedProjectIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(projectId)) {
|
||||
next.delete(projectId)
|
||||
} else {
|
||||
next.add(projectId)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectAllUnassigned = useCallback(() => {
|
||||
const unassigned = filteredProjects
|
||||
.filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
|
||||
.map((ps: any) => ps.project?.id)
|
||||
.filter(Boolean)
|
||||
setSelectedProjectIds(new Set(unassigned))
|
||||
}, [filteredProjects, jurorExistingProjectIds])
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
if (!selectedJurorId || selectedProjectIds.size === 0) return
|
||||
|
||||
const projectIds = Array.from(selectedProjectIds)
|
||||
if (projectIds.length === 1) {
|
||||
createMutation.mutate({
|
||||
userId: selectedJurorId,
|
||||
projectId: projectIds[0],
|
||||
roundId,
|
||||
})
|
||||
} else {
|
||||
bulkCreateMutation.mutate({
|
||||
roundId,
|
||||
assignments: projectIds.map(projectId => ({
|
||||
userId: selectedJurorId,
|
||||
projectId,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
|
||||
|
||||
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -2071,44 +2277,220 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
|
||||
</CardContent>
|
||||
|
||||
{/* Add Assignment Dialog */}
|
||||
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
|
||||
<DialogContent>
|
||||
<Dialog open={addDialogOpen} onOpenChange={(open) => {
|
||||
if (!open) resetDialog()
|
||||
else setAddDialogOpen(true)
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[540px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Assignment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manually assign a juror to evaluate a project
|
||||
Select a juror and one or more projects to assign
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Juror Selector */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Juror User ID</Label>
|
||||
<Input
|
||||
placeholder="Enter jury member user ID..."
|
||||
value={newUserId}
|
||||
onChange={(e) => setNewUserId(e.target.value)}
|
||||
/>
|
||||
<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">
|
||||
<Label className="text-sm">Project ID</Label>
|
||||
<Input
|
||||
placeholder="Enter project ID..."
|
||||
value={newProjectId}
|
||||
onChange={(e) => setNewProjectId(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">
|
||||
Projects
|
||||
{selectedProjectIds.size > 0 && (
|
||||
<span className="ml-1.5 text-muted-foreground font-normal">
|
||||
({selectedProjectIds.size} selected)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{selectedJurorId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={selectAllUnassigned}
|
||||
>
|
||||
Select all
|
||||
</Button>
|
||||
{selectedProjectIds.size > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setSelectedProjectIds(new Set())}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter projects..."
|
||||
value={projectSearch}
|
||||
onChange={(e) => setProjectSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project checklist */}
|
||||
<ScrollArea className="h-[240px] rounded-md border">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{!selectedJurorId ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
Select a juror first
|
||||
</p>
|
||||
) : filteredProjects.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No projects found
|
||||
</p>
|
||||
) : (
|
||||
filteredProjects.map((ps: any) => {
|
||||
const project = ps.project
|
||||
if (!project) return null
|
||||
const alreadyAssigned = jurorExistingProjectIds.has(project.id)
|
||||
const isSelected = selectedProjectIds.has(project.id)
|
||||
|
||||
return (
|
||||
<label
|
||||
key={project.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
|
||||
alreadyAssigned
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isSelected
|
||||
? 'bg-accent'
|
||||
: 'hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
disabled={alreadyAssigned}
|
||||
onCheckedChange={() => toggleProject(project.id)}
|
||||
/>
|
||||
<div className="flex flex-1 items-center justify-between min-w-0">
|
||||
<span className="truncate">{project.title}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{project.competitionCategory === 'STARTUP'
|
||||
? 'Startup'
|
||||
: project.competitionCategory === 'BUSINESS_CONCEPT'
|
||||
? 'Concept'
|
||||
: project.competitionCategory}
|
||||
</Badge>
|
||||
)}
|
||||
{alreadyAssigned && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Assigned
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="outline" onClick={resetDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => createMutation.mutate({
|
||||
userId: newUserId,
|
||||
projectId: newProjectId,
|
||||
roundId,
|
||||
})}
|
||||
disabled={!newUserId || !newProjectId || createMutation.isPending}
|
||||
onClick={handleCreate}
|
||||
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
|
||||
>
|
||||
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Create Assignment
|
||||
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
{selectedProjectIds.size <= 1
|
||||
? 'Create Assignment'
|
||||
: `Create ${selectedProjectIds.size} Assignments`
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -2125,20 +2507,43 @@ function AdvanceProjectsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
roundId,
|
||||
roundType,
|
||||
projectStates,
|
||||
config,
|
||||
advanceMutation,
|
||||
competitionRounds,
|
||||
currentSortOrder,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
roundId: string
|
||||
roundType?: string
|
||||
projectStates: any[] | undefined
|
||||
config: Record<string, unknown>
|
||||
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[] }) => void; isPending: boolean }
|
||||
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
|
||||
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
|
||||
currentSortOrder?: number
|
||||
}) {
|
||||
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
|
||||
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
|
||||
// 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 allProjects = projectStates ?? []
|
||||
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
|
||||
const passedProjects = useMemo(() =>
|
||||
(projectStates ?? []).filter((ps: any) => ps.state === 'PASSED'),
|
||||
[projectStates])
|
||||
allProjects.filter((ps: any) => ps.state === 'PASSED'),
|
||||
[allProjects])
|
||||
|
||||
const startups = useMemo(() =>
|
||||
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
|
||||
@@ -2191,17 +2596,32 @@ function AdvanceProjectsDialog({
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdvance = () => {
|
||||
const ids = Array.from(selected)
|
||||
if (ids.length === 0) return
|
||||
advanceMutation.mutate({ roundId, projectIds: ids })
|
||||
const handleAdvance = (autoPass?: boolean) => {
|
||||
if (autoPass) {
|
||||
// Auto-pass all pending then advance all
|
||||
advanceMutation.mutate({
|
||||
roundId,
|
||||
autoPassPending: true,
|
||||
...(targetRoundId ? { targetRoundId } : {}),
|
||||
})
|
||||
} else {
|
||||
const ids = Array.from(selected)
|
||||
if (ids.length === 0) return
|
||||
advanceMutation.mutate({
|
||||
roundId,
|
||||
projectIds: ids,
|
||||
...(targetRoundId ? { targetRoundId } : {}),
|
||||
})
|
||||
}
|
||||
onOpenChange(false)
|
||||
setSelected(new Set())
|
||||
setTargetRoundId('')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false)
|
||||
setSelected(new Set())
|
||||
setTargetRoundId('')
|
||||
}
|
||||
|
||||
const renderCategorySection = (
|
||||
@@ -2257,32 +2677,89 @@ function AdvanceProjectsDialog({
|
||||
)
|
||||
}
|
||||
|
||||
const totalProjectCount = allProjects.length
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Advance Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select which passed projects to advance to the next round.
|
||||
{selected.size} of {passedProjects.length} selected.
|
||||
{isSimpleAdvance
|
||||
? `Move all ${totalProjectCount} projects to the next round.`
|
||||
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<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')}
|
||||
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
|
||||
</div>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{isSimpleAdvance ? (
|
||||
/* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
|
||||
<div className="py-4 space-y-3">
|
||||
<div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
|
||||
<p className="text-3xl font-bold">{totalProjectCount}</p>
|
||||
<p className="text-sm text-muted-foreground">projects will be advanced</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
|
||||
<p className="text-xs text-blue-700">
|
||||
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
|
||||
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Detailed mode for jury/evaluation rounds — per-project selection */
|
||||
<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')}
|
||||
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleAdvance}
|
||||
disabled={selected.size === 0 || advanceMutation.isPending}
|
||||
>
|
||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
{isSimpleAdvance ? (
|
||||
<Button
|
||||
onClick={() => handleAdvance(true)}
|
||||
disabled={totalProjectCount === 0 || advanceMutation.isPending}
|
||||
>
|
||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleAdvance()}
|
||||
disabled={selected.size === 0 || advanceMutation.isPending}
|
||||
>
|
||||
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
||||
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { SettingsContent } from '@/components/settings/settings-content'
|
||||
|
||||
// Categories that only super admins can access
|
||||
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
|
||||
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
|
||||
|
||||
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
||||
const settings = await prisma.systemSettings.findMany({
|
||||
|
||||
@@ -45,6 +45,8 @@ export function AssignmentPreviewSheet({
|
||||
toast.success(`Created ${result.created} assignments`)
|
||||
utils.roundAssignment.coverageReport.invalidate({ roundId })
|
||||
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
|
||||
utils.assignment.listByStage.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -88,6 +88,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Invitation sent to ${result.email}`)
|
||||
utils.user.list.invalidate()
|
||||
},
|
||||
onError: (err) => {
|
||||
// Don't block — user was created and added, just invitation failed
|
||||
|
||||
@@ -198,6 +198,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar
|
||||
onSuccess: (data) => {
|
||||
utils.filtering.getResults.invalidate()
|
||||
utils.filtering.getResultStats.invalidate({ roundId })
|
||||
utils.roundEngine.getProjectStates.invalidate({ roundId })
|
||||
utils.project.list.invalidate()
|
||||
toast.success(
|
||||
`Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` +
|
||||
(data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '')
|
||||
@@ -1597,7 +1599,7 @@ function FilteringRulesSection({ roundId }: { roundId: string }) {
|
||||
className="text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size), and team size.
|
||||
The AI has access to: category, country, region, founded year, ocean issue, tags, description, file details (type, page count, size, detected language), and team size.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -250,6 +250,7 @@ export function UserMobileActions({
|
||||
try {
|
||||
await sendInvitation.mutateAsync({ userId })
|
||||
toast.success(`Invitation sent to ${userEmail}`)
|
||||
utils.user.list.invalidate()
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
|
||||
} finally {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react'
|
||||
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal, Info } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -67,7 +67,10 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch available models from OpenAI API
|
||||
const watchProvider = form.watch('ai_provider')
|
||||
const isLiteLLM = watchProvider === 'litellm'
|
||||
|
||||
// Fetch available models from OpenAI API (skip for LiteLLM — no models.list support)
|
||||
const {
|
||||
data: modelsData,
|
||||
isLoading: modelsLoading,
|
||||
@@ -76,6 +79,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
} = trpc.settings.listAIModels.useQuery(undefined, {
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
retry: false,
|
||||
enabled: !isLiteLLM,
|
||||
})
|
||||
|
||||
const updateSettings = trpc.settings.updateMultiple.useMutation({
|
||||
@@ -182,32 +186,50 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (API Key)</SelectItem>
|
||||
<SelectItem value="litellm">LiteLLM Proxy (ChatGPT Subscription)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
AI provider for smart assignment suggestions
|
||||
{field.value === 'litellm'
|
||||
? 'Route AI calls through a LiteLLM proxy connected to your ChatGPT Plus/Pro subscription'
|
||||
: 'Direct OpenAI API access using your API key'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{isLiteLLM && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>LiteLLM Proxy Mode</strong> — AI calls will be routed through your LiteLLM proxy
|
||||
using your ChatGPT subscription. Token limits are automatically stripped (not supported by ChatGPT backend).
|
||||
Make sure your LiteLLM proxy is running and accessible.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="openai_api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormLabel>{isLiteLLM ? 'API Key (Optional)' : 'API Key'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={settings.openai_api_key ? '••••••••' : 'Enter API key'}
|
||||
placeholder={isLiteLLM
|
||||
? 'Optional — leave blank for default'
|
||||
: (settings.openai_api_key ? '••••••••' : 'Enter API key')}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your OpenAI API key. Leave blank to keep the existing key.
|
||||
{isLiteLLM
|
||||
? 'LiteLLM proxy usually does not require an API key. Leave blank to use default.'
|
||||
: 'Your OpenAI API key. Leave blank to keep the existing key.'}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -219,16 +241,26 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
name="openai_base_url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Base URL (Optional)</FormLabel>
|
||||
<FormLabel>{isLiteLLM ? 'LiteLLM Proxy URL' : 'API Base URL (Optional)'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
placeholder={isLiteLLM ? 'http://localhost:4000' : 'https://api.openai.com/v1'}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
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.)
|
||||
{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.
|
||||
</>
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -242,7 +274,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>Model</FormLabel>
|
||||
{modelsData?.success && (
|
||||
{!isLiteLLM && modelsData?.success && !modelsData?.manualEntry && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -256,7 +288,13 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modelsLoading ? (
|
||||
{isLiteLLM || modelsData?.manualEntry ? (
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="chatgpt/gpt-5.2"
|
||||
/>
|
||||
) : modelsLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : modelsError || !modelsData?.success ? (
|
||||
<div className="space-y-2">
|
||||
@@ -303,7 +341,15 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
||||
</Select>
|
||||
)}
|
||||
<FormDescription>
|
||||
{form.watch('ai_model')?.startsWith('o') ? (
|
||||
{isLiteLLM ? (
|
||||
<>
|
||||
Enter the model ID with the{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">chatgpt/</code> prefix.
|
||||
Examples:{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">chatgpt/gpt-5.2</code>,{' '}
|
||||
<code className="text-xs bg-muted px-1 rounded">chatgpt/gpt-5.2-codex</code>
|
||||
</>
|
||||
) : form.watch('ai_model')?.startsWith('o') ? (
|
||||
<span className="flex items-center gap-1 text-purple-600">
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
Reasoning model - optimized for complex analysis tasks
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
ShieldAlert,
|
||||
Globe,
|
||||
Webhook,
|
||||
MessageCircle,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
@@ -103,8 +104,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
])
|
||||
|
||||
const storageSettings = getSettingsByKeys([
|
||||
'storage_provider',
|
||||
'local_storage_path',
|
||||
'max_file_size_mb',
|
||||
'avatar_max_size_mb',
|
||||
'allowed_file_types',
|
||||
'allowed_image_types',
|
||||
])
|
||||
|
||||
const securitySettings = getSettingsByKeys([
|
||||
@@ -147,6 +152,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
'anomaly_off_hours_end',
|
||||
])
|
||||
|
||||
const whatsappSettings = getSettingsByKeys([
|
||||
'whatsapp_enabled',
|
||||
'whatsapp_provider',
|
||||
])
|
||||
|
||||
const localizationSettings = getSettingsByKeys([
|
||||
'localization_enabled_locales',
|
||||
'localization_default_locale',
|
||||
@@ -183,6 +193,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
<Newspaper className="h-4 w-4" />
|
||||
Digest
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="whatsapp" className="gap-2 shrink-0">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="security" className="gap-2 shrink-0">
|
||||
<Shield className="h-4 w-4" />
|
||||
@@ -259,6 +275,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
<Newspaper className="h-4 w-4" />
|
||||
Digest
|
||||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="whatsapp" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
<div>
|
||||
@@ -502,6 +524,24 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="whatsapp" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>WhatsApp Notifications</CardTitle>
|
||||
<CardDescription>
|
||||
Configure WhatsApp messaging for notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WhatsAppSettingsSection settings={whatsappSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
</div>{/* end content area */}
|
||||
</div>{/* end lg:flex */}
|
||||
</Tabs>
|
||||
@@ -794,6 +834,29 @@ function AuditSettingsSection({ settings }: { settings: Record<string, string> }
|
||||
)
|
||||
}
|
||||
|
||||
function WhatsAppSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingToggle
|
||||
label="Enable WhatsApp Notifications"
|
||||
description="Send notifications via WhatsApp in addition to email"
|
||||
settingKey="whatsapp_enabled"
|
||||
value={settings.whatsapp_enabled || 'false'}
|
||||
/>
|
||||
<SettingSelect
|
||||
label="WhatsApp Provider"
|
||||
description="Select the API provider for sending WhatsApp messages"
|
||||
settingKey="whatsapp_provider"
|
||||
value={settings.whatsapp_provider || 'META'}
|
||||
options={[
|
||||
{ value: 'META', label: 'Meta (WhatsApp Business API)' },
|
||||
{ value: 'TWILIO', label: 'Twilio' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LocalizationSettingsSection({ settings }: { settings: Record<string, string> }) {
|
||||
const mutation = useSettingsMutation()
|
||||
const enabledLocales = (settings.localization_enabled_locales || 'en').split(',')
|
||||
|
||||
@@ -22,6 +22,14 @@ import {
|
||||
} from '@/components/ui/form'
|
||||
// Note: Storage provider cache is cleared server-side when settings are updated
|
||||
|
||||
const COMMON_IMAGE_TYPES = [
|
||||
{ value: 'image/png', label: 'PNG (.png)' },
|
||||
{ value: 'image/jpeg', label: 'JPEG (.jpg, .jpeg)' },
|
||||
{ value: 'image/webp', label: 'WebP (.webp)' },
|
||||
{ value: 'image/gif', label: 'GIF (.gif)' },
|
||||
{ value: 'image/svg+xml', label: 'SVG (.svg)' },
|
||||
]
|
||||
|
||||
const COMMON_FILE_TYPES = [
|
||||
{ value: 'application/pdf', label: 'PDF Documents (.pdf)' },
|
||||
{ value: 'video/mp4', label: 'MP4 Video (.mp4)' },
|
||||
@@ -41,6 +49,7 @@ const formSchema = z.object({
|
||||
max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
|
||||
avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'),
|
||||
allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'),
|
||||
allowed_image_types: z.array(z.string()).min(1, 'Select at least one image type'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
@@ -52,6 +61,7 @@ interface StorageSettingsFormProps {
|
||||
max_file_size_mb?: string
|
||||
avatar_max_size_mb?: string
|
||||
allowed_file_types?: string
|
||||
allowed_image_types?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,6 +78,16 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||
allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']
|
||||
}
|
||||
|
||||
// Parse allowed image types from JSON string
|
||||
let allowedImageTypes: string[] = []
|
||||
try {
|
||||
allowedImageTypes = settings.allowed_image_types
|
||||
? JSON.parse(settings.allowed_image_types)
|
||||
: ['image/png', 'image/jpeg', 'image/webp']
|
||||
} catch {
|
||||
allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp']
|
||||
}
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -76,6 +96,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||
max_file_size_mb: settings.max_file_size_mb || '500',
|
||||
avatar_max_size_mb: settings.avatar_max_size_mb || '5',
|
||||
allowed_file_types: allowedTypes,
|
||||
allowed_image_types: allowedImageTypes,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -99,6 +120,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||
{ key: 'max_file_size_mb', value: data.max_file_size_mb },
|
||||
{ key: 'avatar_max_size_mb', value: data.avatar_max_size_mb },
|
||||
{ key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) },
|
||||
{ key: 'allowed_image_types', value: JSON.stringify(data.allowed_image_types) },
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -255,6 +277,57 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) {
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowed_image_types"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<div className="mb-4">
|
||||
<FormLabel>Allowed Image Types (Avatars/Logos)</FormLabel>
|
||||
<FormDescription>
|
||||
Select which image formats can be used for profile pictures and project logos
|
||||
</FormDescription>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{COMMON_IMAGE_TYPES.map((type) => (
|
||||
<FormField
|
||||
key={type.value}
|
||||
control={form.control}
|
||||
name="allowed_image_types"
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem
|
||||
key={type.value}
|
||||
className="flex items-start space-x-3 space-y-0"
|
||||
>
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(type.value)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, type.value])
|
||||
: field.onChange(
|
||||
field.value?.filter(
|
||||
(value) => value !== type.value
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="cursor-pointer text-sm font-normal">
|
||||
{type.label}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{storageProvider === 's3' && (
|
||||
<div className="rounded-lg border border-muted bg-muted/50 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -65,6 +65,12 @@ interface ProjectFile {
|
||||
isLate?: boolean
|
||||
requirementId?: string | null
|
||||
requirement?: FileRequirementInfo | null
|
||||
// Document analysis fields
|
||||
pageCount?: number | null
|
||||
textPreview?: string | null
|
||||
detectedLang?: string | null
|
||||
langConfidence?: number | null
|
||||
analyzedAt?: Date | string | null
|
||||
}
|
||||
|
||||
interface RoundGroup {
|
||||
@@ -270,6 +276,25 @@ function FileItem({ file }: { file: ProjectFile }) {
|
||||
</Badge>
|
||||
)}
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
{file.pageCount != null && (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
{file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'}
|
||||
</Badge>
|
||||
)}
|
||||
{file.detectedLang && file.detectedLang !== 'und' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs font-mono uppercase', {
|
||||
'border-green-300 text-green-700 bg-green-50': file.langConfidence != null && file.langConfidence >= 0.8,
|
||||
'border-amber-300 text-amber-700 bg-amber-50': file.langConfidence != null && file.langConfidence >= 0.4 && file.langConfidence < 0.8,
|
||||
'border-red-300 text-red-700 bg-red-50': file.langConfidence != null && file.langConfidence < 0.4,
|
||||
})}
|
||||
title={`Language: ${file.detectedLang} (${Math.round((file.langConfidence ?? 0) * 100)}% confidence)`}
|
||||
>
|
||||
{file.detectedLang.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,33 @@ const globalForOpenAI = globalThis as unknown as {
|
||||
openaiInitialized: boolean
|
||||
}
|
||||
|
||||
// ─── Provider Detection ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the configured AI provider from SystemSettings.
|
||||
* Returns 'openai' (default) or 'litellm' (ChatGPT subscription proxy).
|
||||
*/
|
||||
export async function getConfiguredProvider(): Promise<'openai' | 'litellm'> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'ai_provider' },
|
||||
})
|
||||
const value = setting?.value || 'openai'
|
||||
return value === 'litellm' ? 'litellm' : 'openai'
|
||||
} catch {
|
||||
return 'openai'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model ID indicates LiteLLM ChatGPT subscription routing.
|
||||
* Models like 'chatgpt/gpt-5.2' use the chatgpt/ prefix.
|
||||
* Used by buildCompletionParams (sync) to strip unsupported token limit fields.
|
||||
*/
|
||||
export function isLiteLLMChatGPTModel(model: string): boolean {
|
||||
return model.toLowerCase().startsWith('chatgpt/')
|
||||
}
|
||||
|
||||
// ─── Model Type Detection ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -168,6 +195,12 @@ export function buildCompletionParams(
|
||||
params.response_format = { type: 'json_object' }
|
||||
}
|
||||
|
||||
// LiteLLM ChatGPT subscription models reject token limit fields
|
||||
if (isLiteLLMChatGPTModel(model)) {
|
||||
delete params.max_tokens
|
||||
delete params.max_completion_tokens
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
@@ -209,8 +242,12 @@ async function getBaseURL(): Promise<string | undefined> {
|
||||
*/
|
||||
async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||
const apiKey = await getOpenAIApiKey()
|
||||
const provider = await getConfiguredProvider()
|
||||
|
||||
if (!apiKey) {
|
||||
// LiteLLM proxy may not require a real API key
|
||||
const effectiveApiKey = apiKey || (provider === 'litellm' ? 'sk-litellm' : null)
|
||||
|
||||
if (!effectiveApiKey) {
|
||||
console.warn('OpenAI API key not configured')
|
||||
return null
|
||||
}
|
||||
@@ -218,11 +255,11 @@ async function createOpenAIClient(): Promise<OpenAI | null> {
|
||||
const baseURL = await getBaseURL()
|
||||
|
||||
if (baseURL) {
|
||||
console.log(`[OpenAI] Using custom base URL: ${baseURL}`)
|
||||
console.log(`[OpenAI] Using custom base URL: ${baseURL} (provider: ${provider})`)
|
||||
}
|
||||
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
apiKey: effectiveApiKey,
|
||||
...(baseURL ? { baseURL } : {}),
|
||||
})
|
||||
}
|
||||
@@ -259,6 +296,12 @@ export function resetOpenAIClient(): void {
|
||||
* Check if OpenAI is configured and available
|
||||
*/
|
||||
export async function isOpenAIConfigured(): Promise<boolean> {
|
||||
const provider = await getConfiguredProvider()
|
||||
if (provider === 'litellm') {
|
||||
// LiteLLM just needs a base URL configured
|
||||
const baseURL = await getBaseURL()
|
||||
return !!baseURL
|
||||
}
|
||||
const apiKey = await getOpenAIApiKey()
|
||||
return !!apiKey
|
||||
}
|
||||
@@ -270,8 +313,20 @@ export async function listAvailableModels(): Promise<{
|
||||
success: boolean
|
||||
models?: string[]
|
||||
error?: string
|
||||
manualEntry?: boolean
|
||||
}> {
|
||||
try {
|
||||
const provider = await getConfiguredProvider()
|
||||
|
||||
// LiteLLM proxy for ChatGPT subscription doesn't support models.list()
|
||||
if (provider === 'litellm') {
|
||||
return {
|
||||
success: true,
|
||||
models: [],
|
||||
manualEntry: true,
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getOpenAI()
|
||||
|
||||
if (!client) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getPresignedUrl, generateObjectKey } from '@/lib/minio'
|
||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { createNotification } from '../services/in-app-notification'
|
||||
import { checkRequirementsAndTransition } from '../services/round-engine'
|
||||
|
||||
// Bucket for applicant submissions
|
||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||
@@ -410,6 +411,24 @@ export const applicantRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-transition: if uploading against a round requirement, check completion
|
||||
if (roundId && requirementId) {
|
||||
await checkRequirementsAndTransition(
|
||||
projectId,
|
||||
roundId,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
}
|
||||
|
||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
||||
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
|
||||
isAutoAnalysisEnabled().then((enabled) => {
|
||||
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
|
||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
||||
})
|
||||
).catch(() => {})
|
||||
|
||||
return file
|
||||
}),
|
||||
|
||||
|
||||
@@ -74,10 +74,22 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
description: true,
|
||||
tags: true,
|
||||
teamName: true,
|
||||
projectTags: {
|
||||
select: { tag: { select: { name: true } }, confidence: true },
|
||||
},
|
||||
_count: { select: { assignments: { where: { roundId } } } },
|
||||
},
|
||||
})
|
||||
|
||||
// Enrich projects with tag confidence data for AI matching
|
||||
const projectsWithConfidence = projects.map((p) => ({
|
||||
...p,
|
||||
tagConfidences: p.projectTags.map((pt) => ({
|
||||
name: pt.tag.name,
|
||||
confidence: pt.confidence,
|
||||
})),
|
||||
}))
|
||||
|
||||
const existingAssignments = await prisma.assignment.findMany({
|
||||
where: { roundId },
|
||||
select: { userId: true, projectId: true },
|
||||
@@ -124,7 +136,7 @@ async function runAIAssignmentJob(jobId: string, roundId: string, userId: string
|
||||
|
||||
const result = await generateAIAssignments(
|
||||
jurors,
|
||||
projects,
|
||||
projectsWithConfidence,
|
||||
constraints,
|
||||
userId,
|
||||
roundId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TRPCError } from '@trpc/server'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { getPresignedUrl, generateObjectKey, deleteObject, BUCKET_NAME } from '@/lib/minio'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { checkRequirementsAndTransition } from '../services/round-engine'
|
||||
|
||||
export const fileRouter = router({
|
||||
/**
|
||||
@@ -205,6 +206,14 @@ export const fileRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
||||
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
|
||||
isAutoAnalysisEnabled().then((enabled) => {
|
||||
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
|
||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
||||
})
|
||||
).catch(() => {})
|
||||
|
||||
return {
|
||||
uploadUrl,
|
||||
file,
|
||||
@@ -1200,6 +1209,14 @@ export const fileRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
||||
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
|
||||
isAutoAnalysisEnabled().then((enabled) => {
|
||||
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
|
||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
||||
})
|
||||
).catch(() => {})
|
||||
|
||||
return { uploadUrl, file }
|
||||
}),
|
||||
|
||||
@@ -1501,6 +1518,22 @@ export const fileRouter = router({
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Auto-transition: check if all required documents are now uploaded
|
||||
await checkRequirementsAndTransition(
|
||||
input.projectId,
|
||||
input.roundId,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
|
||||
// Auto-analyze document (fire-and-forget, delayed for presigned upload)
|
||||
import('../services/document-analyzer').then(({ analyzeFileDelayed, isAutoAnalysisEnabled }) =>
|
||||
isAutoAnalysisEnabled().then((enabled) => {
|
||||
if (enabled) analyzeFileDelayed(file.id).catch((err) =>
|
||||
console.warn('[DocAnalyzer] Post-upload analysis failed:', err))
|
||||
})
|
||||
).catch(() => {})
|
||||
|
||||
return { uploadUrl, file }
|
||||
}),
|
||||
|
||||
@@ -1536,4 +1569,25 @@ export const fileRouter = router({
|
||||
)
|
||||
return results
|
||||
}),
|
||||
|
||||
/**
|
||||
* Analyze all files for a specific project (page count, language, text preview).
|
||||
* Retroactive: re-analyzes even previously analyzed files.
|
||||
*/
|
||||
analyzeProjectFiles: adminProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const { analyzeProjectFiles } = await import('../services/document-analyzer')
|
||||
return analyzeProjectFiles(input.projectId)
|
||||
}),
|
||||
|
||||
/**
|
||||
* Batch analyze all unanalyzed files across the platform.
|
||||
* For retroactive analysis of files uploaded before this feature.
|
||||
*/
|
||||
analyzeAllFiles: adminProcedure
|
||||
.mutation(async () => {
|
||||
const { analyzeAllUnanalyzed } = await import('../services/document-analyzer')
|
||||
return analyzeAllUnanalyzed()
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -69,6 +69,8 @@ export async function runFilteringJob(jobId: string, roundId: string, userId: st
|
||||
mimeType: true,
|
||||
size: true,
|
||||
pageCount: true,
|
||||
detectedLang: true,
|
||||
langConfidence: true,
|
||||
objectKey: true,
|
||||
roundId: true,
|
||||
createdAt: true,
|
||||
|
||||
@@ -243,10 +243,11 @@ export const roundRouter = router({
|
||||
roundId: z.string(),
|
||||
targetRoundId: z.string().optional(),
|
||||
projectIds: z.array(z.string()).optional(),
|
||||
autoPassPending: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { roundId, targetRoundId, projectIds } = input
|
||||
const { roundId, targetRoundId, projectIds, autoPassPending } = input
|
||||
|
||||
// Get current round with competition context
|
||||
const currentRound = await ctx.prisma.round.findUniqueOrThrow({
|
||||
@@ -280,6 +281,16 @@ export const roundRouter = router({
|
||||
targetRound = nextRound
|
||||
}
|
||||
|
||||
// Auto-pass all PENDING projects first (for intake/bulk workflows)
|
||||
let autoPassedCount = 0
|
||||
if (autoPassPending) {
|
||||
const result = await ctx.prisma.projectRoundState.updateMany({
|
||||
where: { roundId, state: 'PENDING' },
|
||||
data: { state: 'PASSED' },
|
||||
})
|
||||
autoPassedCount = result.count
|
||||
}
|
||||
|
||||
// Determine which projects to advance
|
||||
let idsToAdvance: string[]
|
||||
if (projectIds && projectIds.length > 0) {
|
||||
@@ -346,6 +357,7 @@ export const roundRouter = router({
|
||||
toRound: targetRound.name,
|
||||
targetRoundId: targetRound.id,
|
||||
projectCount: idsToAdvance.length,
|
||||
autoPassedCount,
|
||||
projectIds: idsToAdvance,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
@@ -354,6 +366,7 @@ export const roundRouter = router({
|
||||
|
||||
return {
|
||||
advancedCount: idsToAdvance.length,
|
||||
autoPassedCount,
|
||||
targetRoundId: targetRound.id,
|
||||
targetRoundName: targetRound.name,
|
||||
}
|
||||
|
||||
@@ -263,4 +263,41 @@ export const roundEngineRouter = router({
|
||||
|
||||
return { success: true, removedCount: deleted.count }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Retroactive document check: auto-PASS any PENDING/IN_PROGRESS projects
|
||||
* that already have all required documents uploaded for this round.
|
||||
* Useful for rounds activated before the auto-transition feature was deployed.
|
||||
*/
|
||||
checkDocumentCompletion: adminProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { batchCheckRequirementsAndTransition } = await import('../services/round-engine')
|
||||
|
||||
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||
where: {
|
||||
roundId: input.roundId,
|
||||
state: { in: ['PENDING', 'IN_PROGRESS'] },
|
||||
},
|
||||
select: { projectId: true },
|
||||
})
|
||||
|
||||
if (projectStates.length === 0) {
|
||||
return { transitionedCount: 0, checkedCount: 0, projectIds: [] }
|
||||
}
|
||||
|
||||
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
||||
const result = await batchCheckRequirementsAndTransition(
|
||||
input.roundId,
|
||||
projectIds,
|
||||
ctx.user.id,
|
||||
ctx.prisma,
|
||||
)
|
||||
|
||||
return {
|
||||
transitionedCount: result.transitionedCount,
|
||||
checkedCount: projectIds.length,
|
||||
projectIds: result.projectIds,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -201,8 +201,8 @@ export const settingsRouter = router({
|
||||
clearStorageProviderCache()
|
||||
}
|
||||
|
||||
// Reset OpenAI client if API key or base URL changed
|
||||
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model')) {
|
||||
// Reset OpenAI client if API key, base URL, model, or provider changed
|
||||
if (input.settings.some((s) => s.key === 'openai_api_key' || s.key === 'openai_base_url' || s.key === 'ai_model' || s.key === 'ai_provider')) {
|
||||
const { resetOpenAIClient } = await import('@/lib/openai')
|
||||
resetOpenAIClient()
|
||||
}
|
||||
@@ -247,6 +247,15 @@ export const settingsRouter = router({
|
||||
listAIModels: superAdminProcedure.query(async () => {
|
||||
const result = await listAvailableModels()
|
||||
|
||||
// LiteLLM mode: manual model entry, no listing available
|
||||
if (result.manualEntry) {
|
||||
return {
|
||||
success: true,
|
||||
models: [],
|
||||
manualEntry: true,
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success || !result.models) {
|
||||
return {
|
||||
success: false,
|
||||
|
||||
@@ -38,7 +38,7 @@ const ASSIGNMENT_SYSTEM_PROMPT = `You are an expert jury assignment optimizer fo
|
||||
Match jurors to projects based on expertise alignment, workload balance, and coverage requirements.
|
||||
|
||||
## Matching Criteria (Weighted)
|
||||
- Expertise Match (50%): How well juror tags/expertise align with project topics
|
||||
- Expertise Match (50%): How well juror tags/expertise align with project topics. Project tags include a confidence score (0-1) — weight higher-confidence tags more heavily as they are more reliably assigned. A tag with confidence 0.9 is a strong signal; one with 0.5 is uncertain.
|
||||
- Workload Balance (30%): Distribute assignments evenly; prefer jurors below capacity
|
||||
- Minimum Target (20%): Prioritize jurors who haven't reached their minimum assignment count
|
||||
|
||||
@@ -99,6 +99,7 @@ interface ProjectForAssignment {
|
||||
title: string
|
||||
description?: string | null
|
||||
tags: string[]
|
||||
tagConfidences?: Array<{ name: string; confidence: number }>
|
||||
teamName?: string | null
|
||||
_count?: {
|
||||
assignments: number
|
||||
@@ -539,7 +540,7 @@ export function generateFallbackAssignments(
|
||||
|
||||
return {
|
||||
juror,
|
||||
score: calculateExpertiseScore(juror.expertiseTags, project.tags),
|
||||
score: calculateExpertiseScore(juror.expertiseTags, project.tags, project.tagConfidences),
|
||||
loadScore: calculateLoadScore(currentLoad, maxLoad),
|
||||
underMinBonus: calculateUnderMinBonus(currentLoad, minTarget),
|
||||
}
|
||||
@@ -586,24 +587,44 @@ export function generateFallbackAssignments(
|
||||
|
||||
/**
|
||||
* Calculate expertise match score based on tag overlap
|
||||
* When tagConfidences are available, weights matches by confidence
|
||||
*/
|
||||
function calculateExpertiseScore(
|
||||
jurorTags: string[],
|
||||
projectTags: string[]
|
||||
projectTags: string[],
|
||||
tagConfidences?: Array<{ name: string; confidence: number }>
|
||||
): number {
|
||||
if (jurorTags.length === 0 || projectTags.length === 0) {
|
||||
return 0.5 // Neutral score if no tags
|
||||
}
|
||||
|
||||
const jurorTagsLower = new Set(jurorTags.map((t) => t.toLowerCase()))
|
||||
|
||||
// If we have confidence data, use weighted scoring
|
||||
if (tagConfidences && tagConfidences.length > 0) {
|
||||
let weightedMatches = 0
|
||||
let totalWeight = 0
|
||||
|
||||
for (const tc of tagConfidences) {
|
||||
totalWeight += tc.confidence
|
||||
if (jurorTagsLower.has(tc.name.toLowerCase())) {
|
||||
weightedMatches += tc.confidence
|
||||
}
|
||||
}
|
||||
|
||||
if (totalWeight === 0) return 0.5
|
||||
|
||||
const weightedRatio = weightedMatches / totalWeight
|
||||
const hasExpertise = weightedMatches > 0 ? 0.2 : 0
|
||||
return Math.min(1, weightedRatio * 0.8 + hasExpertise)
|
||||
}
|
||||
|
||||
// Fallback: unweighted matching using flat tags
|
||||
const matchingTags = projectTags.filter((t) =>
|
||||
jurorTagsLower.has(t.toLowerCase())
|
||||
)
|
||||
|
||||
// Score based on percentage of project tags matched
|
||||
const matchRatio = matchingTags.length / projectTags.length
|
||||
|
||||
// Boost for having expertise, even if not all match
|
||||
const hasExpertise = matchingTags.length > 0 ? 0.2 : 0
|
||||
|
||||
return Math.min(1, matchRatio * 0.8 + hasExpertise)
|
||||
|
||||
@@ -179,10 +179,11 @@ Return a JSON object with this exact structure:
|
||||
- founded_year: when the company/initiative was founded (use for age checks)
|
||||
- ocean_issue: the ocean conservation area
|
||||
- file_count, file_types: uploaded documents summary
|
||||
- files[]: per-file details with file_type, page_count (if known), size_kb, round_name (which round the file was submitted for), and is_current_round flag
|
||||
- files[]: per-file details with file_type, page_count (if known), size_kb, detected_lang (ISO 639-3 language code like 'eng', 'fra'), lang_confidence (0-1), round_name (which round the file was submitted for), and is_current_round flag
|
||||
- description: project summary text
|
||||
- tags: topic tags
|
||||
- If document content is provided (text_content field in files), use it for deeper analysis. Pay SPECIAL ATTENTION to files from the current round (is_current_round=true) as they are the most recent and relevant submissions.
|
||||
- If detected_lang is provided, use it to evaluate language requirements (e.g. 'eng' = English, 'fra' = French). lang_confidence indicates detection reliability.
|
||||
|
||||
## Guidelines
|
||||
- Evaluate ONLY against the provided criteria, not your own standards
|
||||
|
||||
@@ -52,7 +52,7 @@ export interface AnonymizedProject {
|
||||
anonymousId: string
|
||||
title: string
|
||||
description: string | null
|
||||
tags: string[]
|
||||
tags: Array<{ name: string; confidence: number }>
|
||||
teamName: string | null
|
||||
}
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface AnonymizedFileInfo {
|
||||
file_type: string // FileType enum value
|
||||
page_count: number | null // Number of pages if known
|
||||
size_kb: number // File size in KB
|
||||
detected_lang?: string | null // ISO 639-3 language code (e.g. 'eng', 'fra')
|
||||
lang_confidence?: number | null // 0.0–1.0 confidence score
|
||||
round_name?: string | null // Which round the file was submitted for
|
||||
is_current_round?: boolean // Whether this file belongs to the current filtering/evaluation round
|
||||
text_content?: string // Extracted text content (when aiParseFiles is enabled)
|
||||
@@ -209,6 +211,7 @@ interface ProjectInput {
|
||||
title: string
|
||||
description?: string | null
|
||||
tags: string[]
|
||||
tagConfidences?: Array<{ name: string; confidence: number }>
|
||||
teamName?: string | null
|
||||
}
|
||||
|
||||
@@ -253,7 +256,9 @@ export function anonymizeForAI(
|
||||
description: project.description
|
||||
? truncateAndSanitize(project.description, DESCRIPTION_LIMITS.ASSIGNMENT)
|
||||
: null,
|
||||
tags: project.tags,
|
||||
tags: project.tagConfidences && project.tagConfidences.length > 0
|
||||
? project.tagConfidences
|
||||
: project.tags.map((t) => ({ name: t, confidence: 1.0 })),
|
||||
teamName: project.teamName ? `Team ${index + 1}` : null,
|
||||
}
|
||||
}
|
||||
@@ -306,6 +311,8 @@ export function anonymizeProjectForAI(
|
||||
file_type: f.fileType ?? 'OTHER',
|
||||
page_count: f.pageCount ?? null,
|
||||
size_kb: Math.round((f.size ?? 0) / 1024),
|
||||
...(f.detectedLang ? { detected_lang: f.detectedLang } : {}),
|
||||
...(f.langConfidence != null ? { lang_confidence: f.langConfidence } : {}),
|
||||
...(f.roundName ? { round_name: f.roundName } : {}),
|
||||
...(f.isCurrentRound !== undefined ? { is_current_round: f.isCurrentRound } : {}),
|
||||
...(f.textContent ? { text_content: f.textContent } : {}),
|
||||
@@ -524,7 +531,7 @@ export function validateAnonymization(data: AnonymizationResult): boolean {
|
||||
if (!checkText(project.title)) return false
|
||||
if (!checkText(project.description)) return false
|
||||
for (const tag of project.tags) {
|
||||
if (!checkText(tag)) return false
|
||||
if (!checkText(typeof tag === 'string' ? tag : tag.name)) return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
367
src/server/services/document-analyzer.ts
Normal file
367
src/server/services/document-analyzer.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Document Analyzer Service
|
||||
*
|
||||
* Extracts metadata from uploaded files:
|
||||
* - Page count (PDFs)
|
||||
* - Text preview (first ~2000 chars)
|
||||
* - Language detection via franc
|
||||
*
|
||||
* Runs optionally on upload (controlled by SystemSettings) and
|
||||
* retroactively via admin endpoint.
|
||||
*/
|
||||
|
||||
import { getStorageProvider } from '@/lib/storage'
|
||||
import { isParseableMimeType } from './file-content-extractor'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const TEXT_PREVIEW_LIMIT = 2000
|
||||
const BATCH_SIZE = 10
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AnalysisResult = {
|
||||
fileId: string
|
||||
pageCount: number | null
|
||||
textPreview: string | null
|
||||
detectedLang: string | null
|
||||
langConfidence: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
// ─── Language Detection ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect language using franc. Returns ISO 639-3 code and confidence.
|
||||
* franc returns a distance-based score where lower = better match.
|
||||
* We convert to 0-1 confidence where 1 = perfect match.
|
||||
*/
|
||||
async function detectLanguage(
|
||||
text: string
|
||||
): Promise<{ lang: string; confidence: number }> {
|
||||
if (!text || text.trim().length < 20) {
|
||||
return { lang: 'und', confidence: 0 }
|
||||
}
|
||||
|
||||
// Use a reasonable sample for detection (first 5000 chars)
|
||||
const sample = text.slice(0, 5000)
|
||||
|
||||
const { francAll } = await import('franc')
|
||||
const results = francAll(sample, { minLength: 10 })
|
||||
|
||||
if (!results || results.length === 0 || results[0][0] === 'und') {
|
||||
return { lang: 'und', confidence: 0 }
|
||||
}
|
||||
|
||||
const topLang = results[0][0]
|
||||
const topScore = results[0][1] // 1.0 = best match, 0.0 = worst
|
||||
|
||||
// franc scores: 1.0 is best match, scale drops from there
|
||||
// Convert to a 0-1 confidence
|
||||
const confidence = Math.max(0, Math.min(1, topScore))
|
||||
|
||||
return { lang: topLang, confidence: Math.round(confidence * 100) / 100 }
|
||||
}
|
||||
|
||||
// ─── Core Analysis ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Analyze a single file: extract page count, text preview, and detect language.
|
||||
* Downloads the file from storage, parses it, and returns results.
|
||||
*/
|
||||
export async function analyzeFileContent(
|
||||
objectKey: string,
|
||||
bucket: string,
|
||||
mimeType: string,
|
||||
fileName: string,
|
||||
fileId: string
|
||||
): Promise<AnalysisResult> {
|
||||
const result: AnalysisResult = {
|
||||
fileId,
|
||||
pageCount: null,
|
||||
textPreview: null,
|
||||
detectedLang: null,
|
||||
langConfidence: null,
|
||||
}
|
||||
|
||||
if (!isParseableMimeType(mimeType)) {
|
||||
return { ...result, error: 'Unsupported mime type for analysis' }
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = await getStorageProvider()
|
||||
const buffer = await storage.getObject(objectKey)
|
||||
|
||||
let text = ''
|
||||
let pageCount: number | null = null
|
||||
|
||||
if (mimeType === 'application/pdf') {
|
||||
const pdfParseModule = await import('pdf-parse')
|
||||
const pdfParse =
|
||||
typeof pdfParseModule === 'function'
|
||||
? pdfParseModule
|
||||
: (pdfParseModule as any).default ?? pdfParseModule
|
||||
const pdf = await pdfParse(buffer)
|
||||
text = pdf.text || ''
|
||||
pageCount = pdf.numpages ?? null
|
||||
} else {
|
||||
// Text-based files (plain text, CSV, markdown, HTML, RTF)
|
||||
text = buffer.toString('utf-8')
|
||||
}
|
||||
|
||||
result.pageCount = pageCount
|
||||
|
||||
// Text preview
|
||||
if (text.trim()) {
|
||||
result.textPreview =
|
||||
text.length > TEXT_PREVIEW_LIMIT
|
||||
? text.slice(0, TEXT_PREVIEW_LIMIT)
|
||||
: text
|
||||
}
|
||||
|
||||
// Language detection
|
||||
if (text.trim().length >= 20) {
|
||||
const langResult = await detectLanguage(text)
|
||||
result.detectedLang = langResult.lang
|
||||
result.langConfidence = langResult.confidence
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[DocAnalyzer] Failed to analyze ${fileName}:`,
|
||||
error instanceof Error ? error.message : error
|
||||
)
|
||||
return {
|
||||
...result,
|
||||
error: error instanceof Error ? error.message : 'Analysis failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DB-Integrated Operations ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Analyze a single file by ID and persist results to DB.
|
||||
*/
|
||||
export async function analyzeFile(fileId: string): Promise<AnalysisResult> {
|
||||
const file = await prisma.projectFile.findUnique({
|
||||
where: { id: fileId },
|
||||
select: {
|
||||
id: true,
|
||||
objectKey: true,
|
||||
bucket: true,
|
||||
mimeType: true,
|
||||
fileName: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
return {
|
||||
fileId,
|
||||
pageCount: null,
|
||||
textPreview: null,
|
||||
detectedLang: null,
|
||||
langConfidence: null,
|
||||
error: 'File not found',
|
||||
}
|
||||
}
|
||||
|
||||
const result = await analyzeFileContent(
|
||||
file.objectKey,
|
||||
file.bucket,
|
||||
file.mimeType,
|
||||
file.fileName,
|
||||
file.id
|
||||
)
|
||||
|
||||
// Persist results
|
||||
await prisma.projectFile.update({
|
||||
where: { id: fileId },
|
||||
data: {
|
||||
pageCount: result.pageCount,
|
||||
textPreview: result.textPreview,
|
||||
detectedLang: result.detectedLang,
|
||||
langConfidence: result.langConfidence,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a single file by ID with a delay (for post-upload use).
|
||||
* The delay accounts for presigned URL uploads where the file
|
||||
* may not be in storage yet when the DB record is created.
|
||||
*/
|
||||
export async function analyzeFileDelayed(
|
||||
fileId: string,
|
||||
delayMs = 3000
|
||||
): Promise<AnalysisResult> {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
return analyzeFile(fileId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze all files for a specific project.
|
||||
*/
|
||||
export async function analyzeProjectFiles(
|
||||
projectId: string
|
||||
): Promise<{ analyzed: number; failed: number; total: number }> {
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: { projectId },
|
||||
select: {
|
||||
id: true,
|
||||
objectKey: true,
|
||||
bucket: true,
|
||||
mimeType: true,
|
||||
fileName: true,
|
||||
},
|
||||
})
|
||||
|
||||
let analyzed = 0
|
||||
let failed = 0
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
||||
const batch = files.slice(i, i + BATCH_SIZE)
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (file) => {
|
||||
if (!isParseableMimeType(file.mimeType)) {
|
||||
// Mark non-parseable files as analyzed with no data
|
||||
await prisma.projectFile.update({
|
||||
where: { id: file.id },
|
||||
data: { analyzedAt: new Date() },
|
||||
})
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
const result = await analyzeFileContent(
|
||||
file.objectKey,
|
||||
file.bucket,
|
||||
file.mimeType,
|
||||
file.fileName,
|
||||
file.id
|
||||
)
|
||||
|
||||
await prisma.projectFile.update({
|
||||
where: { id: file.id },
|
||||
data: {
|
||||
pageCount: result.pageCount,
|
||||
textPreview: result.textPreview,
|
||||
detectedLang: result.detectedLang,
|
||||
langConfidence: result.langConfidence,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return result.error ? 'failed' : 'analyzed'
|
||||
})
|
||||
)
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
if (r.value === 'analyzed') analyzed++
|
||||
else if (r.value === 'failed') failed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { analyzed, failed, total: files.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retroactive batch analysis: analyze all files that haven't been analyzed yet.
|
||||
* Returns counts. Processes in batches to avoid memory issues.
|
||||
*/
|
||||
export async function analyzeAllUnanalyzed(): Promise<{
|
||||
analyzed: number
|
||||
failed: number
|
||||
skipped: number
|
||||
total: number
|
||||
}> {
|
||||
const files = await prisma.projectFile.findMany({
|
||||
where: { analyzedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
objectKey: true,
|
||||
bucket: true,
|
||||
mimeType: true,
|
||||
fileName: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
let analyzed = 0
|
||||
let failed = 0
|
||||
let skipped = 0
|
||||
|
||||
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
||||
const batch = files.slice(i, i + BATCH_SIZE)
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (file) => {
|
||||
if (!isParseableMimeType(file.mimeType)) {
|
||||
await prisma.projectFile.update({
|
||||
where: { id: file.id },
|
||||
data: { analyzedAt: new Date() },
|
||||
})
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
const result = await analyzeFileContent(
|
||||
file.objectKey,
|
||||
file.bucket,
|
||||
file.mimeType,
|
||||
file.fileName,
|
||||
file.id
|
||||
)
|
||||
|
||||
await prisma.projectFile.update({
|
||||
where: { id: file.id },
|
||||
data: {
|
||||
pageCount: result.pageCount,
|
||||
textPreview: result.textPreview,
|
||||
detectedLang: result.detectedLang,
|
||||
langConfidence: result.langConfidence,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return result.error ? 'failed' : 'analyzed'
|
||||
})
|
||||
)
|
||||
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
if (r.value === 'analyzed') analyzed++
|
||||
else if (r.value === 'failed') failed++
|
||||
else if (r.value === 'skipped') skipped++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[DocAnalyzer] Batch progress: ${i + batch.length}/${files.length} (${analyzed} analyzed, ${skipped} skipped, ${failed} failed)`
|
||||
)
|
||||
}
|
||||
|
||||
return { analyzed, failed, skipped, total: files.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-analysis is enabled via SystemSettings.
|
||||
*/
|
||||
export async function isAutoAnalysisEnabled(): Promise<boolean> {
|
||||
try {
|
||||
const setting = await prisma.systemSettings.findUnique({
|
||||
where: { key: 'file_analysis_auto_enabled' },
|
||||
})
|
||||
// Default to true if setting doesn't exist
|
||||
return setting?.value !== 'false'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,24 @@ export async function activateRound(
|
||||
detailsJson: { name: round.name, roundType: round.roundType },
|
||||
})
|
||||
|
||||
// Retroactive check: auto-PASS any projects that already have all required docs uploaded
|
||||
// Non-fatal — runs after activation so it never blocks the transition
|
||||
try {
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
if (projectStates.length > 0) {
|
||||
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
||||
const result = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
||||
if (result.transitionedCount > 0) {
|
||||
console.log(`[RoundEngine] On activation: auto-passed ${result.transitionedCount} projects with complete documents`)
|
||||
}
|
||||
}
|
||||
} catch (retroError) {
|
||||
console.error('[RoundEngine] Retroactive document check failed (non-fatal):', retroError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
round: { id: updated.id, status: updated.status },
|
||||
@@ -429,6 +447,23 @@ export async function reopenRound(
|
||||
},
|
||||
})
|
||||
|
||||
// Retroactive check: auto-PASS any projects that already have all required docs
|
||||
try {
|
||||
const projectStates = await prisma.projectRoundState.findMany({
|
||||
where: { roundId, state: { in: ['PENDING', 'IN_PROGRESS'] } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
if (projectStates.length > 0) {
|
||||
const projectIds = projectStates.map((ps: { projectId: string }) => ps.projectId)
|
||||
const batchResult = await batchCheckRequirementsAndTransition(roundId, projectIds, actorId, prisma)
|
||||
if (batchResult.transitionedCount > 0) {
|
||||
console.log(`[RoundEngine] On reopen: auto-passed ${batchResult.transitionedCount} projects with complete documents`)
|
||||
}
|
||||
}
|
||||
} catch (retroError) {
|
||||
console.error('[RoundEngine] Retroactive document check on reopen failed (non-fatal):', retroError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
round: { id: result.updated.id, status: result.updated.status },
|
||||
@@ -625,6 +660,109 @@ export async function getProjectRoundState(
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Auto-Transition on Document Completion ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a project has fulfilled all required FileRequirements for a round.
|
||||
* If yes, and the project is currently PENDING, transition it to PASSED.
|
||||
*
|
||||
* Called after file uploads (admin bulk upload or applicant upload).
|
||||
* Non-fatal: errors are logged but never propagated to callers.
|
||||
*/
|
||||
export async function checkRequirementsAndTransition(
|
||||
projectId: string,
|
||||
roundId: string,
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<{ transitioned: boolean; newState?: string }> {
|
||||
try {
|
||||
// Get all required FileRequirements for this round
|
||||
const requirements = await prisma.fileRequirement.findMany({
|
||||
where: { roundId, isRequired: true },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
// If the round has no file requirements, nothing to check
|
||||
if (requirements.length === 0) {
|
||||
return { transitioned: false }
|
||||
}
|
||||
|
||||
// Check which requirements this project has satisfied (has a file uploaded)
|
||||
const fulfilledFiles = await prisma.projectFile.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
roundId,
|
||||
requirementId: { in: requirements.map((r: { id: string }) => r.id) },
|
||||
},
|
||||
select: { requirementId: true },
|
||||
})
|
||||
|
||||
const fulfilledIds = new Set(
|
||||
fulfilledFiles
|
||||
.map((f: { requirementId: string | null }) => f.requirementId)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
// Check if all required requirements are met
|
||||
const allMet = requirements.every((r: { id: string }) => fulfilledIds.has(r.id))
|
||||
|
||||
if (!allMet) {
|
||||
return { transitioned: false }
|
||||
}
|
||||
|
||||
// Check current state — only transition if PENDING or IN_PROGRESS
|
||||
const currentState = await prisma.projectRoundState.findUnique({
|
||||
where: { projectId_roundId: { projectId, roundId } },
|
||||
select: { state: true },
|
||||
})
|
||||
|
||||
const eligibleStates = ['PENDING', 'IN_PROGRESS']
|
||||
if (!currentState || !eligibleStates.includes(currentState.state)) {
|
||||
return { transitioned: false }
|
||||
}
|
||||
|
||||
// All requirements met — transition to PASSED
|
||||
const result = await transitionProject(projectId, roundId, 'PASSED' as ProjectRoundStateValue, actorId, prisma)
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[RoundEngine] Auto-transitioned project ${projectId} to PASSED in round ${roundId} (all ${requirements.length} requirements met)`)
|
||||
return { transitioned: true, newState: 'PASSED' }
|
||||
}
|
||||
|
||||
return { transitioned: false }
|
||||
} catch (error) {
|
||||
// Non-fatal — log and continue
|
||||
console.error('[RoundEngine] checkRequirementsAndTransition failed:', error)
|
||||
return { transitioned: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch version: check all projects in a round and transition any that
|
||||
* have all required documents uploaded. Useful after bulk upload.
|
||||
*/
|
||||
export async function batchCheckRequirementsAndTransition(
|
||||
roundId: string,
|
||||
projectIds: string[],
|
||||
actorId: string,
|
||||
prisma: PrismaClient | any,
|
||||
): Promise<{ transitionedCount: number; projectIds: string[] }> {
|
||||
const transitioned: string[] = []
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const result = await checkRequirementsAndTransition(projectId, roundId, actorId, prisma)
|
||||
if (result.transitioned) {
|
||||
transitioned.push(projectId)
|
||||
}
|
||||
}
|
||||
|
||||
if (transitioned.length > 0) {
|
||||
console.log(`[RoundEngine] Batch auto-transition: ${transitioned.length}/${projectIds.length} projects moved to PASSED in round ${roundId}`)
|
||||
}
|
||||
|
||||
return { transitionedCount: transitioned.length, projectIds: transitioned }
|
||||
}
|
||||
|
||||
// ─── Internals ──────────────────────────────────────────────────────────────
|
||||
|
||||
function isTerminalState(state: ProjectRoundStateValue): boolean {
|
||||
|
||||
Reference in New Issue
Block a user