From 14d5cf17aa03d94588c73905df219e21fb7525b1 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:56:40 +0200 Subject: [PATCH 1/2] feat(judging): batch overview (view subs + who's working), decimal scores, delete submission - Batch overview replaces the flat chips: expand each batch to preview its submissions, see which judges are working on it (claimedBy), multi-select several unclaimed batches and 'Claim selected'. Per-submission shows which judges have saved a score (scoredBy). - Decimal scores (e.g. 4.3/5): widen score columns INTEGER -> NUMERIC(3,1) (backward-compatible), validator accepts 1dp, inputs step 0.1. - Admin-only delete submission (DELETE /:slug/submissions/:id, IDOR-guarded, scores cascade) with a trash button in the batch overview + scoring card. Server changes are additive (claimedBy/scoredBy fields); scoring write path + ballot gate unchanged. 438 server tests pass; client build + lint clean. --- .../admin/ProgramJudgingSection.tsx | 204 +++++++++++++++--- client/src/lib/api.ts | 22 ++ .../__tests__/submission.controller.test.js | 37 +++- .../api/controllers/submission.controller.js | 32 +++ .../program-submission.repository.js | 6 + server/api/routes/program.routes.js | 6 + server/api/services/scoring.service.js | 23 +- .../__tests__/submission.validator.test.js | 13 +- server/api/utils/submission.validator.js | 20 +- ...260617000000_submission_scores_decimal.sql | 14 ++ 10 files changed, 335 insertions(+), 42 deletions(-) create mode 100644 supabase/migrations/20260617000000_submission_scores_decimal.sql diff --git a/client/src/components/admin/ProgramJudgingSection.tsx b/client/src/components/admin/ProgramJudgingSection.tsx index c6fec47..97acd2a 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