Files
MOPC-Portal/src/components/admin/round/file-requirements-editor.tsx
Matt 1308c3ba87
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code
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>
2026-02-19 08:20:13 +01:00

404 lines
15 KiB
TypeScript

'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,
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 MIME_TYPE_OPTIONS: { label: string; value: string }[] = [
{ label: 'PDF', value: 'application/pdf' },
{ label: 'Images', value: 'image/*' },
{ label: 'Video', value: 'video/*' },
{ label: 'Word', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
{ label: 'Excel', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ label: 'PowerPoint', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
]
function getMimeLabel(mime: string): string {
const preset = MIME_TYPE_OPTIONS.find((p) => p.value === mime)
if (preset) return preset.label
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
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 || [],
maxSizeMB: req.maxSizeMB?.toString() || '',
isRequired: req.isRequired ?? true,
})
setEditingId(req.id)
setDialogOpen(true)
}
const toggleMimeType = (mime: string) => {
setForm((prev) => ({
...prev,
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
? prev.acceptedMimeTypes.filter((m) => m !== mime)
: [...prev.acceptedMimeTypes, mime],
}))
}
const handleSubmit = () => {
const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined
if (editingId) {
updateMutation.mutate({
id: editingId,
name: form.name,
description: form.description || null,
acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB: maxSize ?? null,
isRequired: form.isRequired,
})
} else {
createMutation.mutate({
roundId,
name: form.name,
description: form.description || undefined,
acceptedMimeTypes: form.acceptedMimeTypes,
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&apos;s active window
</p>
</div>
<div className="text-right text-sm">
{windowOpenAt || windowCloseAt ? (
<>
<p className="font-medium">
{windowOpenAt ? new Date(windowOpenAt).toLocaleDateString() : 'No start'} &mdash;{' '}
{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 &mdash; 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-1 mt-1.5">
{req.acceptedMimeTypes?.length > 0 ? (
req.acceptedMimeTypes.map((t: string) => (
<Badge key={t} variant="outline" className="text-[10px]">
{getMimeLabel(t)}
</Badge>
))
) : (
<Badge variant="outline" className="text-[10px]">
Any file type
</Badge>
)}
{req.maxSizeMB && (
<Badge variant="outline" className="text-[10px]">
Max {req.maxSizeMB} MB
</Badge>
)}
</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 &quot;{req.name}&quot; 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>
<div className="flex flex-wrap gap-2">
{MIME_TYPE_OPTIONS.map((opt) => (
<Badge
key={opt.value}
variant={form.acceptedMimeTypes.includes(opt.value) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleMimeType(opt.value)}
>
{opt.label}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">
Select one or more file types. Leave empty to accept any file type.
</p>
</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>
)
}