Auto-assign projects to first round, auto-filter on close, pipeline UX consolidation
Build and Push Docker Image / build (push) Successful in 10m30s
Details
Build and Push Docker Image / build (push) Successful in 10m30s
Details
- New projects (admin create, CSV import, public form) auto-assign to program's first round (by sortOrder) when no round is specified - Closing a FILTERING round auto-starts filtering job (configurable via autoFilterOnClose setting, defaults to true) - Add SUBMISSION_RECEIVED notification type for confirming submissions - Replace separate List/Pipeline toggle with integrated pipeline view below the sortable round list - Add autoFilterOnClose toggle to filtering round type settings UI Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2a5fa463b3
commit
7b85fd9602
|
|
@ -64,6 +64,7 @@ const TEAM_NOTIFICATION_OPTIONS = [
|
|||
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' },
|
||||
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' },
|
||||
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' },
|
||||
{ value: 'SUBMISSION_RECEIVED', label: 'Submission Received', description: 'Confirms to the team that their submission has been received' },
|
||||
]
|
||||
|
||||
interface PageProps {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const TEAM_NOTIFICATION_OPTIONS = [
|
|||
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' },
|
||||
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' },
|
||||
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' },
|
||||
{ value: 'SUBMISSION_RECEIVED', label: 'Submission Received', description: 'Confirms to the team that their submission has been received' },
|
||||
]
|
||||
|
||||
const createRoundSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -62,9 +62,6 @@ import {
|
|||
Trash2,
|
||||
Loader2,
|
||||
GripVertical,
|
||||
ArrowRight,
|
||||
List,
|
||||
GitBranchPlus,
|
||||
} from 'lucide-react'
|
||||
import { format, isPast, isFuture } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -84,7 +81,7 @@ type RoundData = {
|
|||
}
|
||||
}
|
||||
|
||||
function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) {
|
||||
function RoundsContent() {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({
|
||||
includeRounds: true,
|
||||
})
|
||||
|
|
@ -110,45 +107,6 @@ function RoundsContent({ viewMode }: { viewMode: 'list' | 'pipeline' }) {
|
|||
)
|
||||
}
|
||||
|
||||
if (viewMode === 'pipeline') {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program, index) => (
|
||||
<AnimatedCard key={program.id} index={index}>
|
||||
<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>
|
||||
{(program.rounds && program.rounds.length > 0) ? (
|
||||
<RoundPipeline rounds={program.rounds} programName={program.name} />
|
||||
) : (
|
||||
<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>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program, index) => (
|
||||
|
|
@ -271,32 +229,10 @@ function ProgramRounds({ program }: { program: any }) {
|
|||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Flow visualization */}
|
||||
{/* Pipeline 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?.projects || 0}
|
||||
</Badge>
|
||||
</div>
|
||||
{index < rounds.length - 1 && (
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground/50" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<RoundPipeline rounds={rounds} programName={program.name} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -711,43 +647,19 @@ function RoundsListSkeleton() {
|
|||
}
|
||||
|
||||
export default function RoundsPage() {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'pipeline'>('list')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Rounds</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage selection rounds and voting periods
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-lg border p-1">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="mr-1.5 h-4 w-4" />
|
||||
List
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'pipeline' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setViewMode('pipeline')}
|
||||
>
|
||||
<GitBranchPlus className="mr-1.5 h-4 w-4" />
|
||||
Pipeline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<RoundsListSkeleton />}>
|
||||
<RoundsContent viewMode={viewMode} />
|
||||
<RoundsContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -260,6 +260,31 @@ function FilteringSettings({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto-Filter on Close */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Auto-Run Filtering on Close</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically start filtering when this round is closed
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.autoFilterOnClose}
|
||||
onCheckedChange={(v) =>
|
||||
onChange({ ...settings, autoFilterOnClose: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
When enabled, closing this round will automatically run the configured filtering rules.
|
||||
Results still require admin review before finalization.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Display Options */}
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium">Display Options</h5>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import { Prisma, CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma
|
|||
import {
|
||||
createNotification,
|
||||
notifyAdmins,
|
||||
notifyProjectTeam,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
|
||||
import { checkRateLimit } from '@/lib/rate-limit'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { parseWizardConfig } from '@/lib/wizard-config'
|
||||
|
|
@ -458,6 +460,18 @@ export const applicationRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Auto-assign to first round if project has no roundId (edition-wide mode)
|
||||
let assignedRound: { id: string; name: string; entryNotificationType: string | null } | null = null
|
||||
if (!project.roundId) {
|
||||
assignedRound = await getFirstRoundForProgram(ctx.prisma, program.id)
|
||||
if (assignedRound) {
|
||||
await ctx.prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { roundId: assignedRound.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create team lead membership
|
||||
await ctx.prisma.teamMember.create({
|
||||
data: {
|
||||
|
|
@ -510,6 +524,7 @@ export const applicationRouter = router({
|
|||
source: 'public_application_form',
|
||||
title: data.projectName,
|
||||
category: data.competitionCategory,
|
||||
autoAssignedRound: assignedRound?.name || null,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
|
|
@ -544,6 +559,26 @@ export const applicationRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Send SUBMISSION_RECEIVED notification if the round is configured for it
|
||||
if (assignedRound?.entryNotificationType === 'SUBMISSION_RECEIVED') {
|
||||
try {
|
||||
await notifyProjectTeam(project.id, {
|
||||
type: NotificationTypes.SUBMISSION_RECEIVED,
|
||||
title: 'Submission Received',
|
||||
message: `Your submission "${data.projectName}" has been received and is now under review.`,
|
||||
linkUrl: `/team/projects/${project.id}`,
|
||||
linkLabel: 'View Submission',
|
||||
metadata: {
|
||||
projectName: data.projectName,
|
||||
roundName: assignedRound.name,
|
||||
programName: program.name,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Never fail on notification
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectId: project.id,
|
||||
|
|
@ -816,6 +851,18 @@ export const applicationRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Auto-assign to first round if project has no roundId
|
||||
let draftAssignedRound: { id: string; name: string; entryNotificationType: string | null } | null = null
|
||||
if (!updated.roundId) {
|
||||
draftAssignedRound = await getFirstRoundForProgram(ctx.prisma, updated.programId)
|
||||
if (draftAssignedRound) {
|
||||
await ctx.prisma.project.update({
|
||||
where: { id: updated.id },
|
||||
data: { roundId: draftAssignedRound.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
try {
|
||||
await logAudit({
|
||||
|
|
@ -828,6 +875,7 @@ export const applicationRouter = router({
|
|||
source: 'draft_submission',
|
||||
title: data.projectName,
|
||||
category: data.competitionCategory,
|
||||
autoAssignedRound: draftAssignedRound?.name || null,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
|
|
@ -836,6 +884,25 @@ export const applicationRouter = router({
|
|||
// Never throw on audit failure
|
||||
}
|
||||
|
||||
// Send SUBMISSION_RECEIVED notification if the round is configured for it
|
||||
if (draftAssignedRound?.entryNotificationType === 'SUBMISSION_RECEIVED') {
|
||||
try {
|
||||
await notifyProjectTeam(updated.id, {
|
||||
type: NotificationTypes.SUBMISSION_RECEIVED,
|
||||
title: 'Submission Received',
|
||||
message: `Your submission "${data.projectName}" has been received and is now under review.`,
|
||||
linkUrl: `/team/projects/${updated.id}`,
|
||||
linkLabel: 'View Submission',
|
||||
metadata: {
|
||||
projectName: data.projectName,
|
||||
roundName: draftAssignedRound.name,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Never fail on notification
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
projectId: updated.id,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import {
|
|||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
|
||||
// Background job execution function
|
||||
async function runFilteringJob(jobId: string, roundId: string, userId: string) {
|
||||
// Background job execution function (exported for auto-filtering on round close)
|
||||
export async function runFilteringJob(jobId: string, roundId: string, userId: string) {
|
||||
try {
|
||||
// Update job to running
|
||||
await prisma.filteringJob.update({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
notifyProjectTeam,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { getFirstRoundForProgram } from '@/server/utils/round-helpers'
|
||||
import { normalizeCountryToCode } from '@/lib/countries'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { sendInvitationEmail } from '@/lib/email'
|
||||
|
|
@ -459,10 +460,19 @@ export const projectRouter = router({
|
|||
: undefined
|
||||
|
||||
const { project, membersToInvite } = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Auto-assign to first round if no roundId provided
|
||||
let resolvedRoundId = input.roundId || null
|
||||
if (!resolvedRoundId) {
|
||||
const firstRound = await getFirstRoundForProgram(tx, resolvedProgramId)
|
||||
if (firstRound) {
|
||||
resolvedRoundId = firstRound.id
|
||||
}
|
||||
}
|
||||
|
||||
const created = await tx.project.create({
|
||||
data: {
|
||||
programId: resolvedProgramId,
|
||||
roundId: input.roundId || null,
|
||||
roundId: resolvedRoundId,
|
||||
title: input.title,
|
||||
teamName: input.teamName,
|
||||
description: input.description,
|
||||
|
|
@ -882,6 +892,15 @@ export const projectRouter = router({
|
|||
}
|
||||
}
|
||||
|
||||
// Auto-assign to first round if no roundId provided
|
||||
let resolvedImportRoundId = input.roundId || null
|
||||
if (!resolvedImportRoundId) {
|
||||
const firstRound = await getFirstRoundForProgram(ctx.prisma, input.programId)
|
||||
if (firstRound) {
|
||||
resolvedImportRoundId = firstRound.id
|
||||
}
|
||||
}
|
||||
|
||||
// Create projects in a transaction
|
||||
const result = await ctx.prisma.$transaction(async (tx) => {
|
||||
// Create all projects with roundId and programId
|
||||
|
|
@ -890,7 +909,7 @@ export const projectRouter = router({
|
|||
return {
|
||||
...rest,
|
||||
programId: input.programId,
|
||||
roundId: input.roundId!,
|
||||
roundId: resolvedImportRoundId,
|
||||
status: 'SUBMITTED' as const,
|
||||
metadataJson: metadataJson as Prisma.InputJsonValue ?? undefined,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import { Prisma } from '@prisma/client'
|
|||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import {
|
||||
notifyRoundJury,
|
||||
notifyAdmins,
|
||||
NotificationTypes,
|
||||
} from '../services/in-app-notification'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { runFilteringJob } from './filtering'
|
||||
import { prisma as globalPrisma } from '@/lib/prisma'
|
||||
|
||||
// Valid round status transitions (state machine)
|
||||
const VALID_ROUND_TRANSITIONS: Record<string, string[]> = {
|
||||
|
|
@ -437,6 +440,60 @@ export const roundRouter = router({
|
|||
}
|
||||
}
|
||||
|
||||
// Auto-run filtering when a FILTERING round is closed (if enabled in settings)
|
||||
const roundSettings = (round.settingsJson as Record<string, unknown>) || {}
|
||||
const autoFilterEnabled = roundSettings.autoFilterOnClose !== false // Default to true
|
||||
if (input.status === 'CLOSED' && round.roundType === 'FILTERING' && autoFilterEnabled) {
|
||||
try {
|
||||
const [filteringRules, projectCount] = await Promise.all([
|
||||
ctx.prisma.filteringRule.findMany({
|
||||
where: { roundId: input.id, isActive: true },
|
||||
}),
|
||||
ctx.prisma.project.count({ where: { roundId: input.id } }),
|
||||
])
|
||||
|
||||
// Check for existing running job
|
||||
const existingJob = await ctx.prisma.filteringJob.findFirst({
|
||||
where: { roundId: input.id, status: 'RUNNING' },
|
||||
})
|
||||
|
||||
if (filteringRules.length > 0 && projectCount > 0 && !existingJob) {
|
||||
// Create filtering job
|
||||
const job = await globalPrisma.filteringJob.create({
|
||||
data: {
|
||||
roundId: input.id,
|
||||
status: 'PENDING',
|
||||
totalProjects: projectCount,
|
||||
},
|
||||
})
|
||||
|
||||
// Start background execution (non-blocking)
|
||||
setImmediate(() => {
|
||||
runFilteringJob(job.id, input.id, ctx.user.id).catch(console.error)
|
||||
})
|
||||
|
||||
// Notify admins that auto-filtering has started
|
||||
await notifyAdmins({
|
||||
type: NotificationTypes.FILTERING_COMPLETE,
|
||||
title: 'Auto-Filtering Started',
|
||||
message: `Filtering automatically started for "${round.name}" after closing. ${projectCount} projects will be processed.`,
|
||||
linkUrl: `/admin/rounds/${input.id}/filtering`,
|
||||
linkLabel: 'View Progress',
|
||||
metadata: {
|
||||
roundId: input.id,
|
||||
roundName: round.name,
|
||||
projectCount,
|
||||
ruleCount: filteringRules.length,
|
||||
autoTriggered: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Auto-filtering failure should not block round closure
|
||||
console.error('[Auto-Filtering] Failed to start:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return round
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export const NotificationTypes = {
|
|||
FEEDBACK_AVAILABLE: 'FEEDBACK_AVAILABLE',
|
||||
EVENT_INVITATION: 'EVENT_INVITATION',
|
||||
WINNER_ANNOUNCEMENT: 'WINNER_ANNOUNCEMENT',
|
||||
SUBMISSION_RECEIVED: 'SUBMISSION_RECEIVED',
|
||||
CERTIFICATE_READY: 'CERTIFICATE_READY',
|
||||
PROGRAM_NEWSLETTER: 'PROGRAM_NEWSLETTER',
|
||||
|
||||
|
|
@ -107,6 +108,7 @@ export const NotificationIcons: Record<string, string> = {
|
|||
[NotificationTypes.MENTEE_ADVANCED]: 'TrendingUp',
|
||||
[NotificationTypes.MENTEE_WON]: 'Trophy',
|
||||
[NotificationTypes.APPLICATION_SUBMITTED]: 'CheckCircle',
|
||||
[NotificationTypes.SUBMISSION_RECEIVED]: 'Inbox',
|
||||
[NotificationTypes.ADVANCED_SEMIFINAL]: 'TrendingUp',
|
||||
[NotificationTypes.ADVANCED_FINAL]: 'Star',
|
||||
[NotificationTypes.MENTOR_ASSIGNED]: 'GraduationCap',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Get the first round (by sortOrder) for a program.
|
||||
* Used to auto-assign new projects to the intake round.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function getFirstRoundForProgram(
|
||||
prisma: any,
|
||||
programId: string
|
||||
): Promise<{ id: string; name: string; entryNotificationType: string | null } | null> {
|
||||
return prisma.round.findFirst({
|
||||
where: { programId },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true, entryNotificationType: true },
|
||||
})
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ export interface FilteringRoundSettings {
|
|||
autoEliminationMinReviews: number // Min reviews required before elimination
|
||||
targetAdvancing: number // Target number of projects to advance (e.g., 60)
|
||||
|
||||
// Auto-run filtering when round closes
|
||||
autoFilterOnClose: boolean
|
||||
|
||||
// Display options
|
||||
showAverageScore: boolean
|
||||
showRanking: boolean
|
||||
|
|
@ -62,6 +65,7 @@ export const defaultFilteringSettings: FilteringRoundSettings = {
|
|||
autoEliminationThreshold: 4,
|
||||
autoEliminationMinReviews: 0,
|
||||
targetAdvancing: 60,
|
||||
autoFilterOnClose: true,
|
||||
showAverageScore: true,
|
||||
showRanking: true,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue