diff --git a/packages/api/src/routers/bootcamp.ts b/packages/api/src/routers/bootcamp.ts index 511ad787..32c0330f 100644 --- a/packages/api/src/routers/bootcamp.ts +++ b/packages/api/src/routers/bootcamp.ts @@ -118,16 +118,33 @@ async function workshopsForTerm( } /** The sessions of one bootcamp, in the order they are taught. */ +const sessionColumns = { + id: events.id, + week: events.bootcampWeek, + title: events.title, + description: events.description, + location: events.location, + eventDate: events.eventDate, + checkInEnabled: events.checkInEnabled, +}; + async function sessionsForTerm(db: DrizzleDB, term: string): Promise { + return db + .select(sessionColumns) + .from(events) + .where(eq(events.bootcampTerm, term)) + .orderBy(asc(events.bootcampWeek)); +} + +// Staff only. Carries the door QR, which must never reach a member-facing +// read: holding the code is enough to check in from anywhere. +async function adminSessionsForTerm(db: DrizzleDB, term: string) { return db .select({ - id: events.id, - week: events.bootcampWeek, - title: events.title, - description: events.description, - location: events.location, - eventDate: events.eventDate, - checkInEnabled: events.checkInEnabled, + ...sessionColumns, + qrCode: events.qrCode, + currentCheckIns: events.currentCheckIns, + maxCheckIns: events.maxCheckIns, }) .from(events) .where(eq(events.bootcampTerm, term)) @@ -432,7 +449,7 @@ export const bootcampRouter = createTRPCRouter({ // Attendance outlives its semester, so past terms stay reachable. const [sessions, roster, eventTerms, workshopTerms] = await Promise.all([ - sessionsForTerm(db, term), + adminSessionsForTerm(db, term), db .select({ userId: members.userId, diff --git a/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx b/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx index fe775e6d..6d097996 100644 --- a/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx +++ b/sites/mainweb/app/(portal)/admin/bootcamp/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import Link from "next/link"; import { useSession } from "next-auth/react"; import { GraduationCap, Check, Copy, Mail } from "lucide-react"; import { LoadingScreen } from "@/components/portal/LoadingScreen"; @@ -9,6 +8,8 @@ import { BootcampMaterialsTable } from "@/components/portal/BootcampMaterialsTab import type { BootcampMaterialRow } from "@/components/portal/BootcampMaterialsTable"; import { BootcampWorkshopModal } from "@/components/portal/BootcampWorkshopModal"; import type { BootcampWorkshopFormData } from "@/components/portal/BootcampWorkshopModal"; +import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; +import { useEventQR } from "@/components/portal/EventQR"; import { trpc } from "@/lib/trpc"; /** `2026-fall` is how it is stored; nobody should have to read it that way. */ @@ -136,6 +137,18 @@ export default function AdminBootcampPage() { const createWorkshop = trpc.bootcamp.createWorkshop.useMutation(); const updateWorkshop = trpc.bootcamp.updateWorkshop.useMutation(); const upsertSession = trpc.bootcamp.upsertSession.useMutation(); + + // Session check-in lives here rather than on the Club Hub: a session is + // still an event underneath, with the same QR and door, but staff run it + // from the bootcamp page. + const qr = useEventQR(); + const [attendanceFor, setAttendanceFor] = useState<{ + id: string; + title: string; + } | null>(null); + const toggleCheckIn = trpc.events.toggleCheckIn.useMutation({ + onSuccess: () => utils.bootcamp.attendance.invalidate(), + }); const setPublished = trpc.bootcamp.setPublished.useMutation({ onSuccess: async () => { await Promise.all([ @@ -476,22 +489,103 @@ export default function AdminBootcampPage() { member.email)} /> )} + {qr.modal} + {attendanceFor && ( + setAttendanceFor(null)} + /> + )} + + {sessions.length > 0 && ( +
+

+ At the door +

+

+ Session check-in +

+ +
    + {sessions.map((row) => ( +
  • +
    +
    + + W{row.week} + + + {row.title} + + + {row.checkInEnabled ? "Open" : "Closed"} + +
    +

    + {row.location || "No location"} ·{" "} + {new Date(row.eventDate).toLocaleDateString()} ·{" "} + {row.attendance} checked in +

    +
    + +
    + + + +
    +
  • + ))} +
+
+ )} + {sessions.length === 0 ? (

No sessions scheduled for {termLabel(data.term)}.

- A bootcamp session is an ordinary event with a bootcamp week set - on it, so it takes attendance through the same QR and the same - check-in desk. Create one from the{" "} - - Club Hub - - . + Give a workshop a session date above to schedule it. Its QR code + and check-in controls then appear here.

) : members.length === 0 ? ( @@ -618,8 +712,7 @@ export default function AdminBootcampPage() {

A session counts anyone who scanned in, member of this bootcamp or not, so a per-session total can run ahead of the rows above. Fix a - wrong check-in from the event’s attendance list on the Club - Hub. + wrong check-in from the session’s Attendance list above.

)} diff --git a/sites/mainweb/app/(portal)/admin/page.tsx b/sites/mainweb/app/(portal)/admin/page.tsx index c95d14a9..d357846d 100644 --- a/sites/mainweb/app/(portal)/admin/page.tsx +++ b/sites/mainweb/app/(portal)/admin/page.tsx @@ -5,7 +5,7 @@ import { trpc } from "@/lib/trpc"; import { usePortalContext } from "@/lib/use-portal-context"; import { useState } from "react"; import Link from "next/link"; -import { QRCodeModal } from "@/components/portal/QRCodeModal"; +import { useEventQR } from "@/components/portal/EventQR"; import { EventFormModal } from "@/components/portal/EventFormModal"; import { EventAttendanceModal } from "@/components/portal/EventAttendanceModal"; import { LiquidGlass } from "@/components/portal/LiquidGlass"; @@ -21,8 +21,6 @@ type Event = { checkInEnabled: boolean; currentCheckIns: number; maxCheckIns: number | null; - bootcampWeek: number | null; - bootcampOnly: boolean; }; export default function AdminPage() { @@ -30,20 +28,18 @@ export default function AdminPage() { const utils = trpc.useUtils(); const [showCreateEvent, setShowCreateEvent] = useState(false); - const [showQRCode, setShowQRCode] = useState(null); - const [qrCodeDataURL, setQrCodeDataURL] = useState(""); - const [selectedEvent, setSelectedEvent] = useState(null); + const qr = useEventQR(); const [editingEvent, setEditingEvent] = useState(null); const [attendanceEvent, setAttendanceEvent] = useState(null); - // The QR encoder is fetched on demand, so the press has to say it is working. - const [generatingQR, setGeneratingQR] = useState(null); const [editError, setEditError] = useState(null); const { data: portalContext } = usePortalContext(); - const { data: events, isLoading: eventsLoading } = + const { data: allEvents, isLoading: eventsLoading } = trpc.events.listAll.useQuery(undefined, { enabled: !!session && !!portalContext?.isAdmin, }); + // Bootcamp sessions have their own check-in controls on /admin/bootcamp. + const events = allEvents?.filter((e) => e.bootcampWeek == null); type StatusFilter = "all" | "open" | "closed"; const [statusFilter, setStatusFilter] = useState("all"); @@ -53,8 +49,7 @@ export default function AdminPage() { if (newEvent) { utils.events.listAll.invalidate(); setShowCreateEvent(false); - generateQRCode(newEvent.qrCode); - setSelectedEvent(newEvent as unknown as Event); + void qr.show(newEvent); } }, }); @@ -66,8 +61,7 @@ export default function AdminPage() { const deleteEventMutation = trpc.events.delete.useMutation({ onSuccess: () => { utils.events.listAll.invalidate(); - setShowQRCode(null); - setSelectedEvent(null); + qr.hide(); }, }); @@ -82,42 +76,12 @@ export default function AdminPage() { onError: (error) => setEditError(error.message), }); - const regenerateQRMutation = trpc.events.regenerateQR.useMutation({ - onSuccess: (updatedEvent) => { - utils.events.listAll.invalidate(); - generateQRCode(updatedEvent.qrCode); - setSelectedEvent(updatedEvent); - }, - }); - - const generateQRCode = async (qrCode: string) => { - setGeneratingQR(qrCode); - try { - // ~240KB, and only needed once somebody asks to see a QR. Loading it up - // front put it in the bundle of the page admins open most. - const { default: QRCode } = await import("qrcode"); - const url = await QRCode.toDataURL(qrCode, { - width: 400, - margin: 3, - color: { dark: "#000000", light: "#ffffff" }, - }); - setQrCodeDataURL(url); - setShowQRCode(qrCode); - } catch (err) { - console.error("Error generating QR code:", err); - } finally { - setGeneratingQR(null); - } - }; - type EventForm = { title: string; description: string; location: string; eventDate: string; maxCheckIns: string; - bootcampWeek: string; - bootcampOnly: boolean; }; // Empty means no cap — the column is nullable and the door gate only runs @@ -130,14 +94,6 @@ export default function AdminPage() { : undefined; }; - /** Empty means this is not a bootcamp session; the column is nullable. */ - const parseWeek = (value: string) => { - const parsed = Number(value); - return value.trim() && Number.isFinite(parsed) && parsed > 0 - ? Math.floor(parsed) - : undefined; - }; - const handleCreateEvent = (formData: EventForm) => { createEventMutation.mutate({ title: formData.title, @@ -145,8 +101,6 @@ export default function AdminPage() { location: formData.location || undefined, eventDate: new Date(formData.eventDate), maxCheckIns: parseCapacity(formData.maxCheckIns), - bootcampWeek: parseWeek(formData.bootcampWeek), - bootcampOnly: formData.bootcampOnly, }); }; @@ -160,8 +114,6 @@ export default function AdminPage() { location: formData.location || null, eventDate: new Date(formData.eventDate), maxCheckIns: parseCapacity(formData.maxCheckIns) ?? null, - bootcampWeek: parseWeek(formData.bootcampWeek) ?? null, - bootcampOnly: formData.bootcampOnly, }); }; @@ -173,13 +125,6 @@ export default function AdminPage() { return local.toISOString().slice(0, 16); }; - const downloadQRCode = () => { - const link = document.createElement("a"); - link.download = `${selectedEvent?.title || "event"}-qr.png`; - link.href = qrCodeDataURL; - link.click(); - }; - return ( <> {showCreateEvent && ( @@ -201,10 +146,6 @@ export default function AdminPage() { maxCheckIns: editingEvent.maxCheckIns ? String(editingEvent.maxCheckIns) : "", - bootcampWeek: editingEvent.bootcampWeek - ? String(editingEvent.bootcampWeek) - : "", - bootcampOnly: editingEvent.bootcampOnly, }} onClose={() => { setEditingEvent(null); @@ -224,18 +165,7 @@ export default function AdminPage() { /> )} - {showQRCode && selectedEvent && ( - setShowQRCode(null)} - onDownload={downloadQRCode} - onRegenerate={() => - regenerateQRMutation.mutate({ eventId: selectedEvent.id }) - } - isRegenerating={regenerateQRMutation.isPending} - /> - )} + {qr.modal}
@@ -248,9 +178,14 @@ export default function AdminPage() { Manager

- Club meetings, workshops, and bootcamp sessions. These are not - part of a Hacklytics weekend — that itinerary lives on the - hackathon dashboard. + Club meetings and workshops. Bootcamp sessions are managed on the{" "} + + Bootcamp + {" "} + page. Hacklytics weekend events live on the hackathon dashboard.

@@ -378,14 +313,11 @@ export default function AdminPage() {
diff --git a/sites/mainweb/components/portal/EventAttendanceModal.tsx b/sites/mainweb/components/portal/EventAttendanceModal.tsx index c40457fb..d03d2303 100644 --- a/sites/mainweb/components/portal/EventAttendanceModal.tsx +++ b/sites/mainweb/components/portal/EventAttendanceModal.tsx @@ -31,6 +31,8 @@ export function EventAttendanceModal({ await Promise.all([ utils.events.attendees.invalidate({ eventId }), utils.events.listAll.invalidate(), + // Opened from the bootcamp page too, whose grid counts these rows. + utils.bootcamp.attendance.invalidate(), ]); }; diff --git a/sites/mainweb/components/portal/EventFormModal.tsx b/sites/mainweb/components/portal/EventFormModal.tsx index 9fbf0ad7..1caf536a 100644 --- a/sites/mainweb/components/portal/EventFormModal.tsx +++ b/sites/mainweb/components/portal/EventFormModal.tsx @@ -11,10 +11,6 @@ interface EventFormData { /** Empty means no cap. The column is nullable and the door gate only runs * when a number is set. */ maxCheckIns: string; - /** Empty means this is an ordinary event, not a bootcamp session. */ - bootcampWeek: string; - /** Refuses anyone not enrolled in this semester's bootcamp. */ - bootcampOnly: boolean; } interface EventFormModalProps { @@ -50,8 +46,6 @@ export function EventFormModal({ // own date, and overwriting it with "now" would silently reschedule it. eventDate: initial?.eventDate || getCurrentDateTimeLocal(), maxCheckIns: initial?.maxCheckIns ?? "", - bootcampWeek: initial?.bootcampWeek ?? "", - bootcampOnly: initial?.bootcampOnly ?? false, }); const handleSubmit = () => { @@ -178,60 +172,6 @@ export function EventFormModal({

- {/* A session is an ordinary event with a week on it, so it uses this - same QR and check-in desk — no second form, no second scanner. */} -
- - { - const value = e.target.value; - setForm((prev) => ({ - ...prev, - bootcampWeek: value, - // Ticking the box is the step people forget. - bootcampOnly: - !prev.bootcampWeek.trim() && value.trim() - ? true - : prev.bootcampOnly, - })); - }} - className="w-full bg-[var(--bg-primary)]/40 border border-[var(--border-subtle)] rounded-none px-4 py-3 text-[var(--text-primary)] text-sm focus:border-accent focus:outline-none transition-ui font-mono" - placeholder="Leave empty for a normal event" - /> -

- Files this event as week N of the bootcamp running this semester, - and puts its attendance on the bootcamp grid. -

- - -

- Turns away anyone who has not paid for this semester’s - bootcamp. Officers can still check somebody in by hand. -

-
- {error && (

(null); + const [qrCodeDataURL, setQrCodeDataURL] = useState(""); + // The QR encoder is fetched on demand, so the press has to say it is working. + const [generatingFor, setGeneratingFor] = useState(null); + + const show = async (event: QREvent) => { + setGeneratingFor(event.qrCode); + try { + // ~240KB, and only needed once somebody asks to see a QR. Loading it up + // front put it in the bundle of the page admins open most. + const { default: QRCode } = await import("qrcode"); + const url = await QRCode.toDataURL(event.qrCode, { + width: 400, + margin: 3, + color: { dark: "#000000", light: "#ffffff" }, + }); + setQrCodeDataURL(url); + setShownEvent(event); + } catch (err) { + console.error("Error generating QR code:", err); + } finally { + setGeneratingFor(null); + } + }; + + const regenerateQRMutation = trpc.events.regenerateQR.useMutation({ + onSuccess: (updatedEvent) => { + utils.events.listAll.invalidate(); + utils.bootcamp.attendance.invalidate(); + void show(updatedEvent); + }, + }); + + const download = () => { + const link = document.createElement("a"); + link.download = `${shownEvent?.title || "event"}-qr.png`; + link.href = qrCodeDataURL; + link.click(); + }; + + const modal = shownEvent ? ( + setShownEvent(null)} + onDownload={download} + onRegenerate={() => + regenerateQRMutation.mutate({ eventId: shownEvent.id }) + } + isRegenerating={regenerateQRMutation.isPending} + /> + ) : null; + + return { + show, + /** The qrCode currently being encoded, for a button's busy state. */ + generatingFor, + /** Close the QR, e.g. when its event is deleted. */ + hide: () => setShownEvent(null), + modal, + }; +}