Rounds overhaul: full CRUD submission windows, scheduling UI, analytics, design refresh
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
- Fix special award FK crash: replace 4x raw auditLog.create with logAudit() helper - Add updateSubmissionWindow + deleteSubmissionWindow mutations to round router - Add per-round analytics (_count, juryGroup) to competition.getById - Remove redundant acceptedCategories from intake config - Rewrite submission window manager with full CRUD, all fields, date pickers - Add round scheduling card (open/close dates) to round detail page - Add project count, assignment count, jury group to round list cards - Visual redesign: pipeline view, brand colors, progress bars, enhanced cards Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -35,6 +37,9 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
Archive,
|
||||
Calendar,
|
||||
FileBox,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
|
||||
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
|
||||
@@ -65,6 +70,8 @@ export default function RoundDetailPage() {
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [confirmAction, setConfirmAction] = useState<string | null>(null)
|
||||
const [windowOpenAt, setWindowOpenAt] = useState<string>('')
|
||||
const [windowCloseAt, setWindowCloseAt] = useState<string>('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -94,6 +101,11 @@ export default function RoundDetailPage() {
|
||||
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
|
||||
setConfig(roundConfig)
|
||||
}
|
||||
// Sync date fields
|
||||
const openStr = round.windowOpenAt ? new Date(round.windowOpenAt).toISOString().slice(0, 16) : ''
|
||||
const closeStr = round.windowCloseAt ? new Date(round.windowCloseAt).toISOString().slice(0, 16) : ''
|
||||
if (openStr !== windowOpenAt) setWindowOpenAt(openStr)
|
||||
if (closeStr !== windowCloseAt) setWindowCloseAt(closeStr)
|
||||
}
|
||||
|
||||
const updateMutation = trpc.round.update.useMutation({
|
||||
@@ -139,7 +151,12 @@ export default function RoundDetailPage() {
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateMutation.mutate({ id: roundId, configJson: config })
|
||||
updateMutation.mutate({
|
||||
id: roundId,
|
||||
configJson: config,
|
||||
windowOpenAt: windowOpenAt ? new Date(windowOpenAt) : null,
|
||||
windowCloseAt: windowCloseAt ? new Date(windowCloseAt) : null,
|
||||
})
|
||||
}
|
||||
|
||||
const handleLifecycleAction = () => {
|
||||
@@ -192,17 +209,23 @@ export default function RoundDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href={"/admin/rounds" as Route} className="mt-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={"/admin/rounds" as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-muted-foreground">Back to Rounds</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-xl font-bold truncate">{round.name}</h1>
|
||||
<Badge variant="secondary" className={cn('text-[10px]', roundTypeColors[round.roundType])}>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-[#053d57]">{round.name}</h1>
|
||||
<Badge variant="secondary" className={cn('text-sm font-medium px-3 py-1', roundTypeColors[round.roundType])}>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
|
||||
@@ -210,11 +233,11 @@ export default function RoundDetailPage() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className={cn(
|
||||
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors hover:opacity-80',
|
||||
'inline-flex items-center gap-1.5 text-sm font-medium px-3 py-1 rounded-full transition-colors hover:opacity-80',
|
||||
statusCfg.bgClass,
|
||||
)}>
|
||||
{statusCfg.label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
@@ -247,38 +270,158 @@ export default function RoundDetailPage() {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="default"
|
||||
onClick={handleSave}
|
||||
disabled={updateMutation.isPending}
|
||||
className="bg-[#de0f1e] hover:bg-[#de0f1e]/90"
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-[#053d57]/10 to-[#557f8c]/10">
|
||||
<FileBox className="h-5 w-5 text-[#053d57]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-[#053d57]">{round._count?.projectRoundStates ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Projects</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-[#557f8c]/10 to-[#053d57]/10">
|
||||
<Users className="h-5 w-5 text-[#557f8c]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-[#053d57]">{round.juryGroup?.members?.length ?? 0}</p>
|
||||
<p className="text-xs text-muted-foreground">Jury Members</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-[#de0f1e]/10 to-[#de0f1e]/5">
|
||||
<Users className="h-5 w-5 text-[#de0f1e]" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{round.juryGroup ? (
|
||||
<>
|
||||
<p className="text-sm font-semibold text-[#053d57] truncate">{round.juryGroup.name}</p>
|
||||
<p className="text-xs text-muted-foreground">Jury Group</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-muted-foreground">No jury assigned</p>
|
||||
<p className="text-xs text-muted-foreground">Jury Group</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-gradient-to-br from-amber-500/10 to-amber-500/5">
|
||||
<Calendar className="h-5 w-5 text-amber-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{round.windowOpenAt || round.windowCloseAt ? (
|
||||
<>
|
||||
<p className="text-xs font-medium text-[#053d57] truncate">
|
||||
{round.windowOpenAt && new Date(round.windowOpenAt).toLocaleDateString()}
|
||||
{round.windowOpenAt && round.windowCloseAt && ' - '}
|
||||
{round.windowCloseAt && new Date(round.windowCloseAt).toLocaleDateString()}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Schedule</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs font-medium text-muted-foreground">Not scheduled</p>
|
||||
<p className="text-xs text-muted-foreground">Schedule</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Schedule Editor */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar className="h-4 w-4 text-[#053d57]" />
|
||||
<h3 className="text-sm font-semibold text-[#053d57]">Round Schedule</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Opens At</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={windowOpenAt}
|
||||
onChange={(e) => { setWindowOpenAt(e.target.value); setHasChanges(true) }}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Closes At</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={windowCloseAt}
|
||||
onChange={(e) => { setWindowCloseAt(e.target.value); setHasChanges(true) }}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="config" className="space-y-4">
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto">
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="projects">Projects</TabsTrigger>
|
||||
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documents</TabsTrigger>
|
||||
<TabsTrigger value="awards">
|
||||
<Tabs defaultValue="config" className="space-y-6">
|
||||
<TabsList className="w-full sm:w-auto overflow-x-auto bg-muted/50 p-1 h-auto">
|
||||
<TabsTrigger value="config" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Configuration
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="projects" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Projects
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="windows" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Submission Windows
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="documents" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Documents
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards" className="data-[state=active]:bg-white data-[state=active]:text-[#053d57] data-[state=active]:shadow-sm">
|
||||
Awards
|
||||
{roundAwards.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
<Badge variant="secondary" className="ml-2 bg-[#de0f1e] text-white">
|
||||
{roundAwards.length}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
@@ -86,6 +86,19 @@ const roundStatusColors: Record<string, string> = {
|
||||
ROUND_ARCHIVED: 'bg-muted text-muted-foreground',
|
||||
}
|
||||
|
||||
type RoundWithStats = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
roundType: string
|
||||
status: string
|
||||
sortOrder: number
|
||||
windowOpenAt: string | null
|
||||
windowCloseAt: string | null
|
||||
juryGroup: { id: string; name: string } | null
|
||||
_count: { projectRoundStates: number; assignments: number }
|
||||
}
|
||||
|
||||
export default function RoundsPage() {
|
||||
const { currentEdition } = useEdition()
|
||||
const programId = currentEdition?.id
|
||||
@@ -183,33 +196,36 @@ export default function RoundsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Rounds</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage all competition rounds for {currentEdition?.name}
|
||||
<h1 className="text-3xl font-bold tracking-tight text-[#053d57]">Competition Rounds</h1>
|
||||
<p className="text-base text-muted-foreground mt-1.5">
|
||||
Configure and manage evaluation rounds for {currentEdition?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setAddRoundOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Button size="default" onClick={() => setAddRoundOpen(true)} className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Round
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{ROUND_TYPES.map((rt) => (
|
||||
<SelectItem key={rt.value} value={rt.value}>{rt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Card className="p-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm font-medium shrink-0">Filter:</Label>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{ROUND_TYPES.map((rt) => (
|
||||
<SelectItem key={rt.value} value={rt.value}>{rt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
@@ -230,17 +246,17 @@ export default function RoundsPage() {
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && (!competitions || competitions.length === 0) && (
|
||||
<Card className="border-2 border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="rounded-full bg-primary/10 p-4 mb-4">
|
||||
<Layers className="h-10 w-10 text-primary" />
|
||||
<Card className="border-2 border-dashed shadow-sm">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="rounded-full bg-gradient-to-br from-[#de0f1e]/10 to-[#053d57]/10 p-6 mb-6">
|
||||
<Layers className="h-12 w-12 text-[#de0f1e]" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6">
|
||||
<h3 className="text-xl font-semibold mb-2 text-[#053d57]">No Competitions Yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mb-6 leading-relaxed">
|
||||
Create a competition first, then add rounds to define the evaluation flow.
|
||||
</p>
|
||||
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
|
||||
<Button>
|
||||
<Button className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Competition
|
||||
</Button>
|
||||
@@ -379,48 +395,53 @@ function CompetitionGroup({
|
||||
{ enabled: isExpanded }
|
||||
)
|
||||
|
||||
const rounds = compDetail?.rounds ?? []
|
||||
const rounds = (compDetail?.rounds ?? []) as RoundWithStats[]
|
||||
const filteredRounds = filterType === 'all'
|
||||
? rounds
|
||||
: rounds.filter((r: any) => r.roundType === filterType)
|
||||
: rounds.filter((r: RoundWithStats) => r.roundType === filterType)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Card className="shadow-sm border-l-4 transition-shadow hover:shadow-md" style={{ borderLeftColor: cfg.dotClass.includes('emerald') ? '#10b981' : cfg.dotClass.includes('blue') ? '#3b82f6' : '#9ca3af' }}>
|
||||
{/* Competition Header */}
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="shrink-0 rounded p-1 hover:bg-muted transition-colors"
|
||||
className="shrink-0 rounded-lg p-2 hover:bg-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronDown className="h-5 w-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CardTitle className="text-base cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-1">
|
||||
<CardTitle className="text-lg font-semibold cursor-pointer text-[#053d57]" onClick={onToggle}>
|
||||
{comp.name}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className={cn('text-[10px]', cfg.bgClass)}>
|
||||
<Badge variant="secondary" className={cn('text-xs font-medium px-2.5 py-0.5', cfg.bgClass)}>
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{comp._count.rounds} rounds
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{comp._count.juryGroups} juries
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5" />
|
||||
<span>{comp._count.rounds} rounds</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span>{comp._count.juryGroups} juries</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); onStartEdit() }}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
@@ -430,72 +451,78 @@ function CompetitionGroup({
|
||||
|
||||
{/* Inline Competition Settings Editor */}
|
||||
{isEditing && (
|
||||
<CardContent className="border-t bg-muted/30 pt-4">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Competition Name</Label>
|
||||
<Input
|
||||
value={(competitionEdits.name as string) ?? ''}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Category Mode</Label>
|
||||
<Input
|
||||
value={(competitionEdits.categoryMode as string) ?? ''}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, categoryMode: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Startup Finalists</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24"
|
||||
value={(competitionEdits.startupFinalistCount as number) ?? 10}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Concept Finalists</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="w-24"
|
||||
value={(competitionEdits.conceptFinalistCount as number) ?? 10}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
|
||||
/>
|
||||
<Label className="text-xs">Notify on Advance</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnDeadlineApproach: v })}
|
||||
/>
|
||||
<Label className="text-xs">Deadline Reminders</Label>
|
||||
<CardContent className="border-t bg-gradient-to-br from-muted/30 to-muted/10 pt-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[#053d57] mb-4">Competition Settings</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Competition Name</Label>
|
||||
<Input
|
||||
value={(competitionEdits.name as string) ?? ''}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, name: e.target.value })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Category Mode</Label>
|
||||
<Input
|
||||
value={(competitionEdits.categoryMode as string) ?? ''}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, categoryMode: e.target.value })}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Startup Finalists</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-9"
|
||||
value={(competitionEdits.startupFinalistCount as number) ?? 10}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, startupFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Concept Finalists</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="h-9"
|
||||
value={(competitionEdits.conceptFinalistCount as number) ?? 10}
|
||||
onChange={(e) => onEditChange({ ...competitionEdits, conceptFinalistCount: parseInt(e.target.value, 10) || 10 })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnRoundAdvance as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnRoundAdvance: v })}
|
||||
/>
|
||||
<Label className="text-xs font-medium">Notify on Advance</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-6">
|
||||
<Switch
|
||||
checked={(competitionEdits.notifyOnDeadlineApproach as boolean) ?? false}
|
||||
onCheckedChange={(v) => onEditChange({ ...competitionEdits, notifyOnDeadlineApproach: v })}
|
||||
/>
|
||||
<Label className="text-xs font-medium">Deadline Reminders</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
size="default"
|
||||
onClick={onSaveEdit}
|
||||
disabled={updateCompMutation.isPending}
|
||||
className="bg-[#de0f1e] hover:bg-[#de0f1e]/90"
|
||||
>
|
||||
{updateCompMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Save
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onCancelEdit}>Cancel</Button>
|
||||
<Button size="default" variant="outline" onClick={onCancelEdit}>Cancel</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -503,46 +530,113 @@ function CompetitionGroup({
|
||||
|
||||
{/* Rounds List */}
|
||||
{isExpanded && (
|
||||
<CardContent className={cn(isEditing ? '' : 'pt-0')}>
|
||||
<CardContent className={cn(isEditing ? 'pt-6' : 'pt-2')}>
|
||||
{!compDetail ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<Skeleton className="h-20 w-full" />
|
||||
</div>
|
||||
) : filteredRounds.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
{filterType !== 'all' ? 'No rounds match the filter.' : 'No rounds configured.'}
|
||||
</p>
|
||||
<div className="py-12 text-center">
|
||||
<FileBox className="h-10 w-10 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filterType !== 'all' ? 'No rounds match the filter.' : 'No rounds configured.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredRounds.map((round: any, index: number) => (
|
||||
<Link
|
||||
key={round.id}
|
||||
href={`/admin/rounds/${round.id}` as Route}
|
||||
>
|
||||
<div className="flex items-center gap-3 rounded-lg border p-3 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
|
||||
{round.sortOrder + 1}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium truncate">{round.name}</p>
|
||||
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-[10px] shrink-0', roundTypeColors[round.roundType])}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] shrink-0 hidden sm:inline-flex', roundStatusColors[round.status])}
|
||||
>
|
||||
{round.status.replace('ROUND_', '')}
|
||||
</Badge>
|
||||
<div className="relative space-y-3">
|
||||
{filteredRounds.map((round: RoundWithStats, index: number) => {
|
||||
const isLast = index === filteredRounds.length - 1
|
||||
const projectCount = round._count.projectRoundStates
|
||||
const progressPercent = projectCount > 0 ? Math.min((round._count.assignments / (projectCount * 3)) * 100, 100) : 0
|
||||
|
||||
return (
|
||||
<div key={round.id} className="relative">
|
||||
<Link href={`/admin/rounds/${round.id}` as Route}>
|
||||
<div className="flex items-start gap-4 rounded-xl border-2 border-border bg-card p-4 transition-all duration-200 hover:-translate-y-1 hover:shadow-lg hover:border-[#de0f1e]/30 cursor-pointer group">
|
||||
{/* Round Number Circle */}
|
||||
<div className="flex flex-col items-center gap-2 shrink-0">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-[#053d57] to-[#557f8c] text-sm font-bold text-white shadow-md">
|
||||
{round.sortOrder + 1}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<div className="h-8 w-0.5 bg-gradient-to-b from-[#053d57]/30 to-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Round Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 mb-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-base font-semibold truncate text-[#053d57] group-hover:text-[#de0f1e] transition-colors">
|
||||
{round.name}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground mt-1.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileBox className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{projectCount}</span>
|
||||
<span>projects</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">{round._count.assignments}</span>
|
||||
<span>assignments</span>
|
||||
</div>
|
||||
{round.juryGroup && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="truncate">Jury: {round.juryGroup.name}</span>
|
||||
</>
|
||||
)}
|
||||
{(round.windowOpenAt || round.windowCloseAt) && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
<span className="truncate">
|
||||
{round.windowOpenAt && new Date(round.windowOpenAt).toLocaleDateString()}
|
||||
{round.windowOpenAt && round.windowCloseAt && ' - '}
|
||||
{round.windowCloseAt && new Date(round.windowCloseAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('text-xs font-medium px-2.5 py-1', roundTypeColors[round.roundType])}
|
||||
>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-xs font-medium px-2.5 py-1 hidden sm:inline-flex', roundStatusColors[round.status])}
|
||||
>
|
||||
{round.status.replace('ROUND_', '')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{projectCount > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-[#557f8c] to-[#053d57] transition-all duration-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -9,16 +9,19 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Plus, Lock, Unlock, LockKeyhole, Loader2 } from 'lucide-react'
|
||||
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
type SubmissionWindowManagerProps = {
|
||||
competitionId: string
|
||||
@@ -27,14 +30,36 @@ type SubmissionWindowManagerProps = {
|
||||
|
||||
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
const [roundNumber, setRoundNumber] = useState(1)
|
||||
const [editingWindow, setEditingWindow] = useState<string | null>(null)
|
||||
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
|
||||
|
||||
// Create form state
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
})
|
||||
|
||||
// Edit form state
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
sortOrder: 1,
|
||||
})
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// For now, we'll query all windows for the competition
|
||||
// In a real implementation, we'd filter by round or have a dedicated endpoint
|
||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
||||
id: competitionId,
|
||||
})
|
||||
@@ -44,9 +69,35 @@ export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWi
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window created')
|
||||
setIsCreateOpen(false)
|
||||
setName('')
|
||||
setSlug('')
|
||||
setRoundNumber(1)
|
||||
// Reset form
|
||||
setCreateForm({
|
||||
name: '',
|
||||
slug: '',
|
||||
roundNumber: 1,
|
||||
windowOpenAt: '',
|
||||
windowCloseAt: '',
|
||||
deadlinePolicy: 'HARD_DEADLINE',
|
||||
graceHours: 0,
|
||||
lockOnClose: true,
|
||||
})
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window updated')
|
||||
setEditingWindow(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.competition.getById.invalidate({ id: competitionId })
|
||||
toast.success('Submission window deleted')
|
||||
setDeletingWindow(null)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -75,24 +126,79 @@ export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWi
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleCreateNameChange = (value: string) => {
|
||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
setCreateForm({ ...createForm, name: value, slug: autoSlug })
|
||||
}
|
||||
|
||||
const handleEditNameChange = (value: string) => {
|
||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
setEditForm({ ...editForm, name: value, slug: autoSlug })
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!name || !slug) {
|
||||
if (!createForm.name || !createForm.slug) {
|
||||
toast.error('Name and slug are required')
|
||||
return
|
||||
}
|
||||
|
||||
createWindowMutation.mutate({
|
||||
competitionId,
|
||||
name,
|
||||
slug,
|
||||
roundNumber,
|
||||
name: createForm.name,
|
||||
slug: createForm.slug,
|
||||
roundNumber: createForm.roundNumber,
|
||||
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
|
||||
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
|
||||
deadlinePolicy: createForm.deadlinePolicy,
|
||||
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
|
||||
lockOnClose: createForm.lockOnClose,
|
||||
})
|
||||
}
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value)
|
||||
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
||||
setSlug(autoSlug)
|
||||
const handleEdit = () => {
|
||||
if (!editingWindow) return
|
||||
if (!editForm.name || !editForm.slug) {
|
||||
toast.error('Name and slug are required')
|
||||
return
|
||||
}
|
||||
|
||||
updateWindowMutation.mutate({
|
||||
id: editingWindow,
|
||||
name: editForm.name,
|
||||
slug: editForm.slug,
|
||||
roundNumber: editForm.roundNumber,
|
||||
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
|
||||
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
|
||||
deadlinePolicy: editForm.deadlinePolicy,
|
||||
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
|
||||
lockOnClose: editForm.lockOnClose,
|
||||
sortOrder: editForm.sortOrder,
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!deletingWindow) return
|
||||
deleteWindowMutation.mutate({ id: deletingWindow })
|
||||
}
|
||||
|
||||
const openEditDialog = (window: any) => {
|
||||
setEditForm({
|
||||
name: window.name,
|
||||
slug: window.slug,
|
||||
roundNumber: window.roundNumber,
|
||||
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
|
||||
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
|
||||
deadlinePolicy: 'HARD_DEADLINE', // Not available in query, use default
|
||||
graceHours: 0, // Not available in query, use default
|
||||
lockOnClose: true, // Not available in query, use default
|
||||
sortOrder: 1, // Not available in query, use default
|
||||
})
|
||||
setEditingWindow(window.id)
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | null | undefined) => {
|
||||
if (!date) return 'Not set'
|
||||
return format(new Date(date), 'MMM d, yyyy h:mm a')
|
||||
}
|
||||
|
||||
const windows = competition?.submissionWindows ?? []
|
||||
@@ -115,42 +221,105 @@ export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWi
|
||||
Create Window
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Submission Window</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="windowName">Window Name</Label>
|
||||
<Label htmlFor="create-name">Window Name</Label>
|
||||
<Input
|
||||
id="windowName"
|
||||
id="create-name"
|
||||
placeholder="e.g., Round 1 Submissions"
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
value={createForm.name}
|
||||
onChange={(e) => handleCreateNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="windowSlug">Slug</Label>
|
||||
<Label htmlFor="create-slug">Slug</Label>
|
||||
<Input
|
||||
id="windowSlug"
|
||||
id="create-slug"
|
||||
placeholder="e.g., round-1-submissions"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value)}
|
||||
value={createForm.slug}
|
||||
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="roundNumber">Round Number</Label>
|
||||
<Label htmlFor="create-roundNumber">Round Number</Label>
|
||||
<Input
|
||||
id="roundNumber"
|
||||
id="create-roundNumber"
|
||||
type="number"
|
||||
min={1}
|
||||
value={roundNumber}
|
||||
onChange={(e) => setRoundNumber(parseInt(e.target.value, 10))}
|
||||
value={createForm.roundNumber}
|
||||
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
|
||||
<Input
|
||||
id="create-windowOpenAt"
|
||||
type="datetime-local"
|
||||
value={createForm.windowOpenAt}
|
||||
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
|
||||
<Input
|
||||
id="create-windowCloseAt"
|
||||
type="datetime-local"
|
||||
value={createForm.windowCloseAt}
|
||||
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
|
||||
<Select
|
||||
value={createForm.deadlinePolicy}
|
||||
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
||||
setCreateForm({ ...createForm, deadlinePolicy: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="create-deadlinePolicy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
||||
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
||||
<SelectItem value="GRACE">Grace Period</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{createForm.deadlinePolicy === 'GRACE' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-graceHours">Grace Hours</Label>
|
||||
<Input
|
||||
id="create-graceHours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={createForm.graceHours}
|
||||
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="create-lockOnClose"
|
||||
checked={createForm.lockOnClose}
|
||||
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
|
||||
/>
|
||||
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
|
||||
Lock window on close
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -195,89 +364,113 @@ export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWi
|
||||
return (
|
||||
<div
|
||||
key={window.id}
|
||||
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border rounded-lg p-3"
|
||||
className="flex flex-col gap-3 border rounded-lg p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">{window.name}</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="text-sm font-medium truncate">{window.name}</p>
|
||||
{isPending && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
|
||||
Open
|
||||
</Badge>
|
||||
)}
|
||||
{isClosed && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
|
||||
Closed
|
||||
</Badge>
|
||||
)}
|
||||
{isLocked && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
|
||||
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
|
||||
Locked
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>Round {window.roundNumber}</span>
|
||||
<span>•</span>
|
||||
<span>{window._count.fileRequirements} requirements</span>
|
||||
<span>•</span>
|
||||
<span>{window._count.projectFiles} files</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>Open: {formatDate(window.windowOpenAt)}</span>
|
||||
<span>•</span>
|
||||
<span>Close: {formatDate(window.windowCloseAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditDialog(window)}
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeletingWindow(window.id)}
|
||||
className="h-8 px-2 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
{isPending && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
|
||||
Pending
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={openWindowMutation.isPending}
|
||||
>
|
||||
{openWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
|
||||
Open
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={closeWindowMutation.isPending}
|
||||
>
|
||||
{closeWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
{isClosed && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
|
||||
Closed
|
||||
</Badge>
|
||||
)}
|
||||
{isLocked && (
|
||||
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
|
||||
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
|
||||
Locked
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={lockWindowMutation.isPending}
|
||||
>
|
||||
{lockWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<LockKeyhole className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Lock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
|
||||
<span>Round {window.roundNumber}</span>
|
||||
<span>•</span>
|
||||
<span>{window._count.fileRequirements} requirements</span>
|
||||
<span>•</span>
|
||||
<span>{window._count.projectFiles} files</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{isPending && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={openWindowMutation.isPending}
|
||||
>
|
||||
{openWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Unlock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Open
|
||||
</Button>
|
||||
)}
|
||||
{isOpen && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={closeWindowMutation.isPending}
|
||||
>
|
||||
{closeWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
{isClosed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
|
||||
disabled={lockWindowMutation.isPending}
|
||||
>
|
||||
{lockWindowMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<LockKeyhole className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Lock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -286,6 +479,176 @@ export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWi
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Submission Window</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Window Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
placeholder="e.g., Round 1 Submissions"
|
||||
value={editForm.name}
|
||||
onChange={(e) => handleEditNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-slug">Slug</Label>
|
||||
<Input
|
||||
id="edit-slug"
|
||||
placeholder="e.g., round-1-submissions"
|
||||
value={editForm.slug}
|
||||
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-roundNumber">Round Number</Label>
|
||||
<Input
|
||||
id="edit-roundNumber"
|
||||
type="number"
|
||||
min={1}
|
||||
value={editForm.roundNumber}
|
||||
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
|
||||
<Input
|
||||
id="edit-windowOpenAt"
|
||||
type="datetime-local"
|
||||
value={editForm.windowOpenAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
|
||||
<Input
|
||||
id="edit-windowCloseAt"
|
||||
type="datetime-local"
|
||||
value={editForm.windowCloseAt}
|
||||
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
|
||||
<Select
|
||||
value={editForm.deadlinePolicy}
|
||||
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
|
||||
setEditForm({ ...editForm, deadlinePolicy: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="edit-deadlinePolicy">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
|
||||
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
|
||||
<SelectItem value="GRACE">Grace Period</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{editForm.deadlinePolicy === 'GRACE' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-graceHours">Grace Hours</Label>
|
||||
<Input
|
||||
id="edit-graceHours"
|
||||
type="number"
|
||||
min={0}
|
||||
value={editForm.graceHours}
|
||||
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="edit-lockOnClose"
|
||||
checked={editForm.lockOnClose}
|
||||
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
|
||||
/>
|
||||
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
|
||||
Lock window on close
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-sortOrder">Sort Order</Label>
|
||||
<Input
|
||||
id="edit-sortOrder"
|
||||
type="number"
|
||||
min={1}
|
||||
value={editForm.sortOrder}
|
||||
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => setEditingWindow(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleEdit}
|
||||
disabled={updateWindowMutation.isPending}
|
||||
>
|
||||
{updateWindowMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Submission Window</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this submission window? This action cannot be undone.
|
||||
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
|
||||
<span className="block mt-2 text-destructive font-medium">
|
||||
Warning: This window has uploaded files and cannot be deleted until they are removed.
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeletingWindow(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteWindowMutation.isPending}
|
||||
>
|
||||
{deleteWindowMutation.isPending && (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
)}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -36,23 +36,11 @@ export function IntakeConfig({ config, onChange }: IntakeConfigProps) {
|
||||
onChange({ ...config, [key]: value })
|
||||
}
|
||||
|
||||
const acceptedCategories = (config.acceptedCategories as string[]) ?? ['STARTUP', 'BUSINESS_CONCEPT']
|
||||
const allowedMimeTypes = (config.allowedMimeTypes as string[]) ?? ['application/pdf']
|
||||
const customFields = (config.customFields as Array<{
|
||||
id: string; label: string; type: string; required: boolean; options?: string[]
|
||||
}>) ?? []
|
||||
|
||||
const toggleCategory = (cat: string) => {
|
||||
const current = [...acceptedCategories]
|
||||
const idx = current.indexOf(cat)
|
||||
if (idx >= 0) {
|
||||
current.splice(idx, 1)
|
||||
} else {
|
||||
current.push(cat)
|
||||
}
|
||||
update('acceptedCategories', current)
|
||||
}
|
||||
|
||||
const toggleMime = (mime: string) => {
|
||||
const current = [...allowedMimeTypes]
|
||||
const idx = current.indexOf(mime)
|
||||
@@ -141,28 +129,6 @@ export function IntakeConfig({ config, onChange }: IntakeConfigProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Categories */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Accepted Categories</CardTitle>
|
||||
<CardDescription>Which project categories can submit in this round</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['STARTUP', 'BUSINESS_CONCEPT'].map((cat) => (
|
||||
<Badge
|
||||
key={cat}
|
||||
variant={acceptedCategories.includes(cat) ? 'default' : 'outline'}
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => toggleCategory(cat)}
|
||||
>
|
||||
{cat === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -78,6 +78,15 @@ export const competitionRouter = router({
|
||||
sortOrder: true,
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
juryGroup: {
|
||||
select: { id: true, name: true },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
projectRoundStates: true,
|
||||
assignments: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
juryGroups: {
|
||||
@@ -102,6 +111,10 @@ export const competitionRouter = router({
|
||||
windowOpenAt: true,
|
||||
windowCloseAt: true,
|
||||
isLocked: true,
|
||||
deadlinePolicy: true,
|
||||
graceHours: true,
|
||||
lockOnClose: true,
|
||||
sortOrder: true,
|
||||
_count: { select: { fileRequirements: true, projectFiles: true } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -293,6 +293,79 @@ export const roundRouter = router({
|
||||
return window
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update an existing submission window
|
||||
*/
|
||||
updateSubmissionWindow: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
|
||||
roundNumber: z.number().int().min(1).optional(),
|
||||
windowOpenAt: z.date().nullable().optional(),
|
||||
windowCloseAt: z.date().nullable().optional(),
|
||||
deadlinePolicy: z.enum(['HARD_DEADLINE', 'FLAG', 'GRACE']).optional(),
|
||||
graceHours: z.number().int().min(0).nullable().optional(),
|
||||
lockOnClose: z.boolean().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
const window = await ctx.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.submissionWindow.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: id,
|
||||
detailsJson: data,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
return updated
|
||||
})
|
||||
return window
|
||||
}),
|
||||
|
||||
/**
|
||||
* Delete a submission window (only if no files uploaded)
|
||||
*/
|
||||
deleteSubmissionWindow: adminProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if window has uploaded files
|
||||
const window = await ctx.prisma.submissionWindow.findUniqueOrThrow({
|
||||
where: { id: input.id },
|
||||
select: { id: true, name: true, _count: { select: { projectFiles: true } } },
|
||||
})
|
||||
if (window._count.projectFiles > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Cannot delete window "${window.name}" — it has ${window._count.projectFiles} uploaded files. Remove files first.`,
|
||||
})
|
||||
}
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.submissionWindow.delete({ where: { id: input.id } })
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SubmissionWindow',
|
||||
entityId: input.id,
|
||||
detailsJson: { name: window.name },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
})
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Open a submission window
|
||||
*/
|
||||
|
||||
@@ -125,14 +125,15 @@ export const specialAwardRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, scoringMode: input.scoringMode } as Prisma.InputJsonValue,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'CREATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: created.id,
|
||||
detailsJson: { name: input.name, scoringMode: input.scoringMode },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return created
|
||||
@@ -190,13 +191,14 @@ export const specialAwardRouter = router({
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.$transaction(async (tx) => {
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
},
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'DELETE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
await tx.specialAward.delete({ where: { id: input.id } })
|
||||
@@ -249,22 +251,23 @@ export const specialAwardRouter = router({
|
||||
data: updateData,
|
||||
})
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_STATUS',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
previousStatus: current.status,
|
||||
newStatus: input.status,
|
||||
...(votingStartAtUpdated && {
|
||||
votingStartAtUpdated: true,
|
||||
previousVotingStartAt: current.votingStartAt,
|
||||
newVotingStartAt: now,
|
||||
}),
|
||||
} as Prisma.InputJsonValue,
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE_STATUS',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.id,
|
||||
detailsJson: {
|
||||
previousStatus: current.status,
|
||||
newStatus: input.status,
|
||||
...(votingStartAtUpdated && {
|
||||
votingStartAtUpdated: true,
|
||||
previousVotingStartAt: current.votingStartAt,
|
||||
newVotingStartAt: now,
|
||||
}),
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
@@ -750,19 +753,20 @@ export const specialAwardRouter = router({
|
||||
},
|
||||
})
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'SET_AWARD_WINNER',
|
||||
previousWinner: previous.winnerProjectId,
|
||||
newWinner: input.projectId,
|
||||
overridden: input.overridden,
|
||||
} as Prisma.InputJsonValue,
|
||||
await logAudit({
|
||||
prisma: tx,
|
||||
userId: ctx.user.id,
|
||||
action: 'UPDATE',
|
||||
entityType: 'SpecialAward',
|
||||
entityId: input.awardId,
|
||||
detailsJson: {
|
||||
action: 'SET_AWARD_WINNER',
|
||||
previousWinner: previous.winnerProjectId,
|
||||
newWinner: input.projectId,
|
||||
overridden: input.overridden,
|
||||
},
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
return updated
|
||||
|
||||
Reference in New Issue
Block a user