Add multiple admin improvements and bug fixes
Build and Push Docker Image / build (push) Successful in 8m58s
Details
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:
parent
1d137ce93e
commit
3be6a743ed
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useSearchParams, usePathname } from 'next/navigation'
|
import { useSearchParams, usePathname } from 'next/navigation'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -27,8 +28,19 @@ import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
|
@ -38,6 +50,8 @@ import {
|
||||||
FileUp,
|
FileUp,
|
||||||
Users,
|
Users,
|
||||||
Search,
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { truncate } from '@/lib/utils'
|
import { truncate } from '@/lib/utils'
|
||||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||||
|
|
@ -210,9 +224,30 @@ export default function ProjectsPage() {
|
||||||
perPage: PER_PAGE,
|
perPage: PER_PAGE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
const { data, isLoading } = trpc.project.list.useQuery(queryInput)
|
const { data, isLoading } = trpc.project.list.useQuery(queryInput)
|
||||||
const { data: filterOptions } = trpc.project.getFilterOptions.useQuery()
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -391,6 +426,17 @@ export default function ProjectsPage() {
|
||||||
Edit
|
Edit
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -459,6 +505,32 @@ export default function ProjectsPage() {
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Project</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete "{projectToDelete?.title}"? 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,7 @@ export default function FilteringResultsPage({
|
||||||
<TableRow key={`${result.id}-detail`}>
|
<TableRow key={`${result.id}-detail`}>
|
||||||
<TableCell colSpan={5} className="bg-muted/30">
|
<TableCell colSpan={5} className="bg-muted/30">
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4">
|
||||||
{/* Rule Results */}
|
{/* Rule Results (non-AI rules only, AI shown separately) */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium mb-2">
|
<p className="text-sm font-medium mb-2">
|
||||||
Rule Results
|
Rule Results
|
||||||
|
|
@ -355,7 +355,7 @@ export default function FilteringResultsPage({
|
||||||
action: string
|
action: string
|
||||||
reasoning?: string
|
reasoning?: string
|
||||||
}>
|
}>
|
||||||
).map((rr, i) => (
|
).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-start gap-2 text-sm"
|
className="flex items-start gap-2 text-sm"
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,8 @@ export default function FilteringRulesPage({
|
||||||
|
|
||||||
// AI screening config state
|
// AI screening config state
|
||||||
const [criteriaText, setCriteriaText] = useState('')
|
const [criteriaText, setCriteriaText] = useState('')
|
||||||
|
const [aiBatchSize, setAiBatchSize] = useState('20')
|
||||||
|
const [aiParallelBatches, setAiParallelBatches] = useState('1')
|
||||||
|
|
||||||
const handleCreateRule = async () => {
|
const handleCreateRule = async () => {
|
||||||
if (!newRuleName.trim()) return
|
if (!newRuleName.trim()) return
|
||||||
|
|
@ -143,6 +145,8 @@ export default function FilteringRulesPage({
|
||||||
configJson = {
|
configJson = {
|
||||||
criteriaText,
|
criteriaText,
|
||||||
action: 'FLAG',
|
action: 'FLAG',
|
||||||
|
batchSize: parseInt(aiBatchSize) || 20,
|
||||||
|
parallelBatches: parseInt(aiParallelBatches) || 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,6 +199,8 @@ export default function FilteringRulesPage({
|
||||||
setMinFileCount('1')
|
setMinFileCount('1')
|
||||||
setDocAction('REJECT')
|
setDocAction('REJECT')
|
||||||
setCriteriaText('')
|
setCriteriaText('')
|
||||||
|
setAiBatchSize('20')
|
||||||
|
setAiParallelBatches('1')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -403,6 +409,7 @@ export default function FilteringRulesPage({
|
||||||
|
|
||||||
{/* AI Screening Config */}
|
{/* AI Screening Config */}
|
||||||
{newRuleType === 'AI_SCREENING' && (
|
{newRuleType === 'AI_SCREENING' && (
|
||||||
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Screening Criteria</Label>
|
<Label>Screening Criteria</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
@ -416,6 +423,52 @@ export default function FilteringRulesPage({
|
||||||
auto-rejects.
|
auto-rejects.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -498,6 +498,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
? 'bg-green-500/10 text-green-700'
|
? 'bg-green-500/10 text-green-700'
|
||||||
: isFuture(new Date(round.votingStartAt))
|
: isFuture(new Date(round.votingStartAt))
|
||||||
? 'bg-amber-500/10 text-amber-700'
|
? 'bg-amber-500/10 text-amber-700'
|
||||||
|
: isFuture(new Date(round.votingEndAt))
|
||||||
|
? 'bg-blue-500/10 text-blue-700'
|
||||||
: 'bg-muted text-muted-foreground'
|
: 'bg-muted text-muted-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
@ -513,6 +515,16 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
||||||
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
|
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-5 w-5" />
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,12 @@ function CreateRoundContent() {
|
||||||
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
|
||||||
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
|
||||||
|
|
||||||
|
const utils = trpc.useUtils()
|
||||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
|
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
|
||||||
|
|
||||||
const createRound = trpc.round.create.useMutation({
|
const createRound = trpc.round.create.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
|
utils.program.list.invalidate({ includeRounds: true })
|
||||||
router.push(`/admin/rounds/${data.id}`)
|
router.push(`/admin/rounds/${data.id}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,23 @@ import { Suspense, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import { toast } from 'sonner'
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -14,14 +31,6 @@ import {
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table'
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -52,10 +61,24 @@ import {
|
||||||
Archive,
|
Archive,
|
||||||
Trash2,
|
Trash2,
|
||||||
Loader2,
|
Loader2,
|
||||||
ChevronUp,
|
GripVertical,
|
||||||
ChevronDown,
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { format, isPast, isFuture } from 'date-fns'
|
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() {
|
function RoundsContent() {
|
||||||
const { data: programs, isLoading } = trpc.program.list.useQuery({
|
const { data: programs, isLoading } = trpc.program.list.useQuery({
|
||||||
|
|
@ -86,7 +109,64 @@ function RoundsContent() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{programs.map((program) => (
|
{programs.map((program) => (
|
||||||
<Card key={program.id}>
|
<ProgramRounds key={program.id} program={program} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgramRounds({ program }: { program: any }) {
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
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 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -104,32 +184,72 @@ function RoundsContent() {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{program.rounds && program.rounds.length > 0 ? (
|
{rounds.length > 0 ? (
|
||||||
<Table>
|
<div className="space-y-2">
|
||||||
<TableHeader>
|
{/* Header */}
|
||||||
<TableRow>
|
<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">
|
||||||
<TableHead className="w-20">Order</TableHead>
|
<div>Order</div>
|
||||||
<TableHead>Round</TableHead>
|
<div>Round</div>
|
||||||
<TableHead>Status</TableHead>
|
<div>Status</div>
|
||||||
<TableHead>Voting Window</TableHead>
|
<div>Voting Window</div>
|
||||||
<TableHead>Projects</TableHead>
|
<div>Projects</div>
|
||||||
<TableHead>Assignments</TableHead>
|
<div>Reviewers</div>
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
<div></div>
|
||||||
</TableRow>
|
</div>
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
{/* Sortable List */}
|
||||||
{program.rounds.map((round, index) => (
|
<DndContext
|
||||||
<RoundRow
|
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}
|
key={round.id}
|
||||||
round={round}
|
round={round}
|
||||||
index={index}
|
index={index}
|
||||||
totalRounds={program.rounds.length}
|
totalRounds={rounds.length}
|
||||||
allRoundIds={program.rounds.map((r) => r.id)}
|
isReordering={reorder.isPending}
|
||||||
programId={program.id}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</div>
|
||||||
</Table>
|
</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">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
|
||||||
|
|
@ -138,45 +258,35 @@ function RoundsContent() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoundRow({
|
function SortableRoundRow({
|
||||||
round,
|
round,
|
||||||
index,
|
index,
|
||||||
totalRounds,
|
totalRounds,
|
||||||
allRoundIds,
|
isReordering,
|
||||||
programId,
|
|
||||||
}: {
|
}: {
|
||||||
round: any
|
round: RoundData
|
||||||
index: number
|
index: number
|
||||||
totalRounds: number
|
totalRounds: number
|
||||||
allRoundIds: string[]
|
isReordering: boolean
|
||||||
programId: string
|
|
||||||
}) {
|
}) {
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
|
|
||||||
const reorder = trpc.round.reorder.useMutation({
|
const {
|
||||||
onSuccess: () => {
|
attributes,
|
||||||
utils.program.list.invalidate({ includeRounds: true })
|
listeners,
|
||||||
},
|
setNodeRef,
|
||||||
})
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: round.id })
|
||||||
|
|
||||||
const moveUp = () => {
|
const style = {
|
||||||
if (index <= 0) return
|
transform: CSS.Transform.toString(transform),
|
||||||
const ids = [...allRoundIds]
|
transition,
|
||||||
;[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]]
|
|
||||||
reorder.mutate({ programId, roundIds: ids })
|
|
||||||
}
|
|
||||||
|
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateStatus = trpc.round.updateStatus.useMutation({
|
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||||
|
|
@ -239,17 +349,16 @@ function RoundRow({
|
||||||
|
|
||||||
const getVotingWindow = () => {
|
const getVotingWindow = () => {
|
||||||
if (!round.votingStartAt || !round.votingEndAt) {
|
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 start = new Date(round.votingStartAt)
|
||||||
const end = new Date(round.votingEndAt)
|
const end = new Date(round.votingEndAt)
|
||||||
const now = new Date()
|
|
||||||
|
|
||||||
if (isFuture(start)) {
|
if (isFuture(start)) {
|
||||||
return (
|
return (
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Opens {format(start, 'MMM d, yyyy')}
|
Opens {format(start, 'MMM d')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -257,68 +366,79 @@ function RoundRow({
|
||||||
if (isPast(end)) {
|
if (isPast(end)) {
|
||||||
return (
|
return (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Ended {format(end, 'MMM d, yyyy')}
|
Ended {format(end, 'MMM d')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
Until {format(end, 'MMM d, yyyy')}
|
Until {format(end, 'MMM d')}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow>
|
<div
|
||||||
<TableCell>
|
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">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
{...attributes}
|
||||||
size="icon"
|
{...listeners}
|
||||||
className="h-7 w-7"
|
className="cursor-grab active:cursor-grabbing p-1 -ml-1 rounded hover:bg-muted transition-colors touch-none"
|
||||||
onClick={moveUp}
|
disabled={isReordering}
|
||||||
disabled={index === 0 || reorder.isPending}
|
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
</button>
|
||||||
<Button
|
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-primary/10 text-primary text-sm font-bold">
|
||||||
variant="ghost"
|
{index}
|
||||||
size="icon"
|
</span>
|
||||||
className="h-7 w-7"
|
|
||||||
onClick={moveDown}
|
|
||||||
disabled={index === totalRounds - 1 || reorder.isPending}
|
|
||||||
>
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
{/* Round name */}
|
||||||
|
<div>
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/rounds/${round.id}`}
|
href={`/admin/rounds/${round.id}`}
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{round.name}
|
{round.name}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
<p className="text-xs text-muted-foreground capitalize">
|
||||||
<TableCell>{getStatusBadge()}</TableCell>
|
{round.roundType?.toLowerCase().replace('_', ' ')}
|
||||||
<TableCell>{getVotingWindow()}</TableCell>
|
</p>
|
||||||
<TableCell>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
|
{/* 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" />
|
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||||
{round._count?.roundProjects || 0}
|
<span className="font-medium">{round._count?.roundProjects || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
{/* Assignments */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<Users className="h-4 w-4 text-muted-foreground" />
|
<Users className="h-4 w-4 text-muted-foreground" />
|
||||||
{round._count?.assignments || 0}
|
<span className="font-medium">{round._count?.assignments || 0}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
{/* Actions */}
|
||||||
|
<div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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" />
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
@ -408,8 +528,8 @@ function RoundRow({
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ const formSchema = z.object({
|
||||||
smtp_port: z.string().regex(/^\d+$/, 'Port must be a number'),
|
smtp_port: z.string().regex(/^\d+$/, 'Port must be a number'),
|
||||||
smtp_user: z.string().min(1, 'SMTP user is required'),
|
smtp_user: z.string().min(1, 'SMTP user is required'),
|
||||||
smtp_password: z.string().optional(),
|
smtp_password: z.string().optional(),
|
||||||
|
email_from_name: z.string().min(1, 'Sender name is required'),
|
||||||
email_from: z.string().email('Invalid email address'),
|
email_from: z.string().email('Invalid email address'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -44,6 +45,7 @@ interface EmailSettingsFormProps {
|
||||||
smtp_port?: string
|
smtp_port?: string
|
||||||
smtp_user?: string
|
smtp_user?: string
|
||||||
smtp_password?: string
|
smtp_password?: string
|
||||||
|
email_from_name?: string
|
||||||
email_from?: string
|
email_from?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +62,7 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
||||||
smtp_port: settings.smtp_port || '587',
|
smtp_port: settings.smtp_port || '587',
|
||||||
smtp_user: settings.smtp_user || '',
|
smtp_user: settings.smtp_user || '',
|
||||||
smtp_password: '',
|
smtp_password: '',
|
||||||
|
email_from_name: settings.email_from_name || 'MOPC Portal',
|
||||||
email_from: settings.email_from || 'noreply@monaco-opc.com',
|
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_host', value: data.smtp_host },
|
||||||
{ key: 'smtp_port', value: data.smtp_port },
|
{ key: 'smtp_port', value: data.smtp_port },
|
||||||
{ key: 'smtp_user', value: data.smtp_user },
|
{ key: 'smtp_user', value: data.smtp_user },
|
||||||
|
{ key: 'email_from_name', value: data.email_from_name },
|
||||||
{ key: 'email_from', value: data.email_from },
|
{ key: 'email_from', value: data.email_from },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -188,23 +192,42 @@ export function EmailSettingsForm({ settings }: EmailSettingsFormProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="email_from"
|
name="email_from_name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>From Email Address</FormLabel>
|
<FormLabel>Sender Display Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="noreply@monaco-opc.com" {...field} />
|
<Input placeholder="MOPC Portal" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
Email address that will appear as the sender
|
Name shown to recipients (e.g., "MOPC Portal")
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</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">
|
<div className="flex gap-2">
|
||||||
<Button type="submit" disabled={updateSettings.isPending}>
|
<Button type="submit" disabled={updateSettings.isPending}>
|
||||||
{updateSettings.isPending ? (
|
{updateSettings.isPending ? (
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ async function getTransporter(): Promise<{ transporter: Transporter; from: strin
|
||||||
// Read DB settings
|
// Read DB settings
|
||||||
const dbSettings = await prisma.systemSettings.findMany({
|
const dbSettings = await prisma.systemSettings.findMany({
|
||||||
where: {
|
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 },
|
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 port = db.smtp_port || process.env.SMTP_PORT || '587'
|
||||||
const user = db.smtp_user || process.env.SMTP_USER || ''
|
const user = db.smtp_user || process.env.SMTP_USER || ''
|
||||||
const pass = db.smtp_password || process.env.SMTP_PASS || ''
|
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
|
// Check if config changed since last call
|
||||||
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
const configHash = `${host}:${port}:${user}:${pass}:${from}`
|
||||||
|
|
|
||||||
|
|
@ -94,14 +94,39 @@ export const roundRouter = router({
|
||||||
}
|
}
|
||||||
|
|
||||||
const { settingsJson, sortOrder: _so, ...rest } = input
|
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({
|
const round = await ctx.prisma.round.create({
|
||||||
data: {
|
data: {
|
||||||
...rest,
|
...rest,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
status: shouldAutoActivate ? 'ACTIVE' : 'DRAFT',
|
||||||
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
|
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
|
// Audit log
|
||||||
await ctx.prisma.auditLog.create({
|
await ctx.prisma.auditLog.create({
|
||||||
data: {
|
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({
|
const round = await ctx.prisma.round.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
|
...(autoActivate && { status: 'ACTIVE' }),
|
||||||
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
|
settingsJson: settingsJson as Prisma.InputJsonValue ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ export type DocumentCheckConfig = {
|
||||||
export type AIScreeningConfig = {
|
export type AIScreeningConfig = {
|
||||||
criteriaText: string
|
criteriaText: string
|
||||||
action: 'FLAG' // AI screening always flags for human review
|
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
|
export type RuleConfig = FieldRuleConfig | DocumentCheckConfig | AIScreeningConfig
|
||||||
|
|
@ -124,7 +127,11 @@ interface FilteringRuleInput {
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
// ─── 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)
|
// Optimized system prompt (compressed for token efficiency)
|
||||||
const AI_SCREENING_SYSTEM_PROMPT = `Project screening assistant. Evaluate against criteria, return JSON.
|
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()
|
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
|
// Convert and anonymize projects
|
||||||
const projectsWithRelations = projects.map(toProjectWithRelations)
|
const projectsWithRelations = projects.map(toProjectWithRelations)
|
||||||
|
|
@ -454,39 +472,56 @@ export async function executeAIScreening(
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalTokens = 0
|
let totalTokens = 0
|
||||||
const totalBatches = Math.ceil(anonymized.length / BATCH_SIZE)
|
const totalBatches = Math.ceil(anonymized.length / batchSize)
|
||||||
|
let processedBatches = 0
|
||||||
|
|
||||||
// Process in batches
|
// Create batch chunks for parallel processing
|
||||||
for (let i = 0; i < anonymized.length; i += BATCH_SIZE) {
|
const batches: Array<{ anon: typeof anonymized; maps: typeof mappings; index: number }> = []
|
||||||
const batchAnon = anonymized.slice(i, i + BATCH_SIZE)
|
for (let i = 0; i < anonymized.length; i += batchSize) {
|
||||||
const batchMappings = mappings.slice(i, i + BATCH_SIZE)
|
batches.push({
|
||||||
const currentBatch = Math.floor(i / BATCH_SIZE) + 1
|
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)
|
||||||
|
|
||||||
|
console.log(`[AI Filtering] Processing batches ${i + 1}-${Math.min(i + parallelBatches, batches.length)} of ${totalBatches} (${parallelChunk.length} in parallel)`)
|
||||||
|
|
||||||
|
// Run parallel batches concurrently
|
||||||
|
const batchPromises = parallelChunk.map(async (batch) => {
|
||||||
const { results: batchResults, tokensUsed } = await processAIBatch(
|
const { results: batchResults, tokensUsed } = await processAIBatch(
|
||||||
openai,
|
openai,
|
||||||
model,
|
model,
|
||||||
config.criteriaText,
|
config.criteriaText,
|
||||||
batchAnon,
|
batch.anon,
|
||||||
batchMappings,
|
batch.maps,
|
||||||
userId,
|
userId,
|
||||||
entityId
|
entityId
|
||||||
)
|
)
|
||||||
|
return { batchResults, tokensUsed, index: batch.index }
|
||||||
|
})
|
||||||
|
|
||||||
|
const parallelResults = await Promise.all(batchPromises)
|
||||||
|
|
||||||
|
// Merge results from all parallel batches
|
||||||
|
for (const { batchResults, tokensUsed } of parallelResults) {
|
||||||
totalTokens += tokensUsed
|
totalTokens += tokensUsed
|
||||||
|
|
||||||
// Merge batch results
|
|
||||||
for (const [id, result] of batchResults) {
|
for (const [id, result] of batchResults) {
|
||||||
results.set(id, result)
|
results.set(id, result)
|
||||||
}
|
}
|
||||||
|
processedBatches++
|
||||||
|
}
|
||||||
|
|
||||||
// Report progress
|
// Report progress after each parallel chunk
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
await onProgress({
|
await onProgress({
|
||||||
currentBatch,
|
currentBatch: processedBatches,
|
||||||
totalBatches,
|
totalBatches,
|
||||||
processedCount: Math.min((currentBatch) * BATCH_SIZE, anonymized.length),
|
processedCount: Math.min(processedBatches * batchSize, anonymized.length),
|
||||||
tokensUsed: totalTokens,
|
tokensUsed: totalTokens,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue