Admin dashboard & round management UX overhaul
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m43s

- Extract round detail monolith (2900→600 lines) into 13 standalone components
- Add shared round/status config (round-config.ts) replacing 4 local copies
- Delete 12 legacy competition-scoped pages, merge project pool into projects page
- Add round-type-specific dashboard stat panels (submission, mentoring, live final, deliberation, summary)
- Add contextual header quick actions based on active round type
- Improve pipeline visualization: progress bars, checkmarks, chevron connectors, overflow fix
- Add config tab completion dots (green/amber/red) and inline validation warnings
- Enhance juries page with round assignments, member avatars, and cap mode badges
- Add context-aware project list (recent submissions vs active evaluations)
- Move competition settings into Manage Editions page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 17:14:00 +01:00
parent f7bc3b4dd2
commit f26ee3f076
51 changed files with 4530 additions and 6276 deletions

View File

@@ -0,0 +1,165 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Loader2, Save, Settings } from 'lucide-react'
type CompetitionSettingsProps = {
competitionId: string
initialSettings: {
categoryMode: string
startupFinalistCount: number
conceptFinalistCount: number
notifyOnRoundAdvance: boolean
notifyOnDeadlineApproach: boolean
deadlineReminderDays: number[]
}
}
export function CompetitionSettings({ competitionId, initialSettings }: CompetitionSettingsProps) {
const [settings, setSettings] = useState(initialSettings)
const [dirty, setDirty] = useState(false)
const updateMutation = trpc.competition.update.useMutation({
onSuccess: () => {
toast.success('Competition settings saved')
setDirty(false)
},
onError: (err) => toast.error(err.message),
})
function update<K extends keyof typeof settings>(key: K, value: (typeof settings)[K]) {
setSettings((prev) => ({ ...prev, [key]: value }))
setDirty(true)
}
function handleSave() {
updateMutation.mutate({ id: competitionId, ...settings })
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Competition Settings
</CardTitle>
<CardDescription>
Category mode, finalist targets, and notification preferences
</CardDescription>
</div>
{dirty && (
<Button onClick={handleSave} disabled={updateMutation.isPending} size="sm">
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
)}
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>Category Mode</Label>
<Select value={settings.categoryMode} onValueChange={(v) => update('categoryMode', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SHARED">Shared Pool</SelectItem>
<SelectItem value="SEPARATE">Separate Tracks</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Startup Finalist Count</Label>
<Input
type="number"
min={1}
value={settings.startupFinalistCount}
onChange={(e) => update('startupFinalistCount', parseInt(e.target.value) || 1)}
/>
</div>
<div className="space-y-2">
<Label>Concept Finalist Count</Label>
<Input
type="number"
min={1}
value={settings.conceptFinalistCount}
onChange={(e) => update('conceptFinalistCount', parseInt(e.target.value) || 1)}
/>
</div>
</div>
<div className="space-y-4">
<h4 className="text-sm font-medium">Notifications</h4>
<div className="flex items-center justify-between">
<div>
<Label>Notify on Round Advance</Label>
<p className="text-xs text-muted-foreground">Email applicants when their project advances</p>
</div>
<Switch
checked={settings.notifyOnRoundAdvance}
onCheckedChange={(v) => update('notifyOnRoundAdvance', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Notify on Deadline Approach</Label>
<p className="text-xs text-muted-foreground">Send reminders before deadlines</p>
</div>
<Switch
checked={settings.notifyOnDeadlineApproach}
onCheckedChange={(v) => update('notifyOnDeadlineApproach', v)}
/>
</div>
<div className="space-y-2">
<Label>Reminder Days Before Deadline</Label>
<div className="flex items-center gap-2">
{settings.deadlineReminderDays.map((day, idx) => (
<Badge key={idx} variant="secondary" className="gap-1">
{day}d
<button
className="ml-1 text-muted-foreground hover:text-foreground"
onClick={() => {
const next = settings.deadlineReminderDays.filter((_, i) => i !== idx)
update('deadlineReminderDays', next)
}}
>
×
</button>
</Badge>
))}
<Input
type="number"
min={1}
placeholder="Add..."
className="w-20 h-7 text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = parseInt((e.target as HTMLInputElement).value)
if (val > 0 && !settings.deadlineReminderDays.includes(val)) {
update('deadlineReminderDays', [...settings.deadlineReminderDays, val].sort((a, b) => b - a))
;(e.target as HTMLInputElement).value = ''
}
}
}}
/>
</div>
</div>
</div>
</CardContent>
</Card>
)
}