Round system redesign: Phases 1-7 complete
Full pipeline/track/stage architecture replacing the legacy round system. Schema: 11 new models (Pipeline, Track, Stage, StageTransition, ProjectStageState, RoutingRule, Cohort, CohortProject, LiveProgressCursor, OverrideAction, AudienceVoter) + 8 new enums. Backend: 9 new routers (pipeline, stage, routing, stageFiltering, stageAssignment, cohort, live, decision, award) + 6 new services (stage-engine, routing-engine, stage-filtering, stage-assignment, stage-notifications, live-control). Frontend: Pipeline wizard (17 components), jury stage pages (7), applicant pipeline pages (3), public stage pages (2), admin pipeline pages (5), shared stage components (3), SSE route, live hook. Phase 6 refit: 23 routers/services migrated from roundId to stageId, all frontend components refitted. Deleted round.ts (985 lines), roundTemplate.ts, round-helpers.ts, round-settings.ts, round-type-settings.tsx, 10 legacy admin pages, 7 legacy jury pages, 3 legacy dialogs. Phase 7 validation: 36 tests (10 unit + 8 integration files) all passing, TypeScript 0 errors, Next.js build succeeds, 13 integrity checks, legacy symbol sweep clean, auto-seed on first Docker startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -290,21 +290,21 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
|
||||
const {
|
||||
edition,
|
||||
activeRoundCount,
|
||||
totalRoundCount,
|
||||
activeStageCount,
|
||||
totalStageCount,
|
||||
projectCount,
|
||||
newProjectsThisWeek,
|
||||
totalJurors,
|
||||
activeJurors,
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
recentRounds,
|
||||
recentStages,
|
||||
latestProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
pendingCOIs,
|
||||
draftRounds,
|
||||
draftStages,
|
||||
unassignedProjects,
|
||||
} = data
|
||||
|
||||
@@ -318,32 +318,25 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
|
||||
const invitedJurors = totalJurors - activeJurors
|
||||
|
||||
// Compute per-round eval stats
|
||||
const roundsWithEvalStats = recentRounds.map((round) => {
|
||||
const submitted = round.assignments.filter(
|
||||
(a) => a.evaluation?.status === 'SUBMITTED'
|
||||
// Compute per-stage eval stats
|
||||
const stagesWithEvalStats = recentStages.map((stage: typeof recentStages[number]) => {
|
||||
const submitted = stage.assignments.filter(
|
||||
(a: { evaluation: { status: string } | null }) => a.evaluation?.status === 'SUBMITTED'
|
||||
).length
|
||||
const total = round._count.assignments
|
||||
const total = stage._count.assignments
|
||||
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
|
||||
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
|
||||
return { ...stage, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
|
||||
})
|
||||
|
||||
// Upcoming deadlines from rounds
|
||||
// Upcoming deadlines from stages
|
||||
const now = new Date()
|
||||
const deadlines: { label: string; roundName: string; date: Date }[] = []
|
||||
for (const round of recentRounds) {
|
||||
if (round.votingEndAt && new Date(round.votingEndAt) > now) {
|
||||
const deadlines: { label: string; stageName: string; date: Date }[] = []
|
||||
for (const stage of recentStages) {
|
||||
if (stage.windowCloseAt && new Date(stage.windowCloseAt) > now) {
|
||||
deadlines.push({
|
||||
label: 'Voting closes',
|
||||
roundName: round.name,
|
||||
date: new Date(round.votingEndAt),
|
||||
})
|
||||
}
|
||||
if (round.submissionEndDate && new Date(round.submissionEndDate) > now) {
|
||||
deadlines.push({
|
||||
label: 'Submissions close',
|
||||
roundName: round.name,
|
||||
date: new Date(round.submissionEndDate),
|
||||
label: 'Window closes',
|
||||
stageName: stage.name,
|
||||
date: new Date(stage.windowCloseAt),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -388,10 +381,10 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
|
||||
<p className="text-sm font-medium text-muted-foreground">Stages</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalStageCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
|
||||
{activeStageCount} active stage{activeStageCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
@@ -474,13 +467,13 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Link href="/admin/rounds/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<Link href="/admin/rounds/pipelines" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
|
||||
<Plus className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">New Round</p>
|
||||
<p className="text-xs text-muted-foreground">Create a voting round</p>
|
||||
<p className="text-sm font-medium">Pipelines</p>
|
||||
<p className="text-xs text-muted-foreground">Manage stages & pipelines</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
|
||||
@@ -507,7 +500,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-7">
|
||||
{/* Rounds Card (enhanced) */}
|
||||
{/* Stages Card (enhanced) */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -517,14 +510,14 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<CircleDot className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Rounds
|
||||
Stages
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Voting rounds in {edition.name}
|
||||
Pipeline stages in {edition.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/rounds"
|
||||
href="/admin/rounds/pipelines"
|
||||
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
View all <ArrowRight className="h-3.5 w-3.5" />
|
||||
@@ -532,52 +525,51 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{roundsWithEvalStats.length === 0 ? (
|
||||
{stagesWithEvalStats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No rounds created yet
|
||||
No stages created yet
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/rounds/new"
|
||||
href="/admin/rounds/pipelines"
|
||||
className="mt-4 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Create your first round
|
||||
Set up your pipeline
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{roundsWithEvalStats.map((round) => (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
{stagesWithEvalStats.map((stage) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="block"
|
||||
>
|
||||
<div className="rounded-lg border p-4 transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{round.name}</p>
|
||||
<StatusBadge status={round.status} />
|
||||
<p className="font-medium">{stage.name}</p>
|
||||
<StatusBadge status={stage.status} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{round._count.projects} projects · {round._count.assignments} assignments
|
||||
{round.totalEvals > 0 && (
|
||||
<> · {round.evalPercent}% evaluated</>
|
||||
{stage._count.projectStageStates} projects · {stage._count.assignments} assignments
|
||||
{stage.totalEvals > 0 && (
|
||||
<> · {stage.evalPercent}% evaluated</>
|
||||
)}
|
||||
</p>
|
||||
{round.votingStartAt && round.votingEndAt && (
|
||||
{stage.windowOpenAt && stage.windowCloseAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Voting: {formatDateOnly(round.votingStartAt)} – {formatDateOnly(round.votingEndAt)}
|
||||
Window: {formatDateOnly(stage.windowOpenAt)} – {formatDateOnly(stage.windowCloseAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{round.totalEvals > 0 && (
|
||||
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
|
||||
{stage.totalEvals > 0 && (
|
||||
<Progress value={stage.evalPercent} className="mt-3 h-1.5" gradient />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -690,7 +682,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{pendingCOIs > 0 && (
|
||||
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm">COI declarations to review</span>
|
||||
@@ -707,16 +699,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
<Badge variant="warning">{unassignedProjects}</Badge>
|
||||
</Link>
|
||||
)}
|
||||
{draftRounds > 0 && (
|
||||
<Link href="/admin/rounds" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
{draftStages > 0 && (
|
||||
<Link href="/admin/rounds/pipelines" className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<CircleDot className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm">Draft rounds to activate</span>
|
||||
<span className="text-sm">Draft stages to activate</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{draftRounds}</Badge>
|
||||
<Badge variant="secondary">{draftStages}</Badge>
|
||||
</Link>
|
||||
)}
|
||||
{pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && (
|
||||
{pendingCOIs === 0 && unassignedProjects === 0 && draftStages === 0 && (
|
||||
<div className="flex flex-col items-center py-4 text-center">
|
||||
<CheckCircle2 className="h-6 w-6 text-emerald-500" />
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">All caught up!</p>
|
||||
@@ -739,7 +731,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{roundsWithEvalStats.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0).length === 0 ? (
|
||||
{stagesWithEvalStats.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0).length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
@@ -748,19 +740,19 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{roundsWithEvalStats
|
||||
.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0)
|
||||
.map((round) => (
|
||||
<div key={round.id} className="space-y-2">
|
||||
{stagesWithEvalStats
|
||||
.filter((s: typeof stagesWithEvalStats[number]) => s.status !== 'STAGE_DRAFT' && s.totalEvals > 0)
|
||||
.map((stage: typeof stagesWithEvalStats[number]) => (
|
||||
<div key={stage.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
<p className="text-sm font-medium truncate">{stage.name}</p>
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{round.evalPercent}%
|
||||
{stage.evalPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={round.evalPercent} className="h-2" gradient />
|
||||
<Progress value={stage.evalPercent} className="h-2" gradient />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{round.submittedEvals} of {round.totalEvals} evaluations submitted
|
||||
{stage.submittedEvals} of {stage.totalEvals} evaluations submitted
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -913,7 +905,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-medium">
|
||||
{deadline.label} — {deadline.roundName}
|
||||
{deadline.label} — {deadline.stageName}
|
||||
</p>
|
||||
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{formatDateOnly(deadline.date)} · in {days} day{days !== 1 ? 's' : ''}
|
||||
|
||||
@@ -76,7 +76,7 @@ type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERV
|
||||
|
||||
interface Assignment {
|
||||
projectId: string
|
||||
roundId: string
|
||||
stageId: string
|
||||
}
|
||||
|
||||
interface MemberRow {
|
||||
@@ -269,7 +269,7 @@ export default function MemberInvitePage() {
|
||||
} | null>(null)
|
||||
|
||||
// Pre-assignment state
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -284,30 +284,27 @@ export default function MemberInvitePage() {
|
||||
},
|
||||
})
|
||||
|
||||
// Fetch programs with rounds for pre-assignment
|
||||
// Fetch programs with stages for pre-assignment
|
||||
const { data: programsData } = trpc.program.list.useQuery({
|
||||
status: 'ACTIVE',
|
||||
includeRounds: true,
|
||||
includeStages: true,
|
||||
})
|
||||
// Flatten all rounds from all programs
|
||||
const rounds = useMemo(() => {
|
||||
// Flatten all stages from all programs
|
||||
const stages = useMemo(() => {
|
||||
if (!programsData) return []
|
||||
type ProgramWithRounds = typeof programsData[number] & {
|
||||
rounds?: Array<{ id: string; name: string }>
|
||||
}
|
||||
return (programsData as ProgramWithRounds[]).flatMap((program) =>
|
||||
(program.rounds || []).map((round) => ({
|
||||
id: round.id,
|
||||
name: round.name,
|
||||
return programsData.flatMap((program) =>
|
||||
((program.stages ?? []) as Array<{ id: string; name: string }>).map((stage: { id: string; name: string }) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
programName: `${program.name} ${program.year}`,
|
||||
}))
|
||||
)
|
||||
}, [programsData])
|
||||
|
||||
// Fetch projects for selected round
|
||||
// Fetch projects for selected stage
|
||||
const { data: projectsData, isLoading: projectsLoading } = trpc.project.list.useQuery(
|
||||
{ roundId: selectedRoundId, perPage: 200 },
|
||||
{ enabled: !!selectedRoundId }
|
||||
{ stageId: selectedStageId, perPage: 200 },
|
||||
{ enabled: !!selectedStageId }
|
||||
)
|
||||
const projects = projectsData?.projects || []
|
||||
|
||||
@@ -355,7 +352,7 @@ export default function MemberInvitePage() {
|
||||
|
||||
// Per-row project assignment management
|
||||
const toggleProjectAssignment = (rowId: string, projectId: string) => {
|
||||
if (!selectedRoundId) return
|
||||
if (!selectedStageId) return
|
||||
setRows((prev) =>
|
||||
prev.map((r) => {
|
||||
if (r.id !== rowId) return r
|
||||
@@ -363,7 +360,7 @@ export default function MemberInvitePage() {
|
||||
if (existing) {
|
||||
return { ...r, assignments: r.assignments.filter((a) => a.projectId !== projectId) }
|
||||
} else {
|
||||
return { ...r, assignments: [...r.assignments, { projectId, roundId: selectedRoundId }] }
|
||||
return { ...r, assignments: [...r.assignments, { projectId, stageId: selectedStageId }] }
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -587,21 +584,21 @@ export default function MemberInvitePage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label className="text-sm font-medium">Pre-assign Projects (Optional)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a round to assign projects to jury members before they onboard
|
||||
Select a stage to assign projects to jury members before they onboard
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedRoundId || 'none'}
|
||||
onValueChange={(v) => setSelectedRoundId(v === 'none' ? '' : v)}
|
||||
value={selectedStageId || 'none'}
|
||||
onValueChange={(v) => setSelectedStageId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Select round" />
|
||||
<SelectValue placeholder="Select stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No pre-assignment</SelectItem>
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -683,7 +680,7 @@ export default function MemberInvitePage() {
|
||||
/>
|
||||
|
||||
{/* Per-member project pre-assignment (only for jury members) */}
|
||||
{row.role === 'JURY_MEMBER' && selectedRoundId && (
|
||||
{row.role === 'JURY_MEMBER' && selectedStageId && (
|
||||
<Collapsible className="space-y-2">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -64,12 +64,12 @@ import {
|
||||
import { toast } from 'sonner'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
|
||||
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'PROGRAM_TEAM' | 'USER'
|
||||
type RecipientType = 'ALL' | 'ROLE' | 'STAGE_JURY' | 'PROGRAM_TEAM' | 'USER'
|
||||
|
||||
const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
|
||||
{ value: 'ALL', label: 'All Users' },
|
||||
{ value: 'ROLE', label: 'By Role' },
|
||||
{ value: 'ROUND_JURY', label: 'Round Jury' },
|
||||
{ value: 'STAGE_JURY', label: 'Stage Jury' },
|
||||
{ value: 'PROGRAM_TEAM', label: 'Program Team' },
|
||||
{ value: 'USER', label: 'Specific User' },
|
||||
]
|
||||
@@ -79,7 +79,7 @@ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'
|
||||
export default function MessagesPage() {
|
||||
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
|
||||
const [selectedRole, setSelectedRole] = useState('')
|
||||
const [roundId, setRoundId] = useState('')
|
||||
const [stageId, setStageId] = useState('')
|
||||
const [selectedProgramId, setSelectedProgramId] = useState('')
|
||||
const [selectedUserId, setSelectedUserId] = useState('')
|
||||
const [subject, setSubject] = useState('')
|
||||
@@ -93,8 +93,11 @@ export default function MessagesPage() {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// Fetch supporting data
|
||||
const { data: rounds } = trpc.round.listAll.useQuery()
|
||||
const { data: programs } = trpc.program.list.useQuery()
|
||||
// Get programs with stages
|
||||
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||
const rounds = programs?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } }))
|
||||
) || []
|
||||
const { data: templates } = trpc.message.listTemplates.useQuery()
|
||||
const { data: users } = trpc.user.list.useQuery(
|
||||
{ page: 1, perPage: 100 },
|
||||
@@ -121,7 +124,7 @@ export default function MessagesPage() {
|
||||
setBody('')
|
||||
setSelectedTemplateId('')
|
||||
setSelectedRole('')
|
||||
setRoundId('')
|
||||
setStageId('')
|
||||
setSelectedProgramId('')
|
||||
setSelectedUserId('')
|
||||
setIsScheduled(false)
|
||||
@@ -170,14 +173,14 @@ export default function MessagesPage() {
|
||||
const roleLabel = selectedRole ? selectedRole.replace(/_/g, ' ') : ''
|
||||
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
|
||||
}
|
||||
case 'ROUND_JURY': {
|
||||
if (!roundId) return 'Round Jury (none selected)'
|
||||
const round = (rounds as Array<{ id: string; name: string; program?: { name: string } }> | undefined)?.find(
|
||||
(r) => r.id === roundId
|
||||
case 'STAGE_JURY': {
|
||||
if (!stageId) return 'Stage Jury (none selected)'
|
||||
const stage = rounds?.find(
|
||||
(r) => r.id === stageId
|
||||
)
|
||||
return round
|
||||
? `Jury of ${round.program ? `${round.program.name} - ` : ''}${round.name}`
|
||||
: 'Round Jury'
|
||||
return stage
|
||||
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
|
||||
: 'Stage Jury'
|
||||
}
|
||||
case 'PROGRAM_TEAM': {
|
||||
if (!selectedProgramId) return 'Program Team (none selected)'
|
||||
@@ -214,8 +217,8 @@ export default function MessagesPage() {
|
||||
toast.error('Please select a role')
|
||||
return
|
||||
}
|
||||
if (recipientType === 'ROUND_JURY' && !roundId) {
|
||||
toast.error('Please select a round')
|
||||
if (recipientType === 'STAGE_JURY' && !stageId) {
|
||||
toast.error('Please select a stage')
|
||||
return
|
||||
}
|
||||
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
|
||||
@@ -234,7 +237,7 @@ export default function MessagesPage() {
|
||||
sendMutation.mutate({
|
||||
recipientType,
|
||||
recipientFilter: buildRecipientFilter(),
|
||||
roundId: roundId || undefined,
|
||||
stageId: stageId || undefined,
|
||||
subject: subject.trim(),
|
||||
body: body.trim(),
|
||||
deliveryChannels,
|
||||
@@ -292,7 +295,7 @@ export default function MessagesPage() {
|
||||
onValueChange={(v) => {
|
||||
setRecipientType(v as RecipientType)
|
||||
setSelectedRole('')
|
||||
setRoundId('')
|
||||
setStageId('')
|
||||
setSelectedProgramId('')
|
||||
setSelectedUserId('')
|
||||
}}
|
||||
@@ -329,15 +332,15 @@ export default function MessagesPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipientType === 'ROUND_JURY' && (
|
||||
{recipientType === 'STAGE_JURY' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Select Round</Label>
|
||||
<Select value={roundId} onValueChange={setRoundId}>
|
||||
<Label>Select Stage</Label>
|
||||
<Select value={stageId} onValueChange={setStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a round..." />
|
||||
<SelectValue placeholder="Choose a stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(rounds as Array<{ id: string; name: string; program?: { name: string } }> | undefined)?.map((round) => (
|
||||
{rounds?.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.program ? `${round.program.name} - ${round.name}` : round.name}
|
||||
</SelectItem>
|
||||
|
||||
@@ -86,53 +86,48 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Rounds</CardTitle>
|
||||
<CardTitle>Stages</CardTitle>
|
||||
<CardDescription>
|
||||
Voting rounds for this program
|
||||
Pipeline stages for this program
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href={`/admin/rounds/new?programId=${id}`}>
|
||||
<Link href={`/admin/rounds/pipelines?programId=${id}`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Round
|
||||
Manage Pipeline
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{program.rounds.length === 0 ? (
|
||||
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number }; createdAt?: Date }>).length === 0 ? (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No rounds created yet. Create a round to start accepting projects.
|
||||
No stages created yet. Set up a pipeline to get started.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Assignments</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{program.rounds.map((round) => (
|
||||
<TableRow key={round.id}>
|
||||
{(program.stages as Array<{ id: string; name: string; status: string; _count: { projects: number; assignments: number } }>).map((stage) => (
|
||||
<TableRow key={stage.id}>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{round.name}
|
||||
</Link>
|
||||
<span className="font-medium">
|
||||
{stage.name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[round.status] || 'secondary'}>
|
||||
{round.status}
|
||||
<Badge variant={statusColors[stage.status] || 'secondary'}>
|
||||
{stage.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{round._count.projects}</TableCell>
|
||||
<TableCell>{round._count.assignments}</TableCell>
|
||||
<TableCell>{formatDateOnly(round.createdAt)}</TableCell>
|
||||
<TableCell>{stage._count.projects}</TableCell>
|
||||
<TableCell>{stage._count.assignments}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -42,20 +42,31 @@ async function ProgramsContent() {
|
||||
const programs = await prisma.program.findMany({
|
||||
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
rounds: true,
|
||||
pipelines: {
|
||||
include: {
|
||||
tracks: {
|
||||
include: {
|
||||
stages: {
|
||||
select: { id: true, status: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rounds: {
|
||||
where: { status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
if (programs.length === 0) {
|
||||
// Flatten stages per program for convenience
|
||||
const programsWithStageCounts = programs.map((p) => {
|
||||
const allStages = p.pipelines.flatMap((pl) =>
|
||||
pl.tracks.flatMap((t) => t.stages)
|
||||
)
|
||||
const activeStages = allStages.filter((s) => s.status === 'STAGE_ACTIVE')
|
||||
return { ...p, stageCount: allStages.length, activeStageCount: activeStages.length }
|
||||
})
|
||||
|
||||
if (programsWithStageCounts.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
@@ -91,14 +102,14 @@ async function ProgramsContent() {
|
||||
<TableRow>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Year</TableHead>
|
||||
<TableHead>Rounds</TableHead>
|
||||
<TableHead>Stages</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{programs.map((program) => (
|
||||
{programsWithStageCounts.map((program) => (
|
||||
<TableRow key={program.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
@@ -113,10 +124,10 @@ async function ProgramsContent() {
|
||||
<TableCell>{program.year}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p>{program._count.rounds} total</p>
|
||||
{program.rounds.length > 0 && (
|
||||
<p>{program.stageCount} total</p>
|
||||
{program.activeStageCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{program.rounds.length} active
|
||||
{program.activeStageCount} active
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -165,7 +176,7 @@ async function ProgramsContent() {
|
||||
|
||||
{/* Mobile card view */}
|
||||
<div className="space-y-4 md:hidden">
|
||||
{programs.map((program) => (
|
||||
{programsWithStageCounts.map((program) => (
|
||||
<Card key={program.id}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -180,9 +191,9 @@ async function ProgramsContent() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Rounds</span>
|
||||
<span className="text-muted-foreground">Stages</span>
|
||||
<span>
|
||||
{program._count.rounds} ({program.rounds.length} active)
|
||||
{program.stageCount} ({program.activeStageCount} active)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
|
||||
@@ -120,9 +120,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
})
|
||||
|
||||
// Fetch existing tags for suggestions
|
||||
const { data: existingTags } = trpc.project.getTags.useQuery({
|
||||
roundId: project?.roundId ?? undefined,
|
||||
})
|
||||
const { data: existingTags } = trpc.project.getTags.useQuery({})
|
||||
|
||||
// Mutations
|
||||
const utils = trpc.useUtils()
|
||||
@@ -137,7 +135,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
const deleteProject = trpc.project.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
router.push('/admin/projects')
|
||||
},
|
||||
})
|
||||
@@ -202,7 +200,6 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
teamName: data.teamName || null,
|
||||
description: data.description || null,
|
||||
status: data.status,
|
||||
roundId: project?.roundId ?? undefined,
|
||||
tags: data.tags,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,17 +86,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
// Fetch files (flat list for backward compatibility)
|
||||
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
|
||||
|
||||
// Fetch grouped files by round (if project has a roundId)
|
||||
const { data: groupedFiles } = trpc.file.listByProjectForRound.useQuery(
|
||||
{ projectId, roundId: project?.roundId || '' },
|
||||
{ enabled: !!project?.roundId }
|
||||
)
|
||||
|
||||
// Fetch available rounds for upload selector (if project has a programId)
|
||||
const { data: rounds } = trpc.round.listByProgram.useQuery(
|
||||
{ programId: project?.programId || '' },
|
||||
// Fetch available stages for upload selector (if project has a programId)
|
||||
const { data: programData } = trpc.program.get.useQuery(
|
||||
{ id: project?.programId || '' },
|
||||
{ enabled: !!project?.programId }
|
||||
)
|
||||
const availableStages = (programData?.stages as Array<{ id: string; name: string }>) || []
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -148,15 +143,15 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||
{project.roundId ? (
|
||||
{project.programId ? (
|
||||
<Link
|
||||
href={`/admin/rounds/${project.roundId}`}
|
||||
href={`/admin/programs/${project.programId}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{project.round?.name ?? 'Round'}
|
||||
{programData?.name ?? 'Program'}
|
||||
</Link>
|
||||
) : (
|
||||
<span>No round</span>
|
||||
<span>No program</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -526,9 +521,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{groupedFiles && groupedFiles.length > 0 ? (
|
||||
<FileViewer groupedFiles={groupedFiles} />
|
||||
) : files && files.length > 0 ? (
|
||||
{files && files.length > 0 ? (
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
files={files.map((f) => ({
|
||||
@@ -551,13 +544,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<p className="text-sm font-medium mb-3">Upload New Files</p>
|
||||
<FileUpload
|
||||
projectId={projectId}
|
||||
roundId={project.roundId || undefined}
|
||||
availableRounds={rounds?.map((r: { id: string; name: string }) => ({ id: r.id, name: r.name }))}
|
||||
availableStages={availableStages?.map((s: { id: string; name: string }) => ({ id: s.id, name: s.name }))}
|
||||
onUploadComplete={() => {
|
||||
utils.file.listByProject.invalidate({ projectId })
|
||||
if (project.roundId) {
|
||||
utils.file.listByProjectForRound.invalidate({ projectId, roundId: project.roundId })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -585,7 +574,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
|
||||
<Link href={`/admin/members`}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -688,10 +677,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
|
||||
{/* AI Evaluation Summary */}
|
||||
{project.roundId && stats && stats.totalEvaluations > 0 && (
|
||||
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
|
||||
<EvaluationSummaryCard
|
||||
projectId={projectId}
|
||||
roundId={project.roundId}
|
||||
stageId={assignments[0].stageId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -30,26 +30,26 @@ function ImportPageContent() {
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const searchParams = useSearchParams()
|
||||
const roundIdParam = searchParams.get('round')
|
||||
const stageIdParam = searchParams.get('stage')
|
||||
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
|
||||
|
||||
// Fetch active programs with rounds
|
||||
// Fetch active programs with stages
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
||||
status: 'ACTIVE',
|
||||
includeRounds: true,
|
||||
includeStages: true,
|
||||
})
|
||||
|
||||
// Get all rounds from programs
|
||||
const rounds = programs?.flatMap((p) =>
|
||||
(p.rounds || []).map((r) => ({
|
||||
...r,
|
||||
// Get all stages from programs
|
||||
const stages = programs?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({
|
||||
...s,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
|
||||
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
|
||||
|
||||
if (loadingPrograms) {
|
||||
return <ImportPageSkeleton />
|
||||
@@ -70,44 +70,44 @@ function ImportPageContent() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Import Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Import projects from a CSV file into a round
|
||||
Import projects from a CSV file into a stage
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Round selection */}
|
||||
{!selectedRoundId && (
|
||||
{/* Stage selection */}
|
||||
{!selectedStageId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Round</CardTitle>
|
||||
<CardTitle>Select Stage</CardTitle>
|
||||
<CardDescription>
|
||||
Choose the round you want to import projects into
|
||||
Choose the stage you want to import projects into
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{rounds.length === 0 ? (
|
||||
{stages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Active Rounds</p>
|
||||
<p className="mt-2 font-medium">No Active Stages</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a round first before importing projects
|
||||
Create a stage first before importing projects
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/rounds/new">Create Round</Link>
|
||||
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a round" />
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
<div className="flex flex-col">
|
||||
<span>{round.name}</span>
|
||||
<span>{stage.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{round.programName}
|
||||
{stage.programName}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -117,11 +117,11 @@ function ImportPageContent() {
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (selectedRoundId) {
|
||||
router.push(`/admin/projects/import?round=${selectedRoundId}`)
|
||||
if (selectedStageId) {
|
||||
router.push(`/admin/projects/import?stage=${selectedStageId}`)
|
||||
}
|
||||
}}
|
||||
disabled={!selectedRoundId}
|
||||
disabled={!selectedStageId}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
@@ -132,14 +132,14 @@ function ImportPageContent() {
|
||||
)}
|
||||
|
||||
{/* Import form */}
|
||||
{selectedRoundId && selectedRound && (
|
||||
{selectedStageId && selectedStage && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Importing into: {selectedRound.name}</p>
|
||||
<p className="font-medium">Importing into: {selectedStage.name}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedRound.programName}
|
||||
{selectedStage.programName}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -147,11 +147,11 @@ function ImportPageContent() {
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
setSelectedRoundId('')
|
||||
setSelectedStageId('')
|
||||
router.push('/admin/projects/import')
|
||||
}}
|
||||
>
|
||||
Change Round
|
||||
Change Stage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -172,32 +172,31 @@ function ImportPageContent() {
|
||||
</TabsList>
|
||||
<TabsContent value="csv" className="mt-4">
|
||||
<CSVImportForm
|
||||
programId={selectedRound.programId}
|
||||
roundId={selectedRoundId}
|
||||
roundName={selectedRound.name}
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="notion" className="mt-4">
|
||||
<NotionImportForm
|
||||
roundId={selectedRoundId}
|
||||
roundName={selectedRound.name}
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="typeform" className="mt-4">
|
||||
<TypeformImportForm
|
||||
roundId={selectedRoundId}
|
||||
roundName={selectedRound.name}
|
||||
programId={selectedStage.programId}
|
||||
stageName={selectedStage.name}
|
||||
onSuccess={() => {
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -85,11 +85,11 @@ const ROLE_SORT_ORDER: Record<string, number> = { LEAD: 0, MEMBER: 1, ADVISOR: 2
|
||||
function NewProjectPageContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const roundIdParam = searchParams.get('round')
|
||||
const stageIdParam = searchParams.get('stage')
|
||||
const programIdParam = searchParams.get('program')
|
||||
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>(programIdParam || '')
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
|
||||
|
||||
// Form state
|
||||
const [title, setTitle] = useState('')
|
||||
@@ -113,7 +113,7 @@ function NewProjectPageContent() {
|
||||
// Fetch programs
|
||||
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
|
||||
status: 'ACTIVE',
|
||||
includeRounds: true,
|
||||
includeStages: true,
|
||||
})
|
||||
|
||||
// Fetch wizard config for selected program (dropdown options)
|
||||
@@ -128,7 +128,7 @@ function NewProjectPageContent() {
|
||||
onSuccess: () => {
|
||||
toast.success('Project created successfully')
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate()
|
||||
utils.program.get.invalidate()
|
||||
router.push('/admin/projects')
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -136,9 +136,9 @@ function NewProjectPageContent() {
|
||||
},
|
||||
})
|
||||
|
||||
// Get rounds for selected program
|
||||
// Get stages for selected program
|
||||
const selectedProgram = programs?.find((p) => p.id === selectedProgramId)
|
||||
const rounds = selectedProgram?.rounds || []
|
||||
const stages = (selectedProgram?.stages || []) as Array<{ id: string; name: string }>
|
||||
|
||||
// Get dropdown options from wizard config
|
||||
const categoryOptions = wizardConfig?.competitionCategories || []
|
||||
@@ -216,7 +216,6 @@ function NewProjectPageContent() {
|
||||
|
||||
createProject.mutate({
|
||||
programId: selectedProgramId,
|
||||
roundId: selectedRoundId || undefined,
|
||||
title: title.trim(),
|
||||
teamName: teamName.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
@@ -264,12 +263,12 @@ function NewProjectPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Program & Round selection */}
|
||||
{/* Program & Stage selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Program & Round</CardTitle>
|
||||
<CardTitle>Program & Stage</CardTitle>
|
||||
<CardDescription>
|
||||
Select the program for this project. Round assignment is optional.
|
||||
Select the program for this project. Stage assignment is optional.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -287,7 +286,7 @@ function NewProjectPageContent() {
|
||||
<Label>Program *</Label>
|
||||
<Select value={selectedProgramId} onValueChange={(v) => {
|
||||
setSelectedProgramId(v)
|
||||
setSelectedRoundId('') // Reset round on program change
|
||||
setSelectedStageId('') // Reset stage on program change
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a program" />
|
||||
@@ -303,16 +302,16 @@ function NewProjectPageContent() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Round (optional)</Label>
|
||||
<Select value={selectedRoundId || '__none__'} onValueChange={(v) => setSelectedRoundId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
|
||||
<Label>Stage (optional)</Label>
|
||||
<Select value={selectedStageId || '__none__'} onValueChange={(v) => setSelectedStageId(v === '__none__' ? '' : v)} disabled={!selectedProgramId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No round assigned" />
|
||||
<SelectValue placeholder="No stage assigned" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No round assigned</SelectItem>
|
||||
{rounds.map((r: { id: string; name: string }) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
<SelectItem value="__none__">No stage assigned</SelectItem>
|
||||
{stages.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -96,6 +96,7 @@ import { CountryFlagImg } from '@/components/ui/country-select'
|
||||
import {
|
||||
ProjectFiltersBar,
|
||||
type ProjectFilters,
|
||||
type FilterOptions,
|
||||
} from './project-filters'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
@@ -121,7 +122,7 @@ function parseFiltersFromParams(
|
||||
statuses: searchParams.get('status')
|
||||
? searchParams.get('status')!.split(',')
|
||||
: [],
|
||||
roundId: searchParams.get('round') || '',
|
||||
stageId: searchParams.get('stage') || '',
|
||||
competitionCategory: searchParams.get('category') || '',
|
||||
oceanIssue: searchParams.get('issue') || '',
|
||||
country: searchParams.get('country') || '',
|
||||
@@ -155,7 +156,7 @@ function filtersToParams(
|
||||
if (filters.search) params.set('q', filters.search)
|
||||
if (filters.statuses.length > 0)
|
||||
params.set('status', filters.statuses.join(','))
|
||||
if (filters.roundId) params.set('round', filters.roundId)
|
||||
if (filters.stageId) params.set('stage', filters.stageId)
|
||||
if (filters.competitionCategory)
|
||||
params.set('category', filters.competitionCategory)
|
||||
if (filters.oceanIssue) params.set('issue', filters.oceanIssue)
|
||||
@@ -180,7 +181,7 @@ export default function ProjectsPage() {
|
||||
const [filters, setFilters] = useState<ProjectFilters>({
|
||||
search: parsed.search,
|
||||
statuses: parsed.statuses,
|
||||
roundId: parsed.roundId,
|
||||
stageId: parsed.stageId,
|
||||
competitionCategory: parsed.competitionCategory,
|
||||
oceanIssue: parsed.oceanIssue,
|
||||
country: parsed.country,
|
||||
@@ -251,7 +252,7 @@ export default function ProjectsPage() {
|
||||
| 'REJECTED'
|
||||
>)
|
||||
: undefined,
|
||||
roundId: filters.roundId || undefined,
|
||||
stageId: filters.stageId || undefined,
|
||||
competitionCategory:
|
||||
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
|
||||
undefined,
|
||||
@@ -283,14 +284,14 @@ export default function ProjectsPage() {
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [projectToDelete, setProjectToDelete] = useState<{ id: string; title: string } | null>(null)
|
||||
// Assign to round dialog state
|
||||
// Assign to stage dialog state
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
||||
const [assignRoundId, setAssignRoundId] = useState('')
|
||||
const [assignStageId, setAssignStageId] = useState('')
|
||||
|
||||
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
||||
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
||||
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
||||
const [taggingScope, setTaggingScope] = useState<'stage' | 'program'>('stage')
|
||||
const [selectedStageForTagging, setSelectedStageForTagging] = useState<string>('')
|
||||
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
|
||||
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
|
||||
|
||||
@@ -350,8 +351,14 @@ export default function ProjectsPage() {
|
||||
: null
|
||||
|
||||
const handleStartTagging = () => {
|
||||
if (taggingScope === 'round' && selectedRoundForTagging) {
|
||||
startTaggingJob.mutate({ roundId: selectedRoundForTagging })
|
||||
if (taggingScope === 'stage' && selectedStageForTagging) {
|
||||
// Router only accepts programId; resolve from the selected stage's parent program
|
||||
const parentProgram = programs?.find((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string }>)?.some((s: { id: string }) => s.id === selectedStageForTagging)
|
||||
)
|
||||
if (parentProgram) {
|
||||
startTaggingJob.mutate({ programId: parentProgram.id })
|
||||
}
|
||||
} else if (taggingScope === 'program' && selectedProgramForTagging) {
|
||||
startTaggingJob.mutate({ programId: selectedProgramForTagging })
|
||||
}
|
||||
@@ -361,20 +368,19 @@ export default function ProjectsPage() {
|
||||
if (!taggingInProgress) {
|
||||
setAiTagDialogOpen(false)
|
||||
setActiveTaggingJobId(null)
|
||||
setSelectedRoundForTagging('')
|
||||
setSelectedStageForTagging('')
|
||||
setSelectedProgramForTagging('')
|
||||
}
|
||||
}
|
||||
|
||||
// Get selected program's rounds
|
||||
// Get selected program's stages (flattened from pipelines -> tracks -> stages)
|
||||
const selectedProgram = programs?.find(p => p.id === selectedProgramForTagging)
|
||||
const programRounds = filterOptions?.rounds?.filter(r => r.program?.id === selectedProgramForTagging) ?? []
|
||||
const programStages = selectedProgram?.stages ?? []
|
||||
|
||||
// Calculate stats for display
|
||||
const selectedRound = filterOptions?.rounds?.find(r => r.id === selectedRoundForTagging)
|
||||
const displayProgram = taggingScope === 'program'
|
||||
? selectedProgram
|
||||
: (selectedRound ? programs?.find(p => p.id === selectedRound.program?.id) : null)
|
||||
: (selectedStageForTagging ? programs?.find(p => (p.stages as Array<{ id: string }>)?.some(s => s.id === selectedStageForTagging)) : null)
|
||||
|
||||
// Calculate progress percentage
|
||||
const taggingProgressPercent = jobStatus && jobStatus.totalProjects > 0
|
||||
@@ -387,7 +393,7 @@ export default function ProjectsPage() {
|
||||
const [bulkStatus, setBulkStatus] = useState<string>('')
|
||||
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
|
||||
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
|
||||
const [bulkAssignRoundId, setBulkAssignRoundId] = useState('')
|
||||
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
|
||||
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
|
||||
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
|
||||
|
||||
@@ -406,7 +412,7 @@ export default function ProjectsPage() {
|
||||
| 'REJECTED'
|
||||
>)
|
||||
: undefined,
|
||||
roundId: filters.roundId || undefined,
|
||||
stageId: filters.stageId || undefined,
|
||||
competitionCategory:
|
||||
(filters.competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT') ||
|
||||
undefined,
|
||||
@@ -446,12 +452,12 @@ export default function ProjectsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const bulkAssignToRound = trpc.projectPool.assignToRound.useMutation({
|
||||
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to ${result.roundName}`)
|
||||
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
|
||||
setSelectedIds(new Set())
|
||||
setAllMatchingSelected(false)
|
||||
setBulkAssignRoundId('')
|
||||
setBulkAssignStageId('')
|
||||
setBulkAssignDialogOpen(false)
|
||||
utils.project.list.invalidate()
|
||||
},
|
||||
@@ -526,10 +532,9 @@ export default function ProjectsPage() {
|
||||
}
|
||||
|
||||
const handleBulkConfirm = () => {
|
||||
if (!bulkStatus || selectedIds.size === 0 || !filters.roundId) return
|
||||
if (!bulkStatus || selectedIds.size === 0) return
|
||||
bulkUpdateStatus.mutate({
|
||||
ids: Array.from(selectedIds),
|
||||
roundId: filters.roundId,
|
||||
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
|
||||
})
|
||||
}
|
||||
@@ -542,13 +547,13 @@ export default function ProjectsPage() {
|
||||
? data.projects.some((p) => selectedIds.has(p.id)) && !allVisibleSelected
|
||||
: false
|
||||
|
||||
const assignToRound = trpc.projectPool.assignToRound.useMutation({
|
||||
const assignToStage = trpc.projectPool.assignToStage.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Project assigned to round')
|
||||
toast.success('Project assigned to stage')
|
||||
utils.project.list.invalidate()
|
||||
setAssignDialogOpen(false)
|
||||
setProjectToAssign(null)
|
||||
setAssignRoundId('')
|
||||
setAssignStageId('')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to assign project')
|
||||
@@ -579,7 +584,7 @@ export default function ProjectsPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{data ? `${data.total} projects across all rounds` : 'Manage submitted projects across all rounds'}
|
||||
{data ? `${data.total} projects across all stages` : 'Manage submitted projects across all stages'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -632,7 +637,17 @@ export default function ProjectsPage() {
|
||||
{/* Filters */}
|
||||
<ProjectFiltersBar
|
||||
filters={filters}
|
||||
filterOptions={filterOptions}
|
||||
filterOptions={filterOptions ? {
|
||||
...filterOptions,
|
||||
stages: programs?.flatMap(p =>
|
||||
(p.stages as Array<{ id: string; name: string }>)?.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
programName: p.name,
|
||||
programYear: p.year,
|
||||
})) ?? []
|
||||
) ?? [],
|
||||
} satisfies FilterOptions : undefined}
|
||||
onChange={handleFiltersChange}
|
||||
/>
|
||||
|
||||
@@ -755,7 +770,7 @@ export default function ProjectsPage() {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filters.search ||
|
||||
filters.statuses.length > 0 ||
|
||||
filters.roundId ||
|
||||
filters.stageId ||
|
||||
filters.competitionCategory ||
|
||||
filters.oceanIssue ||
|
||||
filters.country
|
||||
@@ -799,7 +814,7 @@ export default function ProjectsPage() {
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Tags</TableHead>
|
||||
<TableHead>Assignments</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
@@ -871,8 +886,8 @@ export default function ProjectsPage() {
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{project.round ? (
|
||||
<p>{project.round.name}</p>
|
||||
{project.program ? (
|
||||
<p>{project.program.name}</p>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
|
||||
Unassigned
|
||||
@@ -880,7 +895,7 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.round?.program?.name}
|
||||
{project.program?.year}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -939,7 +954,7 @@ export default function ProjectsPage() {
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!project.round && (
|
||||
{project._count.assignments === 0 && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -948,7 +963,7 @@ export default function ProjectsPage() {
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
Assign to Round
|
||||
Assign to Stage
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -1000,8 +1015,8 @@ export default function ProjectsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Round</span>
|
||||
<span>{project.round?.name ?? 'Unassigned'}</span>
|
||||
<span className="text-muted-foreground">Stage</span>
|
||||
<span>{project.program?.name ?? 'Unassigned'}</span>
|
||||
</div>
|
||||
{project.competitionCategory && (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
@@ -1079,7 +1094,7 @@ export default function ProjectsPage() {
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{!project.round && (
|
||||
{project._count.assignments === 0 && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -1088,7 +1103,7 @@ export default function ProjectsPage() {
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" />
|
||||
Assign to Round
|
||||
Assign to Stage
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -1138,10 +1153,10 @@ export default function ProjectsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Round</span>
|
||||
<span className="text-muted-foreground">Program</span>
|
||||
<span className="text-right">
|
||||
{project.round ? (
|
||||
<>{project.round.name}</>
|
||||
{project.program ? (
|
||||
<>{project.program.name}</>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
|
||||
Unassigned
|
||||
@@ -1207,17 +1222,17 @@ export default function ProjectsPage() {
|
||||
{selectedIds.size} selected
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{/* Assign to Round */}
|
||||
{/* Assign to Stage */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setBulkAssignDialogOpen(true)}
|
||||
>
|
||||
<ArrowRightCircle className="mr-1.5 h-4 w-4" />
|
||||
Assign to Round
|
||||
Assign to Stage
|
||||
</Button>
|
||||
{/* Change Status (only when filtered by round) */}
|
||||
{filters.roundId && (
|
||||
{/* Change Status (only when filtered by stage) */}
|
||||
{filters.stageId && (
|
||||
<>
|
||||
<Select value={bulkStatus} onValueChange={setBulkStatus}>
|
||||
<SelectTrigger className="w-[160px] h-9 text-sm">
|
||||
@@ -1332,30 +1347,30 @@ export default function ProjectsPage() {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Assign to Round Dialog */}
|
||||
{/* Assign to Stage Dialog */}
|
||||
<Dialog open={assignDialogOpen} onOpenChange={(open) => {
|
||||
setAssignDialogOpen(open)
|
||||
if (!open) { setProjectToAssign(null); setAssignRoundId('') }
|
||||
if (!open) { setProjectToAssign(null); setAssignStageId('') }
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Round</DialogTitle>
|
||||
<DialogTitle>Assign to Stage</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign "{projectToAssign?.title}" to a round.
|
||||
Assign "{projectToAssign?.title}" to a stage.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Round</Label>
|
||||
<Select value={assignRoundId} onValueChange={setAssignRoundId}>
|
||||
<Label>Select Stage</Label>
|
||||
<Select value={assignStageId} onValueChange={setAssignStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a round..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.flatMap((p) =>
|
||||
(p.rounds || []).map((r: { id: string; name: string }) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{p.name} {p.year} - {r.name}
|
||||
((p.stages || []) as Array<{ id: string; name: string }>).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{p.name} {p.year} - {s.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
@@ -1369,46 +1384,46 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (projectToAssign && assignRoundId) {
|
||||
assignToRound.mutate({
|
||||
if (projectToAssign && assignStageId) {
|
||||
assignToStage.mutate({
|
||||
projectIds: [projectToAssign.id],
|
||||
roundId: assignRoundId,
|
||||
stageId: assignStageId,
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={!assignRoundId || assignToRound.isPending}
|
||||
disabled={!assignStageId || assignToStage.isPending}
|
||||
>
|
||||
{assignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{assignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Assign to Round Dialog */}
|
||||
{/* Bulk Assign to Stage Dialog */}
|
||||
<Dialog open={bulkAssignDialogOpen} onOpenChange={(open) => {
|
||||
setBulkAssignDialogOpen(open)
|
||||
if (!open) setBulkAssignRoundId('')
|
||||
if (!open) setBulkAssignStageId('')
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Round</DialogTitle>
|
||||
<DialogTitle>Assign to Stage</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a round. Projects will have their status set to "Assigned".
|
||||
Assign {selectedIds.size} selected project{selectedIds.size !== 1 ? 's' : ''} to a stage. Projects will have their status set to "Assigned".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select Round</Label>
|
||||
<Select value={bulkAssignRoundId} onValueChange={setBulkAssignRoundId}>
|
||||
<Label>Select Stage</Label>
|
||||
<Select value={bulkAssignStageId} onValueChange={setBulkAssignStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a round..." />
|
||||
<SelectValue placeholder="Choose a stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.flatMap((p) =>
|
||||
(p.rounds || []).map((r: { id: string; name: string }) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{p.name} {p.year} - {r.name}
|
||||
((p.stages || []) as Array<{ id: string; name: string }>).map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{p.name} {p.year} - {s.name}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
@@ -1422,16 +1437,16 @@ export default function ProjectsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (bulkAssignRoundId && selectedIds.size > 0) {
|
||||
bulkAssignToRound.mutate({
|
||||
if (bulkAssignStageId && selectedIds.size > 0) {
|
||||
bulkAssignToStage.mutate({
|
||||
projectIds: Array.from(selectedIds),
|
||||
roundId: bulkAssignRoundId,
|
||||
stageId: bulkAssignStageId,
|
||||
})
|
||||
}
|
||||
}}
|
||||
disabled={!bulkAssignRoundId || bulkAssignToRound.isPending}
|
||||
disabled={!bulkAssignStageId || bulkAssignToStage.isPending}
|
||||
>
|
||||
{bulkAssignToRound.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{bulkAssignToStage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1608,19 +1623,19 @@ export default function ProjectsPage() {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTaggingScope('round')}
|
||||
onClick={() => setTaggingScope('stage')}
|
||||
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-colors ${
|
||||
taggingScope === 'round'
|
||||
taggingScope === 'stage'
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className={`h-6 w-6 ${taggingScope === 'round' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span className={`text-sm font-medium ${taggingScope === 'round' ? 'text-primary' : ''}`}>
|
||||
Single Round
|
||||
<FolderOpen className={`h-6 w-6 ${taggingScope === 'stage' ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<span className={`text-sm font-medium ${taggingScope === 'stage' ? 'text-primary' : ''}`}>
|
||||
Single Stage
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground text-center">
|
||||
Tag projects in one specific round
|
||||
Tag projects in one specific stage
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
@@ -1637,7 +1652,7 @@ export default function ProjectsPage() {
|
||||
Entire Edition
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground text-center">
|
||||
Tag all projects across all rounds
|
||||
Tag all projects across all stages
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1645,22 +1660,24 @@ export default function ProjectsPage() {
|
||||
|
||||
{/* Selection */}
|
||||
<div className="space-y-2">
|
||||
{taggingScope === 'round' ? (
|
||||
{taggingScope === 'stage' ? (
|
||||
<>
|
||||
<Label htmlFor="round-select">Select Round</Label>
|
||||
<Label htmlFor="stage-select">Select Stage</Label>
|
||||
<Select
|
||||
value={selectedRoundForTagging}
|
||||
onValueChange={setSelectedRoundForTagging}
|
||||
value={selectedStageForTagging}
|
||||
onValueChange={setSelectedStageForTagging}
|
||||
>
|
||||
<SelectTrigger id="round-select">
|
||||
<SelectValue placeholder="Choose a round..." />
|
||||
<SelectTrigger id="stage-select">
|
||||
<SelectValue placeholder="Choose a stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filterOptions?.rounds?.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name} ({round.program?.name})
|
||||
</SelectItem>
|
||||
))}
|
||||
{programs?.flatMap(p =>
|
||||
(p.stages as Array<{ id: string; name: string }>)?.map(s => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name} ({p.name})
|
||||
</SelectItem>
|
||||
)) ?? []
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
@@ -1716,7 +1733,7 @@ export default function ProjectsPage() {
|
||||
onClick={handleStartTagging}
|
||||
disabled={
|
||||
taggingInProgress ||
|
||||
(taggingScope === 'round' && !selectedRoundForTagging) ||
|
||||
(taggingScope === 'stage' && !selectedStageForTagging) ||
|
||||
(taggingScope === 'program' && !selectedProgramForTagging)
|
||||
}
|
||||
>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function ProjectPoolPage() {
|
||||
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
|
||||
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
|
||||
const [targetRoundId, setTargetRoundId] = useState<string>('')
|
||||
const [targetStageId, setTargetStageId] = useState<string>('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
@@ -50,20 +50,22 @@ export default function ProjectPoolPage() {
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
|
||||
const { data: rounds, isLoading: isLoadingRounds } = trpc.round.listByProgram.useQuery(
|
||||
{ programId: selectedProgramId },
|
||||
// Get stages from the selected program (program.list includes rounds/stages)
|
||||
const { data: selectedProgramData, isLoading: isLoadingStages } = trpc.program.get.useQuery(
|
||||
{ id: selectedProgramId },
|
||||
{ enabled: !!selectedProgramId }
|
||||
)
|
||||
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const assignMutation = trpc.projectPool.assignToRound.useMutation({
|
||||
const assignMutation = trpc.projectPool.assignToStage.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.project.list.invalidate()
|
||||
utils.round.get.invalidate()
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
|
||||
utils.program.get.invalidate()
|
||||
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
|
||||
setSelectedProjects([])
|
||||
setAssignDialogOpen(false)
|
||||
setTargetRoundId('')
|
||||
setTargetStageId('')
|
||||
refetch()
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -72,17 +74,17 @@ export default function ProjectPoolPage() {
|
||||
})
|
||||
|
||||
const handleBulkAssign = () => {
|
||||
if (selectedProjects.length === 0 || !targetRoundId) return
|
||||
if (selectedProjects.length === 0 || !targetStageId) return
|
||||
assignMutation.mutate({
|
||||
projectIds: selectedProjects,
|
||||
roundId: targetRoundId,
|
||||
stageId: targetStageId,
|
||||
})
|
||||
}
|
||||
|
||||
const handleQuickAssign = (projectId: string, roundId: string) => {
|
||||
const handleQuickAssign = (projectId: string, stageId: string) => {
|
||||
assignMutation.mutate({
|
||||
projectIds: [projectId],
|
||||
roundId,
|
||||
stageId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,7 +111,7 @@ export default function ProjectPoolPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Project Pool</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Assign unassigned projects to evaluation rounds
|
||||
Assign unassigned projects to evaluation stages
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -236,20 +238,20 @@ export default function ProjectPoolPage() {
|
||||
: '-'}
|
||||
</td>
|
||||
<td className="p-3">
|
||||
{isLoadingRounds ? (
|
||||
{isLoadingStages ? (
|
||||
<Skeleton className="h-9 w-[200px]" />
|
||||
) : (
|
||||
<Select
|
||||
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
|
||||
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
|
||||
disabled={assignMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Assign to round..." />
|
||||
<SelectValue placeholder="Assign to stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
{stages?.map((stage: { id: string; name: string }) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -310,20 +312,20 @@ export default function ProjectPoolPage() {
|
||||
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Projects to Round</DialogTitle>
|
||||
<DialogTitle>Assign Projects to Stage</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
||||
<Select value={targetStageId} onValueChange={setTargetStageId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select round..." />
|
||||
<SelectValue placeholder="Select stage..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{rounds?.map((round: { id: string; name: string; sortOrder: number }) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.name}
|
||||
{stages?.map((stage: { id: string; name: string }) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -335,7 +337,7 @@ export default function ProjectPoolPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkAssign}
|
||||
disabled={!targetRoundId || assignMutation.isPending}
|
||||
disabled={!targetStageId || assignMutation.isPending}
|
||||
>
|
||||
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Assign
|
||||
|
||||
@@ -63,7 +63,7 @@ const ISSUE_LABELS: Record<string, string> = {
|
||||
export interface ProjectFilters {
|
||||
search: string
|
||||
statuses: string[]
|
||||
roundId: string
|
||||
stageId: string
|
||||
competitionCategory: string
|
||||
oceanIssue: string
|
||||
country: string
|
||||
@@ -72,11 +72,11 @@ export interface ProjectFilters {
|
||||
hasAssignments: boolean | undefined
|
||||
}
|
||||
|
||||
interface FilterOptions {
|
||||
rounds: Array<{ id: string; name: string; program: { name: string; year: number } }>
|
||||
export interface FilterOptions {
|
||||
countries: string[]
|
||||
categories: Array<{ value: string; count: number }>
|
||||
issues: Array<{ value: string; count: number }>
|
||||
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
|
||||
}
|
||||
|
||||
interface ProjectFiltersBarProps {
|
||||
@@ -94,7 +94,7 @@ export function ProjectFiltersBar({
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.statuses.length > 0,
|
||||
filters.roundId !== '',
|
||||
filters.stageId !== '',
|
||||
filters.competitionCategory !== '',
|
||||
filters.oceanIssue !== '',
|
||||
filters.country !== '',
|
||||
@@ -114,7 +114,7 @@ export function ProjectFiltersBar({
|
||||
onChange({
|
||||
search: filters.search,
|
||||
statuses: [],
|
||||
roundId: '',
|
||||
stageId: '',
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
country: '',
|
||||
@@ -175,21 +175,21 @@ export function ProjectFiltersBar({
|
||||
{/* Select filters grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Round / Edition</Label>
|
||||
<Label className="text-sm">Stage / Edition</Label>
|
||||
<Select
|
||||
value={filters.roundId || '_all'}
|
||||
value={filters.stageId || '_all'}
|
||||
onValueChange={(v) =>
|
||||
onChange({ ...filters, roundId: v === '_all' ? '' : v })
|
||||
onChange({ ...filters, stageId: v === '_all' ? '' : v })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All rounds" />
|
||||
<SelectValue placeholder="All stages" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_all">All rounds</SelectItem>
|
||||
{filterOptions?.rounds.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name} ({r.program.year} Edition)
|
||||
<SelectItem value="_all">All stages</SelectItem>
|
||||
{filterOptions?.stages?.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name} ({s.programYear} {s.programName})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
GitCompare,
|
||||
UserCheck,
|
||||
Globe,
|
||||
Layers,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import {
|
||||
@@ -51,7 +53,7 @@ import {
|
||||
ProjectRankingsChart,
|
||||
CriteriaScoresChart,
|
||||
GeographicDistribution,
|
||||
CrossRoundComparisonChart,
|
||||
CrossStageComparisonChart,
|
||||
JurorConsistencyChart,
|
||||
DiversityMetricsChart,
|
||||
} from '@/components/charts'
|
||||
@@ -59,11 +61,13 @@ import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
function ReportsOverview() {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
const { data: dashStats, isLoading: statsLoading } = trpc.analytics.getDashboardStats.useQuery()
|
||||
|
||||
// Flatten rounds from all programs
|
||||
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
|
||||
// Flatten stages from all programs
|
||||
const rounds = programs?.flatMap(p =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string; votingEndAt?: string | Date | null }>).map((s: { id: string; name: string; status: string; votingEndAt?: string | Date | null }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
// Project reporting scope (default: latest program, all rounds)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
@@ -73,7 +77,7 @@ function ReportsOverview() {
|
||||
}
|
||||
|
||||
const scopeInput = parseSelection(selectedValue)
|
||||
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
|
||||
const hasScope = !!scopeInput.stageId || !!scopeInput.programId
|
||||
|
||||
const { data: projectRankings, isLoading: projectsLoading } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
@@ -103,7 +107,7 @@ function ReportsOverview() {
|
||||
|
||||
const totalPrograms = dashStats?.programCount ?? programs?.length ?? 0
|
||||
const totalProjects = dashStats?.projectCount ?? 0
|
||||
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r) => r.status === 'ACTIVE').length
|
||||
const activeRounds = dashStats?.activeStageCount ?? rounds.filter((r: { status: string }) => r.status === 'STAGE_ACTIVE').length
|
||||
const jurorCount = dashStats?.jurorCount ?? 0
|
||||
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
|
||||
const totalEvaluations = dashStats?.totalEvaluations ?? 0
|
||||
@@ -365,7 +369,7 @@ function ReportsOverview() {
|
||||
<div className="flex justify-end gap-2 flex-wrap">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`/api/export/evaluations?roundId=${round.id}`}
|
||||
href={`/api/export/evaluations?stageId=${round.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -375,7 +379,7 @@ function ReportsOverview() {
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={`/api/export/results?roundId=${round.id}`}
|
||||
href={`/api/export/results?stageId=${round.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -396,28 +400,30 @@ function ReportsOverview() {
|
||||
)
|
||||
}
|
||||
|
||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
// Parse selection value: "all:programId" for edition-wide, or stageId
|
||||
function parseSelection(value: string | null): { stageId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { roundId: value }
|
||||
return { stageId: value }
|
||||
}
|
||||
|
||||
function RoundAnalytics() {
|
||||
function StageAnalytics() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
// Flatten rounds from all programs with program name
|
||||
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
|
||||
// Flatten stages from all programs with program name
|
||||
const rounds = programs?.flatMap(p =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
// Set default selected round
|
||||
// Set default selected stage
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
@@ -458,11 +464,11 @@ function RoundAnalytics() {
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
const geoInput = queryInput.programId
|
||||
? { programId: queryInput.programId }
|
||||
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
|
||||
: { programId: selectedRound?.programId || '', stageId: queryInput.stageId }
|
||||
const { data: geoData, isLoading: geoLoading } =
|
||||
trpc.analytics.getGeographicDistribution.useQuery(
|
||||
geoInput,
|
||||
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
|
||||
{ enabled: hasSelection && !!(geoInput.programId || geoInput.stageId) }
|
||||
)
|
||||
|
||||
if (roundsLoading) {
|
||||
@@ -600,26 +606,26 @@ function RoundAnalytics() {
|
||||
)
|
||||
}
|
||||
|
||||
function CrossRoundTab() {
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
function CrossStageTab() {
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
||||
const stages = programs?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
|
||||
const [selectedStageIds, setSelectedStageIds] = useState<string[]>([])
|
||||
|
||||
const { data: comparison, isLoading: comparisonLoading } =
|
||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||
{ roundIds: selectedRoundIds },
|
||||
{ enabled: selectedRoundIds.length >= 2 }
|
||||
trpc.analytics.getCrossStageComparison.useQuery(
|
||||
{ stageIds: selectedStageIds },
|
||||
{ enabled: selectedStageIds.length >= 2 }
|
||||
)
|
||||
|
||||
const toggleRound = (roundId: string) => {
|
||||
setSelectedRoundIds((prev) =>
|
||||
prev.includes(roundId)
|
||||
? prev.filter((id) => id !== roundId)
|
||||
: [...prev, roundId]
|
||||
const toggleStage = (stageId: string) => {
|
||||
setSelectedStageIds((prev) =>
|
||||
prev.includes(stageId)
|
||||
? prev.filter((id) => id !== stageId)
|
||||
: [...prev, stageId]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -629,40 +635,40 @@ function CrossRoundTab() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Round selector */}
|
||||
{/* Stage selector */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Select Rounds to Compare</CardTitle>
|
||||
<CardTitle>Select Stages to Compare</CardTitle>
|
||||
<CardDescription>
|
||||
Choose at least 2 rounds to compare metrics side by side
|
||||
Choose at least 2 stages to compare metrics side by side
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rounds.map((round) => {
|
||||
const isSelected = selectedRoundIds.includes(round.id)
|
||||
{stages.map((stage) => {
|
||||
const isSelected = selectedStageIds.includes(stage.id)
|
||||
return (
|
||||
<Badge
|
||||
key={round.id}
|
||||
key={stage.id}
|
||||
variant={isSelected ? 'default' : 'outline'}
|
||||
className="cursor-pointer text-sm py-1.5 px-3"
|
||||
onClick={() => toggleRound(round.id)}
|
||||
onClick={() => toggleStage(stage.id)}
|
||||
>
|
||||
{round.programName} - {round.name}
|
||||
{stage.programName} - {stage.name}
|
||||
</Badge>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{selectedRoundIds.length < 2 && (
|
||||
{selectedStageIds.length < 2 && (
|
||||
<p className="text-sm text-muted-foreground mt-3">
|
||||
Select at least 2 rounds to enable comparison
|
||||
Select at least 2 stages to enable comparison
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comparison charts */}
|
||||
{comparisonLoading && selectedRoundIds.length >= 2 && (
|
||||
{comparisonLoading && selectedStageIds.length >= 2 && (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-[350px]" />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
@@ -673,9 +679,9 @@ function CrossRoundTab() {
|
||||
)}
|
||||
|
||||
{comparison && (
|
||||
<CrossRoundComparisonChart data={comparison as Array<{
|
||||
roundId: string
|
||||
roundName: string
|
||||
<CrossStageComparisonChart data={comparison as Array<{
|
||||
stageId: string
|
||||
stageName: string
|
||||
projectCount: number
|
||||
evaluationCount: number
|
||||
completionRate: number
|
||||
@@ -690,18 +696,18 @@ function CrossRoundTab() {
|
||||
function JurorConsistencyTab() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
const stages = programs?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
if (stages.length && !selectedValue) {
|
||||
setSelectedValue(stages[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading: consistencyLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
@@ -716,20 +722,20 @@ function JurorConsistencyTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<label className="text-sm font-medium">Select Stage:</label>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
{p.year} Edition — All Stages
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -762,18 +768,18 @@ function JurorConsistencyTab() {
|
||||
function DiversityTab() {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
const stages = programs?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
if (stages.length && !selectedValue) {
|
||||
setSelectedValue(stages[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
const hasSelection = !!queryInput.stageId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading: diversityLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
@@ -788,20 +794,20 @@ function DiversityTab() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<label className="text-sm font-medium">Select Stage:</label>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
<SelectValue placeholder="Select a stage" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
{p.year} Edition — All Stages
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{stages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -826,18 +832,18 @@ function DiversityTab() {
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [pdfRoundId, setPdfRoundId] = useState<string | null>(null)
|
||||
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
|
||||
|
||||
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
const pdfRounds = pdfPrograms?.flatMap((p) =>
|
||||
p.rounds.map((r) => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
||||
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
|
||||
const pdfStages = pdfPrograms?.flatMap((p) =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
if (pdfRounds.length && !pdfRoundId) {
|
||||
setPdfRoundId(pdfRounds[0].id)
|
||||
if (pdfStages.length && !pdfStageId) {
|
||||
setPdfStageId(pdfStages[0].id)
|
||||
}
|
||||
|
||||
const selectedPdfRound = pdfRounds.find((r) => r.id === pdfRoundId)
|
||||
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -861,7 +867,7 @@ export default function ReportsPage() {
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cross-round" className="gap-2">
|
||||
<TabsTrigger value="cross-stage" className="gap-2">
|
||||
<GitCompare className="h-4 w-4" />
|
||||
Cross-Round
|
||||
</TabsTrigger>
|
||||
@@ -873,25 +879,31 @@ export default function ReportsPage() {
|
||||
<Globe className="h-4 w-4" />
|
||||
Diversity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pipeline" className="gap-2" asChild>
|
||||
<Link href={"/admin/reports/stages" as Route}>
|
||||
<Layers className="h-4 w-4" />
|
||||
By Pipeline
|
||||
</Link>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
|
||||
<Select value={pdfRoundId || ''} onValueChange={setPdfRoundId}>
|
||||
<Select value={pdfStageId || ''} onValueChange={setPdfStageId}>
|
||||
<SelectTrigger className="w-[220px]">
|
||||
<SelectValue placeholder="Select round for PDF" />
|
||||
<SelectValue placeholder="Select stage for PDF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pdfRounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
{pdfStages.map((stage) => (
|
||||
<SelectItem key={stage.id} value={stage.id}>
|
||||
{stage.programName} - {stage.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{pdfRoundId && (
|
||||
{pdfStageId && (
|
||||
<ExportPdfButton
|
||||
roundId={pdfRoundId}
|
||||
roundName={selectedPdfRound?.name}
|
||||
programName={selectedPdfRound?.programName}
|
||||
stageId={pdfStageId}
|
||||
roundName={selectedPdfStage?.name}
|
||||
programName={selectedPdfStage?.programName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -902,11 +914,11 @@ export default function ReportsPage() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
<RoundAnalytics />
|
||||
<StageAnalytics />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="cross-round">
|
||||
<CrossRoundTab />
|
||||
<TabsContent value="cross-stage">
|
||||
<CrossStageTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="consistency">
|
||||
|
||||
671
src/app/(admin)/admin/reports/stages/page.tsx
Normal file
671
src/app/(admin)/admin/reports/stages/page.tsx
Normal file
@@ -0,0 +1,671 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
ArrowLeft,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
Target,
|
||||
Trophy,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
PENDING: 'bg-gray-100 text-gray-700',
|
||||
IN_PROGRESS: 'bg-blue-100 text-blue-700',
|
||||
PASSED: 'bg-emerald-100 text-emerald-700',
|
||||
REJECTED: 'bg-red-100 text-red-700',
|
||||
COMPLETED: 'bg-emerald-100 text-emerald-700',
|
||||
ELIMINATED: 'bg-red-100 text-red-700',
|
||||
WAITING: 'bg-amber-100 text-amber-700',
|
||||
}
|
||||
|
||||
const awardStatusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
NOMINATIONS_OPEN: 'bg-blue-100 text-blue-700',
|
||||
VOTING_OPEN: 'bg-amber-100 text-amber-700',
|
||||
CLOSED: 'bg-emerald-100 text-emerald-700',
|
||||
ARCHIVED: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
|
||||
// ─── Stages Tab Content ────────────────────────────────────────────────────
|
||||
|
||||
function StagesTabContent({
|
||||
activePipelineId,
|
||||
}: {
|
||||
activePipelineId: string
|
||||
}) {
|
||||
const [selectedStageId, setSelectedStageId] = useState<string>('')
|
||||
|
||||
const { data: overview, isLoading: overviewLoading } =
|
||||
trpc.analytics.getStageCompletionOverview.useQuery(
|
||||
{ pipelineId: activePipelineId },
|
||||
{ enabled: !!activePipelineId }
|
||||
)
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoresLoading } =
|
||||
trpc.analytics.getStageScoreDistribution.useQuery(
|
||||
{ stageId: selectedStageId },
|
||||
{ enabled: !!selectedStageId }
|
||||
)
|
||||
|
||||
if (overviewLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!overview) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No pipeline data</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Select a pipeline to view analytics.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="py-5 text-center">
|
||||
<Layers className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold">{overview.summary.totalStages}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Stages</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-5 text-center">
|
||||
<Target className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold">{overview.summary.totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Projects</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-5 text-center">
|
||||
<Users className="h-5 w-5 text-muted-foreground mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold">{overview.summary.totalAssignments}</p>
|
||||
<p className="text-xs text-muted-foreground">Total Assignments</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-5 text-center">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600 mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold">{overview.summary.totalCompleted}</p>
|
||||
<p className="text-xs text-muted-foreground">Evaluations Completed</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Stage overview table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Stage Completion Overview</CardTitle>
|
||||
<CardDescription>
|
||||
Click a stage to see detailed score distribution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Track</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-center">Projects</TableHead>
|
||||
<TableHead className="text-center">Assignments</TableHead>
|
||||
<TableHead className="text-center">Completed</TableHead>
|
||||
<TableHead className="text-center">Jurors</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{overview.stages.map((stage) => (
|
||||
<TableRow
|
||||
key={stage.stageId}
|
||||
className={cn(
|
||||
'cursor-pointer transition-colors',
|
||||
selectedStageId === stage.stageId && 'bg-brand-blue/5 dark:bg-brand-teal/5'
|
||||
)}
|
||||
onClick={() => setSelectedStageId(stage.stageId)}
|
||||
>
|
||||
<TableCell className="font-medium">{stage.stageName}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">{stage.trackName}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{stage.totalProjects}</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{stage.totalAssignments}</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{stage.completedEvaluations}</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{stage.jurorCount}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 min-w-[120px]">
|
||||
<Progress value={stage.completionRate} className="h-2 flex-1" />
|
||||
<span className="text-xs tabular-nums font-medium w-8 text-right">
|
||||
{stage.completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* State breakdown per stage */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project State Breakdown</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{overview.stages.map((stage) => (
|
||||
<div key={stage.stageId} className="space-y-2">
|
||||
<p className="text-sm font-medium">{stage.stageName}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stage.stateBreakdown.map((sb) => (
|
||||
<Badge
|
||||
key={sb.state}
|
||||
variant="outline"
|
||||
className={cn('text-xs', stateColors[sb.state])}
|
||||
>
|
||||
{sb.state}: {sb.count}
|
||||
</Badge>
|
||||
))}
|
||||
{stage.stateBreakdown.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">No projects</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Score distribution for selected stage */}
|
||||
{selectedStageId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Score Distribution
|
||||
{overview.stages.find((s) => s.stageId === selectedStageId) && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
— {overview.stages.find((s) => s.stageId === selectedStageId)?.stageName}
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{scoresLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : scoreDistribution ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{scoreDistribution.totalEvaluations}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Evaluations</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{scoreDistribution.averageGlobalScore.toFixed(1)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Avg Score</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{scoreDistribution.criterionDistributions.length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Criteria</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score histogram (text-based) */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Global Score Distribution</p>
|
||||
{scoreDistribution.globalDistribution.map((bucket) => {
|
||||
const maxCount = Math.max(
|
||||
...scoreDistribution.globalDistribution.map((b) => b.count),
|
||||
1
|
||||
)
|
||||
const width = (bucket.count / maxCount) * 100
|
||||
return (
|
||||
<div key={bucket.score} className="flex items-center gap-2">
|
||||
<span className="text-xs tabular-nums w-4 text-right">{bucket.score}</span>
|
||||
<div className="flex-1 h-5 bg-muted/30 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-blue/60 dark:bg-brand-teal/60 rounded transition-all"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs tabular-nums w-6 text-right">{bucket.count}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No score data available for this stage.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Awards Tab Content ────────────────────────────────────────────────────
|
||||
|
||||
function AwardsTabContent({
|
||||
activePipelineId,
|
||||
}: {
|
||||
activePipelineId: string
|
||||
}) {
|
||||
const [selectedAwardStageId, setSelectedAwardStageId] = useState<string>('')
|
||||
|
||||
const { data: awards, isLoading: awardsLoading } =
|
||||
trpc.analytics.getAwardSummary.useQuery(
|
||||
{ pipelineId: activePipelineId },
|
||||
{ enabled: !!activePipelineId }
|
||||
)
|
||||
|
||||
const { data: voteDistribution, isLoading: votesLoading } =
|
||||
trpc.analytics.getAwardVoteDistribution.useQuery(
|
||||
{ stageId: selectedAwardStageId },
|
||||
{ enabled: !!selectedAwardStageId }
|
||||
)
|
||||
|
||||
if (awardsLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-24" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!awards || awards.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Trophy className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No award tracks</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This pipeline has no award tracks configured.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Awards summary table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5" />
|
||||
Award Tracks
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Click an award's stage to see vote/score distribution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Award</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Scoring</TableHead>
|
||||
<TableHead>Routing</TableHead>
|
||||
<TableHead className="text-center">Projects</TableHead>
|
||||
<TableHead className="text-center">Completion</TableHead>
|
||||
<TableHead>Winner</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{awards.map((award) => (
|
||||
<TableRow key={award.trackId}>
|
||||
<TableCell className="font-medium">{award.awardName}</TableCell>
|
||||
<TableCell>
|
||||
{award.awardStatus ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs', awardStatusColors[award.awardStatus])}
|
||||
>
|
||||
{award.awardStatus.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{award.scoringMode ? (
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{award.scoringMode.toLowerCase().replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{award.routingMode ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{award.routingMode}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">{award.projectCount}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 min-w-[100px]">
|
||||
<Progress value={award.completionRate} className="h-2 flex-1" />
|
||||
<span className="text-xs tabular-nums font-medium w-8 text-right">
|
||||
{award.completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{award.winner ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Trophy className="h-3.5 w-3.5 text-amber-500 shrink-0" />
|
||||
<span className="text-sm font-medium truncate max-w-[150px]">
|
||||
{award.winner.title}
|
||||
</span>
|
||||
{award.winner.overridden && (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0">
|
||||
overridden
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Not finalized</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Award stages clickable list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Award Stage Details</CardTitle>
|
||||
<CardDescription>
|
||||
Select a stage to view vote and score distribution
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{awards.map((award) =>
|
||||
award.stages.map((stage) => (
|
||||
<button
|
||||
key={stage.id}
|
||||
onClick={() => setSelectedAwardStageId(stage.id)}
|
||||
className={cn(
|
||||
'w-full text-left rounded-lg border p-3 transition-colors',
|
||||
selectedAwardStageId === stage.id
|
||||
? 'border-brand-blue bg-brand-blue/5 dark:border-brand-teal dark:bg-brand-teal/5'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{award.awardName} — {stage.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Type: {stage.stageType.toLowerCase().replace(/_/g, ' ')} | Status: {stage.status}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{stage.stageType.toLowerCase().replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Vote/score distribution for selected award stage */}
|
||||
{selectedAwardStageId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Vote / Score Distribution
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{votesLoading ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : voteDistribution && voteDistribution.projects.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{voteDistribution.totalEvaluations}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Evaluations</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{voteDistribution.totalVotes}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Award Votes</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead className="text-center">Evals</TableHead>
|
||||
<TableHead className="text-center">Votes</TableHead>
|
||||
<TableHead className="text-center">Avg Score</TableHead>
|
||||
<TableHead className="text-center">Min</TableHead>
|
||||
<TableHead className="text-center">Max</TableHead>
|
||||
<TableHead className="text-center">Avg Rank</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{voteDistribution.projects.map((project) => (
|
||||
<TableRow key={project.projectId}>
|
||||
<TableCell className="font-medium truncate max-w-[200px]">
|
||||
{project.title}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground truncate max-w-[120px]">
|
||||
{project.teamName || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.evaluationCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.voteCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums font-medium">
|
||||
{project.avgScore !== null ? project.avgScore.toFixed(1) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.minScore !== null ? project.minScore.toFixed(1) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.maxScore !== null ? project.maxScore.toFixed(1) : '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center tabular-nums">
|
||||
{project.avgRank !== null ? project.avgRank.toFixed(1) : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No vote/score data available for this stage.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StageAnalyticsReportsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id ?? ''
|
||||
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState<string>('')
|
||||
|
||||
// Fetch pipelines
|
||||
const { data: pipelines, isLoading: pipelinesLoading } =
|
||||
trpc.pipeline.list.useQuery(
|
||||
{ programId },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
// Auto-select first pipeline
|
||||
const activePipelineId = selectedPipelineId || (pipelines?.[0]?.id ?? '')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
|
||||
<Link href={"/admin/reports" as Route}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Layers className="h-6 w-6" />
|
||||
Pipeline Analytics
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Stage-level reporting for the pipeline system
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline selector */}
|
||||
{pipelines && pipelines.length > 0 && (
|
||||
<Select
|
||||
value={activePipelineId}
|
||||
onValueChange={(v) => {
|
||||
setSelectedPipelineId(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue placeholder="Select pipeline" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pipelines.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pipelinesLoading ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : !activePipelineId ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50 mb-3" />
|
||||
<p className="font-medium">No pipeline data</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Select a pipeline to view analytics.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs defaultValue="stages" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="stages" className="gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
Stages
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards" className="gap-2">
|
||||
<Trophy className="h-4 w-4" />
|
||||
Awards
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stages" className="space-y-6">
|
||||
<StagesTabContent activePipelineId={activePipelineId} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="awards" className="space-y-6">
|
||||
<AwardsTabContent activePipelineId={activePipelineId} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,443 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Save,
|
||||
Trash2,
|
||||
LayoutTemplate,
|
||||
Plus,
|
||||
X,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const ROUND_TYPE_LABELS: Record<string, string> = {
|
||||
FILTERING: 'Filtering',
|
||||
EVALUATION: 'Evaluation',
|
||||
LIVE_EVENT: 'Live Event',
|
||||
}
|
||||
|
||||
const CRITERION_TYPES = [
|
||||
{ value: 'numeric', label: 'Numeric (1-10)' },
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'boolean', label: 'Yes/No' },
|
||||
{ value: 'section_header', label: 'Section Header' },
|
||||
]
|
||||
|
||||
type Criterion = {
|
||||
id: string
|
||||
label: string
|
||||
type: string
|
||||
description?: string
|
||||
weight?: number
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export default function RoundTemplateDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id } = use(params)
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: template, isLoading } = trpc.roundTemplate.getById.useQuery({ id })
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [roundType, setRoundType] = useState('EVALUATION')
|
||||
const [criteria, setCriteria] = useState<Criterion[]>([])
|
||||
const [initialized, setInitialized] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
|
||||
// Initialize form state from loaded data
|
||||
if (template && !initialized) {
|
||||
setName(template.name)
|
||||
setDescription(template.description || '')
|
||||
setRoundType(template.roundType)
|
||||
setCriteria((template.criteriaJson as Criterion[]) || [])
|
||||
setInitialized(true)
|
||||
}
|
||||
|
||||
const updateTemplate = trpc.roundTemplate.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundTemplate.getById.invalidate({ id })
|
||||
utils.roundTemplate.list.invalidate()
|
||||
toast.success('Template saved')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteTemplate = trpc.roundTemplate.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundTemplate.list.invalidate()
|
||||
router.push('/admin/round-templates')
|
||||
toast.success('Template deleted')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = () => {
|
||||
updateTemplate.mutate({
|
||||
id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
roundType: roundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
|
||||
criteriaJson: criteria,
|
||||
})
|
||||
}
|
||||
|
||||
const addCriterion = () => {
|
||||
setCriteria([
|
||||
...criteria,
|
||||
{
|
||||
id: `criterion_${Date.now()}`,
|
||||
label: '',
|
||||
type: 'numeric',
|
||||
weight: 1,
|
||||
min: 1,
|
||||
max: 10,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const updateCriterion = (index: number, updates: Partial<Criterion>) => {
|
||||
setCriteria(criteria.map((c, i) => (i === index ? { ...c, ...updates } : c)))
|
||||
}
|
||||
|
||||
const removeCriterion = (index: number) => {
|
||||
setCriteria(criteria.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96" />
|
||||
</div>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!template) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/round-templates">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Templates
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="font-medium">Template not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/round-templates">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Templates
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<LayoutTemplate className="h-7 w-7 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{template.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Edit template configuration and criteria
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={updateTemplate.isPending}>
|
||||
{updateTemplate.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Basic Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Basic Information</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Template Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., Standard Evaluation Round"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this template is for..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Round Type</Label>
|
||||
<Select value={roundType} onValueChange={setRoundType}>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ROUND_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="pt-2 text-sm text-muted-foreground">
|
||||
Created {new Date(template.createdAt).toLocaleDateString()} | Last updated{' '}
|
||||
{new Date(template.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Criteria */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
|
||||
<CardDescription>
|
||||
Define the criteria jurors will use to evaluate projects
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={addCriterion}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Criterion
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{criteria.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<p>No criteria defined yet.</p>
|
||||
<Button variant="outline" className="mt-3" onClick={addCriterion}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add First Criterion
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{criteria.map((criterion, index) => (
|
||||
<div key={criterion.id}>
|
||||
{index > 0 && <Separator className="mb-4" />}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-2 text-muted-foreground">
|
||||
<GripVertical className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="sm:col-span-2 lg:col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Label</Label>
|
||||
<Input
|
||||
value={criterion.label}
|
||||
onChange={(e) =>
|
||||
updateCriterion(index, { label: e.target.value })
|
||||
}
|
||||
placeholder="e.g., Innovation"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||
<Select
|
||||
value={criterion.type}
|
||||
onValueChange={(val) =>
|
||||
updateCriterion(index, { type: val })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CRITERION_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{criterion.type === 'numeric' && (
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Weight</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={criterion.weight ?? 1}
|
||||
onChange={(e) =>
|
||||
updateCriterion(index, {
|
||||
weight: parseFloat(e.target.value) || 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="sm:col-span-2 lg:col-span-4">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Description (optional)
|
||||
</Label>
|
||||
<Input
|
||||
value={criterion.description || ''}
|
||||
onChange={(e) =>
|
||||
updateCriterion(index, { description: e.target.value })
|
||||
}
|
||||
placeholder="Help text for jurors..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-5 h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeCriterion(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Template Metadata */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Template Info</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 sm:grid-cols-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Type</p>
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
{ROUND_TYPE_LABELS[template.roundType] || template.roundType}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Criteria Count</p>
|
||||
<p className="font-medium mt-1">{criteria.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Has Custom Settings</p>
|
||||
<p className="font-medium mt-1">
|
||||
{template.settingsJson && Object.keys(template.settingsJson as object).length > 0
|
||||
? 'Yes'
|
||||
: 'No'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{template.name}"? This action
|
||||
cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteTemplate.mutate({ id })}
|
||||
disabled={deleteTemplate.isPending}
|
||||
>
|
||||
{deleteTemplate.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Delete Template
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
LayoutTemplate,
|
||||
Plus,
|
||||
Calendar,
|
||||
Settings2,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const ROUND_TYPE_LABELS: Record<string, string> = {
|
||||
FILTERING: 'Filtering',
|
||||
EVALUATION: 'Evaluation',
|
||||
LIVE_EVENT: 'Live Event',
|
||||
}
|
||||
|
||||
const ROUND_TYPE_COLORS: Record<string, 'default' | 'secondary' | 'outline'> = {
|
||||
FILTERING: 'secondary',
|
||||
EVALUATION: 'default',
|
||||
LIVE_EVENT: 'outline',
|
||||
}
|
||||
|
||||
export default function RoundTemplatesPage() {
|
||||
const utils = trpc.useUtils()
|
||||
const { data: templates, isLoading } = trpc.roundTemplate.list.useQuery()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDescription, setNewDescription] = useState('')
|
||||
const [newRoundType, setNewRoundType] = useState('EVALUATION')
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const createTemplate = trpc.roundTemplate.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundTemplate.list.invalidate()
|
||||
setCreateOpen(false)
|
||||
setNewName('')
|
||||
setNewDescription('')
|
||||
setNewRoundType('EVALUATION')
|
||||
toast.success('Template created')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteTemplate = trpc.roundTemplate.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundTemplate.list.invalidate()
|
||||
setDeleteId(null)
|
||||
toast.success('Template deleted')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!newName.trim()) return
|
||||
createTemplate.mutate({
|
||||
name: newName.trim(),
|
||||
description: newDescription.trim() || undefined,
|
||||
roundType: newRoundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
|
||||
criteriaJson: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-9 w-40" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-40" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Round Templates
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Save and reuse round configurations across editions
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a blank template. You can also save an existing round as a template from the round detail page.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-name">Name</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
placeholder="e.g., Standard Evaluation Round"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-description">Description</Label>
|
||||
<Textarea
|
||||
id="template-description"
|
||||
placeholder="Describe what this template is for..."
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-type">Round Type</Label>
|
||||
<Select value={newRoundType} onValueChange={setNewRoundType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ROUND_TYPE_LABELS).map(([value, label]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim() || createTemplate.isPending}
|
||||
>
|
||||
{createTemplate.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Templates Grid */}
|
||||
{templates && templates.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((template: typeof templates[number]) => {
|
||||
const criteria = (template.criteriaJson as Array<unknown>) || []
|
||||
const hasSettings = template.settingsJson && Object.keys(template.settingsJson as object).length > 0
|
||||
|
||||
return (
|
||||
<Card key={template.id} className="group relative transition-colors hover:bg-muted/50">
|
||||
<Link href={`/admin/round-templates/${template.id}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<LayoutTemplate className="h-5 w-5 text-primary" />
|
||||
{template.name}
|
||||
</CardTitle>
|
||||
<Badge variant={ROUND_TYPE_COLORS[template.roundType] || 'secondary'}>
|
||||
{ROUND_TYPE_LABELS[template.roundType] || template.roundType}
|
||||
</Badge>
|
||||
</div>
|
||||
{template.description && (
|
||||
<CardDescription className="line-clamp-2">
|
||||
{template.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
{criteria.length} criteria
|
||||
</div>
|
||||
{hasSettings && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Custom settings
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{new Date(template.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
{/* Delete button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDeleteId(template.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No templates yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a template or save an existing round configuration as a template
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteId && deleteTemplate.mutate({ id: deleteId })}
|
||||
disabled={deleteTemplate.isPending}
|
||||
>
|
||||
{deleteTemplate.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,404 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ShieldAlert,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
MoreHorizontal,
|
||||
ShieldCheck,
|
||||
UserX,
|
||||
StickyNote,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
function COIManagementContent({ roundId }: { roundId: string }) {
|
||||
const [conflictsOnly, setConflictsOnly] = useState(false)
|
||||
|
||||
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: coiList, isLoading: loadingCOI } = trpc.evaluation.listCOIByRound.useQuery({
|
||||
roundId,
|
||||
hasConflictOnly: conflictsOnly || undefined,
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const reviewCOI = trpc.evaluation.reviewCOI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
utils.evaluation.listCOIByRound.invalidate({ roundId })
|
||||
toast.success(`COI marked as "${data.reviewAction}"`)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to review COI')
|
||||
},
|
||||
})
|
||||
|
||||
if (loadingRound || loadingCOI) {
|
||||
return <COISkeleton />
|
||||
}
|
||||
|
||||
if (!round) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Round Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/rounds">Back to Rounds</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const conflictCount = coiList?.filter((c) => c.hasConflict).length ?? 0
|
||||
const totalCount = coiList?.length ?? 0
|
||||
const reviewedCount = coiList?.filter((c) => c.reviewAction).length ?? 0
|
||||
|
||||
const getReviewBadge = (reviewAction: string | null) => {
|
||||
switch (reviewAction) {
|
||||
case 'cleared':
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<ShieldCheck className="mr-1 h-3 w-3" />
|
||||
Cleared
|
||||
</Badge>
|
||||
)
|
||||
case 'reassigned':
|
||||
return (
|
||||
<Badge variant="default" className="bg-blue-600">
|
||||
<UserX className="mr-1 h-3 w-3" />
|
||||
Reassigned
|
||||
</Badge>
|
||||
)
|
||||
case 'noted':
|
||||
return (
|
||||
<Badge variant="secondary">
|
||||
<StickyNote className="mr-1 h-3 w-3" />
|
||||
Noted
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="text-amber-600 border-amber-300">
|
||||
Pending Review
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getConflictTypeBadge = (type: string | null) => {
|
||||
switch (type) {
|
||||
case 'financial':
|
||||
return <Badge variant="destructive">Financial</Badge>
|
||||
case 'personal':
|
||||
return <Badge variant="secondary">Personal</Badge>
|
||||
case 'organizational':
|
||||
return <Badge variant="outline">Organizational</Badge>
|
||||
case 'other':
|
||||
return <Badge variant="outline">Other</Badge>
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/rounds/${roundId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
|
||||
{round.program.name}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
|
||||
{round.name}
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
<ShieldAlert className="h-6 w-6" />
|
||||
Conflict of Interest Declarations
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Declarations</CardTitle>
|
||||
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Conflicts Declared</CardTitle>
|
||||
<AlertCircle className="h-4 w-4 text-amber-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-amber-600">{conflictCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Reviewed</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{reviewedCount}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* COI Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Declarations</CardTitle>
|
||||
<CardDescription>
|
||||
Review and manage conflict of interest declarations from jury members
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="conflicts-only"
|
||||
checked={conflictsOnly}
|
||||
onCheckedChange={setConflictsOnly}
|
||||
/>
|
||||
<Label htmlFor="conflicts-only" className="text-sm">
|
||||
Conflicts only
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{coiList && coiList.length > 0 ? (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Juror</TableHead>
|
||||
<TableHead>Conflict</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="w-12">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{coiList.map((coi) => (
|
||||
<TableRow key={coi.id}>
|
||||
<TableCell className="font-medium max-w-[200px] truncate">
|
||||
{coi.assignment.project.title}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.user.name || coi.user.email}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.hasConflict ? (
|
||||
<Badge variant="destructive">Yes</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-green-600 border-green-300">
|
||||
No
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.hasConflict ? getConflictTypeBadge(coi.conflictType) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
{coi.description ? (
|
||||
<span className="text-sm text-muted-foreground truncate block">
|
||||
{coi.description}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.hasConflict ? (
|
||||
<div className="space-y-1">
|
||||
{getReviewBadge(coi.reviewAction)}
|
||||
{coi.reviewedBy && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
by {coi.reviewedBy.name || coi.reviewedBy.email}
|
||||
{coi.reviewedAt && (
|
||||
<> {formatDistanceToNow(new Date(coi.reviewedAt), { addSuffix: true })}</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{coi.hasConflict && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={reviewCOI.isPending}
|
||||
>
|
||||
{reviewCOI.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
reviewCOI.mutate({
|
||||
id: coi.id,
|
||||
reviewAction: 'cleared',
|
||||
})
|
||||
}
|
||||
>
|
||||
<ShieldCheck className="mr-2 h-4 w-4" />
|
||||
Clear
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
reviewCOI.mutate({
|
||||
id: coi.id,
|
||||
reviewAction: 'reassigned',
|
||||
})
|
||||
}
|
||||
>
|
||||
<UserX className="mr-2 h-4 w-4" />
|
||||
Reassign
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
reviewCOI.mutate({
|
||||
id: coi.id,
|
||||
reviewAction: 'noted',
|
||||
})
|
||||
}
|
||||
>
|
||||
<StickyNote className="mr-2 h-4 w-4" />
|
||||
Note
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<ShieldAlert className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Declarations Yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{conflictsOnly
|
||||
? 'No conflicts of interest have been declared for this round'
|
||||
: 'No jury members have submitted COI declarations for this round yet'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function COISkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-36" />
|
||||
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-8 w-80" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="pb-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function COIManagementPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
return (
|
||||
<Suspense fallback={<COISkeleton />}>
|
||||
<COIManagementContent roundId={id} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,24 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// Redirect to round details page - filtering is now integrated there
|
||||
export default function FilteringDashboardPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: roundId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
router.replace(`/admin/rounds/${roundId}`)
|
||||
}, [router, roundId])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">Redirecting to round details...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,599 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
ShieldCheck,
|
||||
Download,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const OUTCOME_BADGES: Record<
|
||||
string,
|
||||
{ variant: 'default' | 'destructive' | 'secondary' | 'outline'; icon: React.ReactNode; label: string }
|
||||
> = {
|
||||
PASSED: {
|
||||
variant: 'default',
|
||||
icon: <CheckCircle2 className="mr-1 h-3 w-3" />,
|
||||
label: 'Passed',
|
||||
},
|
||||
FILTERED_OUT: {
|
||||
variant: 'destructive',
|
||||
icon: <XCircle className="mr-1 h-3 w-3" />,
|
||||
label: 'Filtered Out',
|
||||
},
|
||||
FLAGGED: {
|
||||
variant: 'secondary',
|
||||
icon: <AlertTriangle className="mr-1 h-3 w-3" />,
|
||||
label: 'Flagged',
|
||||
},
|
||||
}
|
||||
|
||||
export default function FilteringResultsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: roundId } = use(params)
|
||||
|
||||
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
|
||||
const [page, setPage] = useState(1)
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
const [overrideDialog, setOverrideDialog] = useState<{
|
||||
id: string
|
||||
currentOutcome: string
|
||||
} | null>(null)
|
||||
const [overrideOutcome, setOverrideOutcome] = useState<string>('PASSED')
|
||||
const [overrideReason, setOverrideReason] = useState('')
|
||||
|
||||
const perPage = 20
|
||||
|
||||
const { data, isLoading, refetch } = trpc.filtering.getResults.useQuery({
|
||||
roundId,
|
||||
outcome: outcomeFilter
|
||||
? (outcomeFilter as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED')
|
||||
: undefined,
|
||||
page,
|
||||
perPage,
|
||||
}, {
|
||||
staleTime: 0, // Always refetch - results change after filtering runs
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
||||
const reinstateProject = trpc.filtering.reinstateProject.useMutation()
|
||||
|
||||
const exportResults = trpc.export.filteringResults.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: false }
|
||||
)
|
||||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
|
||||
const handleExport = () => {
|
||||
setShowExportDialog(true)
|
||||
}
|
||||
|
||||
const handleRequestExportData = useCallback(async () => {
|
||||
const result = await exportResults.refetch()
|
||||
return result.data ?? undefined
|
||||
}, [exportResults])
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
const next = new Set(expandedRows)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
setExpandedRows(next)
|
||||
}
|
||||
|
||||
const handleOverride = async () => {
|
||||
if (!overrideDialog || !overrideReason.trim()) return
|
||||
try {
|
||||
await overrideResult.mutateAsync({
|
||||
id: overrideDialog.id,
|
||||
finalOutcome: overrideOutcome as 'PASSED' | 'FILTERED_OUT' | 'FLAGGED',
|
||||
reason: overrideReason.trim(),
|
||||
})
|
||||
toast.success('Result overridden')
|
||||
setOverrideDialog(null)
|
||||
setOverrideReason('')
|
||||
refetch()
|
||||
utils.project.list.invalidate()
|
||||
} catch {
|
||||
toast.error('Failed to override result')
|
||||
}
|
||||
}
|
||||
|
||||
const handleReinstate = async (projectId: string) => {
|
||||
try {
|
||||
await reinstateProject.mutateAsync({ roundId, projectId })
|
||||
toast.success('Project reinstated')
|
||||
refetch()
|
||||
utils.project.list.invalidate()
|
||||
} catch {
|
||||
toast.error('Failed to reinstate project')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<Skeleton className="h-96 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/rounds/${roundId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Round
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Filtering Results
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review and override filtering outcomes
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={exportResults.isFetching}
|
||||
>
|
||||
{exportResults.isFetching ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Outcome Filter Tabs */}
|
||||
<div className="flex gap-2">
|
||||
{['', 'PASSED', 'FILTERED_OUT', 'FLAGGED'].map((outcome) => (
|
||||
<Button
|
||||
key={outcome || 'all'}
|
||||
variant={outcomeFilter === outcome ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOutcomeFilter(outcome)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
{outcome ? (
|
||||
<>
|
||||
{OUTCOME_BADGES[outcome].icon}
|
||||
{OUTCOME_BADGES[outcome].label}
|
||||
</>
|
||||
) : (
|
||||
'All'
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
{data && data.results.length > 0 ? (
|
||||
<>
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Outcome</TableHead>
|
||||
<TableHead className="w-[300px]">AI Reason</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.results.map((result) => {
|
||||
const isExpanded = expandedRows.has(result.id)
|
||||
const effectiveOutcome =
|
||||
result.finalOutcome || result.outcome
|
||||
const badge = OUTCOME_BADGES[effectiveOutcome]
|
||||
|
||||
// Extract AI reasoning from aiScreeningJson
|
||||
const aiScreening = result.aiScreeningJson as Record<string, {
|
||||
meetsCriteria?: boolean
|
||||
confidence?: number
|
||||
reasoning?: string
|
||||
qualityScore?: number
|
||||
spamRisk?: boolean
|
||||
}> | null
|
||||
const firstAiResult = aiScreening ? Object.values(aiScreening)[0] : null
|
||||
const aiReasoning = firstAiResult?.reasoning
|
||||
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
key={result.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => toggleRow(result.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{result.project.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{result.project.teamName}
|
||||
{result.project.country && ` · ${result.project.country}`}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{result.project.competitionCategory ? (
|
||||
<Badge variant="outline">
|
||||
{result.project.competitionCategory.replace(
|
||||
'_',
|
||||
' '
|
||||
)}
|
||||
</Badge>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={badge?.variant || 'secondary'}>
|
||||
{badge?.icon}
|
||||
{badge?.label || effectiveOutcome}
|
||||
</Badge>
|
||||
{result.overriddenByUser && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Overridden by {result.overriddenByUser.name || result.overriddenByUser.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{aiReasoning ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm line-clamp-2">
|
||||
{aiReasoning}
|
||||
</p>
|
||||
{firstAiResult && (
|
||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
||||
{firstAiResult.confidence !== undefined && (
|
||||
<span>Confidence: {Math.round(firstAiResult.confidence * 100)}%</span>
|
||||
)}
|
||||
{firstAiResult.qualityScore !== undefined && (
|
||||
<span>Quality: {firstAiResult.qualityScore}/10</span>
|
||||
)}
|
||||
{firstAiResult.spamRisk && (
|
||||
<Badge variant="destructive" className="text-xs">Spam Risk</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground italic">
|
||||
No AI screening
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div
|
||||
className="flex justify-end gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setOverrideOutcome('PASSED')
|
||||
setOverrideDialog({
|
||||
id: result.id,
|
||||
currentOutcome: effectiveOutcome,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<ShieldCheck className="mr-1 h-3 w-3" />
|
||||
Override
|
||||
</Button>
|
||||
{effectiveOutcome === 'FILTERED_OUT' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleReinstate(result.projectId)
|
||||
}
|
||||
disabled={reinstateProject.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" />
|
||||
Reinstate
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && (
|
||||
<TableRow key={`${result.id}-detail`}>
|
||||
<TableCell colSpan={5} className="bg-muted/30">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Rule Results (non-AI rules only, AI shown separately) */}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Rule Results
|
||||
</p>
|
||||
{result.ruleResultsJson &&
|
||||
Array.isArray(result.ruleResultsJson) ? (
|
||||
<div className="space-y-2">
|
||||
{(
|
||||
result.ruleResultsJson as Array<{
|
||||
ruleName: string
|
||||
ruleType: string
|
||||
passed: boolean
|
||||
action: string
|
||||
reasoning?: string
|
||||
}>
|
||||
).filter((rr) => rr.ruleType !== 'AI_SCREENING').map((rr, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
>
|
||||
{rr.passed ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{rr.ruleName}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{rr.ruleType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{rr.reasoning && (
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
{rr.reasoning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No detailed rule results available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Screening Details */}
|
||||
{aiScreening && Object.keys(aiScreening).length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">
|
||||
AI Screening Analysis
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(aiScreening).map(([ruleId, screening]) => (
|
||||
<div key={ruleId} className="p-3 bg-background rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{screening.meetsCriteria ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<span className="font-medium text-sm">
|
||||
{screening.meetsCriteria ? 'Meets Criteria' : 'Does Not Meet Criteria'}
|
||||
</span>
|
||||
{screening.spamRisk && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Spam Risk
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{screening.reasoning && (
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{screening.reasoning}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||
{screening.confidence !== undefined && (
|
||||
<span>
|
||||
Confidence: <strong>{Math.round(screening.confidence * 100)}%</strong>
|
||||
</span>
|
||||
)}
|
||||
{screening.qualityScore !== undefined && (
|
||||
<span>
|
||||
Quality Score: <strong>{screening.qualityScore}/10</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Override Info */}
|
||||
{result.overriddenByUser && (
|
||||
<div className="pt-3 border-t">
|
||||
<p className="text-sm font-medium mb-1">Manual Override</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Overridden to <strong>{result.finalOutcome}</strong> by{' '}
|
||||
{result.overriddenByUser.name || result.overriddenByUser.email}
|
||||
</p>
|
||||
{result.overrideReason && (
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Reason: {result.overrideReason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Pagination
|
||||
page={data.page}
|
||||
totalPages={data.totalPages}
|
||||
total={data.total}
|
||||
perPage={perPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No results found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{outcomeFilter
|
||||
? 'No results match this filter'
|
||||
: 'Run filtering rules to generate results'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Override Dialog */}
|
||||
<Dialog
|
||||
open={!!overrideDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setOverrideDialog(null)
|
||||
setOverrideReason('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Override Filtering Result</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change the outcome for this project. This will be logged in the
|
||||
audit trail.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>New Outcome</Label>
|
||||
<Select
|
||||
value={overrideOutcome}
|
||||
onValueChange={setOverrideOutcome}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PASSED">Passed</SelectItem>
|
||||
<SelectItem value="FILTERED_OUT">Filtered Out</SelectItem>
|
||||
<SelectItem value="FLAGGED">Flagged</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Reason</Label>
|
||||
<Input
|
||||
value={overrideReason}
|
||||
onChange={(e) => setOverrideReason(e.target.value)}
|
||||
placeholder="Explain why you're overriding..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOverrideDialog(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOverride}
|
||||
disabled={
|
||||
overrideResult.isPending || !overrideReason.trim()
|
||||
}
|
||||
>
|
||||
{overrideResult.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Override
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* CSV Export Dialog with Column Selection */}
|
||||
<CsvExportDialog
|
||||
open={showExportDialog}
|
||||
onOpenChange={setShowExportDialog}
|
||||
exportData={exportResults.data ?? undefined}
|
||||
isLoading={exportResults.isFetching}
|
||||
filename="filtering-results"
|
||||
onRequestData={handleRequestExportData}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,586 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
FileCheck,
|
||||
SlidersHorizontal,
|
||||
Filter,
|
||||
} from 'lucide-react'
|
||||
|
||||
type RuleType = 'FIELD_BASED' | 'DOCUMENT_CHECK' | 'AI_SCREENING'
|
||||
|
||||
const RULE_TYPE_LABELS: Record<RuleType, string> = {
|
||||
FIELD_BASED: 'Field-Based',
|
||||
DOCUMENT_CHECK: 'Document Check',
|
||||
AI_SCREENING: 'AI Screening',
|
||||
}
|
||||
|
||||
const RULE_TYPE_ICONS: Record<RuleType, React.ReactNode> = {
|
||||
FIELD_BASED: <Filter className="h-4 w-4" />,
|
||||
DOCUMENT_CHECK: <FileCheck className="h-4 w-4" />,
|
||||
AI_SCREENING: <SlidersHorizontal className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
const FIELD_OPTIONS = [
|
||||
{ value: 'competitionCategory', label: 'Competition Category' },
|
||||
{ value: 'foundedAt', label: 'Founded Date' },
|
||||
{ value: 'country', label: 'Country' },
|
||||
{ value: 'geographicZone', label: 'Geographic Zone' },
|
||||
{ value: 'tags', label: 'Tags' },
|
||||
{ value: 'oceanIssue', label: 'Ocean Issue' },
|
||||
]
|
||||
|
||||
const OPERATOR_OPTIONS = [
|
||||
{ value: 'equals', label: 'Equals' },
|
||||
{ value: 'not_equals', label: 'Not Equals' },
|
||||
{ value: 'contains', label: 'Contains' },
|
||||
{ value: 'in', label: 'In (list)' },
|
||||
{ value: 'not_in', label: 'Not In (list)' },
|
||||
{ value: 'is_empty', label: 'Is Empty' },
|
||||
{ value: 'older_than_years', label: 'Older Than (years)' },
|
||||
{ value: 'newer_than_years', label: 'Newer Than (years)' },
|
||||
]
|
||||
|
||||
export default function FilteringRulesPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: roundId } = use(params)
|
||||
|
||||
const { data: rules, isLoading, refetch } =
|
||||
trpc.filtering.getRules.useQuery({ roundId })
|
||||
const createRule = trpc.filtering.createRule.useMutation()
|
||||
const updateRule = trpc.filtering.updateRule.useMutation()
|
||||
const deleteRule = trpc.filtering.deleteRule.useMutation()
|
||||
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [newRuleName, setNewRuleName] = useState('')
|
||||
const [newRuleType, setNewRuleType] = useState<RuleType>('FIELD_BASED')
|
||||
|
||||
// Field-based config state
|
||||
const [conditionField, setConditionField] = useState('competitionCategory')
|
||||
const [conditionOperator, setConditionOperator] = useState('equals')
|
||||
const [conditionValue, setConditionValue] = useState('')
|
||||
const [conditionLogic, setConditionLogic] = useState<'AND' | 'OR'>('AND')
|
||||
const [conditionAction, setConditionAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('REJECT')
|
||||
|
||||
// Document check config state
|
||||
const [minFileCount, setMinFileCount] = useState('1')
|
||||
const [docAction, setDocAction] = useState<'PASS' | 'REJECT' | 'FLAG'>('REJECT')
|
||||
|
||||
// AI screening config state
|
||||
const [criteriaText, setCriteriaText] = useState('')
|
||||
const [aiAction, setAiAction] = useState<'REJECT' | 'FLAG'>('REJECT')
|
||||
const [aiBatchSize, setAiBatchSize] = useState('20')
|
||||
const [aiParallelBatches, setAiParallelBatches] = useState('1')
|
||||
|
||||
const handleCreateRule = async () => {
|
||||
if (!newRuleName.trim()) return
|
||||
|
||||
let configJson: Record<string, unknown> = {}
|
||||
|
||||
if (newRuleType === 'FIELD_BASED') {
|
||||
configJson = {
|
||||
conditions: [
|
||||
{
|
||||
field: conditionField,
|
||||
operator: conditionOperator,
|
||||
value: conditionOperator === 'in' || conditionOperator === 'not_in'
|
||||
? conditionValue.split(',').map((v) => v.trim())
|
||||
: conditionOperator === 'older_than_years' ||
|
||||
conditionOperator === 'newer_than_years' ||
|
||||
conditionOperator === 'greater_than' ||
|
||||
conditionOperator === 'less_than'
|
||||
? Number(conditionValue)
|
||||
: conditionValue,
|
||||
},
|
||||
],
|
||||
logic: conditionLogic,
|
||||
action: conditionAction,
|
||||
}
|
||||
} else if (newRuleType === 'DOCUMENT_CHECK') {
|
||||
configJson = {
|
||||
minFileCount: parseInt(minFileCount) || 1,
|
||||
action: docAction,
|
||||
}
|
||||
} else if (newRuleType === 'AI_SCREENING') {
|
||||
configJson = {
|
||||
criteriaText,
|
||||
action: aiAction,
|
||||
batchSize: parseInt(aiBatchSize) || 20,
|
||||
parallelBatches: parseInt(aiParallelBatches) || 1,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await createRule.mutateAsync({
|
||||
roundId,
|
||||
name: newRuleName.trim(),
|
||||
ruleType: newRuleType,
|
||||
configJson,
|
||||
priority: (rules?.length || 0) + 1,
|
||||
})
|
||||
toast.success('Rule created')
|
||||
setShowCreateDialog(false)
|
||||
resetForm()
|
||||
refetch()
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : 'Failed to create rule'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleActive = async (ruleId: string, isActive: boolean) => {
|
||||
try {
|
||||
await updateRule.mutateAsync({ id: ruleId, isActive })
|
||||
refetch()
|
||||
} catch {
|
||||
toast.error('Failed to update rule')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRule = async (ruleId: string) => {
|
||||
try {
|
||||
await deleteRule.mutateAsync({ id: ruleId })
|
||||
toast.success('Rule deleted')
|
||||
refetch()
|
||||
} catch {
|
||||
toast.error('Failed to delete rule')
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setNewRuleName('')
|
||||
setNewRuleType('FIELD_BASED')
|
||||
setConditionField('competitionCategory')
|
||||
setConditionOperator('equals')
|
||||
setConditionValue('')
|
||||
setConditionLogic('AND')
|
||||
setConditionAction('REJECT')
|
||||
setMinFileCount('1')
|
||||
setDocAction('REJECT')
|
||||
setCriteriaText('')
|
||||
setAiBatchSize('20')
|
||||
setAiParallelBatches('1')
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-9 w-48" />
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-24 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/rounds/${roundId}/filtering`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Filtering
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Filtering Rules
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Rules are evaluated in order of priority
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Filtering Rule</DialogTitle>
|
||||
<DialogDescription>
|
||||
Define conditions that projects must meet
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Rule Name</Label>
|
||||
<Input
|
||||
value={newRuleName}
|
||||
onChange={(e) => setNewRuleName(e.target.value)}
|
||||
placeholder="e.g., Startup age check"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Rule Type</Label>
|
||||
<Select
|
||||
value={newRuleType}
|
||||
onValueChange={(v) => setNewRuleType(v as RuleType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="FIELD_BASED">Field-Based</SelectItem>
|
||||
<SelectItem value="DOCUMENT_CHECK">
|
||||
Document Check
|
||||
</SelectItem>
|
||||
<SelectItem value="AI_SCREENING">AI Screening</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Field-Based Config */}
|
||||
{newRuleType === 'FIELD_BASED' && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Field</Label>
|
||||
<Select
|
||||
value={conditionField}
|
||||
onValueChange={setConditionField}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_OPTIONS.map((f) => (
|
||||
<SelectItem key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Operator</Label>
|
||||
<Select
|
||||
value={conditionOperator}
|
||||
onValueChange={setConditionOperator}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATOR_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{conditionOperator !== 'is_empty' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Value</Label>
|
||||
<Input
|
||||
value={conditionValue}
|
||||
onChange={(e) => setConditionValue(e.target.value)}
|
||||
placeholder={
|
||||
conditionOperator === 'in' ||
|
||||
conditionOperator === 'not_in'
|
||||
? 'Comma-separated values'
|
||||
: 'Value'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Logic</Label>
|
||||
<Select
|
||||
value={conditionLogic}
|
||||
onValueChange={(v) =>
|
||||
setConditionLogic(v as 'AND' | 'OR')
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Action</Label>
|
||||
<Select
|
||||
value={conditionAction}
|
||||
onValueChange={(v) =>
|
||||
setConditionAction(v as 'PASS' | 'REJECT' | 'FLAG')
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PASS">Pass</SelectItem>
|
||||
<SelectItem value="REJECT">Reject</SelectItem>
|
||||
<SelectItem value="FLAG">Flag</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Document Check Config */}
|
||||
{newRuleType === 'DOCUMENT_CHECK' && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Minimum File Count</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={minFileCount}
|
||||
onChange={(e) => setMinFileCount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Action if not met</Label>
|
||||
<Select
|
||||
value={docAction}
|
||||
onValueChange={(v) =>
|
||||
setDocAction(v as 'PASS' | 'REJECT' | 'FLAG')
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="REJECT">Reject</SelectItem>
|
||||
<SelectItem value="FLAG">Flag</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* AI Screening Config */}
|
||||
{newRuleType === 'AI_SCREENING' && (
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Action for Non-Matching Projects</Label>
|
||||
<Select value={aiAction} onValueChange={(v) => setAiAction(v as 'REJECT' | 'FLAG')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="REJECT">Auto Filter Out</SelectItem>
|
||||
<SelectItem value="FLAG">Flag for Review</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{aiAction === 'REJECT'
|
||||
? 'Projects that don\'t meet criteria will be automatically filtered out.'
|
||||
: 'Projects that don\'t meet criteria will be flagged for human review.'}
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateRule}
|
||||
disabled={
|
||||
createRule.isPending ||
|
||||
!newRuleName.trim() ||
|
||||
(newRuleType === 'AI_SCREENING' && !criteriaText.trim())
|
||||
}
|
||||
>
|
||||
{createRule.isPending && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Create Rule
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Rules List */}
|
||||
{rules && rules.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule, index) => (
|
||||
<Card key={rule.id}>
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
<span className="text-sm font-mono w-6 text-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{RULE_TYPE_ICONS[rule.ruleType as RuleType]}
|
||||
<p className="font-medium">{rule.name}</p>
|
||||
<Badge variant="outline">
|
||||
{RULE_TYPE_LABELS[rule.ruleType as RuleType]}
|
||||
</Badge>
|
||||
{!rule.isActive && (
|
||||
<Badge variant="secondary">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{rule.ruleType === 'AI_SCREENING'
|
||||
? (rule.configJson as Record<string, unknown>)
|
||||
?.criteriaText
|
||||
? String(
|
||||
(rule.configJson as Record<string, unknown>)
|
||||
.criteriaText
|
||||
).slice(0, 80) + '...'
|
||||
: 'AI screening rule'
|
||||
: rule.ruleType === 'DOCUMENT_CHECK'
|
||||
? `Min ${(rule.configJson as Record<string, unknown>)?.minFileCount || 1} file(s)`
|
||||
: `${((rule.configJson as Record<string, unknown>)?.conditions as Array<Record<string, unknown>>)?.length || 0} condition(s)`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={rule.isActive}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggleActive(rule.id, checked)
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
disabled={deleteRule.isPending}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Filter className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No rules configured</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add filtering rules to screen projects automatically
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
390
src/app/(admin)/admin/rounds/new-pipeline/page.tsx
Normal file
390
src/app/(admin)/admin/rounds/new-pipeline/page.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Loader2, Save, Rocket } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
|
||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||
|
||||
import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
export default function NewPipelinePage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const programId = searchParams.get('programId') ?? ''
|
||||
|
||||
const [state, setState] = useState<WizardState>(() => defaultWizardState(programId))
|
||||
const [openSection, setOpenSection] = useState(0)
|
||||
const initialStateRef = useRef(JSON.stringify(state))
|
||||
|
||||
// Dirty tracking — warn on navigate away
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (JSON.stringify(state) !== initialStateRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [state])
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState((prev) => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
// Get stage configs from the main track
|
||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
||||
|
||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||
|
||||
const updateStageConfig = useCallback(
|
||||
(stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) => {
|
||||
if (track.kind !== 'MAIN') return track
|
||||
return {
|
||||
...track,
|
||||
stages: track.stages.map((stage) =>
|
||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||
),
|
||||
}
|
||||
}),
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateMainTrackStages = useCallback(
|
||||
(stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) =>
|
||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||
),
|
||||
}))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
// Validation
|
||||
const basicsValid = validateBasics(state).valid
|
||||
const tracksValid = validateTracks(state.tracks).valid
|
||||
const allValid = validateAll(state).valid
|
||||
|
||||
// Mutations
|
||||
const createMutation = trpc.pipeline.createStructure.useMutation({
|
||||
onSuccess: (data) => {
|
||||
initialStateRef.current = JSON.stringify(state) // prevent dirty warning
|
||||
toast.success('Pipeline created successfully')
|
||||
router.push(`/admin/rounds/pipeline/${data.pipeline.id}` as Route)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Pipeline published successfully')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSave = async (publish: boolean) => {
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
// Open first section with errors
|
||||
if (!validation.sections.basics.valid) setOpenSection(0)
|
||||
else if (!validation.sections.tracks.valid) setOpenSection(2)
|
||||
return
|
||||
}
|
||||
|
||||
const result = await createMutation.mutateAsync({
|
||||
programId: state.programId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
tracks: state.tracks.map((t) => ({
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind,
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingModeDefault,
|
||||
decisionMode: t.decisionMode,
|
||||
stages: t.stages.map((s) => ({
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType,
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
awardConfig: t.awardConfig,
|
||||
})),
|
||||
autoTransitions: true,
|
||||
})
|
||||
|
||||
if (publish && result.pipeline.id) {
|
||||
await publishMutation.mutateAsync({ id: result.pipeline.id })
|
||||
}
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending || publishMutation.isPending
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Basics',
|
||||
description: 'Pipeline name, slug, and program',
|
||||
isValid: basicsValid,
|
||||
},
|
||||
{
|
||||
title: 'Intake',
|
||||
description: 'Submission windows and file requirements',
|
||||
isValid: !!intakeStage,
|
||||
},
|
||||
{
|
||||
title: 'Main Track Stages',
|
||||
description: `${mainTrack?.stages.length ?? 0} stages configured`,
|
||||
isValid: tracksValid,
|
||||
},
|
||||
{
|
||||
title: 'Filtering',
|
||||
description: 'Gate rules and AI screening settings',
|
||||
isValid: !!filterStage,
|
||||
},
|
||||
{
|
||||
title: 'Assignment',
|
||||
description: 'Jury evaluation assignment strategy',
|
||||
isValid: !!evalStage,
|
||||
},
|
||||
{
|
||||
title: 'Awards',
|
||||
description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`,
|
||||
isValid: true, // Awards are optional
|
||||
},
|
||||
{
|
||||
title: 'Live Finals',
|
||||
description: 'Voting, cohorts, and reveal settings',
|
||||
isValid: !!liveStage,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
description: 'Event notifications and override governance',
|
||||
isValid: true, // Always valid
|
||||
},
|
||||
{
|
||||
title: 'Review & Publish',
|
||||
description: 'Validation summary and publish controls',
|
||||
isValid: allValid,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Create Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure the full pipeline structure for project evaluation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isSaving || !allValid}
|
||||
onClick={() => handleSave(false)}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Save className="h-4 w-4 mr-2" />}
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving || !allValid}
|
||||
onClick={() => handleSave(true)}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Rocket className="h-4 w-4 mr-2" />}
|
||||
Save & Publish
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wizard Sections */}
|
||||
<div className="space-y-3">
|
||||
{/* 0: Basics */}
|
||||
<WizardSection
|
||||
stepNumber={1}
|
||||
title={sections[0].title}
|
||||
description={sections[0].description}
|
||||
isOpen={openSection === 0}
|
||||
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
|
||||
isValid={sections[0].isValid}
|
||||
>
|
||||
<BasicsSection state={state} onChange={updateState} />
|
||||
</WizardSection>
|
||||
|
||||
{/* 1: Intake */}
|
||||
<WizardSection
|
||||
stepNumber={2}
|
||||
title={sections[1].title}
|
||||
description={sections[1].description}
|
||||
isOpen={openSection === 1}
|
||||
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
|
||||
isValid={sections[1].isValid}
|
||||
>
|
||||
<IntakeSection
|
||||
config={intakeConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
{/* 2: Main Track Stages */}
|
||||
<WizardSection
|
||||
stepNumber={3}
|
||||
title={sections[2].title}
|
||||
description={sections[2].description}
|
||||
isOpen={openSection === 2}
|
||||
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
|
||||
isValid={sections[2].isValid}
|
||||
>
|
||||
<MainTrackSection
|
||||
stages={mainTrack?.stages ?? []}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
{/* 3: Filtering */}
|
||||
<WizardSection
|
||||
stepNumber={4}
|
||||
title={sections[3].title}
|
||||
description={sections[3].description}
|
||||
isOpen={openSection === 3}
|
||||
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
|
||||
isValid={sections[3].isValid}
|
||||
>
|
||||
<FilteringSection
|
||||
config={filterConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
{/* 4: Assignment */}
|
||||
<WizardSection
|
||||
stepNumber={5}
|
||||
title={sections[4].title}
|
||||
description={sections[4].description}
|
||||
isOpen={openSection === 4}
|
||||
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
|
||||
isValid={sections[4].isValid}
|
||||
>
|
||||
<AssignmentSection
|
||||
config={evalConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
{/* 5: Awards */}
|
||||
<WizardSection
|
||||
stepNumber={6}
|
||||
title={sections[5].title}
|
||||
description={sections[5].description}
|
||||
isOpen={openSection === 5}
|
||||
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
|
||||
isValid={sections[5].isValid}
|
||||
>
|
||||
<AwardsSection tracks={state.tracks} onChange={(tracks) => updateState({ tracks })} />
|
||||
</WizardSection>
|
||||
|
||||
{/* 6: Live Finals */}
|
||||
<WizardSection
|
||||
stepNumber={7}
|
||||
title={sections[6].title}
|
||||
description={sections[6].description}
|
||||
isOpen={openSection === 6}
|
||||
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
|
||||
isValid={sections[6].isValid}
|
||||
>
|
||||
<LiveFinalsSection
|
||||
config={liveConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
{/* 7: Notifications */}
|
||||
<WizardSection
|
||||
stepNumber={8}
|
||||
title={sections[7].title}
|
||||
description={sections[7].description}
|
||||
isOpen={openSection === 7}
|
||||
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
|
||||
isValid={sections[7].isValid}
|
||||
>
|
||||
<NotificationsSection
|
||||
config={state.notificationConfig}
|
||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||
overridePolicy={state.overridePolicy}
|
||||
onOverridePolicyChange={(overridePolicy) => updateState({ overridePolicy })}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
{/* 8: Review */}
|
||||
<WizardSection
|
||||
stepNumber={9}
|
||||
title={sections[8].title}
|
||||
description={sections[8].description}
|
||||
isOpen={openSection === 8}
|
||||
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
|
||||
isValid={sections[8].isValid}
|
||||
>
|
||||
<ReviewSection state={state} />
|
||||
</WizardSection>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,666 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, 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,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
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,
|
||||
Eye,
|
||||
Edit,
|
||||
Users,
|
||||
FileText,
|
||||
Calendar,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Archive,
|
||||
Trash2,
|
||||
Loader2,
|
||||
GripVertical,
|
||||
} from 'lucide-react'
|
||||
import { format, isPast, isFuture } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { RoundPipeline } from '@/components/admin/round-pipeline'
|
||||
|
||||
type RoundData = {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
roundType: string
|
||||
votingStartAt: string | null
|
||||
votingEndAt: string | null
|
||||
_count?: {
|
||||
projects: number
|
||||
assignments: number
|
||||
}
|
||||
}
|
||||
|
||||
function RoundsContent() {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({
|
||||
includeRounds: true,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <RoundsListSkeleton />
|
||||
}
|
||||
|
||||
if (!programs || programs.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Programs Found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a program first to start managing rounds
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/programs/new">Create Program</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program, index) => (
|
||||
<AnimatedCard key={program.id} index={index}>
|
||||
<ProgramRounds program={program} />
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgramRounds({ program }: { program: any }) {
|
||||
const utils = trpc.useUtils()
|
||||
const [rounds, setRounds] = useState<RoundData[]>(program.rounds || [])
|
||||
|
||||
// Sync local state when query data refreshes (e.g. after status change)
|
||||
useEffect(() => {
|
||||
setRounds(program.rounds || [])
|
||||
}, [program.rounds])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const reorder = trpc.round.reorder.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
toast.success('Round order updated')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to reorder rounds')
|
||||
// Reset to original order on error
|
||||
setRounds(program.rounds || [])
|
||||
},
|
||||
})
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = rounds.findIndex((r) => r.id === active.id)
|
||||
const newIndex = rounds.findIndex((r) => r.id === over.id)
|
||||
|
||||
const newRounds = arrayMove(rounds, oldIndex, newIndex)
|
||||
setRounds(newRounds)
|
||||
|
||||
// Send the new order to the server
|
||||
reorder.mutate({
|
||||
programId: program.id,
|
||||
roundIds: newRounds.map((r) => r.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sync local state when program.rounds changes
|
||||
if (JSON.stringify(rounds.map(r => r.id)) !== JSON.stringify((program.rounds || []).map((r: RoundData) => r.id))) {
|
||||
setRounds(program.rounds || [])
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<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">
|
||||
{/* Desktop: Table header */}
|
||||
<div className="hidden lg: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-2 lg:space-y-1">
|
||||
{rounds.map((round, index) => (
|
||||
<SortableRoundRow
|
||||
key={round.id}
|
||||
round={round}
|
||||
index={index}
|
||||
totalRounds={rounds.length}
|
||||
isReordering={reorder.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Pipeline visualization */}
|
||||
{rounds.length > 1 && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<RoundPipeline rounds={rounds} programName={program.name} />
|
||||
</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({
|
||||
onSuccess: () => {
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteRound = trpc.round.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Round deleted successfully')
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to delete round')
|
||||
},
|
||||
})
|
||||
|
||||
const getStatusBadge = () => {
|
||||
const now = new Date()
|
||||
const isVotingOpen =
|
||||
round.status === 'ACTIVE' &&
|
||||
round.votingStartAt &&
|
||||
round.votingEndAt &&
|
||||
new Date(round.votingStartAt) <= now &&
|
||||
new Date(round.votingEndAt) >= now
|
||||
|
||||
if (round.status === 'ACTIVE' && isVotingOpen) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Voting Open
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
switch (round.status) {
|
||||
case 'DRAFT':
|
||||
return <Badge variant="secondary">Draft</Badge>
|
||||
case 'ACTIVE':
|
||||
return (
|
||||
<Badge variant="default">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Active
|
||||
</Badge>
|
||||
)
|
||||
case 'CLOSED':
|
||||
return <Badge variant="outline">Closed</Badge>
|
||||
case 'ARCHIVED':
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
<Archive className="mr-1 h-3 w-3" />
|
||||
Archived
|
||||
</Badge>
|
||||
)
|
||||
default:
|
||||
return <Badge variant="secondary">{round.status}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const getVotingWindow = () => {
|
||||
if (!round.votingStartAt || !round.votingEndAt) {
|
||||
return <span className="text-muted-foreground text-sm">Not set</span>
|
||||
}
|
||||
|
||||
const start = new Date(round.votingStartAt)
|
||||
const end = new Date(round.votingEndAt)
|
||||
|
||||
if (isFuture(start)) {
|
||||
return (
|
||||
<span className="text-sm">
|
||||
Opens {format(start, 'MMM d')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPast(end)) {
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Ended {format(end, 'MMM d')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm">
|
||||
Until {format(end, 'MMM d')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const actionsMenu = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Round actions">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Round
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/assignments`}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Manage Judge Assignments
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{round.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
|
||||
}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Activate Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
|
||||
}
|
||||
>
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
Close Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{round.status === 'CLOSED' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
|
||||
}
|
||||
>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive Round
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => setShowDeleteDialog(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Round
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
|
||||
const deleteDialog = (
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete "{round.name}"? This will
|
||||
remove {round._count?.projects || 0} project assignments,{' '}
|
||||
{round._count?.assignments || 0} reviewer assignments, and all evaluations
|
||||
in this round. The projects themselves will remain in the program. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteRound.mutate({ id: round.id })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteRound.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
|
||||
isReordering && !isDragging && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* Desktop: Table row layout */}
|
||||
<div className="hidden lg:grid grid-cols-[60px_1fr_120px_140px_80px_100px_50px] gap-3 items-center px-3 py-2.5">
|
||||
{/* 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>
|
||||
<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?.projects || 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>
|
||||
{actionsMenu}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet: Card layout */}
|
||||
<div className="lg:hidden p-4">
|
||||
{/* Top row: drag handle, order, name, status badge, actions */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex items-center gap-1 pt-0.5">
|
||||
<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>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className="font-medium hover:underline line-clamp-1"
|
||||
>
|
||||
{round.name}
|
||||
</Link>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{round.roundType?.toLowerCase().replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{getStatusBadge()}
|
||||
{actionsMenu}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details row */}
|
||||
<div className="mt-3 ml-11 grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Voting Window</p>
|
||||
<div className="mt-0.5">{getVotingWindow()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Projects</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.projects || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Reviewers</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="font-medium">{round._count?.assignments || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deleteDialog}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoundsListSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{[1, 2].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-28" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Desktop skeleton */}
|
||||
<div className="hidden lg:block space-y-3">
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className="flex justify-between items-center py-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-4 w-12" />
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Mobile/Tablet skeleton */}
|
||||
<div className="lg:hidden space-y-3">
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div key={j} className="rounded-lg border p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-7 w-7 rounded-full" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
<div className="ml-10 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoundsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Rounds</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage selection rounds and voting periods
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<RoundsListSkeleton />}>
|
||||
<RoundsContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
554
src/app/(admin)/admin/rounds/pipeline/[id]/advanced/page.tsx
Normal file
554
src/app/(admin)/admin/rounds/pipeline/[id]/advanced/page.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route as NextRoute } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Route,
|
||||
Play,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { PipelineVisualization } from '@/components/admin/pipeline/pipeline-visualization'
|
||||
|
||||
const stageTypeColors: Record<string, string> = {
|
||||
INTAKE: 'text-blue-600',
|
||||
FILTER: 'text-amber-600',
|
||||
EVALUATION: 'text-purple-600',
|
||||
SELECTION: 'text-rose-600',
|
||||
LIVE_FINAL: 'text-emerald-600',
|
||||
RESULTS: 'text-cyan-600',
|
||||
}
|
||||
|
||||
type SelectedItem =
|
||||
| { type: 'stage'; trackId: string; stageId: string }
|
||||
| { type: 'track'; trackId: string }
|
||||
| null
|
||||
|
||||
export default function AdvancedEditorPage() {
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<SelectedItem>(null)
|
||||
const [configEditValue, setConfigEditValue] = useState('')
|
||||
const [simulationProjectIds, setSimulationProjectIds] = useState('')
|
||||
const [showSaveConfirm, setShowSaveConfirm] = useState(false)
|
||||
|
||||
const { data: pipeline, isLoading, refetch } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
const updateConfigMutation = trpc.stage.updateConfig.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Stage config saved')
|
||||
refetch()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const simulateMutation = trpc.pipeline.simulate.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Simulation complete: ${data.simulations?.length ?? 0} results`)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const { data: routingRules } = trpc.routing.listRules.useQuery(
|
||||
{ pipelineId },
|
||||
{ enabled: !!pipelineId }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<Skeleton className="col-span-3 h-96" />
|
||||
<Skeleton className="col-span-5 h-96" />
|
||||
<Skeleton className="col-span-4 h-96" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pipeline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={'/admin/rounds/pipelines' as NextRoute}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleSelectStage = (trackId: string, stageId: string) => {
|
||||
setSelectedItem({ type: 'stage', trackId, stageId })
|
||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
||||
const stage = track?.stages.find((s) => s.id === stageId)
|
||||
setConfigEditValue(
|
||||
JSON.stringify(stage?.configJson ?? {}, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
const executeSaveConfig = () => {
|
||||
if (selectedItem?.type !== 'stage') return
|
||||
try {
|
||||
const parsed = JSON.parse(configEditValue)
|
||||
updateConfigMutation.mutate({
|
||||
id: selectedItem.stageId,
|
||||
configJson: parsed,
|
||||
})
|
||||
} catch {
|
||||
toast.error('Invalid JSON in config editor')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveConfig = () => {
|
||||
if (selectedItem?.type !== 'stage') return
|
||||
// Validate JSON first
|
||||
try {
|
||||
JSON.parse(configEditValue)
|
||||
} catch {
|
||||
toast.error('Invalid JSON in config editor')
|
||||
return
|
||||
}
|
||||
// If pipeline is active or stage has projects, require confirmation
|
||||
const stage = pipeline?.tracks
|
||||
.flatMap((t) => t.stages)
|
||||
.find((s) => s.id === selectedItem.stageId)
|
||||
const hasProjects = (stage?._count?.projectStageStates ?? 0) > 0
|
||||
const isActive = pipeline?.status === 'ACTIVE'
|
||||
if (isActive || hasProjects) {
|
||||
setShowSaveConfirm(true)
|
||||
} else {
|
||||
executeSaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSimulate = () => {
|
||||
const ids = simulationProjectIds
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
if (ids.length === 0) {
|
||||
toast.error('Enter at least one project ID')
|
||||
return
|
||||
}
|
||||
simulateMutation.mutate({ id: pipelineId, projectIds: ids })
|
||||
}
|
||||
|
||||
const selectedTrack =
|
||||
selectedItem?.type === 'stage'
|
||||
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
|
||||
: selectedItem?.type === 'track'
|
||||
? pipeline.tracks.find((t) => t.id === selectedItem.trackId)
|
||||
: null
|
||||
|
||||
const selectedStage =
|
||||
selectedItem?.type === 'stage'
|
||||
? selectedTrack?.stages.find((s) => s.id === selectedItem.stageId)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as NextRoute}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Advanced Editor</h1>
|
||||
<p className="text-sm text-muted-foreground">{pipeline.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visualization */}
|
||||
<PipelineVisualization tracks={pipeline.tracks} />
|
||||
|
||||
{/* Five Panel Layout */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Panel 1 — Track/Stage Tree (left sidebar) */}
|
||||
<div className="col-span-12 lg:col-span-3">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
Structure
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1 max-h-[600px] overflow-y-auto">
|
||||
{pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => (
|
||||
<div key={track.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 rounded text-sm font-medium hover:bg-muted transition-colors',
|
||||
selectedItem?.type === 'track' &&
|
||||
selectedItem.trackId === track.id
|
||||
? 'bg-muted'
|
||||
: ''
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedItem({ type: 'track', trackId: track.id })
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<span>{track.name}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1 ml-auto">
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
<div className="ml-4 space-y-0.5 mt-0.5">
|
||||
{track.stages
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((stage) => (
|
||||
<button
|
||||
key={stage.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1 rounded text-xs hover:bg-muted transition-colors',
|
||||
selectedItem?.type === 'stage' &&
|
||||
selectedItem.stageId === stage.id
|
||||
? 'bg-muted font-medium'
|
||||
: ''
|
||||
)}
|
||||
onClick={() =>
|
||||
handleSelectStage(track.id, stage.id)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono',
|
||||
stageTypeColors[stage.stageType] ?? ''
|
||||
)}
|
||||
>
|
||||
{stage.stageType.slice(0, 3)}
|
||||
</span>
|
||||
<span className="truncate">{stage.name}</span>
|
||||
{stage._count?.projectStageStates > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] h-3.5 px-1 ml-auto"
|
||||
>
|
||||
{stage._count.projectStageStates}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Panel 2 — Stage Config Editor (center) */}
|
||||
<div className="col-span-12 lg:col-span-5">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm">
|
||||
{selectedStage
|
||||
? `${selectedStage.name} Config`
|
||||
: selectedTrack
|
||||
? `${selectedTrack.name} Track`
|
||||
: 'Select a stage'}
|
||||
</CardTitle>
|
||||
{selectedStage && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={updateConfigMutation.isPending}
|
||||
onClick={handleSaveConfig}
|
||||
>
|
||||
{updateConfigMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedStage ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{selectedStage.stageType}
|
||||
</Badge>
|
||||
<span className="font-mono">{selectedStage.slug}</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={configEditValue}
|
||||
onChange={(e) => setConfigEditValue(e.target.value)}
|
||||
className="font-mono text-xs min-h-[400px]"
|
||||
placeholder="{ }"
|
||||
/>
|
||||
</div>
|
||||
) : selectedTrack ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Kind</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{selectedTrack.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Routing Mode</span>
|
||||
<span className="text-xs font-mono">
|
||||
{selectedTrack.routingMode ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Decision Mode</span>
|
||||
<span className="text-xs font-mono">
|
||||
{selectedTrack.decisionMode ?? 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">Stages</span>
|
||||
<span className="font-medium">
|
||||
{selectedTrack.stages.length}
|
||||
</span>
|
||||
</div>
|
||||
{selectedTrack.specialAward && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-xs font-medium mb-1">Special Award</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedTrack.specialAward.name} —{' '}
|
||||
{selectedTrack.specialAward.scoringMode}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Select a track or stage from the tree to view or edit its
|
||||
configuration
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Panel 3+4+5 — Routing + Transitions + Simulation (right sidebar) */}
|
||||
<div className="col-span-12 lg:col-span-4 space-y-4">
|
||||
{/* Routing Rules */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Route className="h-4 w-4" />
|
||||
Routing Rules
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{routingRules && routingRules.length > 0 ? (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{routingRules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 text-xs py-1.5 border-b last:border-0"
|
||||
>
|
||||
<Badge
|
||||
variant={rule.isActive ? 'default' : 'secondary'}
|
||||
className="text-[9px] h-4 shrink-0"
|
||||
>
|
||||
P{rule.priority}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{rule.sourceTrack?.name ?? '—'} →{' '}
|
||||
{rule.destinationTrack?.name ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground py-3 text-center">
|
||||
No routing rules configured
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transitions */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Transitions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(() => {
|
||||
const allTransitions = pipeline.tracks.flatMap((track) =>
|
||||
track.stages.flatMap((stage) =>
|
||||
stage.transitionsFrom.map((t) => ({
|
||||
id: t.id,
|
||||
fromName: stage.name,
|
||||
toName: t.toStage?.name ?? '?',
|
||||
isDefault: t.isDefault,
|
||||
}))
|
||||
)
|
||||
)
|
||||
return allTransitions.length > 0 ? (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{allTransitions.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-1 text-xs py-1 border-b last:border-0"
|
||||
>
|
||||
<span className="truncate">{t.fromName}</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="truncate">{t.toName}</span>
|
||||
{t.isDefault && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[8px] h-3.5 ml-auto shrink-0"
|
||||
>
|
||||
default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground py-3 text-center">
|
||||
No transitions defined
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Simulation */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
Simulation
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Test where projects would route
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">Project IDs (comma-separated)</Label>
|
||||
<Input
|
||||
value={simulationProjectIds}
|
||||
onChange={(e) => setSimulationProjectIds(e.target.value)}
|
||||
placeholder="id1, id2, id3"
|
||||
className="text-xs mt-1"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={simulateMutation.isPending || !simulationProjectIds.trim()}
|
||||
onClick={handleSimulate}
|
||||
>
|
||||
{simulateMutation.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Run Simulation
|
||||
</Button>
|
||||
{simulateMutation.data?.simulations && (
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{simulateMutation.data.simulations.map((r, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs py-1 border-b last:border-0"
|
||||
>
|
||||
<span className="font-mono">{r.projectId.slice(0, 8)}</span>
|
||||
<span className="text-muted-foreground"> → </span>
|
||||
<span>{r.targetTrackName ?? 'unrouted'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog for destructive config saves */}
|
||||
<AlertDialog open={showSaveConfirm} onOpenChange={setShowSaveConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Save Stage Configuration?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This stage belongs to an active pipeline or has projects assigned to it.
|
||||
Changing the configuration may affect ongoing evaluations and project processing.
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setShowSaveConfirm(false)
|
||||
executeSaveConfig()
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
422
src/app/(admin)/admin/rounds/pipeline/[id]/edit/page.tsx
Normal file
422
src/app/(admin)/admin/rounds/pipeline/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, Loader2, Save } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
|
||||
import { WizardSection } from '@/components/admin/pipeline/wizard-section'
|
||||
import { BasicsSection } from '@/components/admin/pipeline/sections/basics-section'
|
||||
import { IntakeSection } from '@/components/admin/pipeline/sections/intake-section'
|
||||
import { MainTrackSection } from '@/components/admin/pipeline/sections/main-track-section'
|
||||
import { FilteringSection } from '@/components/admin/pipeline/sections/filtering-section'
|
||||
import { AssignmentSection } from '@/components/admin/pipeline/sections/assignment-section'
|
||||
import { AwardsSection } from '@/components/admin/pipeline/sections/awards-section'
|
||||
import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-finals-section'
|
||||
import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section'
|
||||
import { ReviewSection } from '@/components/admin/pipeline/sections/review-section'
|
||||
|
||||
import {
|
||||
defaultIntakeConfig,
|
||||
defaultFilterConfig,
|
||||
defaultEvaluationConfig,
|
||||
defaultLiveConfig,
|
||||
defaultNotificationConfig,
|
||||
} from '@/lib/pipeline-defaults'
|
||||
import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation'
|
||||
import type {
|
||||
WizardState,
|
||||
IntakeConfig,
|
||||
FilterConfig,
|
||||
EvaluationConfig,
|
||||
LiveFinalConfig,
|
||||
WizardTrackConfig,
|
||||
} from '@/types/pipeline-wizard'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function pipelineToWizardState(pipeline: any): WizardState {
|
||||
const settings = (pipeline.settingsJson as Record<string, unknown>) ?? {}
|
||||
|
||||
return {
|
||||
name: pipeline.name,
|
||||
slug: pipeline.slug,
|
||||
programId: pipeline.programId,
|
||||
settingsJson: settings,
|
||||
tracks: (pipeline.tracks ?? []).map((t: any) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind as WizardTrackConfig['kind'],
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingMode as WizardTrackConfig['routingModeDefault'],
|
||||
decisionMode: t.decisionMode as WizardTrackConfig['decisionMode'],
|
||||
stages: (t.stages ?? []).map((s: any) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType as WizardTrackConfig['stages'][0]['stageType'],
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: (s.configJson as Record<string, unknown>) ?? {},
|
||||
})),
|
||||
awardConfig: t.specialAward
|
||||
? {
|
||||
name: t.specialAward.name,
|
||||
description: t.specialAward.description ?? undefined,
|
||||
scoringMode: t.specialAward.scoringMode as NonNullable<WizardTrackConfig['awardConfig']>['scoringMode'],
|
||||
}
|
||||
: undefined,
|
||||
})),
|
||||
notificationConfig:
|
||||
(settings.notificationConfig as Record<string, boolean>) ??
|
||||
defaultNotificationConfig(),
|
||||
overridePolicy:
|
||||
(settings.overridePolicy as Record<string, unknown>) ?? {
|
||||
allowedRoles: ['SUPER_ADMIN', 'PROGRAM_ADMIN'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function EditPipelinePage() {
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
const [state, setState] = useState<WizardState | null>(null)
|
||||
const [openSection, setOpenSection] = useState(0)
|
||||
const initialStateRef = useRef<string>('')
|
||||
|
||||
// Initialize state from pipeline data
|
||||
useEffect(() => {
|
||||
if (pipeline && !state) {
|
||||
const wizardState = pipelineToWizardState(pipeline)
|
||||
setState(wizardState)
|
||||
initialStateRef.current = JSON.stringify(wizardState)
|
||||
}
|
||||
}, [pipeline, state])
|
||||
|
||||
// Dirty tracking
|
||||
useEffect(() => {
|
||||
if (!state) return
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (JSON.stringify(state) !== initialStateRef.current) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [state])
|
||||
|
||||
const updateState = useCallback((updates: Partial<WizardState>) => {
|
||||
setState((prev) => (prev ? { ...prev, ...updates } : null))
|
||||
}, [])
|
||||
|
||||
const updateStageConfig = useCallback(
|
||||
(stageType: string, configJson: Record<string, unknown>) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) => {
|
||||
if (track.kind !== 'MAIN') return track
|
||||
return {
|
||||
...track,
|
||||
stages: track.stages.map((stage) =>
|
||||
stage.stageType === stageType ? { ...stage, configJson } : stage
|
||||
),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateMainTrackStages = useCallback(
|
||||
(stages: WizardState['tracks'][0]['stages']) => {
|
||||
setState((prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
tracks: prev.tracks.map((track) =>
|
||||
track.kind === 'MAIN' ? { ...track, stages } : track
|
||||
),
|
||||
}
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const updateStructureMutation = trpc.pipeline.updateStructure.useMutation({
|
||||
onSuccess: () => {
|
||||
if (state) initialStateRef.current = JSON.stringify(state)
|
||||
toast.success('Pipeline updated successfully')
|
||||
router.push(`/admin/rounds/pipeline/${pipelineId}` as Route)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading || !state) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const mainTrack = state.tracks.find((t) => t.kind === 'MAIN')
|
||||
const intakeStage = mainTrack?.stages.find((s) => s.stageType === 'INTAKE')
|
||||
const filterStage = mainTrack?.stages.find((s) => s.stageType === 'FILTER')
|
||||
const evalStage = mainTrack?.stages.find((s) => s.stageType === 'EVALUATION')
|
||||
const liveStage = mainTrack?.stages.find((s) => s.stageType === 'LIVE_FINAL')
|
||||
|
||||
const intakeConfig = (intakeStage?.configJson ?? defaultIntakeConfig()) as unknown as IntakeConfig
|
||||
const filterConfig = (filterStage?.configJson ?? defaultFilterConfig()) as unknown as FilterConfig
|
||||
const evalConfig = (evalStage?.configJson ?? defaultEvaluationConfig()) as unknown as EvaluationConfig
|
||||
const liveConfig = (liveStage?.configJson ?? defaultLiveConfig()) as unknown as LiveFinalConfig
|
||||
|
||||
const basicsValid = validateBasics(state).valid
|
||||
const tracksValid = validateTracks(state.tracks).valid
|
||||
const allValid = validateAll(state).valid
|
||||
const isActive = pipeline?.status === 'ACTIVE'
|
||||
|
||||
const handleSave = async () => {
|
||||
const validation = validateAll(state)
|
||||
if (!validation.valid) {
|
||||
toast.error('Please fix validation errors before saving')
|
||||
if (!validation.sections.basics.valid) setOpenSection(0)
|
||||
else if (!validation.sections.tracks.valid) setOpenSection(2)
|
||||
return
|
||||
}
|
||||
|
||||
await updateStructureMutation.mutateAsync({
|
||||
id: pipelineId,
|
||||
name: state.name,
|
||||
slug: state.slug,
|
||||
settingsJson: {
|
||||
...state.settingsJson,
|
||||
notificationConfig: state.notificationConfig,
|
||||
overridePolicy: state.overridePolicy,
|
||||
},
|
||||
tracks: state.tracks.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
slug: t.slug,
|
||||
kind: t.kind,
|
||||
sortOrder: t.sortOrder,
|
||||
routingModeDefault: t.routingModeDefault,
|
||||
decisionMode: t.decisionMode,
|
||||
stages: t.stages.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
slug: s.slug,
|
||||
stageType: s.stageType,
|
||||
sortOrder: s.sortOrder,
|
||||
configJson: s.configJson,
|
||||
})),
|
||||
awardConfig: t.awardConfig,
|
||||
})),
|
||||
autoTransitions: true,
|
||||
})
|
||||
}
|
||||
|
||||
const isSaving = updateStructureMutation.isPending
|
||||
|
||||
const sections = [
|
||||
{ title: 'Basics', description: 'Pipeline name, slug, and program', isValid: basicsValid },
|
||||
{ title: 'Intake', description: 'Submission windows and file requirements', isValid: !!intakeStage },
|
||||
{ title: 'Main Track Stages', description: `${mainTrack?.stages.length ?? 0} stages configured`, isValid: tracksValid },
|
||||
{ title: 'Filtering', description: 'Gate rules and AI screening settings', isValid: !!filterStage },
|
||||
{ title: 'Assignment', description: 'Jury evaluation assignment strategy', isValid: !!evalStage },
|
||||
{ title: 'Awards', description: `${state.tracks.filter((t) => t.kind === 'AWARD').length} award tracks`, isValid: true },
|
||||
{ title: 'Live Finals', description: 'Voting, cohorts, and reveal settings', isValid: !!liveStage },
|
||||
{ title: 'Notifications', description: 'Event notifications and override governance', isValid: true },
|
||||
{ title: 'Review', description: 'Validation summary', isValid: allValid },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}` as Route}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Edit Pipeline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{pipeline?.name}
|
||||
{isActive && ' (Active — some fields are locked)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving || !allValid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Wizard Sections */}
|
||||
<div className="space-y-3">
|
||||
<WizardSection
|
||||
stepNumber={1}
|
||||
title={sections[0].title}
|
||||
description={sections[0].description}
|
||||
isOpen={openSection === 0}
|
||||
onToggle={() => setOpenSection(openSection === 0 ? -1 : 0)}
|
||||
isValid={sections[0].isValid}
|
||||
>
|
||||
<BasicsSection state={state} onChange={updateState} isActive={isActive} />
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={2}
|
||||
title={sections[1].title}
|
||||
description={sections[1].description}
|
||||
isOpen={openSection === 1}
|
||||
onToggle={() => setOpenSection(openSection === 1 ? -1 : 1)}
|
||||
isValid={sections[1].isValid}
|
||||
>
|
||||
<IntakeSection
|
||||
config={intakeConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('INTAKE', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={3}
|
||||
title={sections[2].title}
|
||||
description={sections[2].description}
|
||||
isOpen={openSection === 2}
|
||||
onToggle={() => setOpenSection(openSection === 2 ? -1 : 2)}
|
||||
isValid={sections[2].isValid}
|
||||
>
|
||||
<MainTrackSection
|
||||
stages={mainTrack?.stages ?? []}
|
||||
onChange={updateMainTrackStages}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={4}
|
||||
title={sections[3].title}
|
||||
description={sections[3].description}
|
||||
isOpen={openSection === 3}
|
||||
onToggle={() => setOpenSection(openSection === 3 ? -1 : 3)}
|
||||
isValid={sections[3].isValid}
|
||||
>
|
||||
<FilteringSection
|
||||
config={filterConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('FILTER', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={5}
|
||||
title={sections[4].title}
|
||||
description={sections[4].description}
|
||||
isOpen={openSection === 4}
|
||||
onToggle={() => setOpenSection(openSection === 4 ? -1 : 4)}
|
||||
isValid={sections[4].isValid}
|
||||
>
|
||||
<AssignmentSection
|
||||
config={evalConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('EVALUATION', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={6}
|
||||
title={sections[5].title}
|
||||
description={sections[5].description}
|
||||
isOpen={openSection === 5}
|
||||
onToggle={() => setOpenSection(openSection === 5 ? -1 : 5)}
|
||||
isValid={sections[5].isValid}
|
||||
>
|
||||
<AwardsSection
|
||||
tracks={state.tracks}
|
||||
onChange={(tracks) => updateState({ tracks })}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={7}
|
||||
title={sections[6].title}
|
||||
description={sections[6].description}
|
||||
isOpen={openSection === 6}
|
||||
onToggle={() => setOpenSection(openSection === 6 ? -1 : 6)}
|
||||
isValid={sections[6].isValid}
|
||||
>
|
||||
<LiveFinalsSection
|
||||
config={liveConfig}
|
||||
onChange={(c) =>
|
||||
updateStageConfig('LIVE_FINAL', c as unknown as Record<string, unknown>)
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={8}
|
||||
title={sections[7].title}
|
||||
description={sections[7].description}
|
||||
isOpen={openSection === 7}
|
||||
onToggle={() => setOpenSection(openSection === 7 ? -1 : 7)}
|
||||
isValid={sections[7].isValid}
|
||||
>
|
||||
<NotificationsSection
|
||||
config={state.notificationConfig}
|
||||
onChange={(notificationConfig) => updateState({ notificationConfig })}
|
||||
overridePolicy={state.overridePolicy}
|
||||
onOverridePolicyChange={(overridePolicy) =>
|
||||
updateState({ overridePolicy })
|
||||
}
|
||||
/>
|
||||
</WizardSection>
|
||||
|
||||
<WizardSection
|
||||
stepNumber={9}
|
||||
title={sections[8].title}
|
||||
description={sections[8].description}
|
||||
isOpen={openSection === 8}
|
||||
onToggle={() => setOpenSection(openSection === 8 ? -1 : 8)}
|
||||
isValid={sections[8].isValid}
|
||||
>
|
||||
<ReviewSection state={state} />
|
||||
</WizardSection>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
439
src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx
Normal file
439
src/app/(admin)/admin/rounds/pipeline/[id]/page.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
MoreHorizontal,
|
||||
Rocket,
|
||||
Archive,
|
||||
Settings2,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { IntakePanel } from '@/components/admin/pipeline/stage-panels/intake-panel'
|
||||
import { FilterPanel } from '@/components/admin/pipeline/stage-panels/filter-panel'
|
||||
import { EvaluationPanel } from '@/components/admin/pipeline/stage-panels/evaluation-panel'
|
||||
import { SelectionPanel } from '@/components/admin/pipeline/stage-panels/selection-panel'
|
||||
import { LiveFinalPanel } from '@/components/admin/pipeline/stage-panels/live-final-panel'
|
||||
import { ResultsPanel } from '@/components/admin/pipeline/stage-panels/results-panel'
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
const stageTypeColors: Record<string, string> = {
|
||||
INTAKE: 'bg-blue-100 text-blue-700',
|
||||
FILTER: 'bg-amber-100 text-amber-700',
|
||||
EVALUATION: 'bg-purple-100 text-purple-700',
|
||||
SELECTION: 'bg-rose-100 text-rose-700',
|
||||
LIVE_FINAL: 'bg-emerald-100 text-emerald-700',
|
||||
RESULTS: 'bg-cyan-100 text-cyan-700',
|
||||
}
|
||||
|
||||
function StagePanel({
|
||||
stageId,
|
||||
stageType,
|
||||
configJson,
|
||||
}: {
|
||||
stageId: string
|
||||
stageType: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}) {
|
||||
switch (stageType) {
|
||||
case 'INTAKE':
|
||||
return <IntakePanel stageId={stageId} configJson={configJson} />
|
||||
case 'FILTER':
|
||||
return <FilterPanel stageId={stageId} configJson={configJson} />
|
||||
case 'EVALUATION':
|
||||
return <EvaluationPanel stageId={stageId} configJson={configJson} />
|
||||
case 'SELECTION':
|
||||
return <SelectionPanel stageId={stageId} configJson={configJson} />
|
||||
case 'LIVE_FINAL':
|
||||
return <LiveFinalPanel stageId={stageId} configJson={configJson} />
|
||||
case 'RESULTS':
|
||||
return <ResultsPanel stageId={stageId} configJson={configJson} />
|
||||
default:
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
Unknown stage type: {stageType}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default function PipelineDetailPage() {
|
||||
const params = useParams()
|
||||
const pipelineId = params.id as string
|
||||
|
||||
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null)
|
||||
const [selectedStageId, setSelectedStageId] = useState<string | null>(null)
|
||||
|
||||
const { data: pipeline, isLoading } = trpc.pipeline.getDraft.useQuery({
|
||||
id: pipelineId,
|
||||
})
|
||||
|
||||
// Auto-select first track and stage
|
||||
useEffect(() => {
|
||||
if (pipeline && pipeline.tracks.length > 0 && !selectedTrackId) {
|
||||
const firstTrack = pipeline.tracks[0]
|
||||
setSelectedTrackId(firstTrack.id)
|
||||
if (firstTrack.stages.length > 0) {
|
||||
setSelectedStageId(firstTrack.stages[0].id)
|
||||
}
|
||||
}
|
||||
}, [pipeline, selectedTrackId])
|
||||
|
||||
const publishMutation = trpc.pipeline.publish.useMutation({
|
||||
onSuccess: () => toast.success('Pipeline published'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.pipeline.update.useMutation({
|
||||
onSuccess: () => toast.success('Pipeline updated'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8" />
|
||||
<div>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-32 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!pipeline) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipeline Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The requested pipeline does not exist
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const selectedTrack = pipeline.tracks.find((t) => t.id === selectedTrackId)
|
||||
const selectedStage = selectedTrack?.stages.find(
|
||||
(s) => s.id === selectedStageId
|
||||
)
|
||||
|
||||
const handleTrackChange = (trackId: string) => {
|
||||
setSelectedTrackId(trackId)
|
||||
const track = pipeline.tracks.find((t) => t.id === trackId)
|
||||
if (track && track.stages.length > 0) {
|
||||
setSelectedStageId(track.stages[0].id)
|
||||
} else {
|
||||
setSelectedStageId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin/rounds/pipelines">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold">{pipeline.name}</h1>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
statusColors[pipeline.status] ?? ''
|
||||
)}
|
||||
>
|
||||
{pipeline.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{pipeline.slug}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/edit` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/rounds/pipeline/${pipelineId}/advanced` as Route}>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings2 className="h-4 w-4 mr-1" />
|
||||
Advanced
|
||||
</Button>
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{pipeline.status === 'DRAFT' && (
|
||||
<DropdownMenuItem
|
||||
disabled={publishMutation.isPending}
|
||||
onClick={() =>
|
||||
publishMutation.mutate({ id: pipelineId })
|
||||
}
|
||||
>
|
||||
{publishMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Publish
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{pipeline.status === 'ACTIVE' && (
|
||||
<DropdownMenuItem
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
id: pipelineId,
|
||||
status: 'CLOSED',
|
||||
})
|
||||
}
|
||||
>
|
||||
Close Pipeline
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() =>
|
||||
updateMutation.mutate({
|
||||
id: pipelineId,
|
||||
status: 'ARCHIVED',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Archive className="h-4 w-4 mr-2" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Tracks</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{pipeline.tracks.length}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{pipeline.tracks.filter((t) => t.kind === 'MAIN').length} main,{' '}
|
||||
{pipeline.tracks.filter((t) => t.kind === 'AWARD').length} award
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Stages</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{pipeline.tracks.reduce((sum, t) => sum + t.stages.length, 0)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">across all tracks</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Transitions</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{pipeline.tracks.reduce(
|
||||
(sum, t) =>
|
||||
sum +
|
||||
t.stages.reduce(
|
||||
(s, stage) => s + stage.transitionsFrom.length,
|
||||
0
|
||||
),
|
||||
0
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">stage connections</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Track Tabs */}
|
||||
{pipeline.tracks.length > 0 && (
|
||||
<Tabs
|
||||
value={selectedTrackId ?? undefined}
|
||||
onValueChange={handleTrackChange}
|
||||
>
|
||||
<TabsList className="w-full justify-start overflow-x-auto">
|
||||
{pipeline.tracks
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((track) => (
|
||||
<TabsTrigger
|
||||
key={track.id}
|
||||
value={track.id}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<span>{track.name}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] h-4 px-1"
|
||||
>
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{pipeline.tracks.map((track) => (
|
||||
<TabsContent key={track.id} value={track.id} className="mt-4">
|
||||
{/* Track Info */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-sm">{track.name}</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{track.slug}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{track.routingMode && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{track.routingMode}
|
||||
</Badge>
|
||||
)}
|
||||
{track.decisionMode && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{track.decisionMode}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Stage Tabs within Track */}
|
||||
{track.stages.length > 0 ? (
|
||||
<Tabs
|
||||
value={
|
||||
selectedTrackId === track.id
|
||||
? selectedStageId ?? undefined
|
||||
: undefined
|
||||
}
|
||||
onValueChange={setSelectedStageId}
|
||||
>
|
||||
<TabsList className="w-full justify-start overflow-x-auto">
|
||||
{track.stages
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
.map((stage) => (
|
||||
<TabsTrigger
|
||||
key={stage.id}
|
||||
value={stage.id}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<span>{stage.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[9px] h-4 px-1',
|
||||
stageTypeColors[stage.stageType] ?? ''
|
||||
)}
|
||||
>
|
||||
{stage.stageType.replace('_', ' ')}
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{track.stages.map((stage) => (
|
||||
<TabsContent
|
||||
key={stage.id}
|
||||
value={stage.id}
|
||||
className="mt-4"
|
||||
>
|
||||
<StagePanel
|
||||
stageId={stage.id}
|
||||
stageType={stage.stageType}
|
||||
configJson={
|
||||
stage.configJson as Record<string, unknown> | null
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No stages configured for this track
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
206
src/app/(admin)/admin/rounds/pipelines/page.tsx
Normal file
206
src/app/(admin)/admin/rounds/pipelines/page.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Edit,
|
||||
Layers,
|
||||
GitBranch,
|
||||
Calendar,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { format } from 'date-fns'
|
||||
import { useEdition } from '@/contexts/edition-context'
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
DRAFT: 'bg-gray-100 text-gray-700',
|
||||
ACTIVE: 'bg-emerald-100 text-emerald-700',
|
||||
ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
CLOSED: 'bg-blue-100 text-blue-700',
|
||||
}
|
||||
|
||||
export default function PipelineListPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
|
||||
const { data: pipelines, isLoading } = trpc.pipeline.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
if (!programId) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipelines</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition to view pipelines
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Calendar className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Edition Selected</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an edition from the sidebar to view its pipelines
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Pipelines</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage evaluation pipelines for {currentEdition?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Pipeline
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-4 w-20 mt-1" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4 mt-2" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!pipelines || pipelines.length === 0) && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<GitBranch className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No Pipelines Yet</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create your first pipeline to start managing project evaluation
|
||||
</p>
|
||||
<Link href={`/admin/rounds/new-pipeline?programId=${programId}` as Route}>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Pipeline
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pipeline Cards */}
|
||||
{pipelines && pipelines.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{pipelines.map((pipeline) => (
|
||||
<Card key={pipeline.id} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-base truncate">
|
||||
{pipeline.name}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs">
|
||||
{pipeline.slug}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] shrink-0',
|
||||
statusColors[pipeline.status] ?? ''
|
||||
)}
|
||||
>
|
||||
{pipeline.status}
|
||||
</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}` as Route}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/rounds/pipeline/${pipeline.id}/edit` as Route}>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>{pipeline._count.tracks} tracks</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
<span>{pipeline._count.routingRules} rules</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Created {format(new Date(pipeline.createdAt), 'MMM d, yyyy')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,431 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Copy,
|
||||
Loader2,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
|
||||
const ROUND_TYPES = [
|
||||
{ value: 'EVALUATION', label: 'Evaluation' },
|
||||
{ value: 'FILTERING', label: 'Filtering' },
|
||||
{ value: 'LIVE_EVENT', label: 'Live Event' },
|
||||
]
|
||||
|
||||
interface TemplateFormData {
|
||||
name: string
|
||||
description: string
|
||||
roundType: string
|
||||
programId: string
|
||||
criteriaJson: string
|
||||
settingsJson: string
|
||||
}
|
||||
|
||||
const defaultForm: TemplateFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
roundType: 'EVALUATION',
|
||||
programId: '',
|
||||
criteriaJson: '[]',
|
||||
settingsJson: '{}',
|
||||
}
|
||||
|
||||
export default function RoundTemplatesPage() {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [formData, setFormData] = useState<TemplateFormData>(defaultForm)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: templates, isLoading } = trpc.roundTemplate.list.useQuery()
|
||||
const { data: programs } = trpc.program.list.useQuery()
|
||||
|
||||
const createMutation = trpc.roundTemplate.create.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundTemplate.list.invalidate()
|
||||
toast.success('Template created')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.roundTemplate.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundTemplate.list.invalidate()
|
||||
toast.success('Template updated')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.roundTemplate.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.roundTemplate.list.invalidate()
|
||||
toast.success('Template deleted')
|
||||
setDeleteId(null)
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingId(null)
|
||||
setFormData(defaultForm)
|
||||
}
|
||||
|
||||
const openEdit = (template: Record<string, unknown>) => {
|
||||
setEditingId(String(template.id))
|
||||
setFormData({
|
||||
name: String(template.name || ''),
|
||||
description: String(template.description || ''),
|
||||
roundType: String(template.roundType || 'EVALUATION'),
|
||||
programId: String(template.programId || ''),
|
||||
criteriaJson: JSON.stringify(template.criteriaJson || [], null, 2),
|
||||
settingsJson: JSON.stringify(template.settingsJson || {}, null, 2),
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
let criteriaJson: unknown
|
||||
let settingsJson: unknown
|
||||
|
||||
try {
|
||||
criteriaJson = JSON.parse(formData.criteriaJson)
|
||||
} catch {
|
||||
toast.error('Invalid criteria JSON')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
settingsJson = JSON.parse(formData.settingsJson)
|
||||
} catch {
|
||||
toast.error('Invalid settings JSON')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
roundType: formData.roundType as 'FILTERING' | 'EVALUATION' | 'LIVE_EVENT',
|
||||
programId: formData.programId || undefined,
|
||||
criteriaJson,
|
||||
settingsJson,
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({ id: editingId, ...payload })
|
||||
} else {
|
||||
createMutation.mutate(payload)
|
||||
}
|
||||
}
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/settings">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Round Templates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Create reusable templates for round configuration
|
||||
</p>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => { setFormData(defaultForm); setEditingId(null); setDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Template
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Template' : 'Create Template'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingId
|
||||
? 'Update the template configuration.'
|
||||
: 'Define a reusable round template.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Template Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Standard Evaluation Round"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Description</Label>
|
||||
<Textarea
|
||||
placeholder="Template description..."
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Round Type</Label>
|
||||
<Select
|
||||
value={formData.roundType}
|
||||
onValueChange={(v) => setFormData({ ...formData, roundType: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROUND_TYPES.map((rt) => (
|
||||
<SelectItem key={rt.value} value={rt.value}>
|
||||
{rt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Program (optional)</Label>
|
||||
<Select
|
||||
value={formData.programId}
|
||||
onValueChange={(v) => setFormData({ ...formData, programId: v === '__none__' ? '' : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Global (all programs)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">Global (all programs)</SelectItem>
|
||||
{(programs as Array<{ id: string; name: string }> | undefined)?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Criteria (JSON)</Label>
|
||||
<Textarea
|
||||
value={formData.criteriaJson}
|
||||
onChange={(e) => setFormData({ ...formData, criteriaJson: e.target.value })}
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
placeholder='[{"name":"Innovation","maxScore":10,"weight":1}]'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Settings (JSON)</Label>
|
||||
<Textarea
|
||||
value={formData.settingsJson}
|
||||
onChange={(e) => setFormData({ ...formData, settingsJson: e.target.value })}
|
||||
rows={3}
|
||||
className="font-mono text-sm"
|
||||
placeholder='{"requiredReviews":3}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!formData.name || isPending}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{/* Templates list */}
|
||||
{isLoading ? (
|
||||
<TemplatesSkeleton />
|
||||
) : templates && (templates as unknown[]).length > 0 ? (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Round Type</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Program</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Created</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(templates as Array<Record<string, unknown>>).map((template) => (
|
||||
<TableRow key={String(template.id)}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{String(template.name)}</p>
|
||||
{!!template.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{String(template.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Badge variant="secondary">
|
||||
{String(template.roundType || 'EVALUATION')}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
{template.program
|
||||
? String((template.program as Record<string, unknown>).name)
|
||||
: 'Global'}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell text-sm text-muted-foreground">
|
||||
{template.createdAt ? formatDate(template.createdAt as string | Date) : ''}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(template)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteId(String(template.id))}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<LayoutTemplate className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No templates yet</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a template to reuse round configurations across programs.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Template</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this template? This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteId && deleteMutation.mutate({ id: deleteId })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplatesSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-6 w-24" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-8 w-16 ml-auto" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user