Add multiple admin improvements and bug fixes

- 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:
2026-02-03 23:19:45 +01:00
parent 1d137ce93e
commit 3be6a743ed
12 changed files with 895 additions and 192 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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