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