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:
230
src/components/admin/settings/edition-settings-tab.tsx
Normal file
230
src/components/admin/settings/edition-settings-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user