Fix round assignment pool, create-page parity, and file settings UX
Build and Push Docker Image / build (push) Successful in 14m5s Details

This commit is contained in:
root 2026-02-12 17:25:30 +01:00
parent 52cdca1b85
commit 8a328357e3
7 changed files with 2233 additions and 878 deletions

View File

@ -1,23 +1,23 @@
'use client' "use client";
import { Suspense, use, useState, useEffect } from 'react' import { Suspense, use, useState, useEffect } from "react";
import Link from 'next/link' import Link from "next/link";
import { useRouter } from 'next/navigation' import { useRouter } from "next/navigation";
import { useForm } from 'react-hook-form' import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod' import { z } from "zod";
import { trpc } from '@/lib/trpc/client' import { trpc } from "@/lib/trpc/client";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from "@/components/ui/card";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { import {
Form, Form,
FormControl, FormControl,
@ -26,15 +26,26 @@ import {
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from '@/components/ui/form' } from "@/components/ui/form";
import { import {
EvaluationFormBuilder, EvaluationFormBuilder,
type Criterion, type Criterion,
} from '@/components/forms/evaluation-form-builder' } from "@/components/forms/evaluation-form-builder";
import { RoundTypeSettings } from '@/components/forms/round-type-settings' import { RoundTypeSettings } from "@/components/forms/round-type-settings";
import { ROUND_FIELD_VISIBILITY } from '@/types/round-settings' import { ROUND_FIELD_VISIBILITY } from "@/types/round-settings";
import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor' import { FileRequirementsEditor } from "@/components/admin/file-requirements-editor";
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react' import {
ArrowLeft,
Loader2,
AlertCircle,
AlertTriangle,
Bell,
GitCompare,
MessageSquare,
FileText,
Calendar,
LayoutTemplate,
} from "lucide-react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -43,37 +54,86 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog' } from "@/components/ui/dialog";
import { toast } from 'sonner' import { toast } from "sonner";
import { Switch } from '@/components/ui/switch' import { Switch } from "@/components/ui/switch";
import { Slider } from '@/components/ui/slider' import { Slider } from "@/components/ui/slider";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { DateTimePicker } from '@/components/ui/datetime-picker' import { DateTimePicker } from "@/components/ui/datetime-picker";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from "@/components/ui/select";
// Available notification types for teams entering a round // Available notification types for teams entering a round
const TEAM_NOTIFICATION_OPTIONS = [ const TEAM_NOTIFICATION_OPTIONS = [
{ value: '', label: 'No automatic notification', description: 'Teams will not receive a notification when entering this round' }, {
{ value: 'ADVANCED_SEMIFINAL', label: 'Advanced to Semi-Finals', description: 'Congratulates team for advancing to semi-finals' }, value: "",
{ value: 'ADVANCED_FINAL', label: 'Selected as Finalist', description: 'Congratulates team for being selected as finalist' }, label: "No automatic notification",
{ value: 'NOT_SELECTED', label: 'Not Selected', description: 'Informs team they were not selected to continue' }, description:
{ value: 'WINNER_ANNOUNCEMENT', label: 'Winner Announcement', description: 'Announces the team as a winner' }, "Teams will not receive a notification when entering this round",
{ value: 'SUBMISSION_RECEIVED', label: 'Submission Received', description: 'Confirms to the team that their submission has been received' }, },
] {
value: "ADVANCED_SEMIFINAL",
label: "Advanced to Semi-Finals",
description: "Congratulates team for advancing to semi-finals",
},
{
value: "ADVANCED_FINAL",
label: "Selected as Finalist",
description: "Congratulates team for being selected as finalist",
},
{
value: "NOT_SELECTED",
label: "Not Selected",
description: "Informs team they were not selected to continue",
},
{
value: "WINNER_ANNOUNCEMENT",
label: "Winner Announcement",
description: "Announces the team as a winner",
},
{
value: "SUBMISSION_RECEIVED",
label: "Submission Received",
description: "Confirms to the team that their submission has been received",
},
];
const FILE_TYPE_PRESETS = [
{ value: "any", label: "Any file type", settingValue: "" },
{ value: "pdf", label: "PDF only", settingValue: "application/pdf" },
{ value: "images", label: "Images only", settingValue: "image/*" },
{ value: "videos", label: "Videos only", settingValue: "video/*" },
{
value: "docs",
label: "Documents (PDF, Word, Excel, PowerPoint)",
settingValue:
"application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation",
},
{
value: "media",
label: "Media (images + videos)",
settingValue: "image/*,video/*",
},
{
value: "all_standard",
label: "Documents + Media",
settingValue:
"application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.openxmlformats-officedocument.presentationml.presentation,image/*,video/*",
},
];
interface PageProps { interface PageProps {
params: Promise<{ id: string }> params: Promise<{ id: string }>;
} }
const updateRoundSchema = z const updateRoundSchema = z
.object({ .object({
name: z.string().min(1, 'Name is required').max(255), name: z.string().min(1, "Name is required").max(255),
requiredReviews: z.number().int().min(0).max(10), requiredReviews: z.number().int().min(0).max(10),
minAssignmentsPerJuror: z.number().int().min(1).max(50), minAssignmentsPerJuror: z.number().int().min(1).max(50),
maxAssignmentsPerJuror: z.number().int().min(1).max(100), maxAssignmentsPerJuror: z.number().int().min(1).max(100),
@ -83,94 +143,98 @@ const updateRoundSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.votingStartAt && data.votingEndAt) { if (data.votingStartAt && data.votingEndAt) {
return data.votingEndAt > data.votingStartAt return data.votingEndAt > data.votingStartAt;
} }
return true return true;
}, },
{ {
message: 'End date must be after start date', message: "End date must be after start date",
path: ['votingEndAt'], path: ["votingEndAt"],
} },
) )
.refine( .refine(
(data) => data.minAssignmentsPerJuror <= data.maxAssignmentsPerJuror, (data) => data.minAssignmentsPerJuror <= data.maxAssignmentsPerJuror,
{ {
message: 'Min must be less than or equal to max', message: "Min must be less than or equal to max",
path: ['minAssignmentsPerJuror'], path: ["minAssignmentsPerJuror"],
} },
) );
type UpdateRoundForm = z.infer<typeof updateRoundSchema> type UpdateRoundForm = z.infer<typeof updateRoundSchema>;
function EditRoundContent({ roundId }: { roundId: string }) { function EditRoundContent({ roundId }: { roundId: string }) {
const router = useRouter() const router = useRouter();
const [criteria, setCriteria] = useState<Criterion[]>([]) const [criteria, setCriteria] = useState<Criterion[]>([]);
const [criteriaInitialized, setCriteriaInitialized] = useState(false) const [criteriaInitialized, setCriteriaInitialized] = useState(false);
const [formInitialized, setFormInitialized] = useState(false) const [formInitialized, setFormInitialized] = useState(false);
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION') const [roundType, setRoundType] = useState<
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({}) "FILTERING" | "EVALUATION" | "LIVE_EVENT"
>("EVALUATION");
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>(
{},
);
// entryNotificationType removed from schema // entryNotificationType removed from schema
// Fetch round data - disable refetch on focus to prevent overwriting user's edits // Fetch round data - disable refetch on focus to prevent overwriting user's edits
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery( const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery(
{ id: roundId }, { id: roundId },
{ refetchOnWindowFocus: false } { refetchOnWindowFocus: false },
) );
// Fetch evaluation form // Fetch evaluation form
const { data: evaluationForm, isLoading: loadingForm } = const { data: evaluationForm, isLoading: loadingForm } =
trpc.round.getEvaluationForm.useQuery({ roundId }) trpc.round.getEvaluationForm.useQuery({ roundId });
// Check if evaluations exist // Check if evaluations exist
const { data: hasEvaluations } = trpc.round.hasEvaluations.useQuery({ const { data: hasEvaluations } = trpc.round.hasEvaluations.useQuery({
roundId, roundId,
}) });
const [saveTemplateOpen, setSaveTemplateOpen] = useState(false) const [saveTemplateOpen, setSaveTemplateOpen] = useState(false);
const [templateName, setTemplateName] = useState('') const [templateName, setTemplateName] = useState("");
const utils = trpc.useUtils() const utils = trpc.useUtils();
// Mutations // Mutations
const saveAsTemplate = trpc.roundTemplate.create.useMutation({ const saveAsTemplate = trpc.roundTemplate.create.useMutation({
onSuccess: () => { onSuccess: () => {
utils.roundTemplate.list.invalidate() utils.roundTemplate.list.invalidate();
toast.success('Round saved as template') toast.success("Round saved as template");
setSaveTemplateOpen(false) setSaveTemplateOpen(false);
setTemplateName('') setTemplateName("");
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message) toast.error(error.message);
}, },
}) });
const updateRound = trpc.round.update.useMutation({ const updateRound = trpc.round.update.useMutation({
onSuccess: () => { onSuccess: () => {
utils.round.get.invalidate({ id: roundId }) utils.round.get.invalidate({ id: roundId });
utils.round.list.invalidate() utils.round.list.invalidate();
utils.program.list.invalidate({ includeRounds: true }) utils.program.list.invalidate({ includeRounds: true });
router.push(`/admin/rounds/${roundId}`) router.push(`/admin/rounds/${roundId}`);
}, },
}) });
const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation({ const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation({
onSuccess: () => { onSuccess: () => {
utils.round.get.invalidate({ id: roundId }) utils.round.get.invalidate({ id: roundId });
}, },
}) });
// Initialize form with existing data // Initialize form with existing data
const form = useForm<UpdateRoundForm>({ const form = useForm<UpdateRoundForm>({
resolver: zodResolver(updateRoundSchema), resolver: zodResolver(updateRoundSchema),
defaultValues: { defaultValues: {
name: '', name: "",
requiredReviews: 3, requiredReviews: 3,
minAssignmentsPerJuror: 5, minAssignmentsPerJuror: 5,
maxAssignmentsPerJuror: 20, maxAssignmentsPerJuror: 20,
votingStartAt: null, votingStartAt: null,
votingEndAt: null, votingEndAt: null,
}, },
}) });
// Update form when round data loads - only initialize once // Update form when round data loads - only initialize once
useEffect(() => { useEffect(() => {
@ -180,57 +244,66 @@ function EditRoundContent({ roundId }: { roundId: string }) {
requiredReviews: round.requiredReviews, requiredReviews: round.requiredReviews,
minAssignmentsPerJuror: round.minAssignmentsPerJuror, minAssignmentsPerJuror: round.minAssignmentsPerJuror,
maxAssignmentsPerJuror: round.maxAssignmentsPerJuror, maxAssignmentsPerJuror: round.maxAssignmentsPerJuror,
votingStartAt: round.votingStartAt ? new Date(round.votingStartAt) : null, votingStartAt: round.votingStartAt
? new Date(round.votingStartAt)
: null,
votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null, votingEndAt: round.votingEndAt ? new Date(round.votingEndAt) : null,
}) });
// Set round type, settings, and notification type // Set round type, settings, and notification type
setRoundType((round.roundType as typeof roundType) || 'EVALUATION') setRoundType((round.roundType as typeof roundType) || "EVALUATION");
setRoundSettings((round.settingsJson as Record<string, unknown>) || {}) setRoundSettings((round.settingsJson as Record<string, unknown>) || {});
setFormInitialized(true) setFormInitialized(true);
} }
}, [round, form, formInitialized]) }, [round, form, formInitialized]);
// Initialize criteria from evaluation form // Initialize criteria from evaluation form
useEffect(() => { useEffect(() => {
if (evaluationForm && !criteriaInitialized) { if (evaluationForm && !criteriaInitialized) {
const existingCriteria = evaluationForm.criteriaJson as unknown as Criterion[] const existingCriteria =
evaluationForm.criteriaJson as unknown as Criterion[];
if (Array.isArray(existingCriteria)) { if (Array.isArray(existingCriteria)) {
setCriteria(existingCriteria) setCriteria(existingCriteria);
} }
setCriteriaInitialized(true) setCriteriaInitialized(true);
} else if (!loadingForm && !evaluationForm && !criteriaInitialized) { } else if (!loadingForm && !evaluationForm && !criteriaInitialized) {
setCriteriaInitialized(true) setCriteriaInitialized(true);
} }
}, [evaluationForm, loadingForm, criteriaInitialized]) }, [evaluationForm, loadingForm, criteriaInitialized]);
const onSubmit = async (data: UpdateRoundForm) => { const onSubmit = async (data: UpdateRoundForm) => {
const visibility = ROUND_FIELD_VISIBILITY[roundType] const visibility = ROUND_FIELD_VISIBILITY[roundType];
// Update round with type, settings, and notification // Update round with type, settings, and notification
await updateRound.mutateAsync({ await updateRound.mutateAsync({
id: roundId, id: roundId,
name: data.name, name: data.name,
requiredReviews: visibility?.showRequiredReviews ? data.requiredReviews : 0, requiredReviews: visibility?.showRequiredReviews
? data.requiredReviews
: 0,
minAssignmentsPerJuror: data.minAssignmentsPerJuror, minAssignmentsPerJuror: data.minAssignmentsPerJuror,
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror, maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
roundType, roundType,
settingsJson: roundSettings, settingsJson: roundSettings,
votingStartAt: visibility?.showVotingWindow ? (data.votingStartAt ?? null) : null, votingStartAt: visibility?.showVotingWindow
votingEndAt: visibility?.showVotingWindow ? (data.votingEndAt ?? null) : null, ? (data.votingStartAt ?? null)
}) : null,
votingEndAt: visibility?.showVotingWindow
? (data.votingEndAt ?? null)
: null,
});
// Update evaluation form if criteria changed and no evaluations exist // Update evaluation form if criteria changed and no evaluations exist
if (!hasEvaluations && criteria.length > 0) { if (!hasEvaluations && criteria.length > 0) {
await updateEvaluationForm.mutateAsync({ await updateEvaluationForm.mutateAsync({
roundId, roundId,
criteria, criteria,
}) });
}
} }
};
const isLoading = loadingRound || loadingForm const isLoading = loadingRound || loadingForm;
if (isLoading) { if (isLoading) {
return <EditRoundSkeleton /> return <EditRoundSkeleton />;
} }
if (!round) { if (!round) {
@ -253,11 +326,11 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }
const isPending = updateRound.isPending || updateEvaluationForm.isPending const isPending = updateRound.isPending || updateEvaluationForm.isPending;
const isActive = round.status === 'ACTIVE' const isActive = round.status === "ACTIVE";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -273,7 +346,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Edit Round</h1> <h1 className="text-2xl font-semibold tracking-tight">Edit Round</h1>
<Badge variant={isActive ? 'default' : 'secondary'}> <Badge variant={isActive ? "default" : "secondary"}>
{round.status} {round.status}
</Badge> </Badge>
</div> </div>
@ -452,7 +525,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Leave empty to disable the voting window enforcement. Past dates are allowed. Leave empty to disable the voting window enforcement. Past dates
are allowed.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -467,7 +541,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<Select <Select
value={(roundSettings.uploadDeadlinePolicy as string) || ''} value={(roundSettings.uploadDeadlinePolicy as string) || ""}
onValueChange={(value) => onValueChange={(value) =>
setRoundSettings((prev) => ({ setRoundSettings((prev) => ({
...prev, ...prev,
@ -479,9 +553,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<SelectValue placeholder="Default (no restriction)" /> <SelectValue placeholder="Default (no restriction)" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="NONE"> <SelectItem value="NONE">Default - No restriction</SelectItem>
Default - No restriction
</SelectItem>
<SelectItem value="BLOCK"> <SelectItem value="BLOCK">
Block uploads after round starts Block uploads after round starts
</SelectItem> </SelectItem>
@ -491,8 +563,10 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
When set to &ldquo;Block&rdquo;, applicants cannot upload files after the voting start date. When set to &ldquo;Block&rdquo;, applicants cannot upload files
When set to &ldquo;Allow late&rdquo;, uploads are accepted but flagged as late submissions. after the voting start date. When set to &ldquo;Allow
late&rdquo;, uploads are accepted but flagged as late
submissions.
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -516,7 +590,9 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label className="text-sm font-medium">Enable Project Comparison</Label> <Label className="text-sm font-medium">
Enable Project Comparison
</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Allow jury members to compare projects side by side Allow jury members to compare projects side by side
</p> </p>
@ -542,7 +618,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
onChange={(e) => onChange={(e) =>
setRoundSettings((prev) => ({ setRoundSettings((prev) => ({
...prev, ...prev,
comparison_max_projects: parseInt(e.target.value) || 3, comparison_max_projects:
parseInt(e.target.value) || 3,
})) }))
} }
className="max-w-[120px]" className="max-w-[120px]"
@ -578,18 +655,22 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Divergence Threshold</Label> <Label className="text-sm">Divergence Threshold</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Score divergence level that triggers a warning (0.0 - 1.0) Score divergence level that triggers a warning (0.0 -
1.0)
</p> </p>
<Input <Input
type="number" type="number"
min={0} min={0}
max={1} max={1}
step={0.05} step={0.05}
value={Number(roundSettings.divergence_threshold || 0.3)} value={Number(
roundSettings.divergence_threshold || 0.3,
)}
onChange={(e) => onChange={(e) =>
setRoundSettings((prev) => ({ setRoundSettings((prev) => ({
...prev, ...prev,
divergence_threshold: parseFloat(e.target.value) || 0.3, divergence_threshold:
parseFloat(e.target.value) || 0.3,
})) }))
} }
className="max-w-[120px]" className="max-w-[120px]"
@ -598,7 +679,9 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Anonymization Level</Label> <Label className="text-sm">Anonymization Level</Label>
<Select <Select
value={String(roundSettings.anonymization_level || 'partial')} value={String(
roundSettings.anonymization_level || "partial",
)}
onValueChange={(v) => onValueChange={(v) =>
setRoundSettings((prev) => ({ setRoundSettings((prev) => ({
...prev, ...prev,
@ -611,22 +694,31 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">No anonymization</SelectItem> <SelectItem value="none">No anonymization</SelectItem>
<SelectItem value="partial">Partial (Juror 1, 2...)</SelectItem> <SelectItem value="partial">
<SelectItem value="full">Full anonymization</SelectItem> Partial (Juror 1, 2...)
</SelectItem>
<SelectItem value="full">
Full anonymization
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Discussion Window (hours)</Label> <Label className="text-sm">
Discussion Window (hours)
</Label>
<Input <Input
type="number" type="number"
min={1} min={1}
max={720} max={720}
value={Number(roundSettings.discussion_window_hours || 48)} value={Number(
roundSettings.discussion_window_hours || 48,
)}
onChange={(e) => onChange={(e) =>
setRoundSettings((prev) => ({ setRoundSettings((prev) => ({
...prev, ...prev,
discussion_window_hours: parseInt(e.target.value) || 48, discussion_window_hours:
parseInt(e.target.value) || 48,
})) }))
} }
className="max-w-[120px]" className="max-w-[120px]"
@ -642,7 +734,8 @@ function EditRoundContent({ roundId }: { roundId: string }) {
onChange={(e) => onChange={(e) =>
setRoundSettings((prev) => ({ setRoundSettings((prev) => ({
...prev, ...prev,
max_comment_length: parseInt(e.target.value) || 2000, max_comment_length:
parseInt(e.target.value) || 2000,
})) }))
} }
className="max-w-[120px]" className="max-w-[120px]"
@ -668,19 +761,35 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Allowed File Types</Label> <Label className="text-sm">Allowed File Types</Label>
<p className="text-xs text-muted-foreground"> <Select
Comma-separated MIME types or extensions value={
</p> FILE_TYPE_PRESETS.find(
<Input (option) =>
placeholder="application/pdf, video/mp4, image/jpeg" option.settingValue ===
value={String(roundSettings.allowed_file_types || '')} String(roundSettings.allowed_file_types || ""),
onChange={(e) => )?.value || "any"
}
onValueChange={(selectedValue) => {
const selected = FILE_TYPE_PRESETS.find(
(option) => option.value === selectedValue,
);
setRoundSettings((prev) => ({ setRoundSettings((prev) => ({
...prev, ...prev,
allowed_file_types: e.target.value, allowed_file_types: selected?.settingValue || undefined,
})) }));
} }}
/> >
<SelectTrigger className="max-w-[420px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILE_TYPE_PRESETS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Max File Size (MB)</Label> <Label className="text-sm">Max File Size (MB)</Label>
@ -700,7 +809,9 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label className="text-sm font-medium">Enable File Versioning</Label> <Label className="text-sm font-medium">
Enable File Versioning
</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Keep previous versions when files are replaced Keep previous versions when files are replaced
</p> </p>
@ -750,9 +861,12 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
<Label className="text-sm font-medium">Require Availability</Label> <Label className="text-sm font-medium">
Require Availability
</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Jury members must set availability before receiving assignments Jury members must set availability before receiving
assignments
</p> </p>
</div> </div>
<Switch <Switch
@ -769,7 +883,9 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Availability Mode</Label> <Label className="text-sm">Availability Mode</Label>
<Select <Select
value={String(roundSettings.availability_mode || 'soft_penalty')} value={String(
roundSettings.availability_mode || "soft_penalty",
)}
onValueChange={(v) => onValueChange={(v) =>
setRoundSettings((prev) => ({ setRoundSettings((prev) => ({
...prev, ...prev,
@ -793,10 +909,12 @@ function EditRoundContent({ roundId }: { roundId: string }) {
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm"> <Label className="text-sm">
Availability Weight ({Number(roundSettings.availability_weight || 50)}%) Availability Weight (
{Number(roundSettings.availability_weight || 50)}%)
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
How much weight to give availability when using soft penalty mode How much weight to give availability when using soft penalty
mode
</p> </p>
<Slider <Slider
value={[Number(roundSettings.availability_weight || 50)]} value={[Number(roundSettings.availability_weight || 50)]}
@ -905,7 +1023,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
criteriaJson: criteria, criteriaJson: criteria,
settingsJson: roundSettings, settingsJson: roundSettings,
programId: round?.programId, programId: round?.programId,
}) });
}} }}
> >
{saveAsTemplate.isPending && ( {saveAsTemplate.isPending && (
@ -927,7 +1045,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
</form> </form>
</Form> </Form>
</div> </div>
) );
} }
function EditRoundSkeleton() { function EditRoundSkeleton() {
@ -977,15 +1095,15 @@ function EditRoundSkeleton() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) );
} }
export default function EditRoundPage({ params }: PageProps) { export default function EditRoundPage({ params }: PageProps) {
const { id } = use(params) const { id } = use(params);
return ( return (
<Suspense fallback={<EditRoundSkeleton />}> <Suspense fallback={<EditRoundSkeleton />}>
<EditRoundContent roundId={id} /> <EditRoundContent roundId={id} />
</Suspense> </Suspense>
) );
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
'use client' "use client";
import { useState, useCallback, useEffect, useMemo } from 'react' import { useState, useCallback, useEffect, useMemo } from "react";
import { trpc } from '@/lib/trpc/client' import { trpc } from "@/lib/trpc/client";
import { toast } from 'sonner' import { toast } from "sonner";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -10,18 +10,18 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from "@/components/ui/dialog";
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from "@/components/ui/select";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { import {
Table, Table,
TableBody, TableBody,
@ -29,15 +29,15 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { ArrowRightCircle, Loader2, Info } from 'lucide-react' import { ArrowRightCircle, Loader2, Info } from "lucide-react";
interface AdvanceProjectsDialogProps { interface AdvanceProjectsDialogProps {
roundId: string roundId: string;
programId: string programId: string;
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
onSuccess?: () => void onSuccess?: () => void;
} }
export function AdvanceProjectsDialog({ export function AdvanceProjectsDialog({
@ -47,98 +47,98 @@ export function AdvanceProjectsDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: AdvanceProjectsDialogProps) { }: AdvanceProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [targetRoundId, setTargetRoundId] = useState<string>('') const [targetRoundId, setTargetRoundId] = useState<string>("");
const utils = trpc.useUtils() const utils = trpc.useUtils();
// Reset state when dialog opens // Reset state when dialog opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSelectedIds(new Set()) setSelectedIds(new Set());
setTargetRoundId('') setTargetRoundId("");
} }
}, [open]) }, [open]);
// Fetch rounds in program // Fetch rounds in program
const { data: roundsData } = trpc.round.list.useQuery( const { data: roundsData } = trpc.round.list.useQuery(
{ programId }, { programId },
{ enabled: open } { enabled: open },
) );
// Fetch projects in current round // Fetch projects in current round
const { data: projectsData, isLoading } = trpc.project.list.useQuery( const { data: projectsData, isLoading } = trpc.project.list.useQuery(
{ roundId, page: 1, perPage: 5000 }, { roundId, page: 1, perPage: 200 },
{ enabled: open } { enabled: open },
) );
// Auto-select next round by sortOrder // Auto-select next round by sortOrder
const otherRounds = useMemo(() => { const otherRounds = useMemo(() => {
if (!roundsData) return [] if (!roundsData) return [];
return roundsData return roundsData
.filter((r) => r.id !== roundId) .filter((r) => r.id !== roundId)
.sort((a, b) => a.sortOrder - b.sortOrder) .sort((a, b) => a.sortOrder - b.sortOrder);
}, [roundsData, roundId]) }, [roundsData, roundId]);
const currentRound = useMemo(() => { const currentRound = useMemo(() => {
return roundsData?.find((r) => r.id === roundId) return roundsData?.find((r) => r.id === roundId);
}, [roundsData, roundId]) }, [roundsData, roundId]);
// Auto-select next round in sort order // Auto-select next round in sort order
useEffect(() => { useEffect(() => {
if (open && otherRounds.length > 0 && !targetRoundId && currentRound) { if (open && otherRounds.length > 0 && !targetRoundId && currentRound) {
const nextRound = otherRounds.find( const nextRound = otherRounds.find(
(r) => r.sortOrder > currentRound.sortOrder (r) => r.sortOrder > currentRound.sortOrder,
) );
setTargetRoundId(nextRound?.id || otherRounds[0].id) setTargetRoundId(nextRound?.id || otherRounds[0].id);
} }
}, [open, otherRounds, targetRoundId, currentRound]) }, [open, otherRounds, targetRoundId, currentRound]);
const advanceMutation = trpc.round.advanceProjects.useMutation({ const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
const targetName = otherRounds.find((r) => r.id === targetRoundId)?.name const targetName = otherRounds.find((r) => r.id === targetRoundId)?.name;
toast.success( toast.success(
`${result.advanced} project${result.advanced !== 1 ? 's' : ''} advanced to ${targetName}` `${result.advanced} project${result.advanced !== 1 ? "s" : ""} advanced to ${targetName}`,
) );
utils.round.get.invalidate() utils.round.get.invalidate();
utils.project.list.invalidate() utils.project.list.invalidate();
onSuccess?.() onSuccess?.();
onOpenChange(false) onOpenChange(false);
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message) toast.error(error.message);
}, },
}) });
const projects = projectsData?.projects ?? [] const projects = projectsData?.projects ?? [];
const toggleProject = useCallback((id: string) => { const toggleProject = useCallback((id: string) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(id)) next.delete(id) if (next.has(id)) next.delete(id);
else next.add(id) else next.add(id);
return next return next;
}) });
}, []) }, []);
const toggleAll = useCallback(() => { const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) { if (selectedIds.size === projects.length) {
setSelectedIds(new Set()) setSelectedIds(new Set());
} else { } else {
setSelectedIds(new Set(projects.map((p) => p.id))) setSelectedIds(new Set(projects.map((p) => p.id)));
} }
}, [selectedIds.size, projects]) }, [selectedIds.size, projects]);
const handleAdvance = () => { const handleAdvance = () => {
if (selectedIds.size === 0 || !targetRoundId) return if (selectedIds.size === 0 || !targetRoundId) return;
advanceMutation.mutate({ advanceMutation.mutate({
fromRoundId: roundId, fromRoundId: roundId,
toRoundId: targetRoundId, toRoundId: targetRoundId,
projectIds: Array.from(selectedIds), projectIds: Array.from(selectedIds),
}) });
} };
const targetRoundName = otherRounds.find((r) => r.id === targetRoundId)?.name const targetRoundName = otherRounds.find((r) => r.id === targetRoundId)?.name;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -158,7 +158,8 @@ export function AdvanceProjectsDialog({
<Label>Target Round</Label> <Label>Target Round</Label>
{otherRounds.length === 0 ? ( {otherRounds.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No other rounds available in this program. Create another round first. No other rounds available in this program. Create another round
first.
</p> </p>
) : ( ) : (
<Select value={targetRoundId} onValueChange={setTargetRoundId}> <Select value={targetRoundId} onValueChange={setTargetRoundId}>
@ -179,8 +180,9 @@ export function AdvanceProjectsDialog({
<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"> <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" /> <Info className="h-4 w-4 mt-0.5 shrink-0" />
<span> <span>
Projects will be copied to the target round with &quot;Submitted&quot; status. Projects will be copied to the target round with
They will remain in the current round with their existing status. &quot;Submitted&quot; status. They will remain in the current round
with their existing status.
</span> </span>
</div> </div>
@ -200,7 +202,9 @@ export function AdvanceProjectsDialog({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={selectedIds.size === projects.length && projects.length > 0} checked={
selectedIds.size === projects.length && projects.length > 0
}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@ -222,7 +226,11 @@ export function AdvanceProjectsDialog({
{projects.map((project) => ( {projects.map((project) => (
<TableRow <TableRow
key={project.id} key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'} className={
selectedIds.has(project.id)
? "bg-muted/50"
: "cursor-pointer"
}
onClick={() => toggleProject(project.id)} onClick={() => toggleProject(project.id)}
> >
<TableCell> <TableCell>
@ -236,11 +244,11 @@ export function AdvanceProjectsDialog({
{project.title} {project.title}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{project.teamName || '—'} {project.teamName || "—"}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{(project.status ?? 'SUBMITTED').replace('_', ' ')} {(project.status ?? "SUBMITTED").replace("_", " ")}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -270,10 +278,10 @@ export function AdvanceProjectsDialog({
<ArrowRightCircle className="mr-2 h-4 w-4" /> <ArrowRightCircle className="mr-2 h-4 w-4" />
)} )}
Advance Selected ({selectedIds.size}) Advance Selected ({selectedIds.size})
{targetRoundName ? ` to ${targetRoundName}` : ''} {targetRoundName ? ` to ${targetRoundName}` : ""}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }

View File

@ -1,8 +1,8 @@
'use client' "use client";
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from "react";
import { trpc } from '@/lib/trpc/client' import { trpc } from "@/lib/trpc/client";
import { toast } from 'sonner' import { toast } from "sonner";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -10,11 +10,11 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from "@/components/ui/dialog";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { import {
Table, Table,
TableBody, TableBody,
@ -22,16 +22,16 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { Search, Loader2, Plus, Package, CheckCircle2 } from 'lucide-react' import { Search, Loader2, Plus, Package, CheckCircle2 } from "lucide-react";
import { getCountryName } from '@/lib/countries' import { getCountryName } from "@/lib/countries";
interface AssignProjectsDialogProps { interface AssignProjectsDialogProps {
roundId: string roundId: string;
programId: string programId: string;
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
onSuccess?: () => void onSuccess?: () => void;
} }
export function AssignProjectsDialog({ export function AssignProjectsDialog({
@ -41,81 +41,87 @@ export function AssignProjectsDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: AssignProjectsDialogProps) { }: AssignProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [search, setSearch] = useState('') const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState('') const [debouncedSearch, setDebouncedSearch] = useState("");
const utils = trpc.useUtils() const utils = trpc.useUtils();
// Debounce search // Debounce search
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(search), 300) const timer = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(timer) return () => clearTimeout(timer);
}, [search]) }, [search]);
// Reset state when dialog opens // Reset state when dialog opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSelectedIds(new Set()) setSelectedIds(new Set());
setSearch('') setSearch("");
setDebouncedSearch('') setDebouncedSearch("");
} }
}, [open]) }, [open]);
const { data, isLoading } = trpc.project.list.useQuery( const { data, isLoading, error } = trpc.project.list.useQuery(
{ {
programId, programId,
unassignedOnly: true,
search: debouncedSearch || undefined, search: debouncedSearch || undefined,
page: 1, page: 1,
perPage: 5000, perPage: 200,
}, },
{ enabled: open } { enabled: open },
) );
const assignMutation = trpc.round.assignProjects.useMutation({ const assignMutation = trpc.round.assignProjects.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
toast.success(`${result.assigned} project${result.assigned !== 1 ? 's' : ''} assigned to round`) toast.success(
utils.round.get.invalidate({ id: roundId }) `${result.assigned} project${result.assigned !== 1 ? "s" : ""} assigned to round`,
utils.project.list.invalidate() );
onSuccess?.() utils.round.get.invalidate({ id: roundId });
onOpenChange(false) utils.project.list.invalidate();
onSuccess?.();
onOpenChange(false);
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message) toast.error(error.message);
}, },
}) });
const projects = data?.projects ?? [] const projects = data?.projects ?? [];
const alreadyInRound = new Set( const alreadyInRound = new Set(
projects.filter((p) => p.round?.id === roundId).map((p) => p.id) projects.filter((p) => p.round?.id === roundId).map((p) => p.id),
) );
const assignableProjects = projects.filter((p) => !alreadyInRound.has(p.id)) const assignableProjects = projects.filter((p) => !alreadyInRound.has(p.id));
const toggleProject = useCallback((id: string) => { const toggleProject = useCallback(
if (alreadyInRound.has(id)) return (id: string) => {
if (alreadyInRound.has(id)) return;
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(id)) next.delete(id) if (next.has(id)) next.delete(id);
else next.add(id) else next.add(id);
return next return next;
}) });
}, [alreadyInRound]) },
[alreadyInRound],
);
const toggleAll = useCallback(() => { const toggleAll = useCallback(() => {
if (selectedIds.size === assignableProjects.length) { if (selectedIds.size === assignableProjects.length) {
setSelectedIds(new Set()) setSelectedIds(new Set());
} else { } else {
setSelectedIds(new Set(assignableProjects.map((p) => p.id))) setSelectedIds(new Set(assignableProjects.map((p) => p.id)));
} }
}, [selectedIds.size, assignableProjects]) }, [selectedIds.size, assignableProjects]);
const handleAssign = () => { const handleAssign = () => {
if (selectedIds.size === 0) return if (selectedIds.size === 0) return;
assignMutation.mutate({ assignMutation.mutate({
roundId, roundId,
projectIds: Array.from(selectedIds), projectIds: Array.from(selectedIds),
}) });
} };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@ -126,7 +132,7 @@ export function AssignProjectsDialog({
Assign Projects to Round Assign Projects to Round
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Select projects from the program to add to this round. Select projects from the program pool to add to this round.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -145,12 +151,19 @@ export function AssignProjectsDialog({
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div> </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 ? ( ) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center"> <div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground/50" /> <Package className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects found</p> <p className="mt-2 font-medium">No projects found</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{debouncedSearch ? 'No projects match your search.' : 'This program has no projects yet.'} {debouncedSearch
? "No projects in the pool match your search."
: "This program has no projects in the pool yet."}
</p> </p>
</div> </div>
) : ( ) : (
@ -158,14 +171,20 @@ export function AssignProjectsDialog({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={assignableProjects.length > 0 && selectedIds.size === assignableProjects.length} checked={
assignableProjects.length > 0 &&
selectedIds.size === assignableProjects.length
}
disabled={assignableProjects.length === 0} disabled={assignableProjects.length === 0}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{selectedIds.size} of {assignableProjects.length} assignable selected {selectedIds.size} of {assignableProjects.length} assignable
selected
{alreadyInRound.size > 0 && ( {alreadyInRound.size > 0 && (
<span className="ml-1">({alreadyInRound.size} already in round)</span> <span className="ml-1">
({alreadyInRound.size} already in round)
</span>
)} )}
</span> </span>
</div> </div>
@ -183,16 +202,16 @@ export function AssignProjectsDialog({
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{projects.map((project) => { {projects.map((project) => {
const isInRound = alreadyInRound.has(project.id) const isInRound = alreadyInRound.has(project.id);
return ( return (
<TableRow <TableRow
key={project.id} key={project.id}
className={ className={
isInRound isInRound
? 'opacity-60' ? "opacity-60"
: selectedIds.has(project.id) : selectedIds.has(project.id)
? 'bg-muted/50' ? "bg-muted/50"
: 'cursor-pointer' : "cursor-pointer"
} }
onClick={() => toggleProject(project.id)} onClick={() => toggleProject(project.id)}
> >
@ -202,7 +221,9 @@ export function AssignProjectsDialog({
) : ( ) : (
<Checkbox <Checkbox
checked={selectedIds.has(project.id)} checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)} onCheckedChange={() =>
toggleProject(project.id)
}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
)} )}
@ -218,17 +239,19 @@ export function AssignProjectsDialog({
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{project.teamName || '—'} {project.teamName || "—"}
</TableCell> </TableCell>
<TableCell> <TableCell>
{project.country ? ( {project.country ? (
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{getCountryName(project.country)} {getCountryName(project.country)}
</Badge> </Badge>
) : '—'} ) : (
"—"
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
) );
})} })}
</TableBody> </TableBody>
</Table> </Table>
@ -255,5 +278,5 @@ export function AssignProjectsDialog({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }

View File

@ -1,20 +1,20 @@
'use client' "use client";
import { useState } from 'react' import { useState } from "react";
import { trpc } from '@/lib/trpc/client' import { trpc } from "@/lib/trpc/client";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Input } from '@/components/ui/input' import { Input } from "@/components/ui/input";
import { Label } from '@/components/ui/label' import { Label } from "@/components/ui/label";
import { Textarea } from '@/components/ui/textarea' import { Textarea } from "@/components/ui/textarea";
import { Switch } from '@/components/ui/switch' import { Switch } from "@/components/ui/switch";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card' } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -22,8 +22,8 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from "@/components/ui/dialog";
import { toast } from 'sonner' import { toast } from "sonner";
import { import {
Plus, Plus,
Pencil, Pencil,
@ -33,103 +33,117 @@ import {
ArrowDown, ArrowDown,
FileText, FileText,
Loader2, Loader2,
} from 'lucide-react' } from "lucide-react";
const MIME_TYPE_PRESETS = [ const MIME_TYPE_PRESETS = [
{ label: 'PDF', value: 'application/pdf' }, { label: "PDF", value: "application/pdf" },
{ label: 'Images', value: 'image/*' }, { label: "Images", value: "image/*" },
{ label: 'Video', value: 'video/*' }, { label: "Video", value: "video/*" },
{ label: 'Word Documents', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, {
{ label: 'Excel', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, label: "Word Documents",
{ label: 'PowerPoint', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' }, value:
] "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
},
{
label: "Excel",
value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
},
{
label: "PowerPoint",
value:
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
},
];
function getMimeLabel(mime: string): string { function getMimeLabel(mime: string): string {
const preset = MIME_TYPE_PRESETS.find((p) => p.value === mime) const preset = MIME_TYPE_PRESETS.find((p) => p.value === mime);
if (preset) return preset.label if (preset) return preset.label;
if (mime.endsWith('/*')) return mime.replace('/*', '') if (mime.endsWith("/*")) return mime.replace("/*", "");
return mime return mime;
} }
interface FileRequirementsEditorProps { interface FileRequirementsEditorProps {
roundId: string roundId: string;
} }
interface RequirementFormData { interface RequirementFormData {
name: string name: string;
description: string description: string;
acceptedMimeTypes: string[] acceptedMimeTypes: string[];
maxSizeMB: string maxSizeMB: string;
isRequired: boolean isRequired: boolean;
} }
const emptyForm: RequirementFormData = { const emptyForm: RequirementFormData = {
name: '', name: "",
description: '', description: "",
acceptedMimeTypes: [], acceptedMimeTypes: [],
maxSizeMB: '', maxSizeMB: "",
isRequired: true, isRequired: true,
} };
export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps) { export function FileRequirementsEditor({
const utils = trpc.useUtils() roundId,
}: FileRequirementsEditorProps) {
const utils = trpc.useUtils();
const { data: requirements = [], isLoading } = trpc.file.listRequirements.useQuery({ roundId }) const { data: requirements = [], isLoading } =
trpc.file.listRequirements.useQuery({ roundId });
const createMutation = trpc.file.createRequirement.useMutation({ const createMutation = trpc.file.createRequirement.useMutation({
onSuccess: () => { onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId }) utils.file.listRequirements.invalidate({ roundId });
toast.success('Requirement created') toast.success("Requirement created");
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) });
const updateMutation = trpc.file.updateRequirement.useMutation({ const updateMutation = trpc.file.updateRequirement.useMutation({
onSuccess: () => { onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId }) utils.file.listRequirements.invalidate({ roundId });
toast.success('Requirement updated') toast.success("Requirement updated");
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) });
const deleteMutation = trpc.file.deleteRequirement.useMutation({ const deleteMutation = trpc.file.deleteRequirement.useMutation({
onSuccess: () => { onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId }) utils.file.listRequirements.invalidate({ roundId });
toast.success('Requirement deleted') toast.success("Requirement deleted");
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) });
const reorderMutation = trpc.file.reorderRequirements.useMutation({ const reorderMutation = trpc.file.reorderRequirements.useMutation({
onSuccess: () => utils.file.listRequirements.invalidate({ roundId }), onSuccess: () => utils.file.listRequirements.invalidate({ roundId }),
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) });
const [dialogOpen, setDialogOpen] = useState(false) const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<RequirementFormData>(emptyForm) const [form, setForm] = useState<RequirementFormData>(emptyForm);
const openCreate = () => { const openCreate = () => {
setEditingId(null) setEditingId(null);
setForm(emptyForm) setForm(emptyForm);
setDialogOpen(true) setDialogOpen(true);
} };
const openEdit = (req: typeof requirements[number]) => { const openEdit = (req: (typeof requirements)[number]) => {
setEditingId(req.id) setEditingId(req.id);
setForm({ setForm({
name: req.name, name: req.name,
description: req.description || '', description: req.description || "",
acceptedMimeTypes: req.acceptedMimeTypes, acceptedMimeTypes: req.acceptedMimeTypes,
maxSizeMB: req.maxSizeMB?.toString() || '', maxSizeMB: req.maxSizeMB?.toString() || "",
isRequired: req.isRequired, isRequired: req.isRequired,
}) });
setDialogOpen(true) setDialogOpen(true);
} };
const handleSave = async () => { const handleSave = async () => {
if (!form.name.trim()) { if (!form.name.trim()) {
toast.error('Name is required') toast.error("Name is required");
return return;
} }
const maxSizeMB = form.maxSizeMB ? parseInt(form.maxSizeMB) : undefined const maxSizeMB = form.maxSizeMB ? parseInt(form.maxSizeMB) : undefined;
if (editingId) { if (editingId) {
await updateMutation.mutateAsync({ await updateMutation.mutateAsync({
@ -139,7 +153,7 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
acceptedMimeTypes: form.acceptedMimeTypes, acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB: maxSizeMB || null, maxSizeMB: maxSizeMB || null,
isRequired: form.isRequired, isRequired: form.isRequired,
}) });
} else { } else {
await createMutation.mutateAsync({ await createMutation.mutateAsync({
roundId, roundId,
@ -149,26 +163,29 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
maxSizeMB, maxSizeMB,
isRequired: form.isRequired, isRequired: form.isRequired,
sortOrder: requirements.length, sortOrder: requirements.length,
}) });
} }
setDialogOpen(false) setDialogOpen(false);
} };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
await deleteMutation.mutateAsync({ id }) await deleteMutation.mutateAsync({ id });
} };
const handleMove = async (index: number, direction: 'up' | 'down') => { const handleMove = async (index: number, direction: "up" | "down") => {
const newOrder = [...requirements] const newOrder = [...requirements];
const swapIndex = direction === 'up' ? index - 1 : index + 1 const swapIndex = direction === "up" ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= newOrder.length) return if (swapIndex < 0 || swapIndex >= newOrder.length) return;
;[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]] [newOrder[index], newOrder[swapIndex]] = [
newOrder[swapIndex],
newOrder[index],
];
await reorderMutation.mutateAsync({ await reorderMutation.mutateAsync({
roundId, roundId,
orderedIds: newOrder.map((r) => r.id), orderedIds: newOrder.map((r) => r.id),
}) });
} };
const toggleMimeType = (mime: string) => { const toggleMimeType = (mime: string) => {
setForm((prev) => ({ setForm((prev) => ({
@ -176,10 +193,10 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime) acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
? prev.acceptedMimeTypes.filter((m) => m !== mime) ? prev.acceptedMimeTypes.filter((m) => m !== mime)
: [...prev.acceptedMimeTypes, mime], : [...prev.acceptedMimeTypes, mime],
})) }));
} };
const isSaving = createMutation.isPending || updateMutation.isPending const isSaving = createMutation.isPending || updateMutation.isPending;
return ( return (
<Card> <Card>
@ -194,7 +211,7 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
Define required files applicants must upload for this round Define required files applicants must upload for this round
</CardDescription> </CardDescription>
</div> </div>
<Button onClick={openCreate} size="sm"> <Button type="button" onClick={openCreate} size="sm">
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
Add Requirement Add Requirement
</Button> </Button>
@ -207,7 +224,8 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
</div> </div>
) : requirements.length === 0 ? ( ) : requirements.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-center py-8 text-muted-foreground">
No file requirements defined. Applicants can still upload files freely. No file requirements defined. Applicants can still upload files
freely.
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
@ -218,19 +236,21 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
> >
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={() => handleMove(index, 'up')} onClick={() => handleMove(index, "up")}
disabled={index === 0} disabled={index === 0}
> >
<ArrowUp className="h-3 w-3" /> <ArrowUp className="h-3 w-3" />
</Button> </Button>
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={() => handleMove(index, 'down')} onClick={() => handleMove(index, "down")}
disabled={index === requirements.length - 1} disabled={index === requirements.length - 1}
> >
<ArrowDown className="h-3 w-3" /> <ArrowDown className="h-3 w-3" />
@ -240,12 +260,17 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{req.name}</span> <span className="font-medium truncate">{req.name}</span>
<Badge variant={req.isRequired ? 'destructive' : 'secondary'} className="text-xs shrink-0"> <Badge
{req.isRequired ? 'Required' : 'Optional'} variant={req.isRequired ? "destructive" : "secondary"}
className="text-xs shrink-0"
>
{req.isRequired ? "Required" : "Optional"}
</Badge> </Badge>
</div> </div>
{req.description && ( {req.description && (
<p className="text-sm text-muted-foreground line-clamp-1">{req.description}</p> <p className="text-sm text-muted-foreground line-clamp-1">
{req.description}
</p>
)} )}
<div className="flex flex-wrap gap-1 mt-1"> <div className="flex flex-wrap gap-1 mt-1">
{req.acceptedMimeTypes.map((mime) => ( {req.acceptedMimeTypes.map((mime) => (
@ -261,10 +286,17 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
</div> </div>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(req)}> <Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => openEdit(req)}
>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button
type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-destructive hover:text-destructive" className="h-8 w-8 text-destructive hover:text-destructive"
@ -284,7 +316,9 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingId ? 'Edit' : 'Add'} File Requirement</DialogTitle> <DialogTitle>
{editingId ? "Edit" : "Add"} File Requirement
</DialogTitle>
<DialogDescription> <DialogDescription>
Define what file applicants need to upload for this round. Define what file applicants need to upload for this round.
</DialogDescription> </DialogDescription>
@ -296,7 +330,9 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
<Input <Input
id="req-name" id="req-name"
value={form.name} value={form.name}
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} onChange={(e) =>
setForm((p) => ({ ...p, name: e.target.value }))
}
placeholder="e.g., Executive Summary" placeholder="e.g., Executive Summary"
/> />
</div> </div>
@ -306,7 +342,9 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
<Textarea <Textarea
id="req-desc" id="req-desc"
value={form.description} value={form.description}
onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} onChange={(e) =>
setForm((p) => ({ ...p, description: e.target.value }))
}
placeholder="Describe what this file should contain..." placeholder="Describe what this file should contain..."
rows={3} rows={3}
/> />
@ -318,7 +356,11 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
{MIME_TYPE_PRESETS.map((preset) => ( {MIME_TYPE_PRESETS.map((preset) => (
<Badge <Badge
key={preset.value} key={preset.value}
variant={form.acceptedMimeTypes.includes(preset.value) ? 'default' : 'outline'} variant={
form.acceptedMimeTypes.includes(preset.value)
? "default"
: "outline"
}
className="cursor-pointer" className="cursor-pointer"
onClick={() => toggleMimeType(preset.value)} onClick={() => toggleMimeType(preset.value)}
> >
@ -337,7 +379,9 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
id="req-size" id="req-size"
type="number" type="number"
value={form.maxSizeMB} value={form.maxSizeMB}
onChange={(e) => setForm((p) => ({ ...p, maxSizeMB: e.target.value }))} onChange={(e) =>
setForm((p) => ({ ...p, maxSizeMB: e.target.value }))
}
placeholder="No limit" placeholder="No limit"
min={1} min={1}
max={5000} max={5000}
@ -354,22 +398,28 @@ export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps)
<Switch <Switch
id="req-required" id="req-required"
checked={form.isRequired} checked={form.isRequired}
onCheckedChange={(checked) => setForm((p) => ({ ...p, isRequired: checked }))} onCheckedChange={(checked) =>
setForm((p) => ({ ...p, isRequired: checked }))
}
/> />
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}> <Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
Cancel Cancel
</Button> </Button>
<Button onClick={handleSave} disabled={isSaving}> <Button type="button" onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingId ? 'Update' : 'Create'} {editingId ? "Update" : "Create"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</Card> </Card>
) );
} }

View File

@ -1,8 +1,8 @@
'use client' "use client";
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from "react";
import { trpc } from '@/lib/trpc/client' import { trpc } from "@/lib/trpc/client";
import { toast } from 'sonner' import { toast } from "sonner";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -10,7 +10,7 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from "@/components/ui/dialog";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -20,10 +20,10 @@ import {
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from "@/components/ui/alert-dialog";
import { Button } from '@/components/ui/button' import { Button } from "@/components/ui/button";
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from '@/components/ui/badge' import { Badge } from "@/components/ui/badge";
import { import {
Table, Table,
TableBody, TableBody,
@ -31,14 +31,14 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from "@/components/ui/table";
import { Minus, Loader2, AlertTriangle } from 'lucide-react' import { Minus, Loader2, AlertTriangle } from "lucide-react";
interface RemoveProjectsDialogProps { interface RemoveProjectsDialogProps {
roundId: string roundId: string;
open: boolean open: boolean;
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void;
onSuccess?: () => void onSuccess?: () => void;
} }
export function RemoveProjectsDialog({ export function RemoveProjectsDialog({
@ -47,66 +47,66 @@ export function RemoveProjectsDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
}: RemoveProjectsDialogProps) { }: RemoveProjectsDialogProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [confirmOpen, setConfirmOpen] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false);
const utils = trpc.useUtils() const utils = trpc.useUtils();
// Reset state when dialog opens // Reset state when dialog opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSelectedIds(new Set()) setSelectedIds(new Set());
setConfirmOpen(false) setConfirmOpen(false);
} }
}, [open]) }, [open]);
const { data, isLoading } = trpc.project.list.useQuery( const { data, isLoading } = trpc.project.list.useQuery(
{ roundId, page: 1, perPage: 5000 }, { roundId, page: 1, perPage: 200 },
{ enabled: open } { enabled: open },
) );
const removeMutation = trpc.round.removeProjects.useMutation({ const removeMutation = trpc.round.removeProjects.useMutation({
onSuccess: (result) => { onSuccess: (result) => {
toast.success( toast.success(
`${result.removed} project${result.removed !== 1 ? 's' : ''} removed from round` `${result.removed} project${result.removed !== 1 ? "s" : ""} removed from round`,
) );
utils.round.get.invalidate({ id: roundId }) utils.round.get.invalidate({ id: roundId });
utils.project.list.invalidate() utils.project.list.invalidate();
onSuccess?.() onSuccess?.();
onOpenChange(false) onOpenChange(false);
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message) toast.error(error.message);
}, },
}) });
const projects = data?.projects ?? [] const projects = data?.projects ?? [];
const toggleProject = useCallback((id: string) => { const toggleProject = useCallback((id: string) => {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(id)) next.delete(id) if (next.has(id)) next.delete(id);
else next.add(id) else next.add(id);
return next return next;
}) });
}, []) }, []);
const toggleAll = useCallback(() => { const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) { if (selectedIds.size === projects.length) {
setSelectedIds(new Set()) setSelectedIds(new Set());
} else { } else {
setSelectedIds(new Set(projects.map((p) => p.id))) setSelectedIds(new Set(projects.map((p) => p.id)));
} }
}, [selectedIds.size, projects]) }, [selectedIds.size, projects]);
const handleRemove = () => { const handleRemove = () => {
if (selectedIds.size === 0) return if (selectedIds.size === 0) return;
removeMutation.mutate({ removeMutation.mutate({
roundId, roundId,
projectIds: Array.from(selectedIds), projectIds: Array.from(selectedIds),
}) });
setConfirmOpen(false) setConfirmOpen(false);
} };
return ( return (
<> <>
@ -118,8 +118,8 @@ export function RemoveProjectsDialog({
Remove Projects from Round Remove Projects from Round
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Select projects to remove from this round. The projects will remain Select projects to remove from this round. The projects will
in the program and can be re-assigned later. remain in the program and can be re-assigned later.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -147,7 +147,10 @@ export function RemoveProjectsDialog({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={selectedIds.size === projects.length && projects.length > 0} checked={
selectedIds.size === projects.length &&
projects.length > 0
}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
/> />
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
@ -169,7 +172,11 @@ export function RemoveProjectsDialog({
{projects.map((project) => ( {projects.map((project) => (
<TableRow <TableRow
key={project.id} key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'} className={
selectedIds.has(project.id)
? "bg-muted/50"
: "cursor-pointer"
}
onClick={() => toggleProject(project.id)} onClick={() => toggleProject(project.id)}
> >
<TableCell> <TableCell>
@ -183,11 +190,14 @@ export function RemoveProjectsDialog({
{project.title} {project.title}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{project.teamName || '—'} {project.teamName || "—"}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{(project.status ?? 'SUBMITTED').replace('_', ' ')} {(project.status ?? "SUBMITTED").replace(
"_",
" ",
)}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -225,7 +235,7 @@ export function RemoveProjectsDialog({
<AlertDialogTitle>Confirm Removal</AlertDialogTitle> <AlertDialogTitle>Confirm Removal</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to remove {selectedIds.size} project Are you sure you want to remove {selectedIds.size} project
{selectedIds.size !== 1 ? 's' : ''} from this round? Their {selectedIds.size !== 1 ? "s" : ""} from this round? Their
assignments and evaluations in this round will be deleted. The assignments and evaluations in this round will be deleted. The
projects will remain in the program. projects will remain in the program.
</AlertDialogDescription> </AlertDialogDescription>
@ -242,5 +252,5 @@ export function RemoveProjectsDialog({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</> </>
) );
} }

File diff suppressed because it is too large Load Diff