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) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 20:01:48 +02:00
parent 78992a493a
commit 2f59b87e4f
2 changed files with 246 additions and 0 deletions

View File

@@ -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<string>(value != null ? String(value) : '')
useEffect(() => {
setDraft(value != null ? String(value) : '')
}, [value])
return (
<div className="space-y-1.5">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type="number"
min={min}
max={max}
value={draft}
disabled={disabled}
onChange={(e) => 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 && <p className="text-muted-foreground text-xs">{hint}</p>}
</div>
)
}
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 (
<p className="text-muted-foreground text-sm">
Select an edition from the sidebar dropdown to manage settings.
</p>
)
}
if (isLoading || !data) {
return (
<div className="space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)
}
const noLiveFinalRound = data.liveFinalRoundId == null
return (
<div className="space-y-6">
{/* Grand-finale logistics */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<Users className="h-4 w-4 text-emerald-500" />
</div>
Grand-finale logistics
</CardTitle>
<CardDescription>
Per-edition limits and deadlines that drive finalist confirmation, attendee
editing, and visa visibility.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<NumberField
id="default-attendee-cap"
label="Default attendee cap"
hint="Maximum number of team members allowed at the grand finale per finalist team."
min={1}
max={20}
value={data.defaultAttendeeCap}
disabled={update.isPending}
onCommit={(next) =>
update.mutate({ programId, defaultAttendeeCap: next })
}
/>
<NumberField
id="confirmation-window-hours"
label="Confirmation window (hours)"
hint={
noLiveFinalRound
? 'Will be editable once the grand-finale (LIVE_FINAL) round is created.'
: 'How long teams have to click the confirm/decline link after we send it.'
}
min={1}
max={720}
value={data.confirmationWindowHours}
disabled={update.isPending || noLiveFinalRound}
onCommit={(next) =>
update.mutate({ programId, confirmationWindowHours: next })
}
/>
<NumberField
id="attendee-edit-cutoff-hours"
label="Attendee edit cutoff (hours before grand finale)"
hint={
noLiveFinalRound
? 'Will be editable once the grand-finale (LIVE_FINAL) round is created.'
: 'After this many hours before the grand finale opens, the team lead can no longer change attendees.'
}
min={0}
max={720}
value={data.attendeeEditCutoffHours}
disabled={update.isPending || noLiveFinalRound}
onCommit={(next) =>
update.mutate({ programId, attendeeEditCutoffHours: next })
}
/>
</CardContent>
</Card>
{/* Visa */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2.5 text-base">
<div className="rounded-lg bg-sky-500/10 p-1.5">
<Stamp className="h-4 w-4 text-sky-500" />
</div>
Visa
</CardTitle>
<CardDescription>
Visa documents are exchanged over email and never stored on the platform
we track only process metadata. Choose whether teams see their own status.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<div className="space-y-1">
<Label htmlFor="visa-visibility-edition">Visible to teams</Label>
<p className="text-muted-foreground text-xs">
When on, attendees with needsVisa=true see their status on the
applicant dashboard. When off, only admins see the workflow.
</p>
</div>
<Switch
id="visa-visibility-edition"
checked={data.visaStatusVisibleToMembers}
disabled={update.isPending}
onCheckedChange={(v) =>
update.mutate({ programId, visaStatusVisibleToMembers: v })
}
/>
</div>
</CardContent>
</Card>
{/* Coming soon */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
<CardDescription>
Lunch-event configuration and editable email templates land in upcoming
updates and will surface here.
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center gap-2">
<Salad className="h-4 w-4" /> Lunch event dishes, allergies, RSVP deadline
</div>
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" /> Email templates editable subject + body
for confirmation, decline-cascade, mentor onboarding, etc.
</div>
</CardContent>
</Card>
{update.isPending && (
<div className="text-muted-foreground inline-flex items-center gap-2 text-xs">
<Loader2 className="h-3 w-3 animate-spin" /> Saving
</div>
)}
</div>
)
}

View File

@@ -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
<SettingsIcon className="h-4 w-4" />
Defaults
</TabsTrigger>
<TabsTrigger value="edition" className="gap-2 shrink-0">
<Trophy className="h-4 w-4" />
Edition
</TabsTrigger>
<TabsTrigger value="branding" className="gap-2 shrink-0">
<Palette className="h-4 w-4" />
Branding
@@ -252,6 +258,10 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<SettingsIcon className="h-4 w-4" />
Defaults
</TabsTrigger>
<TabsTrigger value="edition" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Trophy className="h-4 w-4" />
Edition
</TabsTrigger>
<TabsTrigger value="branding" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Palette className="h-4 w-4" />
Branding
@@ -464,6 +474,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</TabsContent>
)}
<TabsContent value="edition" className="space-y-6">
<AnimatedCard>
<EditionSettingsTab />
</AnimatedCard>
</TabsContent>
<TabsContent value="defaults" className="space-y-6">
<AnimatedCard>
<Card>