From 2f59b87e4f7c0a86c6dd07b924b3154aa6fca345 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 28 Apr 2026 20:01:48 +0200 Subject: [PATCH] feat: Edition tab on /admin/settings New top-level Edition tab in the admin settings sidebar (under General, between Defaults and Branding). Driven by the EditionSettingsTab component which uses the EditionContext to scope to the current edition and calls program.getEditionSettings / updateEditionSettings. Three sub-sections: - Grand-finale logistics: defaultAttendeeCap, confirmationWindowHours, attendeeEditCutoffHours. - Visa: visaStatusVisibleToMembers toggle (will be removed from the Logistics > Visas tab in the next commit). - Coming soon: placeholders for Lunch and Email Templates. Each numeric input commits on blur; the visa toggle commits immediately. All writes invalidate the query so the rest of the UI reflects changes without a refresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/settings/edition-settings-tab.tsx | 230 ++++++++++++++++++ src/components/settings/settings-content.tsx | 16 ++ 2 files changed, 246 insertions(+) create mode 100644 src/components/admin/settings/edition-settings-tab.tsx diff --git a/src/components/admin/settings/edition-settings-tab.tsx b/src/components/admin/settings/edition-settings-tab.tsx new file mode 100644 index 0000000..10a1300 --- /dev/null +++ b/src/components/admin/settings/edition-settings-tab.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useEffect, useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { useEdition } from '@/contexts/edition-context' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Skeleton } from '@/components/ui/skeleton' +import { Loader2, Salad, ScrollText, Stamp, Users } from 'lucide-react' +import { toast } from 'sonner' + +function NumberField({ + id, + label, + hint, + value, + onCommit, + disabled, + min, + max, +}: { + id: string + label: string + hint?: string + value: number | null + onCommit: (next: number) => void + disabled?: boolean + min?: number + max?: number +}) { + const [draft, setDraft] = useState(value != null ? String(value) : '') + useEffect(() => { + setDraft(value != null ? String(value) : '') + }, [value]) + + return ( +
+ + setDraft(e.target.value)} + onBlur={() => { + const parsed = Number(draft) + if (!Number.isFinite(parsed) || parsed === value) return + if (min !== undefined && parsed < min) return + if (max !== undefined && parsed > max) return + onCommit(parsed) + }} + className="max-w-[12rem]" + /> + {hint &&

{hint}

} +
+ ) +} + +export function EditionSettingsTab() { + const { currentEdition } = useEdition() + const programId = currentEdition?.id ?? null + + const utils = trpc.useUtils() + const { data, isLoading } = trpc.program.getEditionSettings.useQuery( + { programId: programId ?? '' }, + { enabled: !!programId }, + ) + const update = trpc.program.updateEditionSettings.useMutation({ + onSuccess: () => { + if (programId) utils.program.getEditionSettings.invalidate({ programId }) + toast.success('Edition settings updated') + }, + onError: (e) => toast.error(e.message), + }) + + if (!programId) { + return ( +

+ Select an edition from the sidebar dropdown to manage settings. +

+ ) + } + if (isLoading || !data) { + return ( +
+ + +
+ ) + } + + const noLiveFinalRound = data.liveFinalRoundId == null + + return ( +
+ {/* Grand-finale logistics */} + + + +
+ +
+ Grand-finale logistics +
+ + Per-edition limits and deadlines that drive finalist confirmation, attendee + editing, and visa visibility. + +
+ + + update.mutate({ programId, defaultAttendeeCap: next }) + } + /> + + update.mutate({ programId, confirmationWindowHours: next }) + } + /> + + update.mutate({ programId, attendeeEditCutoffHours: next }) + } + /> + +
+ + {/* Visa */} + + + +
+ +
+ Visa +
+ + Visa documents are exchanged over email and never stored on the platform — + we track only process metadata. Choose whether teams see their own status. + +
+ +
+
+ +

+ When on, attendees with needsVisa=true see their status on the + applicant dashboard. When off, only admins see the workflow. +

+
+ + update.mutate({ programId, visaStatusVisibleToMembers: v }) + } + /> +
+
+
+ + {/* Coming soon */} + + + Coming soon + + Lunch-event configuration and editable email templates land in upcoming + updates and will surface here. + + + +
+ Lunch event — dishes, allergies, RSVP deadline +
+
+ Email templates — editable subject + body + for confirmation, decline-cascade, mentor onboarding, etc. +
+
+
+ + {update.isPending && ( +
+ Saving… +
+ )} +
+ ) +} diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index afcac06..2c76fea 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -25,7 +25,9 @@ import { ShieldAlert, Webhook, MessageCircle, + Trophy, } from 'lucide-react' +import { EditionSettingsTab } from '@/components/admin/settings/edition-settings-tab' import Link from 'next/link' import { AnimatedCard } from '@/components/shared/animated-container' import { AISettingsForm } from './ai-settings-form' @@ -179,6 +181,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin Defaults + + + Edition + Branding @@ -252,6 +258,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin Defaults + + + Edition + Branding @@ -464,6 +474,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin )} + + + + + +