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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user