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 { 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 "{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue