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:
@@ -1,287 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ArrowRightCircle, Loader2, Info } from "lucide-react";
|
||||
|
||||
interface AdvanceProjectsDialogProps {
|
||||
roundId: string;
|
||||
programId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function AdvanceProjectsDialog({
|
||||
roundId,
|
||||
programId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: AdvanceProjectsDialogProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [targetRoundId, setTargetRoundId] = useState<string>("");
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedIds(new Set());
|
||||
setTargetRoundId("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Fetch rounds in program
|
||||
const { data: roundsData } = trpc.round.list.useQuery(
|
||||
{ programId },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
// Fetch projects in current round
|
||||
const { data: projectsData, isLoading } = trpc.project.list.useQuery(
|
||||
{ roundId, page: 1, perPage: 200 },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
// Auto-select next round by sortOrder
|
||||
const otherRounds = useMemo(() => {
|
||||
if (!roundsData) return [];
|
||||
return roundsData
|
||||
.filter((r) => r.id !== roundId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}, [roundsData, roundId]);
|
||||
|
||||
const currentRound = useMemo(() => {
|
||||
return roundsData?.find((r) => r.id === roundId);
|
||||
}, [roundsData, roundId]);
|
||||
|
||||
// Auto-select next round in sort order
|
||||
useEffect(() => {
|
||||
if (open && otherRounds.length > 0 && !targetRoundId && currentRound) {
|
||||
const nextRound = otherRounds.find(
|
||||
(r) => r.sortOrder > currentRound.sortOrder,
|
||||
);
|
||||
setTargetRoundId(nextRound?.id || otherRounds[0].id);
|
||||
}
|
||||
}, [open, otherRounds, targetRoundId, currentRound]);
|
||||
|
||||
const advanceMutation = trpc.round.advanceProjects.useMutation({
|
||||
onSuccess: (result) => {
|
||||
const targetName = otherRounds.find((r) => r.id === targetRoundId)?.name;
|
||||
toast.success(
|
||||
`${result.advanced} project${result.advanced !== 1 ? "s" : ""} advanced to ${targetName}`,
|
||||
);
|
||||
utils.round.get.invalidate();
|
||||
utils.project.list.invalidate();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const projects = projectsData?.projects ?? [];
|
||||
|
||||
const toggleProject = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (selectedIds.size === projects.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(projects.map((p) => p.id)));
|
||||
}
|
||||
}, [selectedIds.size, projects]);
|
||||
|
||||
const handleAdvance = () => {
|
||||
if (selectedIds.size === 0 || !targetRoundId) return;
|
||||
advanceMutation.mutate({
|
||||
fromRoundId: roundId,
|
||||
toRoundId: targetRoundId,
|
||||
projectIds: Array.from(selectedIds),
|
||||
});
|
||||
};
|
||||
|
||||
const targetRoundName = otherRounds.find((r) => r.id === targetRoundId)?.name;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<ArrowRightCircle className="h-5 w-5" />
|
||||
Advance Projects
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select projects to advance to the next round. Projects will remain
|
||||
visible in the current round.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Target Round</Label>
|
||||
{otherRounds.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No other rounds available in this program. Create another round
|
||||
first.
|
||||
</p>
|
||||
) : (
|
||||
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{otherRounds.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.name} (Order: {r.sortOrder})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg bg-blue-50 p-3 text-sm text-blue-800 dark:bg-blue-950/50 dark:text-blue-200">
|
||||
<Info className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Projects will be copied to the target round with
|
||||
"Submitted" status. They will remain in the current round
|
||||
with their existing status.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="font-medium">No projects in this round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Assign projects to this round first.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.size === projects.length && projects.length > 0
|
||||
}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size} of {projects.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border max-h-[300px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12" />
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((project) => (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className={
|
||||
selectedIds.has(project.id)
|
||||
? "bg-muted/50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
onClick={() => toggleProject(project.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => toggleProject(project.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{project.title}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{project.teamName || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{(project.status ?? "SUBMITTED").replace("_", " ")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAdvance}
|
||||
disabled={
|
||||
selectedIds.size === 0 ||
|
||||
!targetRoundId ||
|
||||
advanceMutation.isPending
|
||||
}
|
||||
>
|
||||
{advanceMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRightCircle className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Advance Selected ({selectedIds.size})
|
||||
{targetRoundName ? ` to ${targetRoundName}` : ""}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Search, Loader2, Plus, Package, CheckCircle2 } from "lucide-react";
|
||||
import { getCountryName } from "@/lib/countries";
|
||||
|
||||
interface AssignProjectsDialogProps {
|
||||
roundId: string;
|
||||
programId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function AssignProjectsDialog({
|
||||
roundId,
|
||||
programId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: AssignProjectsDialogProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedIds(new Set());
|
||||
setSearch("");
|
||||
setDebouncedSearch("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data, isLoading, error } = trpc.project.list.useQuery(
|
||||
{
|
||||
programId,
|
||||
unassignedOnly: true,
|
||||
search: debouncedSearch || undefined,
|
||||
page: 1,
|
||||
perPage: 200,
|
||||
},
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const assignMutation = trpc.round.assignProjects.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
`${result.assigned} project${result.assigned !== 1 ? "s" : ""} assigned to round`,
|
||||
);
|
||||
utils.round.get.invalidate({ id: roundId });
|
||||
utils.project.list.invalidate();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const projects = data?.projects ?? [];
|
||||
const alreadyInRound = new Set(
|
||||
projects.filter((p) => p.round?.id === roundId).map((p) => p.id),
|
||||
);
|
||||
const assignableProjects = projects.filter((p) => !alreadyInRound.has(p.id));
|
||||
|
||||
const toggleProject = useCallback(
|
||||
(id: string) => {
|
||||
if (alreadyInRound.has(id)) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[alreadyInRound],
|
||||
);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (selectedIds.size === assignableProjects.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(assignableProjects.map((p) => p.id)));
|
||||
}
|
||||
}, [selectedIds.size, assignableProjects]);
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
assignMutation.mutate({
|
||||
roundId,
|
||||
projectIds: Array.from(selectedIds),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
Assign Projects to Round
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select projects from the program pool to add to this round.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search projects..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="mt-2 font-medium">Failed to load projects</p>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No projects found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{debouncedSearch
|
||||
? "No projects in the pool match your search."
|
||||
: "This program has no projects in the pool yet."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
assignableProjects.length > 0 &&
|
||||
selectedIds.size === assignableProjects.length
|
||||
}
|
||||
disabled={assignableProjects.length === 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size} of {assignableProjects.length} assignable
|
||||
selected
|
||||
{alreadyInRound.size > 0 && (
|
||||
<span className="ml-1">
|
||||
({alreadyInRound.size} already in round)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12" />
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Country</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((project) => {
|
||||
const isInRound = alreadyInRound.has(project.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className={
|
||||
isInRound
|
||||
? "opacity-60"
|
||||
: selectedIds.has(project.id)
|
||||
? "bg-muted/50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
onClick={() => toggleProject(project.id)}
|
||||
>
|
||||
<TableCell>
|
||||
{isInRound ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() =>
|
||||
toggleProject(project.id)
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{project.title}
|
||||
{isInRound && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
In round
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{project.teamName || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.country ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getCountryName(project.country)}
|
||||
</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleAssign}
|
||||
disabled={selectedIds.size === 0 || assignMutation.isPending}
|
||||
>
|
||||
{assignMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Assign Selected ({selectedIds.size})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import { formatDistanceToNow } from 'date-fns'
|
||||
|
||||
interface EvaluationSummaryCardProps {
|
||||
projectId: string
|
||||
roundId: string
|
||||
stageId: string
|
||||
}
|
||||
|
||||
interface ScoringPatterns {
|
||||
@@ -71,7 +71,7 @@ const sentimentColors: Record<string, { badge: 'default' | 'secondary' | 'destru
|
||||
|
||||
export function EvaluationSummaryCard({
|
||||
projectId,
|
||||
roundId,
|
||||
stageId,
|
||||
}: EvaluationSummaryCardProps) {
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
@@ -79,7 +79,7 @@ export function EvaluationSummaryCard({
|
||||
data: summary,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = trpc.evaluation.getSummary.useQuery({ projectId, roundId })
|
||||
} = trpc.evaluation.getSummary.useQuery({ projectId, stageId })
|
||||
|
||||
const generateMutation = trpc.evaluation.generateSummary.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -95,7 +95,7 @@ export function EvaluationSummaryCard({
|
||||
|
||||
const handleGenerate = () => {
|
||||
setIsGenerating(true)
|
||||
generateMutation.mutate({ projectId, roundId })
|
||||
generateMutation.mutate({ projectId, stageId })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -63,7 +63,7 @@ function getMimeLabel(mime: string): string {
|
||||
}
|
||||
|
||||
interface FileRequirementsEditorProps {
|
||||
roundId: string;
|
||||
stageId: string;
|
||||
}
|
||||
|
||||
interface RequirementFormData {
|
||||
@@ -83,35 +83,35 @@ const emptyForm: RequirementFormData = {
|
||||
};
|
||||
|
||||
export function FileRequirementsEditor({
|
||||
roundId,
|
||||
stageId,
|
||||
}: FileRequirementsEditorProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: requirements = [], isLoading } =
|
||||
trpc.file.listRequirements.useQuery({ roundId });
|
||||
trpc.file.listRequirements.useQuery({ stageId });
|
||||
const createMutation = trpc.file.createRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
utils.file.listRequirements.invalidate({ stageId });
|
||||
toast.success("Requirement created");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const updateMutation = trpc.file.updateRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
utils.file.listRequirements.invalidate({ stageId });
|
||||
toast.success("Requirement updated");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const deleteMutation = trpc.file.deleteRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId });
|
||||
utils.file.listRequirements.invalidate({ stageId });
|
||||
toast.success("Requirement deleted");
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
const reorderMutation = trpc.file.reorderRequirements.useMutation({
|
||||
onSuccess: () => utils.file.listRequirements.invalidate({ roundId }),
|
||||
onSuccess: () => utils.file.listRequirements.invalidate({ stageId }),
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ export function FileRequirementsEditor({
|
||||
});
|
||||
} else {
|
||||
await createMutation.mutateAsync({
|
||||
roundId,
|
||||
stageId,
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
acceptedMimeTypes: form.acceptedMimeTypes,
|
||||
@@ -182,7 +182,7 @@ export function FileRequirementsEditor({
|
||||
newOrder[index],
|
||||
];
|
||||
await reorderMutation.mutateAsync({
|
||||
roundId,
|
||||
stageId,
|
||||
orderedIds: newOrder.map((r) => r.id),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,15 +18,15 @@ import {
|
||||
} from '@/lib/pdf-generator'
|
||||
|
||||
interface PdfReportProps {
|
||||
roundId: string
|
||||
stageId: string
|
||||
sections: string[]
|
||||
}
|
||||
|
||||
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
|
||||
export function PdfReportGenerator({ stageId, sections }: PdfReportProps) {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
|
||||
const { refetch } = trpc.export.getReportData.useQuery(
|
||||
{ roundId, sections },
|
||||
{ stageId, sections },
|
||||
{ enabled: false }
|
||||
)
|
||||
|
||||
|
||||
121
src/components/admin/pipeline/pipeline-visualization.tsx
Normal file
121
src/components/admin/pipeline/pipeline-visualization.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
type StageNode = {
|
||||
id?: string
|
||||
name: string
|
||||
stageType: string
|
||||
sortOrder: number
|
||||
_count?: { projectStageStates: number }
|
||||
}
|
||||
|
||||
type TrackLane = {
|
||||
id?: string
|
||||
name: string
|
||||
kind: string
|
||||
sortOrder: number
|
||||
stages: StageNode[]
|
||||
}
|
||||
|
||||
type PipelineVisualizationProps = {
|
||||
tracks: TrackLane[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const stageColors: Record<string, string> = {
|
||||
INTAKE: 'bg-blue-50 border-blue-300 text-blue-700',
|
||||
FILTER: 'bg-amber-50 border-amber-300 text-amber-700',
|
||||
EVALUATION: 'bg-purple-50 border-purple-300 text-purple-700',
|
||||
SELECTION: 'bg-rose-50 border-rose-300 text-rose-700',
|
||||
LIVE_FINAL: 'bg-emerald-50 border-emerald-300 text-emerald-700',
|
||||
RESULTS: 'bg-cyan-50 border-cyan-300 text-cyan-700',
|
||||
}
|
||||
|
||||
const trackKindBadge: Record<string, string> = {
|
||||
MAIN: 'bg-blue-100 text-blue-700',
|
||||
AWARD: 'bg-amber-100 text-amber-700',
|
||||
SHOWCASE: 'bg-purple-100 text-purple-700',
|
||||
}
|
||||
|
||||
export function PipelineVisualization({
|
||||
tracks,
|
||||
className,
|
||||
}: PipelineVisualizationProps) {
|
||||
const sortedTracks = [...tracks].sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{sortedTracks.map((track) => {
|
||||
const sortedStages = [...track.stages].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder
|
||||
)
|
||||
|
||||
return (
|
||||
<Card key={track.id ?? track.name} className="p-4">
|
||||
{/* Track header */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-semibold">{track.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px] h-5',
|
||||
trackKindBadge[track.kind] ?? ''
|
||||
)}
|
||||
>
|
||||
{track.kind}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Stage flow */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1">
|
||||
{sortedStages.map((stage, index) => (
|
||||
<div key={stage.id ?? index} className="flex items-center gap-1 shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center rounded-lg border px-3 py-2 min-w-[100px]',
|
||||
stageColors[stage.stageType] ?? 'bg-gray-50 border-gray-300'
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium text-center leading-tight">
|
||||
{stage.name}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-70 mt-0.5">
|
||||
{stage.stageType.replace('_', ' ')}
|
||||
</span>
|
||||
{stage._count?.projectStageStates !== undefined &&
|
||||
stage._count.projectStageStates > 0 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] h-4 px-1 mt-1"
|
||||
>
|
||||
{stage._count.projectStageStates}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{index < sortedStages.length - 1 && (
|
||||
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sortedStages.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground italic">
|
||||
No stages
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{tracks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No tracks to visualize
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
src/components/admin/pipeline/sections/assignment-section.tsx
Normal file
120
src/components/admin/pipeline/sections/assignment-section.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { EvaluationConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type AssignmentSectionProps = {
|
||||
config: EvaluationConfig
|
||||
onChange: (config: EvaluationConfig) => void
|
||||
}
|
||||
|
||||
export function AssignmentSection({ config, onChange }: AssignmentSectionProps) {
|
||||
const updateConfig = (updates: Partial<EvaluationConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>Required Reviews per Project</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={config.requiredReviews}
|
||||
onChange={(e) =>
|
||||
updateConfig({ requiredReviews: parseInt(e.target.value) || 3 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Minimum number of jury evaluations per project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Max Load per Juror</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={config.maxLoadPerJuror}
|
||||
onChange={(e) =>
|
||||
updateConfig({ maxLoadPerJuror: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum projects assigned to one juror
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Min Load per Juror</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
value={config.minLoadPerJuror}
|
||||
onChange={(e) =>
|
||||
updateConfig({ minLoadPerJuror: parseInt(e.target.value) || 5 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Target minimum projects per juror
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Availability Weighting</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Factor in juror availability when assigning projects
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.availabilityWeighting}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ availabilityWeighting: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Overflow Policy</Label>
|
||||
<Select
|
||||
value={config.overflowPolicy}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
overflowPolicy: value as EvaluationConfig['overflowPolicy'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="queue">
|
||||
Queue — Hold unassigned projects for manual assignment
|
||||
</SelectItem>
|
||||
<SelectItem value="expand_pool">
|
||||
Expand Pool — Invite additional jurors automatically
|
||||
</SelectItem>
|
||||
<SelectItem value="reduce_reviews">
|
||||
Reduce Reviews — Lower required reviews to fit available jurors
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
src/components/admin/pipeline/sections/awards-section.tsx
Normal file
241
src/components/admin/pipeline/sections/awards-section.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Plus, Trash2, Trophy } from 'lucide-react'
|
||||
import { defaultAwardTrack } from '@/lib/pipeline-defaults'
|
||||
import type { WizardTrackConfig } from '@/types/pipeline-wizard'
|
||||
import type { RoutingMode, DecisionMode, AwardScoringMode } from '@prisma/client'
|
||||
|
||||
type AwardsSectionProps = {
|
||||
tracks: WizardTrackConfig[]
|
||||
onChange: (tracks: WizardTrackConfig[]) => void
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function AwardsSection({ tracks, onChange }: AwardsSectionProps) {
|
||||
const awardTracks = tracks.filter((t) => t.kind === 'AWARD')
|
||||
const nonAwardTracks = tracks.filter((t) => t.kind !== 'AWARD')
|
||||
|
||||
const addAward = () => {
|
||||
const newTrack = defaultAwardTrack(awardTracks.length)
|
||||
newTrack.sortOrder = tracks.length
|
||||
onChange([...tracks, newTrack])
|
||||
}
|
||||
|
||||
const updateAward = (index: number, updates: Partial<WizardTrackConfig>) => {
|
||||
const updated = [...tracks]
|
||||
const awardIndex = tracks.findIndex(
|
||||
(t) => t.kind === 'AWARD' && awardTracks.indexOf(t) === index
|
||||
)
|
||||
if (awardIndex >= 0) {
|
||||
updated[awardIndex] = { ...updated[awardIndex], ...updates }
|
||||
onChange(updated)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAward = (index: number) => {
|
||||
const toRemove = awardTracks[index]
|
||||
onChange(tracks.filter((t) => t !== toRemove))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Configure special award tracks that run alongside the main competition.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addAward}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Award Track
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{awardTracks.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
<Trophy className="h-8 w-8 mx-auto mb-2 text-muted-foreground/50" />
|
||||
No award tracks configured. Awards are optional.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{awardTracks.map((track, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
Award Track {index + 1}
|
||||
</CardTitle>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Award Track?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove the "{track.name}" award track and all
|
||||
its stages. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => removeAward(index)}>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Award Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Innovation Award"
|
||||
value={track.awardConfig?.name ?? track.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
updateAward(index, {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
awardConfig: {
|
||||
...track.awardConfig,
|
||||
name,
|
||||
},
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Routing Mode</Label>
|
||||
<Select
|
||||
value={track.routingModeDefault ?? 'PARALLEL'}
|
||||
onValueChange={(value) =>
|
||||
updateAward(index, {
|
||||
routingModeDefault: value as RoutingMode,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PARALLEL">
|
||||
Parallel — Runs alongside main track
|
||||
</SelectItem>
|
||||
<SelectItem value="EXCLUSIVE">
|
||||
Exclusive — Projects enter only this track
|
||||
</SelectItem>
|
||||
<SelectItem value="POST_MAIN">
|
||||
Post-Main — After main track completes
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Decision Mode</Label>
|
||||
<Select
|
||||
value={track.decisionMode ?? 'JURY_VOTE'}
|
||||
onValueChange={(value) =>
|
||||
updateAward(index, { decisionMode: value as DecisionMode })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="JURY_VOTE">Jury Vote</SelectItem>
|
||||
<SelectItem value="AWARD_MASTER_DECISION">
|
||||
Award Master Decision
|
||||
</SelectItem>
|
||||
<SelectItem value="ADMIN_DECISION">Admin Decision</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Scoring Mode</Label>
|
||||
<Select
|
||||
value={track.awardConfig?.scoringMode ?? 'PICK_WINNER'}
|
||||
onValueChange={(value) =>
|
||||
updateAward(index, {
|
||||
awardConfig: {
|
||||
...track.awardConfig!,
|
||||
scoringMode: value as AwardScoringMode,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Description (optional)</Label>
|
||||
<Textarea
|
||||
placeholder="Brief description of this award..."
|
||||
value={track.awardConfig?.description ?? ''}
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
onChange={(e) =>
|
||||
updateAward(index, {
|
||||
awardConfig: {
|
||||
...track.awardConfig!,
|
||||
description: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
src/components/admin/pipeline/sections/basics-section.tsx
Normal file
92
src/components/admin/pipeline/sections/basics-section.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import type { WizardState } from '@/types/pipeline-wizard'
|
||||
|
||||
type BasicsSectionProps = {
|
||||
state: WizardState
|
||||
onChange: (updates: Partial<WizardState>) => void
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function BasicsSection({ state, onChange, isActive }: BasicsSectionProps) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({})
|
||||
|
||||
// Auto-generate slug from name
|
||||
useEffect(() => {
|
||||
if (state.name && !state.slug) {
|
||||
onChange({ slug: slugify(state.name) })
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pipeline-name">Pipeline Name</Label>
|
||||
<Input
|
||||
id="pipeline-name"
|
||||
placeholder="e.g., MOPC 2026"
|
||||
value={state.name}
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
onChange({ name, slug: slugify(name) })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pipeline-slug">Slug</Label>
|
||||
<Input
|
||||
id="pipeline-slug"
|
||||
placeholder="e.g., mopc-2026"
|
||||
value={state.slug}
|
||||
onChange={(e) => onChange({ slug: e.target.value })}
|
||||
pattern="^[a-z0-9-]+$"
|
||||
disabled={isActive}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isActive
|
||||
? 'Slug cannot be changed on active pipelines'
|
||||
: 'Lowercase letters, numbers, and hyphens only'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pipeline-program">Program</Label>
|
||||
<Select
|
||||
value={state.programId}
|
||||
onValueChange={(value) => onChange({ programId: value })}
|
||||
>
|
||||
<SelectTrigger id="pipeline-program">
|
||||
<SelectValue placeholder={isLoading ? 'Loading...' : 'Select a program'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name} ({p.year})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
203
src/components/admin/pipeline/sections/filtering-section.tsx
Normal file
203
src/components/admin/pipeline/sections/filtering-section.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import type { FilterConfig, FilterRuleConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type FilteringSectionProps = {
|
||||
config: FilterConfig
|
||||
onChange: (config: FilterConfig) => void
|
||||
}
|
||||
|
||||
export function FilteringSection({ config, onChange }: FilteringSectionProps) {
|
||||
const updateConfig = (updates: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
const updateRule = (index: number, updates: Partial<FilterRuleConfig>) => {
|
||||
const updated = [...config.rules]
|
||||
updated[index] = { ...updated[index], ...updates }
|
||||
onChange({ ...config, rules: updated })
|
||||
}
|
||||
|
||||
const addRule = () => {
|
||||
onChange({
|
||||
...config,
|
||||
rules: [
|
||||
...config.rules,
|
||||
{ field: '', operator: 'equals', value: '', weight: 1 },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
onChange({ ...config, rules: config.rules.filter((_, i) => i !== index) })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Deterministic Gate Rules */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Gate Rules</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Deterministic rules that projects must pass. Applied in order.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRule}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.rules.map((rule, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="pt-3 pb-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 grid gap-2 sm:grid-cols-3">
|
||||
<Input
|
||||
placeholder="Field name"
|
||||
value={rule.field}
|
||||
className="h-8 text-sm"
|
||||
onChange={(e) => updateRule(index, { field: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
value={rule.operator}
|
||||
onValueChange={(value) => updateRule(index, { operator: value })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="equals">Equals</SelectItem>
|
||||
<SelectItem value="notEquals">Not Equals</SelectItem>
|
||||
<SelectItem value="contains">Contains</SelectItem>
|
||||
<SelectItem value="greaterThan">Greater Than</SelectItem>
|
||||
<SelectItem value="lessThan">Less Than</SelectItem>
|
||||
<SelectItem value="exists">Exists</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder="Value"
|
||||
value={String(rule.value)}
|
||||
className="h-8 text-sm"
|
||||
onChange={(e) => updateRule(index, { value: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeRule(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{config.rules.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-3">
|
||||
No gate rules configured. All projects will pass through.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Rubric */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>AI Screening</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use AI to evaluate projects against rubric criteria
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.aiRubricEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ aiRubricEnabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.aiRubricEnabled && (
|
||||
<div className="space-y-4 pl-4 border-l-2 border-muted">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">High Confidence Threshold</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[config.aiConfidenceThresholds.high * 100]}
|
||||
onValueChange={([v]) =>
|
||||
updateConfig({
|
||||
aiConfidenceThresholds: {
|
||||
...config.aiConfidenceThresholds,
|
||||
high: v / 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
min={50}
|
||||
max={100}
|
||||
step={5}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs font-mono w-10 text-right">
|
||||
{Math.round(config.aiConfidenceThresholds.high * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Medium Confidence Threshold</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[config.aiConfidenceThresholds.medium * 100]}
|
||||
onValueChange={([v]) =>
|
||||
updateConfig({
|
||||
aiConfidenceThresholds: {
|
||||
...config.aiConfidenceThresholds,
|
||||
medium: v / 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
min={20}
|
||||
max={80}
|
||||
step={5}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs font-mono w-10 text-right">
|
||||
{Math.round(config.aiConfidenceThresholds.medium * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Queue */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Manual Review Queue</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects below medium confidence go to manual review
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.manualQueueEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ manualQueueEnabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
196
src/components/admin/pipeline/sections/intake-section.tsx
Normal file
196
src/components/admin/pipeline/sections/intake-section.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client'
|
||||
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Plus, Trash2, FileText } from 'lucide-react'
|
||||
import type { IntakeConfig, FileRequirementConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type IntakeSectionProps = {
|
||||
config: IntakeConfig
|
||||
onChange: (config: IntakeConfig) => void
|
||||
}
|
||||
|
||||
export function IntakeSection({ config, onChange }: IntakeSectionProps) {
|
||||
const updateConfig = (updates: Partial<IntakeConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
const updateFileReq = (index: number, updates: Partial<FileRequirementConfig>) => {
|
||||
const updated = [...config.fileRequirements]
|
||||
updated[index] = { ...updated[index], ...updates }
|
||||
onChange({ ...config, fileRequirements: updated })
|
||||
}
|
||||
|
||||
const addFileReq = () => {
|
||||
onChange({
|
||||
...config,
|
||||
fileRequirements: [
|
||||
...config.fileRequirements,
|
||||
{
|
||||
name: '',
|
||||
description: '',
|
||||
acceptedMimeTypes: ['application/pdf'],
|
||||
maxSizeMB: 50,
|
||||
isRequired: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const removeFileReq = (index: number) => {
|
||||
const updated = config.fileRequirements.filter((_, i) => i !== index)
|
||||
onChange({ ...config, fileRequirements: updated })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Submission Window */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Submission Window</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable timed submission windows for project intake
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.submissionWindowEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ submissionWindowEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Late Policy */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Late Submission Policy</Label>
|
||||
<Select
|
||||
value={config.lateSubmissionPolicy}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
lateSubmissionPolicy: value as IntakeConfig['lateSubmissionPolicy'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="reject">Reject late submissions</SelectItem>
|
||||
<SelectItem value="flag">Accept but flag as late</SelectItem>
|
||||
<SelectItem value="accept">Accept normally</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.lateSubmissionPolicy === 'flag' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Grace Period (hours)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={168}
|
||||
value={config.lateGraceHours}
|
||||
onChange={(e) =>
|
||||
updateConfig({ lateGraceHours: parseInt(e.target.value) || 0 })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Requirements */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>File Requirements</Label>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addFileReq}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Requirement
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{config.fileRequirements.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No file requirements configured. Projects can be submitted without files.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{config.fileRequirements.map((req, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="h-4 w-4 text-muted-foreground mt-2 shrink-0" />
|
||||
<div className="flex-1 grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">File Name</Label>
|
||||
<Input
|
||||
placeholder="e.g., Executive Summary"
|
||||
value={req.name}
|
||||
onChange={(e) => updateFileReq(index, { name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Max Size (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={req.maxSizeMB ?? ''}
|
||||
onChange={(e) =>
|
||||
updateFileReq(index, {
|
||||
maxSizeMB: parseInt(e.target.value) || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Description</Label>
|
||||
<Input
|
||||
placeholder="Brief description of this requirement"
|
||||
value={req.description ?? ''}
|
||||
onChange={(e) =>
|
||||
updateFileReq(index, { description: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={req.isRequired}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFileReq(index, { isRequired: checked })
|
||||
}
|
||||
/>
|
||||
<Label className="text-xs">Required</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFileReq(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
src/components/admin/pipeline/sections/live-finals-section.tsx
Normal file
137
src/components/admin/pipeline/sections/live-finals-section.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type LiveFinalsSectionProps = {
|
||||
config: LiveFinalConfig
|
||||
onChange: (config: LiveFinalConfig) => void
|
||||
}
|
||||
|
||||
export function LiveFinalsSection({ config, onChange }: LiveFinalsSectionProps) {
|
||||
const updateConfig = (updates: Partial<LiveFinalConfig>) => {
|
||||
onChange({ ...config, ...updates })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Jury Voting</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jury members to vote during the live finals event
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.juryVotingEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ juryVotingEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>Audience Voting</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow audience members to vote on projects
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.audienceVotingEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig({ audienceVotingEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.audienceVotingEnabled && (
|
||||
<div className="pl-4 border-l-2 border-muted space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Audience Vote Weight</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Slider
|
||||
value={[config.audienceVoteWeight * 100]}
|
||||
onValueChange={([v]) =>
|
||||
updateConfig({ audienceVoteWeight: v / 100 })
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs font-mono w-10 text-right">
|
||||
{Math.round(config.audienceVoteWeight * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Percentage weight of audience votes in the final score
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Cohort Setup Mode</Label>
|
||||
<Select
|
||||
value={config.cohortSetupMode}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
cohortSetupMode: value as LiveFinalConfig['cohortSetupMode'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">
|
||||
Manual — Admin creates cohorts and assigns projects
|
||||
</SelectItem>
|
||||
<SelectItem value="auto">
|
||||
Auto — System creates cohorts from pipeline results
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Result Reveal Policy</Label>
|
||||
<Select
|
||||
value={config.revealPolicy}
|
||||
onValueChange={(value) =>
|
||||
updateConfig({
|
||||
revealPolicy: value as LiveFinalConfig['revealPolicy'],
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">
|
||||
Immediate — Results shown after each vote
|
||||
</SelectItem>
|
||||
<SelectItem value="delayed">
|
||||
Delayed — Results hidden until admin reveals
|
||||
</SelectItem>
|
||||
<SelectItem value="ceremony">
|
||||
Ceremony — Results revealed in dramatic sequence
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
src/components/admin/pipeline/sections/main-track-section.tsx
Normal file
208
src/components/admin/pipeline/sections/main-track-section.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { WizardStageConfig } from '@/types/pipeline-wizard'
|
||||
import type { StageType } from '@prisma/client'
|
||||
|
||||
type MainTrackSectionProps = {
|
||||
stages: WizardStageConfig[]
|
||||
onChange: (stages: WizardStageConfig[]) => void
|
||||
}
|
||||
|
||||
const STAGE_TYPE_OPTIONS: { value: StageType; label: string; color: string }[] = [
|
||||
{ value: 'INTAKE', label: 'Intake', color: 'bg-blue-100 text-blue-700' },
|
||||
{ value: 'FILTER', label: 'Filter', color: 'bg-amber-100 text-amber-700' },
|
||||
{ value: 'EVALUATION', label: 'Evaluation', color: 'bg-purple-100 text-purple-700' },
|
||||
{ value: 'SELECTION', label: 'Selection', color: 'bg-emerald-100 text-emerald-700' },
|
||||
{ value: 'LIVE_FINAL', label: 'Live Final', color: 'bg-rose-100 text-rose-700' },
|
||||
{ value: 'RESULTS', label: 'Results', color: 'bg-gray-100 text-gray-700' },
|
||||
]
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
export function MainTrackSection({ stages, onChange }: MainTrackSectionProps) {
|
||||
const updateStage = useCallback(
|
||||
(index: number, updates: Partial<WizardStageConfig>) => {
|
||||
const updated = [...stages]
|
||||
updated[index] = { ...updated[index], ...updates }
|
||||
onChange(updated)
|
||||
},
|
||||
[stages, onChange]
|
||||
)
|
||||
|
||||
const addStage = () => {
|
||||
const maxOrder = Math.max(...stages.map((s) => s.sortOrder), -1)
|
||||
onChange([
|
||||
...stages,
|
||||
{
|
||||
name: '',
|
||||
slug: '',
|
||||
stageType: 'EVALUATION',
|
||||
sortOrder: maxOrder + 1,
|
||||
configJson: {},
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const removeStage = (index: number) => {
|
||||
if (stages.length <= 2) return // Minimum 2 stages
|
||||
const updated = stages.filter((_, i) => i !== index)
|
||||
// Re-number sortOrder
|
||||
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
|
||||
}
|
||||
|
||||
const moveStage = (index: number, direction: 'up' | 'down') => {
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1
|
||||
if (newIndex < 0 || newIndex >= stages.length) return
|
||||
const updated = [...stages]
|
||||
const temp = updated[index]
|
||||
updated[index] = updated[newIndex]
|
||||
updated[newIndex] = temp
|
||||
onChange(updated.map((s, i) => ({ ...s, sortOrder: i })))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define the stages projects flow through in the main competition track.
|
||||
Drag to reorder. Minimum 2 stages required.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addStage}>
|
||||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||
Add Stage
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{stages.map((stage, index) => {
|
||||
const typeInfo = STAGE_TYPE_OPTIONS.find((t) => t.value === stage.stageType)
|
||||
return (
|
||||
<Card key={index}>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Reorder */}
|
||||
<div className="flex flex-col shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveStage(index, 'up')}
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground mx-auto" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
disabled={index === stages.length - 1}
|
||||
onClick={() => moveStage(index, 'down')}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Order number */}
|
||||
<span className="text-xs text-muted-foreground font-mono w-5 text-center shrink-0">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* Stage name */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
placeholder="Stage name"
|
||||
value={stage.name}
|
||||
className="h-8 text-sm"
|
||||
onChange={(e) => {
|
||||
const name = e.target.value
|
||||
updateStage(index, { name, slug: slugify(name) })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stage type */}
|
||||
<div className="w-36 shrink-0">
|
||||
<Select
|
||||
value={stage.stageType}
|
||||
onValueChange={(value) =>
|
||||
updateStage(index, { stageType: value as StageType })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STAGE_TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('shrink-0 text-[10px]', typeInfo?.color)}
|
||||
>
|
||||
{typeInfo?.label}
|
||||
</Badge>
|
||||
|
||||
{/* Remove */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
disabled={stages.length <= 2}
|
||||
onClick={() => removeStage(index)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{stages.length === 0 && (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No stages configured. Click "Add Stage" to begin.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
161
src/components/admin/pipeline/sections/notifications-section.tsx
Normal file
161
src/components/admin/pipeline/sections/notifications-section.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client'
|
||||
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Bell } from 'lucide-react'
|
||||
|
||||
type NotificationsSectionProps = {
|
||||
config: Record<string, boolean>
|
||||
onChange: (config: Record<string, boolean>) => void
|
||||
overridePolicy: Record<string, unknown>
|
||||
onOverridePolicyChange: (policy: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
const NOTIFICATION_EVENTS = [
|
||||
{
|
||||
key: 'stage.transitioned',
|
||||
label: 'Stage Transitioned',
|
||||
description: 'When a stage changes status (draft → active → closed)',
|
||||
},
|
||||
{
|
||||
key: 'filtering.completed',
|
||||
label: 'Filtering Completed',
|
||||
description: 'When batch filtering finishes processing',
|
||||
},
|
||||
{
|
||||
key: 'assignment.generated',
|
||||
label: 'Assignments Generated',
|
||||
description: 'When jury assignments are created or updated',
|
||||
},
|
||||
{
|
||||
key: 'routing.executed',
|
||||
label: 'Routing Executed',
|
||||
description: 'When projects are routed into tracks/stages',
|
||||
},
|
||||
{
|
||||
key: 'live.cursor.updated',
|
||||
label: 'Live Cursor Updated',
|
||||
description: 'When the live presentation moves to next project',
|
||||
},
|
||||
{
|
||||
key: 'cohort.window.changed',
|
||||
label: 'Cohort Window Changed',
|
||||
description: 'When a cohort voting window opens or closes',
|
||||
},
|
||||
{
|
||||
key: 'decision.overridden',
|
||||
label: 'Decision Overridden',
|
||||
description: 'When an admin overrides an automated decision',
|
||||
},
|
||||
{
|
||||
key: 'award.winner.finalized',
|
||||
label: 'Award Winner Finalized',
|
||||
description: 'When a special award winner is selected',
|
||||
},
|
||||
]
|
||||
|
||||
export function NotificationsSection({
|
||||
config,
|
||||
onChange,
|
||||
overridePolicy,
|
||||
onOverridePolicyChange,
|
||||
}: NotificationsSectionProps) {
|
||||
const toggleEvent = (key: string, enabled: boolean) => {
|
||||
onChange({ ...config, [key]: enabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which pipeline events trigger notifications. All events are enabled by default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{NOTIFICATION_EVENTS.map((event) => (
|
||||
<Card key={event.key}>
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Bell className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm font-medium">{event.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{event.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config[event.key] !== false}
|
||||
onCheckedChange={(checked) => toggleEvent(event.key, checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Override Governance */}
|
||||
<div className="space-y-3 pt-2 border-t">
|
||||
<Label>Override Governance</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Who can override automated decisions in this pipeline?
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={
|
||||
Array.isArray(overridePolicy.allowedRoles) &&
|
||||
overridePolicy.allowedRoles.includes('SUPER_ADMIN')
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
<Label className="text-sm">Super Admins (always enabled)</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={
|
||||
Array.isArray(overridePolicy.allowedRoles) &&
|
||||
overridePolicy.allowedRoles.includes('PROGRAM_ADMIN')
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const roles = Array.isArray(overridePolicy.allowedRoles)
|
||||
? [...overridePolicy.allowedRoles]
|
||||
: ['SUPER_ADMIN']
|
||||
if (checked && !roles.includes('PROGRAM_ADMIN')) {
|
||||
roles.push('PROGRAM_ADMIN')
|
||||
} else if (!checked) {
|
||||
const idx = roles.indexOf('PROGRAM_ADMIN')
|
||||
if (idx >= 0) roles.splice(idx, 1)
|
||||
}
|
||||
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
|
||||
}}
|
||||
/>
|
||||
<Label className="text-sm">Program Admins</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={
|
||||
Array.isArray(overridePolicy.allowedRoles) &&
|
||||
overridePolicy.allowedRoles.includes('AWARD_MASTER')
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const roles = Array.isArray(overridePolicy.allowedRoles)
|
||||
? [...overridePolicy.allowedRoles]
|
||||
: ['SUPER_ADMIN']
|
||||
if (checked && !roles.includes('AWARD_MASTER')) {
|
||||
roles.push('AWARD_MASTER')
|
||||
} else if (!checked) {
|
||||
const idx = roles.indexOf('AWARD_MASTER')
|
||||
if (idx >= 0) roles.splice(idx, 1)
|
||||
}
|
||||
onOverridePolicyChange({ ...overridePolicy, allowedRoles: roles })
|
||||
}}
|
||||
/>
|
||||
<Label className="text-sm">Award Masters</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
src/components/admin/pipeline/sections/review-section.tsx
Normal file
173
src/components/admin/pipeline/sections/review-section.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { CheckCircle2, AlertCircle, AlertTriangle, Layers, GitBranch, ArrowRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { validateAll } from '@/lib/pipeline-validation'
|
||||
import type { WizardState, ValidationResult } from '@/types/pipeline-wizard'
|
||||
|
||||
type ReviewSectionProps = {
|
||||
state: WizardState
|
||||
}
|
||||
|
||||
function ValidationStatusIcon({ result }: { result: ValidationResult }) {
|
||||
if (result.valid && result.warnings.length === 0) {
|
||||
return <CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
}
|
||||
if (result.valid && result.warnings.length > 0) {
|
||||
return <AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
}
|
||||
return <AlertCircle className="h-4 w-4 text-destructive" />
|
||||
}
|
||||
|
||||
function ValidationSection({
|
||||
label,
|
||||
result,
|
||||
}: {
|
||||
label: string
|
||||
result: ValidationResult
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<ValidationStatusIcon result={result} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{result.errors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-destructive mt-0.5">
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
{result.warnings.map((warn, i) => (
|
||||
<p key={i} className="text-xs text-amber-600 mt-0.5">
|
||||
{warn}
|
||||
</p>
|
||||
))}
|
||||
{result.valid && result.errors.length === 0 && result.warnings.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">Looks good</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReviewSection({ state }: ReviewSectionProps) {
|
||||
const validation = validateAll(state)
|
||||
|
||||
const totalTracks = state.tracks.length
|
||||
const mainTracks = state.tracks.filter((t) => t.kind === 'MAIN').length
|
||||
const awardTracks = state.tracks.filter((t) => t.kind === 'AWARD').length
|
||||
const totalStages = state.tracks.reduce((sum, t) => sum + t.stages.length, 0)
|
||||
const totalTransitions = state.tracks.reduce(
|
||||
(sum, t) => sum + Math.max(0, t.stages.length - 1),
|
||||
0
|
||||
)
|
||||
const enabledNotifications = Object.values(state.notificationConfig).filter(Boolean).length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overall Status */}
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
validation.valid
|
||||
? 'border-emerald-200 bg-emerald-50'
|
||||
: 'border-destructive/30 bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{validation.valid ? (
|
||||
<>
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
<p className="font-medium text-emerald-800">
|
||||
Pipeline is ready to be saved
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<p className="font-medium text-destructive">
|
||||
Pipeline has validation errors that must be fixed
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Checks */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Validation Checks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="divide-y">
|
||||
<ValidationSection label="Basics" result={validation.sections.basics} />
|
||||
<ValidationSection label="Tracks & Stages" result={validation.sections.tracks} />
|
||||
<ValidationSection label="Notifications" result={validation.sections.notifications} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Structure Summary */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Structure Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{totalTracks}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<Layers className="h-3 w-3" />
|
||||
Tracks
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{totalStages}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
Stages
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{totalTransitions}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
Transitions
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-2xl font-bold">{enabledNotifications}</p>
|
||||
<p className="text-xs text-muted-foreground">Notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Track breakdown */}
|
||||
<div className="mt-4 space-y-2">
|
||||
{state.tracks.map((track, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'text-[10px]',
|
||||
track.kind === 'MAIN'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: track.kind === 'AWARD'
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
)}
|
||||
>
|
||||
{track.kind}
|
||||
</Badge>
|
||||
<span>{track.name || '(unnamed)'}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{track.stages.length} stages
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
136
src/components/admin/pipeline/stage-panels/evaluation-panel.tsx
Normal file
136
src/components/admin/pipeline/stage-panels/evaluation-panel.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Users, ClipboardList, BarChart3 } from 'lucide-react'
|
||||
import type { EvaluationConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type EvaluationPanelProps = {
|
||||
stageId: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export function EvaluationPanel({ stageId, configJson }: EvaluationPanelProps) {
|
||||
const config = configJson as unknown as EvaluationConfig | null
|
||||
|
||||
const { data: coverage, isLoading: coverageLoading } =
|
||||
trpc.stageAssignment.getCoverageReport.useQuery({ stageId })
|
||||
|
||||
const { data: projectStates, isLoading: statesLoading } =
|
||||
trpc.stage.getProjectStates.useQuery({ stageId, limit: 50 })
|
||||
|
||||
const totalProjects = projectStates?.items.length ?? 0
|
||||
const requiredReviews = config?.requiredReviews ?? 3
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Config Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Required Reviews</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{requiredReviews}</p>
|
||||
<p className="text-xs text-muted-foreground">per project</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Juror Load</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold mt-1">
|
||||
{config?.minLoadPerJuror ?? 5}–{config?.maxLoadPerJuror ?? 20}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">projects per juror</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Projects</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground">in stage</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Coverage Report */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Assignment Coverage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{coverageLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : coverage ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Coverage</span>
|
||||
<span className="font-medium">
|
||||
{coverage.fullyCoveredProjects}/{coverage.totalProjectsInStage} projects
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
coverage.totalProjectsInStage > 0
|
||||
? (coverage.fullyCoveredProjects / coverage.totalProjectsInStage) * 100
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<p className="font-bold text-emerald-600">{coverage.fullyCoveredProjects}</p>
|
||||
<p className="text-xs text-muted-foreground">Fully Covered</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-amber-600">{coverage.partiallyCoveredProjects}</p>
|
||||
<p className="text-xs text-muted-foreground">Partial</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-destructive">{coverage.uncoveredProjects}</p>
|
||||
<p className="text-xs text-muted-foreground">Unassigned</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-3 text-center">
|
||||
No coverage data available
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Overflow Policy */}
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Overflow Policy</span>
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{config?.overflowPolicy ?? 'queue'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-sm font-medium">Availability Weighting</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config?.availabilityWeighting ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
179
src/components/admin/pipeline/stage-panels/filter-panel.tsx
Normal file
179
src/components/admin/pipeline/stage-panels/filter-panel.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, 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 { toast } from 'sonner'
|
||||
import { Filter, Play, Loader2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'
|
||||
import type { FilterConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type FilterPanelProps = {
|
||||
stageId: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export function FilterPanel({ stageId, configJson }: FilterPanelProps) {
|
||||
const config = configJson as unknown as FilterConfig | null
|
||||
|
||||
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
|
||||
stageId,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
const runFiltering = trpc.stageFiltering.runStageFiltering.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(
|
||||
`Filtering complete: ${data.passedCount} passed, ${data.rejectedCount} filtered`
|
||||
)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const totalProjects = projectStates?.items.length ?? 0
|
||||
const passed = projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
|
||||
const rejected = projectStates?.items.filter((p) => p.state === 'REJECTED').length ?? 0
|
||||
const pending = projectStates?.items.filter(
|
||||
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
|
||||
).length ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center">
|
||||
<p className="text-2xl font-bold">{totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center">
|
||||
<p className="text-2xl font-bold text-emerald-600">{passed}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" /> Passed
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center">
|
||||
<p className="text-2xl font-bold text-destructive">{rejected}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<XCircle className="h-3 w-3" /> Filtered
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4 text-center">
|
||||
<p className="text-2xl font-bold text-amber-600">{pending}</p>
|
||||
<p className="text-xs text-muted-foreground flex items-center justify-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" /> Pending
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Rules Summary */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtering Rules
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={runFiltering.isPending || pending === 0}
|
||||
onClick={() => runFiltering.mutate({ stageId })}
|
||||
>
|
||||
{runFiltering.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Run Filtering
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{config?.rules && config.rules.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{config.rules.map((rule, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 text-sm py-1.5 border-b last:border-0"
|
||||
>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{rule.field}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{rule.operator}</span>
|
||||
<span className="font-mono text-xs">{String(rule.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-3">
|
||||
No deterministic rules configured.
|
||||
{config?.aiRubricEnabled ? ' AI screening is enabled.' : ''}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{config?.aiRubricEnabled && (
|
||||
<div className="mt-3 pt-3 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
AI Screening: Enabled (High: {Math.round((config.aiConfidenceThresholds?.high ?? 0.85) * 100)}%,
|
||||
Medium: {Math.round((config.aiConfidenceThresholds?.medium ?? 0.6) * 100)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Projects List */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Projects in Stage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !projectStates?.items.length ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No projects in this stage
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{projectStates.items.map((ps) => (
|
||||
<div
|
||||
key={ps.id}
|
||||
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
|
||||
>
|
||||
<span className="truncate">{ps.project.title}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${
|
||||
ps.state === 'PASSED'
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: ps.state === 'REJECTED'
|
||||
? 'border-destructive text-destructive'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{ps.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
src/components/admin/pipeline/stage-panels/intake-panel.tsx
Normal file
130
src/components/admin/pipeline/stage-panels/intake-panel.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { FileText, Upload, Clock, AlertTriangle } from 'lucide-react'
|
||||
import type { IntakeConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type IntakePanelProps = {
|
||||
stageId: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export function IntakePanel({ stageId, configJson }: IntakePanelProps) {
|
||||
const config = configJson as unknown as IntakeConfig | null
|
||||
|
||||
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
|
||||
stageId,
|
||||
limit: 10,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Config Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Submission Window</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{config?.submissionWindowEnabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm font-medium">Late Policy</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 capitalize">
|
||||
{config?.lateSubmissionPolicy ?? 'Not set'}
|
||||
{config?.lateSubmissionPolicy === 'flag' && ` (${config.lateGraceHours}h grace)`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">File Requirements</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{config?.fileRequirements?.length ?? 0} requirements
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* File Requirements List */}
|
||||
{config?.fileRequirements && config.fileRequirements.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">File Requirements</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{config.fileRequirements.map((req, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>{req.name}</span>
|
||||
{req.isRequired && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{req.maxSizeMB ? `${req.maxSizeMB} MB max` : 'No limit'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Recent Projects */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Recent Submissions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !projectStates?.items.length ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No projects in this stage yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{projectStates.items.map((ps) => (
|
||||
<div
|
||||
key={ps.id}
|
||||
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
|
||||
>
|
||||
<span className="truncate">{ps.project.title}</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0">
|
||||
{ps.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
src/components/admin/pipeline/stage-panels/live-final-panel.tsx
Normal file
173
src/components/admin/pipeline/stage-panels/live-final-panel.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { toast } from 'sonner'
|
||||
import { Play, Users, Vote, Radio, Loader2, Layers } from 'lucide-react'
|
||||
import type { LiveFinalConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type LiveFinalPanelProps = {
|
||||
stageId: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export function LiveFinalPanel({ stageId, configJson }: LiveFinalPanelProps) {
|
||||
const config = configJson as unknown as LiveFinalConfig | null
|
||||
|
||||
const { data: projectStates } =
|
||||
trpc.stage.getProjectStates.useQuery({ stageId, limit: 50 })
|
||||
|
||||
const { data: cohorts, isLoading: cohortsLoading } = trpc.cohort.list.useQuery({
|
||||
stageId,
|
||||
})
|
||||
|
||||
const startSession = trpc.live.start.useMutation({
|
||||
onSuccess: () => toast.success('Live session started'),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const totalProjects = projectStates?.items.length ?? 0
|
||||
const totalCohorts = cohorts?.length ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Config Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Vote className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Jury Voting</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{config?.juryVotingEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Audience Voting</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{config?.audienceVotingEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
{config?.audienceVotingEnabled && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Weight: {Math.round((config.audienceVoteWeight ?? 0.2) * 100)}%
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-4 w-4 text-emerald-500" />
|
||||
<span className="text-sm font-medium">Reveal Policy</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium mt-1 capitalize">
|
||||
{config?.revealPolicy ?? 'ceremony'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Cohorts */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
Cohorts
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{totalCohorts} cohort{totalCohorts !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{cohortsLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : !cohorts?.length ? (
|
||||
<p className="text-sm text-muted-foreground py-3 text-center">
|
||||
No cohorts configured.{' '}
|
||||
{config?.cohortSetupMode === 'auto'
|
||||
? 'Cohorts will be auto-generated when the session starts.'
|
||||
: 'Create cohorts manually to organize presentations.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{cohorts.map((cohort) => (
|
||||
<div
|
||||
key={cohort.id}
|
||||
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
|
||||
>
|
||||
<span>{cohort.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{cohort._count?.projects ?? 0} projects
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] ${
|
||||
cohort.isOpen
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{cohort.isOpen ? 'OPEN' : 'CLOSED'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Live Session Controls */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Live Session</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
{totalProjects} project{totalProjects !== 1 ? 's' : ''} ready for
|
||||
presentation
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Cohort mode: {config?.cohortSetupMode ?? 'auto'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={startSession.isPending || totalProjects === 0}
|
||||
onClick={() =>
|
||||
startSession.mutate({
|
||||
stageId,
|
||||
projectOrder: projectStates?.items.map((p) => p.project.id) ?? [],
|
||||
})
|
||||
}
|
||||
>
|
||||
{startSession.isPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5 mr-1" />
|
||||
)}
|
||||
Start Session
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
src/components/admin/pipeline/stage-panels/results-panel.tsx
Normal file
120
src/components/admin/pipeline/stage-panels/results-panel.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Trophy, Medal, FileText } from 'lucide-react'
|
||||
import type { ResultsConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type ResultsPanelProps = {
|
||||
stageId: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export function ResultsPanel({ stageId, configJson }: ResultsPanelProps) {
|
||||
const config = configJson as unknown as ResultsConfig | null
|
||||
|
||||
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
|
||||
stageId,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const totalProjects = projectStates?.items.length ?? 0
|
||||
const winners =
|
||||
projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Config Summary */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm font-medium">Winners</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{winners}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
of {totalProjects} finalists
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Publication</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="mt-1 text-xs capitalize">
|
||||
{config?.publicationMode ?? 'manual'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Medal className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Rankings</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{config?.showRankings ? 'Visible' : 'Hidden'}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Final Rankings */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Final Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !projectStates?.items.length ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No results available yet
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-80 overflow-y-auto">
|
||||
{projectStates.items.map((ps, index) => (
|
||||
<div
|
||||
key={ps.id}
|
||||
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{ps.state === 'PASSED' && index < 3 ? (
|
||||
<span className="text-lg">
|
||||
{index === 0 ? '🥇' : index === 1 ? '🥈' : '🥉'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground font-mono w-6">
|
||||
#{index + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{ps.project.title}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${
|
||||
ps.state === 'PASSED'
|
||||
? 'border-amber-500 text-amber-600'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{ps.state === 'PASSED' ? 'Winner' : ps.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
166
src/components/admin/pipeline/stage-panels/selection-panel.tsx
Normal file
166
src/components/admin/pipeline/stage-panels/selection-panel.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Trophy, Users, ArrowUpDown } from 'lucide-react'
|
||||
import type { SelectionConfig } from '@/types/pipeline-wizard'
|
||||
|
||||
type SelectionPanelProps = {
|
||||
stageId: string
|
||||
configJson: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export function SelectionPanel({ stageId, configJson }: SelectionPanelProps) {
|
||||
const config = configJson as unknown as SelectionConfig | null
|
||||
|
||||
const { data: projectStates, isLoading } = trpc.stage.getProjectStates.useQuery({
|
||||
stageId,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const totalProjects = projectStates?.items.length ?? 0
|
||||
const passed = projectStates?.items.filter((p) => p.state === 'PASSED').length ?? 0
|
||||
const rejected = projectStates?.items.filter((p) => p.state === 'REJECTED').length ?? 0
|
||||
const pending =
|
||||
projectStates?.items.filter(
|
||||
(p) => p.state === 'PENDING' || p.state === 'IN_PROGRESS'
|
||||
).length ?? 0
|
||||
|
||||
const finalistTarget = config?.finalistCount ?? 6
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-4 w-4 text-amber-500" />
|
||||
<span className="text-sm font-medium">Finalist Target</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{finalistTarget}</p>
|
||||
<p className="text-xs text-muted-foreground">to be selected</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">Candidates</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground">in selection pool</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowUpDown className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Ranking Mode</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium mt-1 capitalize">
|
||||
{config?.rankingMethod ?? 'score_average'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config?.tieBreaker ?? 'admin_decides'} tiebreak
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Selection Progress */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Selection Progress</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Selected</span>
|
||||
<span className="font-medium">
|
||||
{passed}/{finalistTarget} finalists
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={finalistTarget > 0 ? (passed / finalistTarget) * 100 : 0}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-sm">
|
||||
<div>
|
||||
<p className="font-bold text-emerald-600">{passed}</p>
|
||||
<p className="text-xs text-muted-foreground">Selected</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-destructive">{rejected}</p>
|
||||
<p className="text-xs text-muted-foreground">Eliminated</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-amber-600">{pending}</p>
|
||||
<p className="text-xs text-muted-foreground">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Project Rankings */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Project Rankings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !projectStates?.items.length ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No projects in selection stage
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{projectStates.items.map((ps, index) => (
|
||||
<div
|
||||
key={ps.id}
|
||||
className="flex items-center justify-between text-sm py-1.5 border-b last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-mono w-6">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className="truncate">{ps.project.title}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] shrink-0 ${
|
||||
ps.state === 'PASSED'
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: ps.state === 'REJECTED'
|
||||
? 'border-destructive text-destructive'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{ps.state}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
src/components/admin/pipeline/wizard-section.tsx
Normal file
82
src/components/admin/pipeline/wizard-section.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react'
|
||||
|
||||
type WizardSectionProps = {
|
||||
title: string
|
||||
description?: string
|
||||
stepNumber: number
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
isValid: boolean
|
||||
hasErrors?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function WizardSection({
|
||||
title,
|
||||
description,
|
||||
stepNumber,
|
||||
isOpen,
|
||||
onToggle,
|
||||
isValid,
|
||||
hasErrors,
|
||||
children,
|
||||
}: WizardSectionProps) {
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={onToggle}>
|
||||
<Card className={cn(isOpen && 'ring-1 ring-ring')}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardHeader className="cursor-pointer select-none hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={isValid ? 'default' : 'outline'}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full p-0 flex items-center justify-center text-xs font-bold',
|
||||
isValid
|
||||
? 'bg-emerald-500 text-white hover:bg-emerald-500'
|
||||
: hasErrors
|
||||
? 'border-destructive text-destructive'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{isValid ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : hasErrors ? (
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
) : (
|
||||
stepNumber
|
||||
)}
|
||||
</Badge>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{description && !isOpen && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
isOpen && 'rotate-180'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0">{children}</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Card>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Minus, Loader2, AlertTriangle } from "lucide-react";
|
||||
|
||||
interface RemoveProjectsDialogProps {
|
||||
roundId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function RemoveProjectsDialog({
|
||||
roundId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: RemoveProjectsDialogProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedIds(new Set());
|
||||
setConfirmOpen(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const { data, isLoading } = trpc.project.list.useQuery(
|
||||
{ roundId, page: 1, perPage: 200 },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const removeMutation = trpc.round.removeProjects.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(
|
||||
`${result.removed} project${result.removed !== 1 ? "s" : ""} removed from round`,
|
||||
);
|
||||
utils.round.get.invalidate({ id: roundId });
|
||||
utils.project.list.invalidate();
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const projects = data?.projects ?? [];
|
||||
|
||||
const toggleProject = useCallback((id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (selectedIds.size === projects.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(projects.map((p) => p.id)));
|
||||
}
|
||||
}, [selectedIds.size, projects]);
|
||||
|
||||
const handleRemove = () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
removeMutation.mutate({
|
||||
roundId,
|
||||
projectIds: Array.from(selectedIds),
|
||||
});
|
||||
setConfirmOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Minus className="h-5 w-5" />
|
||||
Remove Projects from Round
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select projects to remove from this round. The projects will
|
||||
remain in the program and can be re-assigned later.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-lg bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/50 dark:text-amber-200">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
|
||||
<span>
|
||||
Removing projects from a round will also delete their jury
|
||||
assignments and evaluations in this round.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="font-medium">No projects in this round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
There are no projects to remove.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.size === projects.length &&
|
||||
projects.length > 0
|
||||
}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size} of {projects.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border max-h-[350px] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12" />
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead>Team</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((project) => (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className={
|
||||
selectedIds.has(project.id)
|
||||
? "bg-muted/50"
|
||||
: "cursor-pointer"
|
||||
}
|
||||
onClick={() => toggleProject(project.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => toggleProject(project.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{project.title}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{project.teamName || "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{(project.status ?? "SUBMITTED").replace(
|
||||
"_",
|
||||
" ",
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
disabled={selectedIds.size === 0 || removeMutation.isPending}
|
||||
>
|
||||
{removeMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Minus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Remove Selected ({selectedIds.size})
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Removal</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove {selectedIds.size} project
|
||||
{selectedIds.size !== 1 ? "s" : ""} from this round? Their
|
||||
assignments and evaluations in this round will be deleted. The
|
||||
projects will remain in the program.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRemove}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove Projects
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Filter,
|
||||
ClipboardCheck,
|
||||
Zap,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Archive,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
roundType: string
|
||||
_count?: {
|
||||
projects: number
|
||||
assignments: number
|
||||
}
|
||||
}
|
||||
|
||||
interface RoundPipelineProps {
|
||||
rounds: PipelineRound[]
|
||||
programName?: string
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, typeof Filter> = {
|
||||
FILTERING: Filter,
|
||||
EVALUATION: ClipboardCheck,
|
||||
LIVE_EVENT: Zap,
|
||||
}
|
||||
|
||||
const typeColors: Record<string, { bg: string; text: string; border: string }> = {
|
||||
FILTERING: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-950/30',
|
||||
text: 'text-amber-700 dark:text-amber-300',
|
||||
border: 'border-amber-200 dark:border-amber-800',
|
||||
},
|
||||
EVALUATION: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-950/30',
|
||||
text: 'text-blue-700 dark:text-blue-300',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
},
|
||||
LIVE_EVENT: {
|
||||
bg: 'bg-violet-50 dark:bg-violet-950/30',
|
||||
text: 'text-violet-700 dark:text-violet-300',
|
||||
border: 'border-violet-200 dark:border-violet-800',
|
||||
},
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { color: string; icon: typeof CheckCircle2; label: string }> = {
|
||||
DRAFT: { color: 'text-muted-foreground', icon: Clock, label: 'Draft' },
|
||||
ACTIVE: { color: 'text-green-600', icon: CheckCircle2, label: 'Active' },
|
||||
CLOSED: { color: 'text-amber-600', icon: Archive, label: 'Closed' },
|
||||
ARCHIVED: { color: 'text-muted-foreground', icon: Archive, label: 'Archived' },
|
||||
}
|
||||
|
||||
export function RoundPipeline({ rounds }: RoundPipelineProps) {
|
||||
if (rounds.length === 0) return null
|
||||
|
||||
// Detect bottlenecks: rounds with many more incoming projects than outgoing
|
||||
const projectCounts = rounds.map((r) => r._count?.projects || 0)
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto pb-2">
|
||||
<div className="flex items-stretch gap-1 min-w-max px-1 py-2">
|
||||
{rounds.map((round, index) => {
|
||||
const TypeIcon = typeIcons[round.roundType] || ClipboardCheck
|
||||
const colors = typeColors[round.roundType] || typeColors.EVALUATION
|
||||
const status = statusConfig[round.status] || statusConfig.DRAFT
|
||||
const StatusIcon = status.icon
|
||||
const projectCount = round._count?.projects || 0
|
||||
const prevCount = index > 0 ? projectCounts[index - 1] : 0
|
||||
const dropRate = prevCount > 0 ? Math.round(((prevCount - projectCount) / prevCount) * 100) : 0
|
||||
const isBottleneck = dropRate > 50 && index > 0
|
||||
|
||||
return (
|
||||
<div key={round.id} className="flex items-center">
|
||||
{/* Round Card */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={`/admin/rounds/${round.id}`}
|
||||
className={cn(
|
||||
'group relative flex flex-col items-center gap-2 rounded-xl border-2 px-5 py-4 transition-all duration-200 hover:shadow-lg hover:-translate-y-1 min-w-[140px]',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
round.status === 'ACTIVE' && 'ring-2 ring-green-500/30'
|
||||
)}
|
||||
>
|
||||
{/* Status indicator dot */}
|
||||
<div className="absolute -top-1.5 -right-1.5">
|
||||
<div className={cn(
|
||||
'h-3.5 w-3.5 rounded-full border-2 border-background',
|
||||
round.status === 'ACTIVE' ? 'bg-green-500' :
|
||||
round.status === 'CLOSED' ? 'bg-amber-500' :
|
||||
round.status === 'DRAFT' ? 'bg-muted-foreground/40' :
|
||||
'bg-muted-foreground/20'
|
||||
)} />
|
||||
</div>
|
||||
|
||||
{/* Type Icon */}
|
||||
<div className={cn(
|
||||
'rounded-lg p-2',
|
||||
round.status === 'ACTIVE' ? 'bg-green-100 dark:bg-green-900/30' : 'bg-background'
|
||||
)}>
|
||||
<TypeIcon className={cn('h-5 w-5', colors.text)} />
|
||||
</div>
|
||||
|
||||
{/* Round Name */}
|
||||
<p className="text-sm font-medium text-center line-clamp-2 leading-tight max-w-[120px]">
|
||||
{round.name}
|
||||
</p>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
{projectCount}
|
||||
</span>
|
||||
{round._count?.assignments !== undefined && round._count.assignments > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{round._count.assignments}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] px-1.5 py-0', status.color)}
|
||||
>
|
||||
<StatusIcon className="mr-1 h-2.5 w-2.5" />
|
||||
{status.label}
|
||||
</Badge>
|
||||
|
||||
{/* Bottleneck indicator */}
|
||||
{isBottleneck && (
|
||||
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{round.name}</p>
|
||||
<p className="text-xs capitalize">
|
||||
{round.roundType.toLowerCase().replace('_', ' ')} · {round.status.toLowerCase()}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{projectCount} projects
|
||||
{round._count?.assignments ? `, ${round._count.assignments} assignments` : ''}
|
||||
</p>
|
||||
{isBottleneck && (
|
||||
<p className="text-xs text-amber-600">
|
||||
{dropRate}% drop from previous round
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Arrow connector */}
|
||||
{index < rounds.length - 1 && (
|
||||
<div className="flex flex-col items-center px-2">
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground/40" />
|
||||
{prevCount > 0 && index > 0 && dropRate > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60 -mt-0.5">
|
||||
-{dropRate}%
|
||||
</span>
|
||||
)}
|
||||
{index === 0 && projectCounts[0] > 0 && projectCounts[1] !== undefined && (
|
||||
<span className="text-[10px] text-muted-foreground/60 -mt-0.5">
|
||||
{projectCounts[0]} → {projectCounts[1] || '?'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user