All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
654 lines
25 KiB
TypeScript
654 lines
25 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { toast } from 'sonner'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
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, Pencil, Trash2 } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { format } from 'date-fns'
|
|
|
|
type SubmissionWindowManagerProps = {
|
|
competitionId: string
|
|
roundId: string
|
|
}
|
|
|
|
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
|
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()
|
|
|
|
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
|
id: competitionId,
|
|
})
|
|
|
|
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
|
|
onSuccess: () => {
|
|
utils.competition.getById.invalidate({ id: competitionId })
|
|
toast.success('Submission window created')
|
|
setIsCreateOpen(false)
|
|
// 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),
|
|
})
|
|
|
|
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
|
|
onSuccess: () => {
|
|
utils.competition.getById.invalidate({ id: competitionId })
|
|
toast.success('Window opened')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
|
|
onSuccess: () => {
|
|
utils.competition.getById.invalidate({ id: competitionId })
|
|
toast.success('Window closed')
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
|
|
onSuccess: () => {
|
|
utils.competition.getById.invalidate({ id: competitionId })
|
|
toast.success('Window locked')
|
|
},
|
|
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 (!createForm.name || !createForm.slug) {
|
|
toast.error('Name and slug are required')
|
|
return
|
|
}
|
|
|
|
createWindowMutation.mutate({
|
|
competitionId,
|
|
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 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: window.deadlinePolicy ?? 'HARD_DEADLINE',
|
|
graceHours: window.graceHours ?? 0,
|
|
lockOnClose: window.lockOnClose ?? true,
|
|
sortOrder: window.sortOrder ?? 1,
|
|
})
|
|
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 ?? []
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<CardTitle className="text-base">Submission Windows</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
File upload windows for this round
|
|
</p>
|
|
</div>
|
|
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" variant="outline" className="w-full sm:w-auto">
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
Create Window
|
|
</Button>
|
|
</DialogTrigger>
|
|
<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="create-name">Window Name</Label>
|
|
<Input
|
|
id="create-name"
|
|
placeholder="e.g., Round 1 Submissions"
|
|
value={createForm.name}
|
|
onChange={(e) => handleCreateNameChange(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="create-slug">Slug</Label>
|
|
<Input
|
|
id="create-slug"
|
|
placeholder="e.g., round-1-submissions"
|
|
value={createForm.slug}
|
|
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="create-roundNumber">Round Number</Label>
|
|
<Input
|
|
id="create-roundNumber"
|
|
type="number"
|
|
min={1}
|
|
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"
|
|
className="flex-1"
|
|
onClick={() => setIsCreateOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
className="flex-1"
|
|
onClick={handleCreate}
|
|
disabled={createWindowMutation.isPending}
|
|
>
|
|
{createWindowMutation.isPending && (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
)}
|
|
Create
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
Loading windows...
|
|
</div>
|
|
) : windows.length === 0 ? (
|
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
|
No submission windows yet. Create one to enable file uploads.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{windows.map((window) => {
|
|
const isPending = !window.windowOpenAt
|
|
const isOpen = window.windowOpenAt && !window.windowCloseAt
|
|
const isClosed = window.windowCloseAt && !window.isLocked
|
|
const isLocked = window.isLocked
|
|
|
|
return (
|
|
<div
|
|
key={window.id}
|
|
className="flex flex-col gap-3 border rounded-lg p-3"
|
|
>
|
|
<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 && (
|
|
<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>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</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>
|
|
)
|
|
} |