From a5b47ac890ead66c9febfd692f1149a0ba880918 Mon Sep 17 00:00:00 2001 From: sacha <23283108+sacha-l@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:24:33 +0100 Subject: [PATCH 1/8] feat(admin): program results summary panel for completed hackathons When a program is marked "completed", the wallet-admin view gains a ProgramResultsSummarySection showing: checked-in / submission / winner / hours-of-hacking stats, top-3 per scoring category with judge attribution, prize winners with video + GitHub links, honorary mentions, Recharts feedback charts with auto-generated insight text, and a gallery preview. ProgramFormModal gains gallery URL + honorary mentions fields (with per-row video/GitHub inputs) and an inline completeness warning when status is set to "completed" with missing data. Backed by a new DB migration (honorary_mentions JSONB, gallery_url TEXT on programs), a new aggregateFeedback() repository method, and a new admin-gated GET /programs/:slug/submissions/feedback-aggregate endpoint. --- .../src/components/admin/ProgramFormModal.tsx | 126 ++++++ .../admin/ProgramResultsSummarySection.tsx | 405 ++++++++++++++++++ client/src/lib/api.ts | 41 ++ client/src/pages/AdminProgramPage.tsx | 8 + .../api/controllers/submission.controller.js | 13 + .../program-submission.repository.js | 43 ++ server/api/repositories/program.repository.js | 4 + server/api/routes/program.routes.js | 2 + server/api/utils/validation.js | 29 ++ ...0260625000000_program_results_metadata.sql | 6 + 10 files changed, 677 insertions(+) create mode 100644 client/src/components/admin/ProgramResultsSummarySection.tsx create mode 100644 supabase/migrations/20260625000000_program_results_metadata.sql diff --git a/client/src/components/admin/ProgramFormModal.tsx b/client/src/components/admin/ProgramFormModal.tsx index 3c353fd..4a496f2 100644 --- a/client/src/components/admin/ProgramFormModal.tsx +++ b/client/src/components/admin/ProgramFormModal.tsx @@ -105,6 +105,9 @@ export function ProgramFormModal({ const [eventStartsAt, setEventStartsAt] = useState(""); const [eventEndsAt, setEventEndsAt] = useState(""); const [prizeTierRows, setPrizeTierRows] = useState([]); + const [galleryUrl, setGalleryUrl] = useState(""); + type HonoraryMentionRow = { name: string; videoUrl: string; githubUrl: string }; + const [honoraryMentionRows, setHonoraryMentionRows] = useState([]); const [errors, setErrors] = useState>({}); const [submitting, setSubmitting] = useState(false); const [uploadingCover, setUploadingCover] = useState(false); @@ -129,6 +132,14 @@ export function ProgramFormModal({ setEventStartsAt(isoToLocal(program.eventStartsAt)); setEventEndsAt(isoToLocal(program.eventEndsAt)); setPrizeTierRows(tierRowsFromProgram(program)); + setGalleryUrl(program.galleryUrl || ""); + setHonoraryMentionRows( + (program.honoraryMentions ?? []).map((m) => ({ + name: m.name, + videoUrl: m.videoUrl ?? "", + githubUrl: m.githubUrl ?? "", + })), + ); } else { setName(""); setSlug(""); @@ -145,6 +156,8 @@ export function ProgramFormModal({ setEventStartsAt(""); setEventEndsAt(""); setPrizeTierRows(tierRowsFromProgram(null)); + setGalleryUrl(""); + setHonoraryMentionRows([]); } setErrors({}); }, [open, program]); @@ -174,6 +187,9 @@ export function ProgramFormModal({ if (coverImageUrl.trim() && !/^https?:\/\//i.test(coverImageUrl.trim())) { e.coverImageUrl = "Must start with http:// or https://"; } + if (galleryUrl.trim() && !/^https?:\/\//i.test(galleryUrl.trim())) { + e.galleryUrl = "Must start with http:// or https://"; + } if (applicationsOpenAt && applicationsCloseAt) { if (new Date(applicationsOpenAt).getTime() >= new Date(applicationsCloseAt).getTime()) { e.applicationsCloseAt = "Close date must be after open date."; @@ -254,6 +270,14 @@ export function ProgramFormModal({ prizeTiers: prizeTierRows .map((r) => ({ amount: Number(r.amount), currency: r.currency.trim(), label: r.label.trim() })) .filter((t) => Number.isInteger(t.amount) && t.amount > 0 && t.currency), + galleryUrl: galleryUrl.trim() || null, + honoraryMentions: honoraryMentionRows + .filter((m) => m.name.trim()) + .map((m) => ({ + name: m.name.trim(), + videoUrl: m.videoUrl.trim() || null, + githubUrl: m.githubUrl.trim() || null, + })), }; const res = editing @@ -354,6 +378,29 @@ export function ProgramFormModal({ + {status === "completed" && ( +
+ {!galleryUrl.trim() && ( +

+

+ )} + {honoraryMentionRows.length === 0 && ( +

+

+ )} + {(!eventStartsAt || !eventEndsAt) && ( +

+

+ )} +
+ )} +