diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index c6fec47..b322974 100644 --- a/client/src/components/admin/ProgramJudgingSection.tsx +++ b/client/src/components/admin/ProgramJudgingSection.tsx @@ -1,5 +1,5 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; -import { Loader2, ExternalLink, Lock, Trophy, Plus, ChevronRight, ChevronDown, Play } from "lucide-react"; +import { Loader2, ExternalLink, Lock, Trophy, Plus, ChevronRight, ChevronDown, Play, Trash2 } from "lucide-react"; import { api, type AdminAuthArg, @@ -26,8 +26,12 @@ const draftFromRow = (row: ApiSubmissionRow): Draft => ({ }); const fmt = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(2)); +const round1 = (n: number) => Math.round(n * 10) / 10; const prizeText = (amount?: number | null, currency?: string | null) => amount != null ? `${amount} ${currency ?? ""}`.trim() : null; +// Short, readable judge label (local-part of the email) for "who's working on this". +const judgeLabel = (email: string) => email.split("@")[0]; +const judgesText = (emails?: string[]) => (emails && emails.length ? emails.map(judgeLabel).join(", ") : ""); /** * Judge/admin scoring surface. Judging is split into fixed batches of 10; a @@ -77,6 +81,11 @@ export function ProgramJudgingSection({ const [publishing, setPublishing] = useState(false); const [expanded, setExpanded] = useState>({}); const [exporting, setExporting] = useState(false); + // Batch overview: which unclaimed batches are checked for multi-claim, which + // batch is expanded to preview its submissions, and an in-flight delete. + const [selectedBatches, setSelectedBatches] = useState>(new Set()); + const [openBatch, setOpenBatch] = useState(null); + const [deletingId, setDeletingId] = useState(null); const handleExportCsv = async () => { setExporting(true); @@ -177,7 +186,8 @@ export function ProgramJudgingSection({ const cur = prev[id] ?? { requirements: 0, techStack: 0, innovation: 0, notes: "" }; if (field === "notes") return { ...prev, [id]: { ...cur, notes: raw } }; const max = BOUNDS[field]; - const n = Math.max(0, Math.min(max, Math.round(Number(raw) || 0))); + // One decimal place (e.g. 4.3), clamped to [0, max]. + const n = Math.max(0, Math.min(max, round1(Number(raw) || 0))); return { ...prev, [id]: { ...cur, [field]: n } }; }); }; @@ -202,6 +212,63 @@ export function ProgramJudgingSection({ } }; + const toggleSelect = (batchNumber: number) => + setSelectedBatches((prev) => { + const next = new Set(prev); + if (next.has(batchNumber)) next.delete(batchNumber); + else next.add(batchNumber); + return next; + }); + + // Claim several batches at once (sequential idempotent claims; last view wins). + const claimSelected = async () => { + const nums = [...selectedBatches].sort((a, b) => a - b); + if (nums.length === 0) return; + setClaiming(true); + try { + const auth = await getAuth(); + let last: Awaited> | undefined; + for (const n of nums) { + last = await api.claimBatch(programSlug, n, auth); + } + if (last) { + setView(last.data); + seedDrafts(last.data.submissions); + } + setSelectedBatches(new Set()); + toast({ title: `Claimed ${nums.length} batch${nums.length === 1 ? "" : "es"}` }); + } catch (e) { + toast({ + title: "Couldn't claim batches", + description: (e as Error)?.message || "Unknown error", + variant: "destructive", + }); + } finally { + setClaiming(false); + } + }; + + // Admin: delete a submission (e.g. a test entry). Reloads so batch membership + // (which shifts when a submission is removed) stays correct. + const deleteSub = async (s: ApiSubmissionRow) => { + if (!window.confirm(`Delete "${s.projectTitle}" by ${s.submitterName}? This removes the submission and any scores, and can't be undone.`)) return; + setDeletingId(s.id); + try { + const auth = await getAuth(); + await api.deleteSubmission(programSlug, s.id, auth); + toast({ title: "Submission deleted" }); + await loadSubmissions(); + } catch (e) { + toast({ + title: "Couldn't delete submission", + description: (e as Error)?.message || "Unknown error", + variant: "destructive", + }); + } finally { + setDeletingId(null); + } + }; + const saveBatch = async (batchNumber: number, subs: ApiSubmissionRow[]) => { setSavingBatch(batchNumber); try { @@ -375,6 +442,17 @@ export function ProgramJudgingSection({ GITHUB + {canSelectWinners && ( + + )} {s.projectBrief && ( @@ -390,13 +468,14 @@ export function ProgramJudgingSection({ type="number" min={0} max={BOUNDS[field]} + step={0.1} value={d[field]} onChange={(e) => setField(s.id, field, e.target.value)} className="w-full font-mono text-[13px] bg-panel-deep border border-hairline text-display px-2 py-1.5 focus:outline-none focus:border-display disabled:opacity-50" /> ))} -
TOTAL {total} / 12
+
TOTAL {round1(total)} / 12