diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ce1a0b1..325ecbe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -452,6 +452,7 @@ model Round { taggingJobs TaggingJob[] reminderLogs ReminderLog[] projectFiles ProjectFile[] + fileRequirements FileRequirement[] evaluationDiscussions EvaluationDiscussion[] messages Message[] @@ -581,10 +582,31 @@ model Project { @@index([country]) } +model FileRequirement { + id String @id @default(cuid()) + roundId String + name String + description String? + acceptedMimeTypes String[] // e.g. ["application/pdf", "video/*"] + maxSizeMB Int? // Max file size in MB + isRequired Boolean @default(true) + sortOrder Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + round Round @relation(fields: [roundId], references: [id], onDelete: Cascade) + files ProjectFile[] + + @@index([roundId]) +} + model ProjectFile { - id String @id @default(cuid()) - projectId String - roundId String? // Which round this file was submitted for + id String @id @default(cuid()) + projectId String + roundId String? // Which round this file was submitted for + requirementId String? // FK to FileRequirement (if uploaded against a requirement) // File info fileType FileType @@ -605,16 +627,18 @@ model ProjectFile { createdAt DateTime @default(now()) // Relations - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - round Round? @relation(fields: [roundId], references: [id]) - replacedBy ProjectFile? @relation("FileVersions", fields: [replacedById], references: [id], onDelete: SetNull) - replacements ProjectFile[] @relation("FileVersions") + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + round Round? @relation(fields: [roundId], references: [id]) + requirement FileRequirement? @relation(fields: [requirementId], references: [id], onDelete: SetNull) + replacedBy ProjectFile? @relation("FileVersions", fields: [replacedById], references: [id], onDelete: SetNull) + replacements ProjectFile[] @relation("FileVersions") @@unique([bucket, objectKey]) @@index([projectId]) @@index([roundId]) @@index([projectId, roundId]) @@index([fileType]) + @@index([requirementId]) } // ============================================================================= diff --git a/src/app/(admin)/admin/members/[id]/page.tsx b/src/app/(admin)/admin/members/[id]/page.tsx index b1dc794..326096e 100644 --- a/src/app/(admin)/admin/members/[id]/page.tsx +++ b/src/app/(admin)/admin/members/[id]/page.tsx @@ -35,6 +35,16 @@ import { import { toast } from 'sonner' import { TagInput } from '@/components/shared/tag-input' import { UserActivityLog } from '@/components/shared/user-activity-log' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { ArrowLeft, Save, @@ -51,6 +61,8 @@ export default function MemberDetailPage() { const userId = params.id as string const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId }) + const { data: currentUser } = trpc.user.me.useQuery() + const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN' const updateUser = trpc.user.update.useMutation() const sendInvitation = trpc.user.sendInvitation.useMutation() @@ -65,6 +77,8 @@ export default function MemberDetailPage() { const [status, setStatus] = useState('INVITED') const [expertiseTags, setExpertiseTags] = useState([]) const [maxAssignments, setMaxAssignments] = useState('') + const [showSuperAdminConfirm, setShowSuperAdminConfirm] = useState(false) + const [pendingSuperAdminRole, setPendingSuperAdminRole] = useState(false) useEffect(() => { if (user) { @@ -81,7 +95,7 @@ export default function MemberDetailPage() { await updateUser.mutateAsync({ id: userId, name: name || null, - role: role as 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN', + role: role as 'SUPER_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' | 'PROGRAM_ADMIN', status: status as 'INVITED' | 'ACTIVE' | 'SUSPENDED', expertiseTags, maxAssignments: maxAssignments ? parseInt(maxAssignments) : null, @@ -211,15 +225,28 @@ export default function MemberDetailPage() {
- { + if (v === 'SUPER_ADMIN') { + setPendingSuperAdminRole(true) + setShowSuperAdminConfirm(true) + } else { + setRole(v) + } + }} + > + {isSuperAdmin && ( + Super Admin + )} + Program Admin Jury Member Mentor Observer - Program Admin
@@ -377,6 +404,39 @@ export default function MemberDetailPage() { Save Changes + + {/* Super Admin Confirmation Dialog */} + + + + Grant Super Admin Access? + + This will grant {name || user?.name || 'this user'} full Super Admin + access, including user management, system settings, and all administrative + capabilities. This action should only be performed for trusted administrators. + + + + { + setPendingSuperAdminRole(false) + }} + > + Cancel + + { + setRole('SUPER_ADMIN') + setPendingSuperAdminRole(false) + setShowSuperAdminConfirm(false) + }} + className="bg-red-600 hover:bg-red-700" + > + Confirm Super Admin + + + + ) } diff --git a/src/app/(admin)/admin/members/invite/page.tsx b/src/app/(admin)/admin/members/invite/page.tsx index 30e0404..5a42f3d 100644 --- a/src/app/(admin)/admin/members/invite/page.tsx +++ b/src/app/(admin)/admin/members/invite/page.tsx @@ -69,7 +69,7 @@ import { import { cn } from '@/lib/utils' type Step = 'input' | 'preview' | 'sending' | 'complete' -type Role = 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' +type Role = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' interface Assignment { projectId: string @@ -99,6 +99,7 @@ interface ParsedUser { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ const ROLE_LABELS: Record = { + SUPER_ADMIN: 'Super Admin', PROGRAM_ADMIN: 'Program Admin', JURY_MEMBER: 'Jury Member', MENTOR: 'Mentor', @@ -399,16 +400,18 @@ export default function MemberInvitePage() { const name = nameKey ? row[nameKey]?.trim() : undefined const rawRole = roleKey ? row[roleKey]?.trim().toUpperCase() : '' const role: Role = - rawRole === 'PROGRAM_ADMIN' - ? 'PROGRAM_ADMIN' - : rawRole === 'MENTOR' - ? 'MENTOR' - : rawRole === 'OBSERVER' - ? 'OBSERVER' - : 'JURY_MEMBER' + rawRole === 'SUPER_ADMIN' + ? 'SUPER_ADMIN' + : rawRole === 'PROGRAM_ADMIN' + ? 'PROGRAM_ADMIN' + : rawRole === 'MENTOR' + ? 'MENTOR' + : rawRole === 'OBSERVER' + ? 'OBSERVER' + : 'JURY_MEMBER' const isValidFormat = emailRegex.test(email) const isDuplicate = email ? seenEmails.has(email) : false - const isUnauthorizedAdmin = role === 'PROGRAM_ADMIN' && !isSuperAdmin + const isUnauthorizedAdmin = (role === 'PROGRAM_ADMIN' || role === 'SUPER_ADMIN') && !isSuperAdmin if (isValidFormat && !isDuplicate && email) seenEmails.add(email) return { email, @@ -646,6 +649,11 @@ export default function MemberInvitePage() { + {isSuperAdmin && ( + + Super Admin + + )} {isSuperAdmin && ( Program Admin diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 8dc4dab..4478c04 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -32,6 +32,7 @@ import { type Criterion, } from '@/components/forms/evaluation-form-builder' import { RoundTypeSettings } from '@/components/forms/round-type-settings' +import { FileRequirementsEditor } from '@/components/admin/file-requirements-editor' import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react' import { Dialog, @@ -485,6 +486,9 @@ function EditRoundContent({ roundId }: { roundId: string }) { + {/* File Requirements */} + + {/* Jury Features */} diff --git a/src/app/(public)/my-submission/[id]/submission-detail-client.tsx b/src/app/(public)/my-submission/[id]/submission-detail-client.tsx index 493c548..9ed5d45 100644 --- a/src/app/(public)/my-submission/[id]/submission-detail-client.tsx +++ b/src/app/(public)/my-submission/[id]/submission-detail-client.tsx @@ -20,6 +20,7 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { StatusTracker } from '@/components/shared/status-tracker' import { MentorChat } from '@/components/shared/mentor-chat' +import { RequirementUploadList } from '@/components/shared/requirement-upload-slot' import { ArrowLeft, FileText, @@ -321,6 +322,17 @@ export function SubmissionDetailClient() { {/* Documents Tab */} + {/* File Requirements Upload Slots */} + {project.roundId && ( +
+ +
+ )} + Uploaded Documents diff --git a/src/app/(public)/my-submission/[id]/team/page.tsx b/src/app/(public)/my-submission/[id]/team/page.tsx index 54a8f38..b6c8e56 100644 --- a/src/app/(public)/my-submission/[id]/team/page.tsx +++ b/src/app/(public)/my-submission/[id]/team/page.tsx @@ -27,6 +27,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { RequirementUploadList } from '@/components/shared/requirement-upload-slot' import { Dialog, DialogContent, @@ -405,6 +406,24 @@ export default function TeamManagementPage() { + {/* Team Documents */} + {teamData?.roundId && ( + + + Team Documents + + Upload required documents for your project. Any team member can upload files. + + + + + + + )} + {/* Info Card */} diff --git a/src/components/admin/file-requirements-editor.tsx b/src/components/admin/file-requirements-editor.tsx new file mode 100644 index 0000000..cce7b1d --- /dev/null +++ b/src/components/admin/file-requirements-editor.tsx @@ -0,0 +1,375 @@ +'use client' + +import { useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { toast } from 'sonner' +import { + Plus, + Pencil, + Trash2, + GripVertical, + ArrowUp, + ArrowDown, + FileText, + Loader2, +} from 'lucide-react' + +const MIME_TYPE_PRESETS = [ + { label: 'PDF', value: 'application/pdf' }, + { label: 'Images', value: 'image/*' }, + { label: 'Video', value: 'video/*' }, + { label: 'Word Documents', 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_PRESETS.find((p) => p.value === mime) + if (preset) return preset.label + if (mime.endsWith('/*')) return mime.replace('/*', '') + return mime +} + +interface FileRequirementsEditorProps { + roundId: string +} + +interface RequirementFormData { + name: string + description: string + acceptedMimeTypes: string[] + maxSizeMB: string + isRequired: boolean +} + +const emptyForm: RequirementFormData = { + name: '', + description: '', + acceptedMimeTypes: [], + maxSizeMB: '', + isRequired: true, +} + +export function FileRequirementsEditor({ roundId }: FileRequirementsEditorProps) { + 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 created') + }, + onError: (err) => toast.error(err.message), + }) + const updateMutation = trpc.file.updateRequirement.useMutation({ + onSuccess: () => { + utils.file.listRequirements.invalidate({ roundId }) + toast.success('Requirement updated') + }, + onError: (err) => toast.error(err.message), + }) + const deleteMutation = trpc.file.deleteRequirement.useMutation({ + onSuccess: () => { + utils.file.listRequirements.invalidate({ roundId }) + toast.success('Requirement deleted') + }, + onError: (err) => toast.error(err.message), + }) + const reorderMutation = trpc.file.reorderRequirements.useMutation({ + onSuccess: () => utils.file.listRequirements.invalidate({ roundId }), + onError: (err) => toast.error(err.message), + }) + + const [dialogOpen, setDialogOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState(emptyForm) + + const openCreate = () => { + setEditingId(null) + setForm(emptyForm) + setDialogOpen(true) + } + + const openEdit = (req: typeof requirements[number]) => { + setEditingId(req.id) + setForm({ + name: req.name, + description: req.description || '', + acceptedMimeTypes: req.acceptedMimeTypes, + maxSizeMB: req.maxSizeMB?.toString() || '', + isRequired: req.isRequired, + }) + setDialogOpen(true) + } + + const handleSave = async () => { + if (!form.name.trim()) { + toast.error('Name is required') + return + } + + const maxSizeMB = form.maxSizeMB ? parseInt(form.maxSizeMB) : undefined + + if (editingId) { + await updateMutation.mutateAsync({ + id: editingId, + name: form.name.trim(), + description: form.description.trim() || null, + acceptedMimeTypes: form.acceptedMimeTypes, + maxSizeMB: maxSizeMB || null, + isRequired: form.isRequired, + }) + } else { + await createMutation.mutateAsync({ + roundId, + name: form.name.trim(), + description: form.description.trim() || undefined, + acceptedMimeTypes: form.acceptedMimeTypes, + maxSizeMB, + isRequired: form.isRequired, + sortOrder: requirements.length, + }) + } + + setDialogOpen(false) + } + + const handleDelete = async (id: string) => { + await deleteMutation.mutateAsync({ id }) + } + + const handleMove = async (index: number, direction: 'up' | 'down') => { + const newOrder = [...requirements] + const swapIndex = direction === 'up' ? index - 1 : index + 1 + if (swapIndex < 0 || swapIndex >= newOrder.length) return + ;[newOrder[index], newOrder[swapIndex]] = [newOrder[swapIndex], newOrder[index]] + await reorderMutation.mutateAsync({ + roundId, + orderedIds: newOrder.map((r) => r.id), + }) + } + + const toggleMimeType = (mime: string) => { + setForm((prev) => ({ + ...prev, + acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime) + ? prev.acceptedMimeTypes.filter((m) => m !== mime) + : [...prev.acceptedMimeTypes, mime], + })) + } + + const isSaving = createMutation.isPending || updateMutation.isPending + + return ( + + +
+
+ + + File Requirements + + + Define required files applicants must upload for this round + +
+ +
+
+ + {isLoading ? ( +
+ +
+ ) : requirements.length === 0 ? ( +
+ No file requirements defined. Applicants can still upload files freely. +
+ ) : ( +
+ {requirements.map((req, index) => ( +
+
+ + +
+ +
+
+ {req.name} + + {req.isRequired ? 'Required' : 'Optional'} + +
+ {req.description && ( +

{req.description}

+ )} +
+ {req.acceptedMimeTypes.map((mime) => ( + + {getMimeLabel(mime)} + + ))} + {req.maxSizeMB && ( + + Max {req.maxSizeMB}MB + + )} +
+
+
+ + +
+
+ ))} +
+ )} +
+ + {/* Create/Edit Dialog */} + + + + {editingId ? 'Edit' : 'Add'} File Requirement + + Define what file applicants need to upload for this round. + + + +
+
+ + setForm((p) => ({ ...p, name: e.target.value }))} + placeholder="e.g., Executive Summary" + /> +
+ +
+ +