Round detail overhaul, file requirements, project management, audit log fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents) - Add jury group assignment selector in round stats bar - Add FileRequirementsEditor component replacing SubmissionWindowManager - Add FilteringDashboard component for AI-powered project screening - Add project removal from rounds (single + bulk) with cascading to subsequent rounds - Add project add/remove UI in ProjectStatesTable with confirmation dialogs - Fix logAudit inside $transaction pattern across all 12 router files (PostgreSQL aborted-transaction state caused silent operation failures) - Fix special awards creation, deletion, status update, and winner assignment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
399
src/components/admin/round/file-requirements-editor.tsx
Normal file
399
src/components/admin/round/file-requirements-editor.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
FileText,
|
||||
GripVertical,
|
||||
FileCheck,
|
||||
FileQuestion,
|
||||
} from 'lucide-react'
|
||||
|
||||
type FileRequirementsEditorProps = {
|
||||
roundId: string
|
||||
windowOpenAt?: Date | string | null
|
||||
windowCloseAt?: Date | string | null
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
description: string
|
||||
acceptedMimeTypes: string
|
||||
maxSizeMB: string
|
||||
isRequired: boolean
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
acceptedMimeTypes: '',
|
||||
maxSizeMB: '',
|
||||
isRequired: true,
|
||||
}
|
||||
|
||||
const COMMON_MIME_PRESETS: { label: string; value: string }[] = [
|
||||
{ label: 'PDF only', value: 'application/pdf' },
|
||||
{ label: 'Images', value: 'image/png, image/jpeg, image/webp' },
|
||||
{ label: 'Video', value: 'video/mp4, video/quicktime, video/webm' },
|
||||
{ label: 'Documents', value: 'application/pdf, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
|
||||
{ label: 'Spreadsheets', value: 'application/vnd.ms-excel, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, text/csv' },
|
||||
{ label: 'Presentations', value: 'application/vnd.ms-powerpoint, application/vnd.openxmlformats-officedocument.presentationml.presentation' },
|
||||
{ label: 'Any file', value: '' },
|
||||
]
|
||||
|
||||
export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const { data: requirements, isLoading } = trpc.file.listRequirements.useQuery({ roundId })
|
||||
|
||||
const createMutation = trpc.file.createRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId })
|
||||
toast.success('Requirement added')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const updateMutation = trpc.file.updateRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId })
|
||||
toast.success('Requirement updated')
|
||||
closeDialog()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const deleteMutation = trpc.file.deleteRequirement.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.file.listRequirements.invalidate({ roundId })
|
||||
toast.success('Requirement removed')
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogOpen(false)
|
||||
setEditingId(null)
|
||||
setForm(emptyForm)
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setForm(emptyForm)
|
||||
setEditingId(null)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEditDialog = (req: any) => {
|
||||
setForm({
|
||||
name: req.name,
|
||||
description: req.description || '',
|
||||
acceptedMimeTypes: (req.acceptedMimeTypes || []).join(', '),
|
||||
maxSizeMB: req.maxSizeMB?.toString() || '',
|
||||
isRequired: req.isRequired ?? true,
|
||||
})
|
||||
setEditingId(req.id)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
const mimeTypes = form.acceptedMimeTypes
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined
|
||||
|
||||
if (editingId) {
|
||||
updateMutation.mutate({
|
||||
id: editingId,
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
acceptedMimeTypes: mimeTypes,
|
||||
maxSizeMB: maxSize ?? null,
|
||||
isRequired: form.isRequired,
|
||||
})
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
roundId,
|
||||
name: form.name,
|
||||
description: form.description || undefined,
|
||||
acceptedMimeTypes: mimeTypes,
|
||||
maxSizeMB: maxSize,
|
||||
isRequired: form.isRequired,
|
||||
sortOrder: (requirements?.length ?? 0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isSaving = createMutation.isPending || updateMutation.isPending
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-20 w-full" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Submission period info */}
|
||||
<Card>
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Submission Period</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Applicants can upload documents during the round's active window
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
{windowOpenAt || windowCloseAt ? (
|
||||
<>
|
||||
<p className="font-medium">
|
||||
{windowOpenAt ? new Date(windowOpenAt).toLocaleDateString() : 'No start'} —{' '}
|
||||
{windowCloseAt ? new Date(windowCloseAt).toLocaleDateString() : 'No deadline'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set in the Config tab under round time windows
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No dates configured — set in Config tab</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Requirements list */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base">Required Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Define what files applicants must submit for this round
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Requirement
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!requirements || requirements.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
||||
<div className="rounded-full bg-muted p-4 mb-4">
|
||||
<FileText className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">No Document Requirements</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
|
||||
Add requirements to specify what documents applicants must upload during this round.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="mt-4" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add First Requirement
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{requirements.map((req: any) => (
|
||||
<div
|
||||
key={req.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="mt-0.5 text-muted-foreground">
|
||||
{req.isRequired ? (
|
||||
<FileCheck className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<FileQuestion className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium">{req.name}</p>
|
||||
<Badge variant={req.isRequired ? 'default' : 'secondary'} className="text-[10px]">
|
||||
{req.isRequired ? 'Required' : 'Optional'}
|
||||
</Badge>
|
||||
</div>
|
||||
{req.description && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{req.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-1.5">
|
||||
{req.acceptedMimeTypes?.length > 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
{req.acceptedMimeTypes.map((t: string) => {
|
||||
if (t === 'application/pdf') return 'PDF'
|
||||
if (t.startsWith('image/')) return t.replace('image/', '').toUpperCase()
|
||||
if (t.startsWith('video/')) return t.replace('video/', '').toUpperCase()
|
||||
return t.split('/').pop()?.toUpperCase() || t
|
||||
}).join(', ')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
Any file type
|
||||
</span>
|
||||
)}
|
||||
{req.maxSizeMB && (
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
Max {req.maxSizeMB} MB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditDialog(req)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete requirement?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove "{req.name}" from the round. Previously uploaded files will not be deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteMutation.mutate({ id: req.id })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create / Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!open) closeDialog() }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? 'Edit Requirement' : 'Add Document Requirement'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingId
|
||||
? 'Update the document requirement details.'
|
||||
: 'Define a new document that applicants must submit.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
placeholder="e.g. Business Plan, Pitch Deck, Financial Projections"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Textarea
|
||||
placeholder="Describe what this document should contain..."
|
||||
rows={3}
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Accepted File Types</label>
|
||||
<Input
|
||||
placeholder="application/pdf, image/png (leave empty for any)"
|
||||
value={form.acceptedMimeTypes}
|
||||
onChange={(e) => setForm((f) => ({ ...f, acceptedMimeTypes: e.target.value }))}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{COMMON_MIME_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => setForm((f) => ({ ...f, acceptedMimeTypes: preset.value }))}
|
||||
className="text-[10px] px-2 py-1 rounded-full border hover:bg-muted transition-colors"
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Max File Size (MB)</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="e.g. 50"
|
||||
value={form.maxSizeMB}
|
||||
onChange={(e) => setForm((f) => ({ ...f, maxSizeMB: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isRequired"
|
||||
checked={form.isRequired}
|
||||
onCheckedChange={(checked) => setForm((f) => ({ ...f, isRequired: !!checked }))}
|
||||
/>
|
||||
<label htmlFor="isRequired" className="text-sm">
|
||||
Required document (applicant must upload to proceed)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSaving || !form.name.trim()}>
|
||||
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{editingId ? 'Update' : 'Add Requirement'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user