Add multiple admin improvements and bug fixes
Build and Push Docker Image / build (push) Successful in 8m58s Details

- Email settings: Add separate sender display name field
- Rounds page: Drag-and-drop reordering with visible order numbers
- Round creation: Auto-assign projects to filtering rounds, auto-activate if voting started
- Round detail: Fix incorrect "voting period ended" message for draft rounds
- Projects page: Add delete option with confirmation dialog
- AI filtering: Add configurable batch size and parallel request settings
- Filtering results: Fix duplicate criteria display
- Add seed scripts for notification settings and MOPC onboarding form

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-02-03 23:19:45 +01:00
parent 1d137ce93e
commit 3be6a743ed
12 changed files with 895 additions and 192 deletions

View File

@ -0,0 +1,270 @@
/**
* Seed script for MOPC Onboarding Form (ESM version for production)
* Run with: node prisma/seed-mopc-onboarding.mjs
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const MOPC_FORM_CONFIG = {
name: 'MOPC Application 2026',
description: 'Monaco Ocean Protection Challenge application form',
publicSlug: 'mopc-2026',
status: 'PUBLISHED',
isPublic: true,
sendConfirmationEmail: true,
sendTeamInviteEmails: true,
confirmationEmailSubject: 'Application Received - Monaco Ocean Protection Challenge',
confirmationEmailBody: `Thank you for applying to the Monaco Ocean Protection Challenge 2026!
We have received your application and our team will review it carefully.
If you have any questions, please don't hesitate to reach out.
Good luck!
The MOPC Team`,
confirmationMessage: 'Thank you for your application! We have sent a confirmation email to the address you provided. Our team will review your submission and get back to you soon.',
}
const STEPS = [
{
name: 'category',
title: 'Competition Category',
description: 'Select your competition track',
sortOrder: 0,
isOptional: false,
fields: [
{
name: 'competitionCategory',
label: 'Which category best describes your project?',
fieldType: 'RADIO',
specialType: 'COMPETITION_CATEGORY',
required: true,
sortOrder: 0,
width: 'full',
projectMapping: 'competitionCategory',
description: 'Choose the category that best fits your stage of development',
optionsJson: [
{ value: 'STARTUP', label: 'Startup', description: 'You have an existing company or registered business entity' },
{ value: 'BUSINESS_CONCEPT', label: 'Business Concept', description: 'You are a student, graduate, or have an idea not yet incorporated' },
],
},
],
},
{
name: 'contact',
title: 'Contact Information',
description: 'Tell us how to reach you',
sortOrder: 1,
isOptional: false,
fields: [
{ name: 'contactName', label: 'Full Name', fieldType: 'TEXT', required: true, sortOrder: 0, width: 'half', placeholder: 'Enter your full name' },
{ name: 'contactEmail', label: 'Email Address', fieldType: 'EMAIL', required: true, sortOrder: 1, width: 'half', placeholder: 'your.email@example.com', description: 'We will use this email for all communications' },
{ name: 'contactPhone', label: 'Phone Number', fieldType: 'PHONE', required: true, sortOrder: 2, width: 'half', placeholder: '+1 (555) 123-4567' },
{ name: 'country', label: 'Country', fieldType: 'SELECT', specialType: 'COUNTRY_SELECT', required: true, sortOrder: 3, width: 'half', projectMapping: 'country' },
{ name: 'city', label: 'City', fieldType: 'TEXT', required: false, sortOrder: 4, width: 'half', placeholder: 'City name' },
],
},
{
name: 'project',
title: 'Project Details',
description: 'Tell us about your ocean protection project',
sortOrder: 2,
isOptional: false,
fields: [
{ name: 'projectName', label: 'Project Name', fieldType: 'TEXT', required: true, sortOrder: 0, width: 'full', projectMapping: 'title', maxLength: 200, placeholder: 'Give your project a memorable name' },
{ name: 'teamName', label: 'Team / Company Name', fieldType: 'TEXT', required: false, sortOrder: 1, width: 'half', projectMapping: 'teamName', placeholder: 'Your team or company name' },
{ name: 'oceanIssue', label: 'Primary Ocean Issue', fieldType: 'SELECT', specialType: 'OCEAN_ISSUE', required: true, sortOrder: 2, width: 'half', projectMapping: 'oceanIssue', description: 'Select the primary ocean issue your project addresses' },
{ name: 'description', label: 'Project Description', fieldType: 'TEXTAREA', required: true, sortOrder: 3, width: 'full', projectMapping: 'description', minLength: 50, maxLength: 2000, placeholder: 'Describe your project, its goals, and how it will help protect the ocean...', description: 'Provide a clear description of your project (50-2000 characters)' },
{ name: 'websiteUrl', label: 'Website URL', fieldType: 'URL', required: false, sortOrder: 4, width: 'half', projectMapping: 'websiteUrl', placeholder: 'https://yourproject.com' },
],
},
{
name: 'team',
title: 'Team Members',
description: 'Add your team members (they will receive email invitations)',
sortOrder: 3,
isOptional: true,
fields: [
{ name: 'teamMembers', label: 'Team Members', fieldType: 'TEXT', specialType: 'TEAM_MEMBERS', required: false, sortOrder: 0, width: 'full', description: 'Add up to 5 team members. They will receive an invitation email to join your application.' },
],
},
{
name: 'additional',
title: 'Additional Details',
description: 'A few more questions about your project',
sortOrder: 4,
isOptional: false,
fields: [
{ name: 'institution', label: 'University / School', fieldType: 'TEXT', required: false, sortOrder: 0, width: 'half', projectMapping: 'institution', placeholder: 'Name of your institution', conditionJson: { field: 'competitionCategory', operator: 'equals', value: 'BUSINESS_CONCEPT' } },
{ name: 'startupCreatedDate', label: 'Startup Founded Date', fieldType: 'DATE', required: false, sortOrder: 1, width: 'half', description: 'When was your company founded?', conditionJson: { field: 'competitionCategory', operator: 'equals', value: 'STARTUP' } },
{ name: 'wantsMentorship', label: 'I am interested in receiving mentorship', fieldType: 'CHECKBOX', required: false, sortOrder: 2, width: 'full', projectMapping: 'wantsMentorship', description: 'Check this box if you would like to be paired with an expert mentor' },
{ name: 'referralSource', label: 'How did you hear about MOPC?', fieldType: 'SELECT', required: false, sortOrder: 3, width: 'half', optionsJson: [
{ value: 'social_media', label: 'Social Media' },
{ value: 'search_engine', label: 'Search Engine' },
{ value: 'word_of_mouth', label: 'Word of Mouth' },
{ value: 'university', label: 'University / School' },
{ value: 'partner', label: 'Partner Organization' },
{ value: 'media', label: 'News / Media' },
{ value: 'event', label: 'Event / Conference' },
{ value: 'other', label: 'Other' },
]},
],
},
{
name: 'review',
title: 'Review & Submit',
description: 'Review your application and accept the terms',
sortOrder: 5,
isOptional: false,
fields: [
{ name: 'instructions', label: 'Review Instructions', fieldType: 'INSTRUCTIONS', required: false, sortOrder: 0, width: 'full', description: 'Please review all the information you have provided. Once submitted, you will not be able to make changes.' },
{ name: 'gdprConsent', label: 'I consent to the processing of my personal data in accordance with the GDPR and the MOPC Privacy Policy', fieldType: 'CHECKBOX', specialType: 'GDPR_CONSENT', required: true, sortOrder: 1, width: 'full' },
{ name: 'termsAccepted', label: 'I have read and accept the Terms and Conditions of the Monaco Ocean Protection Challenge', fieldType: 'CHECKBOX', required: true, sortOrder: 2, width: 'full' },
],
},
]
async function main() {
console.log('Seeding MOPC onboarding form...')
// Check if form already exists
const existingForm = await prisma.applicationForm.findUnique({
where: { publicSlug: MOPC_FORM_CONFIG.publicSlug },
})
if (existingForm) {
console.log('Form with slug "mopc-2026" already exists. Updating...')
// Delete existing steps and fields to recreate them
await prisma.applicationFormField.deleteMany({ where: { formId: existingForm.id } })
await prisma.onboardingStep.deleteMany({ where: { formId: existingForm.id } })
// Update the form
await prisma.applicationForm.update({
where: { id: existingForm.id },
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: existingForm.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const field of stepData.fields) {
await prisma.applicationFormField.create({
data: {
formId: existingForm.id,
stepId: step.id,
name: field.name,
label: field.label,
fieldType: field.fieldType,
specialType: field.specialType || null,
required: field.required,
sortOrder: field.sortOrder,
width: field.width,
description: field.description || null,
placeholder: field.placeholder || null,
projectMapping: field.projectMapping || null,
minLength: field.minLength || null,
maxLength: field.maxLength || null,
optionsJson: field.optionsJson || undefined,
conditionJson: field.conditionJson || undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nForm updated: ${existingForm.id}`)
return
}
// Create new form
const form = await prisma.applicationForm.create({
data: {
name: MOPC_FORM_CONFIG.name,
description: MOPC_FORM_CONFIG.description,
publicSlug: MOPC_FORM_CONFIG.publicSlug,
status: MOPC_FORM_CONFIG.status,
isPublic: MOPC_FORM_CONFIG.isPublic,
sendConfirmationEmail: MOPC_FORM_CONFIG.sendConfirmationEmail,
sendTeamInviteEmails: MOPC_FORM_CONFIG.sendTeamInviteEmails,
confirmationEmailSubject: MOPC_FORM_CONFIG.confirmationEmailSubject,
confirmationEmailBody: MOPC_FORM_CONFIG.confirmationEmailBody,
confirmationMessage: MOPC_FORM_CONFIG.confirmationMessage,
},
})
console.log(`Created form: ${form.id}`)
// Create steps and fields
for (const stepData of STEPS) {
const step = await prisma.onboardingStep.create({
data: {
formId: form.id,
name: stepData.name,
title: stepData.title,
description: stepData.description,
sortOrder: stepData.sortOrder,
isOptional: stepData.isOptional,
},
})
for (const field of stepData.fields) {
await prisma.applicationFormField.create({
data: {
formId: form.id,
stepId: step.id,
name: field.name,
label: field.label,
fieldType: field.fieldType,
specialType: field.specialType || null,
required: field.required,
sortOrder: field.sortOrder,
width: field.width,
description: field.description || null,
placeholder: field.placeholder || null,
projectMapping: field.projectMapping || null,
minLength: field.minLength || null,
maxLength: field.maxLength || null,
optionsJson: field.optionsJson || undefined,
conditionJson: field.conditionJson || undefined,
},
})
}
console.log(` - Created step: ${stepData.title} (${stepData.fields.length} fields)`)
}
console.log(`\nMOPC form seeded successfully!`)
console.log(`Form ID: ${form.id}`)
console.log(`Public URL: /apply/${form.publicSlug}`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,73 @@
/**
* Seed script for notification email settings (ESM version for production)
* Run with: node prisma/seed-notification-settings.mjs
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
const NOTIFICATION_EMAIL_SETTINGS = [
// Team / Applicant notifications
{ notificationType: 'APPLICATION_SUBMITTED', category: 'team', label: 'Application Submitted', description: 'When a team submits their application', sendEmail: true },
{ notificationType: 'TEAM_INVITE_RECEIVED', category: 'team', label: 'Team Invitation Received', description: 'When someone is invited to join a team', sendEmail: true },
{ notificationType: 'TEAM_MEMBER_JOINED', category: 'team', label: 'Team Member Joined', description: 'When a new member joins the team', sendEmail: false },
{ notificationType: 'ADVANCED_SEMIFINAL', category: 'team', label: 'Advanced to Semi-Finals', description: 'When a project advances to semi-finals', sendEmail: true },
{ notificationType: 'ADVANCED_FINAL', category: 'team', label: 'Selected as Finalist', description: 'When a project is selected as a finalist', sendEmail: true },
{ notificationType: 'MENTOR_ASSIGNED', category: 'team', label: 'Mentor Assigned', description: 'When a mentor is assigned to the team', sendEmail: true },
{ notificationType: 'NOT_SELECTED', category: 'team', label: 'Not Selected', description: 'When a project is not selected for the next round', sendEmail: true },
{ notificationType: 'FEEDBACK_AVAILABLE', category: 'team', label: 'Feedback Available', description: 'When jury feedback becomes available', sendEmail: true },
{ notificationType: 'WINNER_ANNOUNCEMENT', category: 'team', label: 'Winner Announcement', description: 'When a project wins an award', sendEmail: true },
// Jury notifications
{ notificationType: 'ASSIGNED_TO_PROJECT', category: 'jury', label: 'Assigned to Project', description: 'When a jury member is assigned to a project', sendEmail: true },
{ notificationType: 'BATCH_ASSIGNED', category: 'jury', label: 'Batch Assignment', description: 'When multiple projects are assigned at once', sendEmail: true },
{ notificationType: 'ROUND_NOW_OPEN', category: 'jury', label: 'Round Now Open', description: 'When a round opens for evaluation', sendEmail: true },
{ notificationType: 'REMINDER_24H', category: 'jury', label: 'Reminder (24h)', description: 'Reminder 24 hours before deadline', sendEmail: true },
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
// Mentor notifications
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },
{ notificationType: 'MENTEE_ADVANCED', category: 'mentor', label: 'Mentee Advanced', description: 'When a mentee advances to the next round', sendEmail: true },
{ notificationType: 'MENTEE_FINALIST', category: 'mentor', label: 'Mentee is Finalist', description: 'When a mentee is selected as finalist', sendEmail: true },
{ notificationType: 'MENTEE_WON', category: 'mentor', label: 'Mentee Won', description: 'When a mentee wins an award', sendEmail: true },
// Observer notifications
{ notificationType: 'ROUND_STARTED', category: 'observer', label: 'Round Started', description: 'When a new round begins', sendEmail: false },
{ notificationType: 'ROUND_COMPLETED', category: 'observer', label: 'Round Completed', description: 'When a round is completed', sendEmail: true },
{ notificationType: 'FINALISTS_ANNOUNCED', category: 'observer', label: 'Finalists Announced', description: 'When finalists are announced', sendEmail: true },
{ notificationType: 'WINNERS_ANNOUNCED', category: 'observer', label: 'Winners Announced', description: 'When winners are announced', sendEmail: true },
// Admin notifications
{ notificationType: 'FILTERING_COMPLETE', category: 'admin', label: 'AI Filtering Complete', description: 'When AI filtering job completes', sendEmail: false },
{ notificationType: 'FILTERING_FAILED', category: 'admin', label: 'AI Filtering Failed', description: 'When AI filtering job fails', sendEmail: true },
{ notificationType: 'NEW_APPLICATION', category: 'admin', label: 'New Application', description: 'When a new application is received', sendEmail: false },
{ notificationType: 'SYSTEM_ERROR', category: 'admin', label: 'System Error', description: 'When a system error occurs', sendEmail: true },
]
async function main() {
console.log('Seeding notification email settings...')
for (const setting of NOTIFICATION_EMAIL_SETTINGS) {
await prisma.notificationEmailSetting.upsert({
where: { notificationType: setting.notificationType },
update: { category: setting.category, label: setting.label, description: setting.description },
create: setting,
})
console.log(` - ${setting.label}`)
}
console.log(`\nSeeded ${NOTIFICATION_EMAIL_SETTINGS.length} notification email settings.`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useSearchParams, usePathname } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
@ -27,8 +28,19 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Plus,
MoreHorizontal,
@ -38,6 +50,8 @@ import {
FileUp,
Users,
Search,
Trash2,
Loader2,
} from 'lucide-react'
import { truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
@ -210,9 +224,30 @@ export default function ProjectsPage() {
perPage: PER_PAGE,
}
const utils = trpc.useUtils()
const { data, isLoading } = trpc.project.list.useQuery(queryInput)
const { data: filterOptions } = trpc.project.getFilterOptions.useQuery()
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
toast.success('Project deleted successfully')
utils.project.list.invalidate()
setDeleteDialogOpen(false)
setProjectToDelete(null)
},
onError: (error) => {
toast.error(error.message || 'Failed to delete project')
},
})
const handleDeleteClick = (project: { id: string; title: string }) => {
setProjectToDelete(project)
setDeleteDialogOpen(true)
}
return (
<div className="space-y-6">
{/* Header */}
@ -391,6 +426,17 @@ export default function ProjectsPage() {
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation()
handleDeleteClick({ id: project.id, title: project.title })
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
@ -459,6 +505,32 @@ export default function ProjectsPage() {
/>
</>
) : null}
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Project</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{projectToDelete?.title}&quot;? This will
permanently remove the project, all its files, assignments, and evaluations.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => projectToDelete && deleteProject.mutate({ id: projectToDelete.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteProject.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -339,7 +339,7 @@ export default function FilteringResultsPage({
<TableRow key={`${result.id}-detail`}>
<TableCell colSpan={5} className="bg-muted/30">
<div className="p-4 space-y-4">
{/* Rule Results */}
{/* Rule Results (non-AI rules only, AI shown separately) */}
<div>
<p className="text-sm font-medium mb-2">
Rule Results
@ -355,7 +355,7 @@ export default function FilteringResultsPage({
action: string
reasoning?: string
}>
).map((rr, i) => (
).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
<div
key={i}
className="flex items-start gap-2 text-sm"

View File

@ -109,6 +109,8 @@ export default function FilteringRulesPage({
// AI screening config state
const [criteriaText, setCriteriaText] = useState('')
const [aiBatchSize, setAiBatchSize] = useState('20')
const [aiParallelBatches, setAiParallelBatches] = useState('1')
const handleCreateRule = async () => {
if (!newRuleName.trim()) return
@ -143,6 +145,8 @@ export default function FilteringRulesPage({
configJson = {
criteriaText,
action: 'FLAG',
batchSize: parseInt(aiBatchSize) || 20,
parallelBatches: parseInt(aiParallelBatches) || 1,
}
}
@ -195,6 +199,8 @@ export default function FilteringRulesPage({
setMinFileCount('1')
setDocAction('REJECT')
setCriteriaText('')
setAiBatchSize('20')
setAiParallelBatches('1')
}
if (isLoading) {
@ -403,18 +409,65 @@ export default function FilteringRulesPage({
{/* AI Screening Config */}
{newRuleType === 'AI_SCREENING' && (
<div className="space-y-2">
<Label>Screening Criteria</Label>
<Textarea
value={criteriaText}
onChange={(e) => setCriteriaText(e.target.value)}
placeholder="Describe the criteria for AI to evaluate projects against..."
rows={4}
/>
<p className="text-xs text-muted-foreground">
AI screening always flags projects for human review, never
auto-rejects.
</p>
<div className="space-y-4">
<div className="space-y-2">
<Label>Screening Criteria</Label>
<Textarea
value={criteriaText}
onChange={(e) => setCriteriaText(e.target.value)}
placeholder="Describe the criteria for AI to evaluate projects against..."
rows={4}
/>
<p className="text-xs text-muted-foreground">
AI screening always flags projects for human review, never
auto-rejects.
</p>
</div>
<div className="border-t pt-4">
<Label className="text-sm font-medium">Performance Settings</Label>
<p className="text-xs text-muted-foreground mb-3">
Adjust batch settings to balance speed vs. cost
</p>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Batch Size</Label>
<Select value={aiBatchSize} onValueChange={setAiBatchSize}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 (Individual)</SelectItem>
<SelectItem value="5">5 (Small)</SelectItem>
<SelectItem value="10">10 (Medium)</SelectItem>
<SelectItem value="20">20 (Default)</SelectItem>
<SelectItem value="50">50 (Large)</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Projects per API call. Smaller = more parallel potential
</p>
</div>
<div className="space-y-2">
<Label className="text-xs">Parallel Requests</Label>
<Select value={aiParallelBatches} onValueChange={setAiParallelBatches}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 (Sequential)</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5 (Fast)</SelectItem>
<SelectItem value="10">10 (Maximum)</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
Concurrent API calls. Higher = faster but more costly
</p>
</div>
</div>
</div>
</div>
)}
</div>

View File

@ -498,7 +498,9 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
? 'bg-green-500/10 text-green-700'
: isFuture(new Date(round.votingStartAt))
? 'bg-amber-500/10 text-amber-700'
: 'bg-muted text-muted-foreground'
: isFuture(new Date(round.votingEndAt))
? 'bg-blue-500/10 text-blue-700'
: 'bg-muted text-muted-foreground'
}`}
>
{isVotingOpen ? (
@ -513,6 +515,16 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
</span>
</div>
) : isFuture(new Date(round.votingEndAt)) ? (
<div className="flex items-center gap-2">
<Clock className="h-5 w-5" />
<span className="font-medium">
{round.status === 'DRAFT'
? 'Voting window configured (round not yet active)'
: `Voting ends ${formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}`
}
</span>
</div>
) : (
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />

View File

@ -62,10 +62,12 @@ function CreateRoundContent() {
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
const utils = trpc.useUtils()
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
const createRound = trpc.round.create.useMutation({
onSuccess: (data) => {
utils.program.list.invalidate({ includeRounds: true })
router.push(`/admin/rounds/${data.id}`)
},
})

View File

@ -4,6 +4,23 @@ import { Suspense, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import {
Card,
CardContent,
@ -14,14 +31,6 @@ import {
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
@ -52,10 +61,24 @@ import {
Archive,
Trash2,
Loader2,
ChevronUp,
ChevronDown,
GripVertical,
ArrowRight,
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
import { cn } from '@/lib/utils'
type RoundData = {
id: string
name: string
status: string
roundType: string
votingStartAt: string | null
votingEndAt: string | null
_count?: {
roundProjects: number
assignments: number
}
}
function RoundsContent() {
const { data: programs, isLoading } = trpc.program.list.useQuery({
@ -86,97 +109,184 @@ function RoundsContent() {
return (
<div className="space-y-6">
{programs.map((program) => (
<Card key={program.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{program.year} Edition</CardTitle>
<CardDescription>
{program.name} - {program.status}
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?program=${program.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Round
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{program.rounds && program.rounds.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">Order</TableHead>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Voting Window</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{program.rounds.map((round, index) => (
<RoundRow
key={round.id}
round={round}
index={index}
totalRounds={program.rounds.length}
allRoundIds={program.rounds.map((r) => r.id)}
programId={program.id}
/>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No rounds created yet</p>
</div>
)}
</CardContent>
</Card>
<ProgramRounds key={program.id} program={program} />
))}
</div>
)
}
function RoundRow({
round,
index,
totalRounds,
allRoundIds,
programId,
}: {
round: any
index: number
totalRounds: number
allRoundIds: string[]
programId: string
}) {
function ProgramRounds({ program }: { program: any }) {
const utils = trpc.useUtils()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [rounds, setRounds] = useState<RoundData[]>(program.rounds || [])
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const reorder = trpc.round.reorder.useMutation({
onSuccess: () => {
utils.program.list.invalidate({ includeRounds: true })
toast.success('Round order updated')
},
onError: (error) => {
toast.error(error.message || 'Failed to reorder rounds')
// Reset to original order on error
setRounds(program.rounds || [])
},
})
const moveUp = () => {
if (index <= 0) return
const ids = [...allRoundIds]
;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]
reorder.mutate({ programId, roundIds: ids })
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = rounds.findIndex((r) => r.id === active.id)
const newIndex = rounds.findIndex((r) => r.id === over.id)
const newRounds = arrayMove(rounds, oldIndex, newIndex)
setRounds(newRounds)
// Send the new order to the server
reorder.mutate({
programId: program.id,
roundIds: newRounds.map((r) => r.id),
})
}
}
const moveDown = () => {
if (index >= totalRounds - 1) return
const ids = [...allRoundIds]
;[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]]
reorder.mutate({ programId, roundIds: ids })
// Sync local state when program.rounds changes
if (JSON.stringify(rounds.map(r => r.id)) !== JSON.stringify((program.rounds || []).map((r: RoundData) => r.id))) {
setRounds(program.rounds || [])
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{program.year} Edition</CardTitle>
<CardDescription>
{program.name} - {program.status}
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?program=${program.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Round
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{rounds.length > 0 ? (
<div className="space-y-2">
{/* Header */}
<div className="grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wide">
<div>Order</div>
<div>Round</div>
<div>Status</div>
<div>Voting Window</div>
<div>Projects</div>
<div>Reviewers</div>
<div></div>
</div>
{/* Sortable List */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={rounds.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{rounds.map((round, index) => (
<SortableRoundRow
key={round.id}
round={round}
index={index}
totalRounds={rounds.length}
isReordering={reorder.isPending}
/>
))}
</div>
</SortableContext>
</DndContext>
{/* Flow visualization */}
{rounds.length > 1 && (
<div className="mt-6 pt-4 border-t">
<p className="text-xs text-muted-foreground mb-3 uppercase tracking-wide font-medium">
Project Flow
</p>
<div className="flex items-center gap-2 flex-wrap">
{rounds.map((round, index) => (
<div key={round.id} className="flex items-center gap-2">
<div className="flex items-center gap-2 bg-muted/50 rounded-lg px-3 py-1.5">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-primary/10 text-primary text-xs font-bold">
{index}
</span>
<span className="text-sm font-medium truncate max-w-[120px]">
{round.name}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{round._count?.roundProjects || 0}
</Badge>
</div>
{index < rounds.length - 1 && (
<ArrowRight className="h-4 w-4 text-muted-foreground/50" />
)}
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No rounds created yet</p>
</div>
)}
</CardContent>
</Card>
)
}
function SortableRoundRow({
round,
index,
totalRounds,
isReordering,
}: {
round: RoundData
index: number
totalRounds: number
isReordering: boolean
}) {
const utils = trpc.useUtils()
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: round.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
const updateStatus = trpc.round.updateStatus.useMutation({
@ -239,17 +349,16 @@ function RoundRow({
const getVotingWindow = () => {
if (!round.votingStartAt || !round.votingEndAt) {
return <span className="text-muted-foreground">Not set</span>
return <span className="text-muted-foreground text-sm">Not set</span>
}
const start = new Date(round.votingStartAt)
const end = new Date(round.votingEndAt)
const now = new Date()
if (isFuture(start)) {
return (
<span className="text-sm">
Opens {format(start, 'MMM d, yyyy')}
Opens {format(start, 'MMM d')}
</span>
)
}
@ -257,68 +366,79 @@ function RoundRow({
if (isPast(end)) {
return (
<span className="text-sm text-muted-foreground">
Ended {format(end, 'MMM d, yyyy')}
Ended {format(end, 'MMM d')}
</span>
)
}
return (
<span className="text-sm">
Until {format(end, 'MMM d, yyyy')}
Until {format(end, 'MMM d')}
</span>
)
}
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={moveUp}
disabled={index === 0 || reorder.isPending}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={moveDown}
disabled={index === totalRounds - 1 || reorder.isPending}
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
</TableCell>
<TableCell>
<div
ref={setNodeRef}
style={style}
className={cn(
'grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5 rounded-lg border bg-card transition-all',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}
>
{/* Order number with drag handle */}
<div className="flex items-center gap-1">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
disabled={isReordering}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
{index}
</span>
</div>
{/* Round name */}
<div>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
</TableCell>
<TableCell>{getStatusBadge()}</TableCell>
<TableCell>{getVotingWindow()}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-muted-foreground" />
{round._count?.roundProjects || 0}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{round._count?.assignments || 0}
</div>
</TableCell>
<TableCell className="text-right">
<p className="text-xs text-muted-foreground capitalize">
{round.roundType?.toLowerCase().replace('_', ' ')}
</p>
</div>
{/* Status */}
<div>{getStatusBadge()}</div>
{/* Voting window */}
<div>{getVotingWindow()}</div>
{/* Projects */}
<div className="flex items-center gap-1.5">
<FileText className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.roundProjects || 0}</span>
</div>
{/* Assignments */}
<div className="flex items-center gap-1.5">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{round._count?.assignments || 0}</span>
</div>
{/* Actions */}
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" aria-label="Round actions">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
@ -408,8 +528,8 @@ function RoundRow({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
</div>
</div>
)
}

View File

@ -33,6 +33,7 @@ const formSchema = z.object({
smtp_port: z.string().regex(/^\d+$/, 'Port must be a number'),
smtp_user: z.string().min(1, 'SMTP user is required'),
smtp_password: z.string().optional(),
email_from_name: z.string().min(1, 'Sender name is required'),
email_from: z.string().email('Invalid email address'),
})
@ -44,6 +45,7 @@ interface EmailSettingsFormProps {
smtp_port?: string
smtp_user?: string
smtp_password?: string
email_from_name?: string
email_from?: string
}
}
@ -60,6 +62,7 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
smtp_port: settings.smtp_port || '587',
smtp_user: settings.smtp_user || '',
smtp_password: '',
email_from_name: settings.email_from_name || 'MOPC Portal',
email_from: settings.email_from || 'noreply@monaco-opc.com',
},
})
@ -93,6 +96,7 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
{ key: 'smtp_host', value: data.smtp_host },
{ key: 'smtp_port', value: data.smtp_port },
{ key: 'smtp_user', value: data.smtp_user },
{ key: 'email_from_name', value: data.email_from_name },
{ key: 'email_from', value: data.email_from },
]
@ -188,22 +192,41 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
/>
</div>
<FormField
control={form.control}
name="email_from"
render={({ field }) => (
<FormItem>
<FormLabel>From Email Address</FormLabel>
<FormControl>
<Input placeholder="noreply@monaco-opc.com" {...field} />
</FormControl>
<FormDescription>
Email address that will appear as the sender
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="email_from_name"
render={({ field }) => (
<FormItem>
<FormLabel>Sender Display Name</FormLabel>
<FormControl>
<Input placeholder="MOPC Portal" {...field} />
</FormControl>
<FormDescription>
Name shown to recipients (e.g., "MOPC Portal")
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email_from"
render={({ field }) => (
<FormItem>
<FormLabel>Sender Email Address</FormLabel>
<FormControl>
<Input placeholder="noreply@monaco-opc.com" {...field} />
</FormControl>
<FormDescription>
Email address for replies
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={updateSettings.isPending}>

View File

@ -15,7 +15,7 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
// Read DB settings
const dbSettings = await prisma.systemSettings.findMany({
where: {
key: { in: ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'email_from'] },
key: { in: ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'email_from_name', 'email_from'] },
},
select: { key: true, value: true },
})
@ -30,7 +30,11 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
const port = db.smtp_port || process.env.SMTP_PORT || '587'
const user = db.smtp_user || process.env.SMTP_USER || ''
const pass = db.smtp_password || process.env.SMTP_PASS || ''
const from = db.email_from || process.env.EMAIL_FROM || 'MOPC Portal <noreply@monaco-opc.com>'
// Combine sender name and email into "Name <email>" format
const fromName = db.email_from_name || 'MOPC Portal'
const fromEmail = db.email_from || process.env.EMAIL_FROM || 'noreply@monaco-opc.com'
const from = `${fromName} <${fromEmail}>`
// Check if config changed since last call
const configHash = `${host}:${port}:${user}:${pass}:${from}`

View File

@ -94,14 +94,39 @@ export const roundRouter = router({
}
const { settingsJson, sortOrder: _so, ...rest } = input
// Auto-activate if voting start date is in the past
const now = new Date()
const shouldAutoActivate = input.votingStartAt && input.votingStartAt <= now
const round = await ctx.prisma.round.create({
data: {
...rest,
sortOrder,
status: shouldAutoActivate ? 'ACTIVE' : 'DRAFT',
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
},
})
// For FILTERING rounds, automatically add all projects from the program
if (input.roundType === 'FILTERING') {
const projects = await ctx.prisma.project.findMany({
where: { programId: input.programId },
select: { id: true },
})
if (projects.length > 0) {
await ctx.prisma.roundProject.createMany({
data: projects.map((p) => ({
roundId: round.id,
projectId: p.id,
status: 'SUBMITTED',
})),
skipDuplicates: true,
})
}
}
// Audit log
await ctx.prisma.auditLog.create({
data: {
@ -148,10 +173,24 @@ export const roundRouter = router({
}
}
// Check if we should auto-activate (if voting start is in the past and round is DRAFT)
const now = new Date()
let autoActivate = false
if (data.votingStartAt && data.votingStartAt <= now) {
const existingRound = await ctx.prisma.round.findUnique({
where: { id },
select: { status: true },
})
if (existingRound?.status === 'DRAFT') {
autoActivate = true
}
}
const round = await ctx.prisma.round.update({
where: { id },
data: {
...data,
...(autoActivate && { status: 'ACTIVE' }),
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
},
})

View File

@ -72,6 +72,9 @@ export type DocumentCheckConfig = {
export type AIScreeningConfig = {
criteriaText: string
action: 'FLAG' // AI screening always flags for human review
// Performance settings
batchSize?: number // Projects per API call (1-50, default 20)
parallelBatches?: number // Concurrent API calls (1-10, default 1)
}
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
@ -124,7 +127,11 @@ interface FilteringRuleInput {
// ─── Constants ───────────────────────────────────────────────────────────────
const BATCH_SIZE = 20
const DEFAULT_BATCH_SIZE = 20
const MAX_BATCH_SIZE = 50
const MIN_BATCH_SIZE = 1
const DEFAULT_PARALLEL_BATCHES = 1
const MAX_PARALLEL_BATCHES = 10
// Optimized system prompt (compressed for token efficiency)
const AI_SCREENING_SYSTEM_PROMPT = `Project screening assistant. Evaluate against criteria, return JSON.
@ -441,7 +448,18 @@ export async function executeAIScreening(
}
const model = await getConfiguredModel()
console.log(`[AI Filtering] Using model: ${model} for ${projects.length} projects`)
// Get batch settings from config
const batchSize = Math.min(
MAX_BATCH_SIZE,
Math.max(MIN_BATCH_SIZE, config.batchSize ?? DEFAULT_BATCH_SIZE)
)
const parallelBatches = Math.min(
MAX_PARALLEL_BATCHES,
Math.max(1, config.parallelBatches ?? DEFAULT_PARALLEL_BATCHES)
)
console.log(`[AI Filtering] Using model: ${model} for ${projects.length} projects (batch size: ${batchSize}, parallel: ${parallelBatches})`)
// Convert and anonymize projects
const projectsWithRelations = projects.map(toProjectWithRelations)
@ -454,39 +472,56 @@ export async function executeAIScreening(
}
let totalTokens = 0
const totalBatches = Math.ceil(anonymized.length / BATCH_SIZE)
const totalBatches = Math.ceil(anonymized.length / batchSize)
let processedBatches = 0
// Process in batches
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) {
const batchAnon = anonymized.slice(i, i + BATCH_SIZE)
const batchMappings = mappings.slice(i, i + BATCH_SIZE)
const currentBatch = Math.floor(i / BATCH_SIZE) + 1
// Create batch chunks for parallel processing
const batches: Array<{ anon: typeof anonymized; maps: typeof mappings; index: number }> = []
for (let i = 0; i < anonymized.length; i += batchSize) {
batches.push({
anon: anonymized.slice(i, i + batchSize),
maps: mappings.slice(i, i + batchSize),
index: batches.length,
})
}
console.log(`[AI Filtering] Processing batch ${currentBatch}/${totalBatches}`)
// Process batches in parallel chunks
for (let i = 0; i < batches.length; i += parallelBatches) {
const parallelChunk = batches.slice(i, i + parallelBatches)
const { results: batchResults, tokensUsed } = await processAIBatch(
openai,
model,
config.criteriaText,
batchAnon,
batchMappings,
userId,
entityId
)
console.log(`[AI Filtering] Processing batches ${i + 1}-${Math.min(i + parallelBatches, batches.length)} of ${totalBatches} (${parallelChunk.length} in parallel)`)
totalTokens += tokensUsed
// Run parallel batches concurrently
const batchPromises = parallelChunk.map(async (batch) => {
const { results: batchResults, tokensUsed } = await processAIBatch(
openai,
model,
config.criteriaText,
batch.anon,
batch.maps,
userId,
entityId
)
return { batchResults, tokensUsed, index: batch.index }
})
// Merge batch results
for (const [id, result] of batchResults) {
results.set(id, result)
const parallelResults = await Promise.all(batchPromises)
// Merge results from all parallel batches
for (const { batchResults, tokensUsed } of parallelResults) {
totalTokens += tokensUsed
for (const [id, result] of batchResults) {
results.set(id, result)
}
processedBatches++
}
// Report progress
// Report progress after each parallel chunk
if (onProgress) {
await onProgress({
currentBatch,
currentBatch: processedBatches,
totalBatches,
processedCount: Math.min((currentBatch) * BATCH_SIZE, anonymized.length),
processedCount: Math.min(processedBatches * batchSize, anonymized.length),
tokensUsed: totalTokens,
})
}