diff --git a/src/app/(admin)/admin/dashboard-content.tsx b/src/app/(admin)/admin/dashboard-content.tsx index 6966586..1ccf7b1 100644 --- a/src/app/(admin)/admin/dashboard-content.tsx +++ b/src/app/(admin)/admin/dashboard-content.tsx @@ -34,6 +34,10 @@ import { Send, Eye, Trash2, + Waves, + Clock, + BarChart3, + Zap, } from 'lucide-react' import { GeographicSummaryCard } from '@/components/charts' import { AnimatedCard } from '@/components/shared/animated-container' @@ -47,6 +51,7 @@ import { truncate, daysUntil, } from '@/lib/utils' +import { motion } from 'motion/react' type DashboardContentProps = { editionId: string @@ -55,7 +60,6 @@ type DashboardContentProps = { function formatEntity(entityType: string | null): string { if (!entityType) return 'record' - // Insert space before uppercase letters (PascalCase → words), then lowercase return entityType .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/_/g, ' ') @@ -65,15 +69,12 @@ function formatEntity(entityType: string | null): string { function formatAction(action: string, entityType: string | null): string { const entity = formatEntity(entityType) const actionMap: Record = { - // Generic CRUD CREATE: `created a ${entity}`, UPDATE: `updated a ${entity}`, DELETE: `deleted a ${entity}`, IMPORT: `imported ${entity}s`, EXPORT: `exported ${entity} data`, REORDER: `reordered ${entity}s`, - - // Auth LOGIN: 'logged in', LOGIN_SUCCESS: 'logged in', LOGIN_FAILED: 'failed to log in', @@ -82,16 +83,12 @@ function formatAction(action: string, entityType: string | null): string { REQUEST_PASSWORD_RESET: 'requested a password reset', COMPLETE_ONBOARDING: 'completed onboarding', DELETE_OWN_ACCOUNT: 'deleted their account', - - // Evaluations EVALUATION_SUBMITTED: 'submitted an evaluation', COI_DECLARED: 'declared a conflict of interest', COI_REVIEWED: 'reviewed a COI declaration', REMINDERS_TRIGGERED: 'triggered evaluation reminders', DISCUSSION_COMMENT_ADDED: 'added a discussion comment', DISCUSSION_CLOSED: 'closed a discussion', - - // Assignments ASSIGN: `assigned a ${entity}`, BULK_CREATE: `bulk created ${entity}s`, BULK_ASSIGN: 'bulk assigned users', @@ -104,32 +101,22 @@ function formatAction(action: string, entityType: string | null): string { ADVANCE_PROJECTS: 'advanced projects to next round', BULK_ASSIGN_TO_ROUND: 'bulk assigned to round', REORDER_ROUNDS: 'reordered rounds', - - // Status STATUS_CHANGE: `changed ${entity} status`, UPDATE_STATUS: `updated ${entity} status`, ROLE_CHANGED: 'changed a user role', - - // Invitations INVITE: 'invited a user', SEND_INVITATION: 'sent an invitation', BULK_SEND_INVITATIONS: 'sent bulk invitations', - - // Files UPLOAD_FILE: 'uploaded a file', DELETE_FILE: 'deleted a file', REPLACE_FILE: 'replaced a file', FILE_DOWNLOADED: 'downloaded a file', - - // Filtering EXECUTE_FILTERING: 'ran project filtering', FINALIZE_FILTERING: 'finalized filtering results', OVERRIDE: `overrode a ${entity} result`, BULK_OVERRIDE: 'bulk overrode filtering results', REINSTATE: 'reinstated a project', BULK_REINSTATE: 'bulk reinstated projects', - - // AI AI_TAG: 'ran AI tagging', START_AI_TAG_JOB: 'started AI tagging job', EVALUATION_SUMMARY: 'generated an AI summary', @@ -137,21 +124,15 @@ function formatAction(action: string, entityType: string | null): string { PROJECT_TAGGING: 'ran project tagging', FILTERING: 'ran AI filtering', MENTOR_MATCHING: 'ran mentor matching', - - // Tags ADD_TAG: 'added a tag', REMOVE_TAG: 'removed a tag', BULK_CREATE_TAGS: 'bulk created tags', - - // Mentor MENTOR_ASSIGN: 'assigned a mentor', MENTOR_UNASSIGN: 'unassigned a mentor', MENTOR_AUTO_ASSIGN: 'auto-assigned mentors', MENTOR_BULK_ASSIGN: 'bulk assigned mentors', CREATE_MENTOR_NOTE: 'created a mentor note', COMPLETE_MILESTONE: 'completed a milestone', - - // Messages & Webhooks SEND_MESSAGE: 'sent a message', CREATE_MESSAGE_TEMPLATE: 'created a message template', UPDATE_MESSAGE_TEMPLATE: 'updated a message template', @@ -161,8 +142,6 @@ function formatAction(action: string, entityType: string | null): string { DELETE_WEBHOOK: 'deleted a webhook', TEST_WEBHOOK: 'tested a webhook', REGENERATE_WEBHOOK_SECRET: 'regenerated a webhook secret', - - // Settings UPDATE_SETTING: 'updated a setting', UPDATE_SETTINGS_BATCH: 'updated settings', UPDATE_NOTIFICATION_PREFERENCES: 'updated notification preferences', @@ -171,36 +150,24 @@ function formatAction(action: string, entityType: string | null): string { UPDATE_AUDIT_SETTINGS: 'updated audit settings', UPDATE_LOCALIZATION_SETTINGS: 'updated localization settings', UPDATE_RETENTION_CONFIG: 'updated retention config', - - // Live Voting START_VOTING: 'started live voting', END_SESSION: 'ended a live voting session', UPDATE_SESSION_CONFIG: 'updated session config', - - // Round Templates CREATE_ROUND_TEMPLATE: 'created a round template', CREATE_ROUND_TEMPLATE_FROM_ROUND: 'saved round as template', UPDATE_ROUND_TEMPLATE: 'updated a round template', DELETE_ROUND_TEMPLATE: 'deleted a round template', UPDATE_EVALUATION_FORM: 'updated the evaluation form', - - // Grace Period GRANT_GRACE_PERIOD: 'granted a grace period', UPDATE_GRACE_PERIOD: 'updated a grace period', REVOKE_GRACE_PERIOD: 'revoked a grace period', BULK_GRANT_GRACE_PERIOD: 'bulk granted grace periods', - - // Awards SET_AWARD_WINNER: 'set an award winner', - - // Reports & Applications REPORT_GENERATED: 'generated a report', DRAFT_SUBMITTED: 'submitted a draft application', SUBMIT: `submitted a ${entity}`, } if (actionMap[action]) return actionMap[action] - - // Fallback: convert ACTION_NAME to readable text return action.toLowerCase().replace(/_/g, ' ') } @@ -208,46 +175,46 @@ function getActionIcon(action: string) { switch (action) { case 'CREATE': case 'BULK_CREATE': - return + return case 'UPDATE': case 'UPDATE_STATUS': case 'BULK_UPDATE': case 'BULK_UPDATE_STATUS': case 'STATUS_CHANGE': case 'ROLE_CHANGED': - return + return case 'DELETE': case 'BULK_DELETE': - return + return case 'LOGIN': case 'LOGIN_SUCCESS': case 'LOGIN_FAILED': case 'PASSWORD_SET': case 'PASSWORD_CHANGED': case 'COMPLETE_ONBOARDING': - return + return case 'EXPORT': case 'REPORT_GENERATED': - return + return case 'SUBMIT': case 'EVALUATION_SUBMITTED': case 'DRAFT_SUBMITTED': - return + return case 'ASSIGN': case 'BULK_ASSIGN': case 'APPLY_SUGGESTIONS': case 'ASSIGN_PROJECTS_TO_ROUND': case 'MENTOR_ASSIGN': case 'MENTOR_BULK_ASSIGN': - return + return case 'INVITE': case 'SEND_INVITATION': case 'BULK_SEND_INVITATIONS': - return + return case 'IMPORT': - return + return default: - return + return } } @@ -319,7 +286,6 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro const invitedJurors = totalJurors - activeJurors - // Compute per-round eval stats const roundsWithEvalStats = recentRounds.map((round: typeof recentRounds[number]) => { const submitted = round.assignments.filter( (a: { evaluation: { status: string } | null }) => a.evaluation?.status === 'SUBMITTED' @@ -329,7 +295,6 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent } }) - // Upcoming deadlines from rounds const now = new Date() const deadlines: { label: string; roundName: string; date: Date }[] = [] for (const round of recentRounds) { @@ -344,7 +309,6 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro deadlines.sort((a, b) => a.date.getTime() - b.date.getTime()) const upcomingDeadlines = deadlines.slice(0, 4) - // Category/issue bars const categories = categoryBreakdown .filter((c) => c.competitionCategory !== null) .map((c) => ({ @@ -365,31 +329,88 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1) const maxIssueCount = Math.max(...issues.map((i) => i.count), 1) + const pendingTotal = pendingCOIs + unassignedProjects + draftRounds + const summaryParts: string[] = [] + if (activeRoundCount > 0) summaryParts.push(`${activeRoundCount} round${activeRoundCount !== 1 ? 's' : ''} active`) + if (totalAssignments - submittedCount > 0) summaryParts.push(`${totalAssignments - submittedCount} evaluations pending`) + if (pendingTotal > 0) summaryParts.push(`${pendingTotal} action${pendingTotal !== 1 ? 's' : ''} needed`) + return ( <> - {/* Header */} -
-

Dashboard

-

- Welcome back, {sessionName} — {edition.name} {edition.year} -

-
+ {/* ── Header Banner ── */} + + {/* Decorative background pattern */} +
+
+
+
+
- {/* Stats Grid */} -
+
+
+
+
+ +
+
+

+ Welcome back, {sessionName} +

+

+ {edition.name} {edition.year} — Command Center +

+
+
+ {summaryParts.length > 0 && ( +

+ {summaryParts.join(' \u00b7 ')} +

+ )} +
+ +
+ + + + + + + + + +
+
+ + + {/* ── Stats Row ── */} +
- - + +
-
-

Rounds

-

{totalRoundCount}

-

- {activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''} +

+

Rounds

+

{totalRoundCount}

+

+ {activeRoundCount} active

-
- +
+
@@ -397,20 +418,18 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro - - + +
-
-

Projects

-

{projectCount}

-

- {newProjectsThisWeek > 0 - ? `${newProjectsThisWeek} new this week` - : 'In this edition'} +

+

Projects

+

{projectCount}

+

+ {newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : 'In edition'}

-
- +
+
@@ -419,17 +438,17 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro - +
-
-

Jury Members

-

{totalJurors}

-

- {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`} +

+

Jury

+

{totalJurors}

+

+ {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} pending`}

-
- +
+
@@ -437,495 +456,526 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro - - + +
-
-

Evaluations

-

+

+

Evaluations

+

{submittedCount} - {totalAssignments > 0 && ( - - {' '}/ {totalAssignments} - - )} + /{totalAssignments} +

+

+ {completionRate.toFixed(0)}% complete

-
- +
+
-
- -

- {completionRate.toFixed(0)}% completion rate -

+ + + + + + 0 ? 'border-l-amber-500' : 'border-l-emerald-400'}`}> + +
+
+

Pending

+

{pendingTotal}

+

0 ? 'text-amber-600' : 'text-emerald-600'}`}> + {pendingTotal > 0 ? 'Actions needed' : 'All clear'} +

+
+
0 ? 'bg-amber-500/10' : 'bg-emerald-400/10'}`}> + {pendingTotal > 0 + ? + : + } +
- {/* Quick Actions */} -
- -
- -
-
-

Rounds

-

Manage rounds

-
- - -
- -
-
-

Import Projects

-

Upload a CSV file

-
- - -
- -
-
-

Invite Jury

-

Add jury members

-
- -
- - {/* Two-Column Content */} + {/* ── Main Two-Column Layout ── */}
- {/* Left Column */} -
- {/* Rounds Card (enhanced) */} - - - -
-
- -
- + + {/* Left Column (2/3) */} +
+ + {/* Active Rounds */} + + + +
+
+
+
- Rounds - - - Active rounds in {edition.name} - -
- - View all - -
-
- - {roundsWithEvalStats.length === 0 ? ( -
- -

- No rounds created yet -

+
+ Active Rounds + + {edition.name} — {roundsWithEvalStats.length} round{roundsWithEvalStats.length !== 1 ? 's' : ''} + +
+
- Set up your rounds + All rounds
- ) : ( -
- {roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number]) => ( - -
-
-
-
-

{round.name}

- -
-

- {round._count.projectRoundStates} projects · {round._count.assignments} assignments + + + {roundsWithEvalStats.length === 0 ? ( +

+
+ +
+

+ No rounds created yet +

+ + + +
+ ) : ( +
+ {roundsWithEvalStats.map((round: typeof roundsWithEvalStats[number], idx: number) => ( + + +
+
+
+
+

{round.name}

+ +
+
+ + + {round._count.projectRoundStates} projects + + + + {round._count.assignments} assignments + + {round.windowOpenAt && round.windowCloseAt && ( + + + {formatDateOnly(round.windowOpenAt)} – {formatDateOnly(round.windowCloseAt)} + + )} +
+
{round.totalEvals > 0 && ( - <> · {round.evalPercent}% evaluated +
+

+ {round.evalPercent}% +

+

evaluated

+
)} -

- {round.windowOpenAt && round.windowCloseAt && ( -

- Window: {formatDateOnly(round.windowOpenAt)} – {formatDateOnly(round.windowCloseAt)} -

+
+ {round.totalEvals > 0 && ( +
+ +

+ {round.submittedEvals} of {round.totalEvals} evaluations submitted +

+
)}
-
- {round.totalEvals > 0 && ( - - )} -
- - ))} -
- )} - - + + + ))} +
+ )} + + - {/* Latest Projects Card */} - - - -
-
- -
- + {/* Latest Projects */} + + + +
+
+
+
- Latest Projects - - Recently submitted projects +
+ Recent Projects + Latest submissions +
+
+ + All projects +
- - View all - -
- - - {latestProjects.length === 0 ? ( -
- -

- No projects submitted yet -

-
- ) : ( -
- {latestProjects.map((project) => ( - -
- -
-
-

- {truncate(project.title, 45)} -

- + + {latestProjects.length === 0 ? ( +
+
+ +
+

+ No projects submitted yet +

+
+ ) : ( +
+ {latestProjects.map((project, idx) => ( + + +
+ +
+
+

+ {truncate(project.title, 50)} +

+ +
+

+ {[ + project.teamName, + project.country ? getCountryName(project.country) : null, + formatDateOnly(project.submittedAt || project.createdAt), + ] + .filter(Boolean) + .join(' \u00b7 ')} +

+
-

- {[ - project.teamName, - project.country ? getCountryName(project.country) : null, - formatDateOnly(project.submittedAt || project.createdAt), - ] - .filter(Boolean) - .join(' \u00b7 ')} -

- {(project.competitionCategory || project.oceanIssue) && ( -

- {[ - project.competitionCategory - ? formatEnumLabel(project.competitionCategory) - : null, - project.oceanIssue - ? formatEnumLabel(project.oceanIssue) - : null, - ] - .filter(Boolean) - .join(' \u00b7 ')} -

- )} -
-
- - ))} -
- )} - - + + + ))} +
+ )} + +
- {/* Right Column */} -
- {/* Pending Actions Card */} - - - - -
- + {/* Right Column (1/3) */} +
+ + {/* Action Required */} + + 0 ? 'border-amber-200/60' : ''}> + +
+
0 ? 'bg-amber-500/10' : 'bg-emerald-500/10'}`}> + {pendingTotal > 0 + ? + : + } +
+ + {pendingTotal > 0 ? 'Action Required' : 'All Clear'} +
- Pending Actions - -
- -
- {pendingCOIs > 0 && ( - -
- - COI declarations to review + + +
+ {pendingCOIs > 0 && ( + +
+ + COI declarations +
+ {pendingCOIs} + + )} + {unassignedProjects > 0 && ( + +
+ + Unassigned projects +
+ {unassignedProjects} + + )} + {draftRounds > 0 && ( + +
+ + Draft rounds +
+ {draftRounds} + + )} + {pendingTotal === 0 && ( +
+
+ +
+

All caught up!

+

No pending actions

- {pendingCOIs} - - )} - {unassignedProjects > 0 && ( - -
- - Projects without assignments -
- {unassignedProjects} - - )} - {draftRounds > 0 && ( - -
- - Draft rounds to activate -
- {draftRounds} - - )} - {pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && ( -
- -

All caught up!

+ )} +
+ + + + + {/* Evaluation Progress */} + + + +
+
+ +
+ Eval Progress +
+
+ + {roundsWithEvalStats.filter((s: typeof roundsWithEvalStats[number]) => s.status !== 'ROUND_DRAFT' && s.totalEvals > 0).length === 0 ? ( +
+ +

+ No evaluations yet +

+
+ ) : ( +
+ {roundsWithEvalStats + .filter((r: typeof roundsWithEvalStats[number]) => r.status !== 'ROUND_DRAFT' && r.totalEvals > 0) + .map((round: typeof roundsWithEvalStats[number]) => ( +
+
+

{round.name}

+ + {round.evalPercent}% + +
+ +

+ {round.submittedEvals}/{round.totalEvals} submitted +

+
+ ))}
)} -
-
- + + - {/* Evaluation Progress Card */} - - - - -
- -
- Evaluation Progress -
-
- - {roundsWithEvalStats.filter((s: typeof roundsWithEvalStats[number]) => s.status !== 'ROUND_DRAFT' && s.totalEvals > 0).length === 0 ? ( -
- -

- No evaluations in progress -

-
- ) : ( -
- {roundsWithEvalStats - .filter((r: typeof roundsWithEvalStats[number]) => r.status !== 'ROUND_DRAFT' && r.totalEvals > 0) - .map((round: typeof roundsWithEvalStats[number]) => ( -
-
-

{round.name}

- - {round.evalPercent}% - -
- -

- {round.submittedEvals} of {round.totalEvals} evaluations submitted -

-
- ))} -
- )} -
-
-
- - {/* Category Breakdown Card */} - - - - -
- -
- Project Categories -
-
- - {categories.length === 0 && issues.length === 0 ? ( -
- -

- No category data available -

-
- ) : ( -
- {categories.length > 0 && ( -
-

- By Type -

- {categories.map((cat) => ( -
-
- {cat.label} - {cat.count} + {/* Upcoming Deadlines */} + {upcomingDeadlines.length > 0 && ( + + + +
+
+ +
+ Deadlines +
+
+ +
+ {upcomingDeadlines.map((deadline, i) => { + const days = daysUntil(deadline.date) + const isUrgent = days <= 7 + return ( +
+
+
-
-
+
+

+ {deadline.roundName} +

+

+ {formatDateOnly(deadline.date)} · {days}d remaining +

- ))} -
- )} - {issues.length > 0 && ( -
-

- Top Issues -

- {issues.map((issue) => ( -
-
- {issue.label} - {issue.count} -
-
-
-
-
- ))} -
- )} -
- )} - - - + ) + })} +
+ + + + )} - {/* Recent Activity Card */} - - - - -
- -
- Recent Activity -
-
- - {recentActivity.length === 0 ? ( -
- -

- No recent activity -

-
- ) : ( -
- {recentActivity.map((log) => ( -
-
- {getActionIcon(log.action)} -
-
-

- {log.user?.name || 'System'} - {' '}{formatAction(log.action, log.entityType)} -

-

- {formatRelativeTime(log.timestamp)} -

-
-
- ))} -
- )} -
-
-
- - {/* Upcoming Deadlines Card */} + {/* Activity Feed */} - - - -
- + + +
+
+ +
+ Activity
- Upcoming Deadlines - -
- - {upcomingDeadlines.length === 0 ? ( -
- -

- No upcoming deadlines -

-
- ) : ( -
- {upcomingDeadlines.map((deadline, i) => { - const days = daysUntil(deadline.date) - const isUrgent = days <= 7 - return ( -
-
- -
-
-

- {deadline.label} — {deadline.roundName} -

-

- {formatDateOnly(deadline.date)} · in {days} day{days !== 1 ? 's' : ''} -

-
-
- ) - })} -
- )} -
-
+ + + {recentActivity.length === 0 ? ( +
+ +

+ No recent activity +

+
+ ) : ( +
+ {/* Timeline line */} +
+
+ {recentActivity.map((log, idx) => ( + +
+ {getActionIcon(log.action)} +
+
+

+ {log.user?.name || 'System'} + {' '}{formatAction(log.action, log.entityType)} +

+

+ {formatRelativeTime(log.timestamp)} +

+
+
+ ))} +
+
+ )} + +
- {/* Geographic Distribution (full width, at the bottom) */} - + {/* ── Bottom Full Width Section ── */} +
+ {/* Geographic Distribution */} +
+ + + +
+ + {/* Category & Issue Breakdown */} +
+ + + +
+
+ +
+ Categories +
+
+ + {categories.length === 0 && issues.length === 0 ? ( +
+ +

+ No category data +

+
+ ) : ( +
+ {categories.length > 0 && ( +
+

+ Competition Type +

+ {categories.map((cat) => ( +
+
+ {cat.label} + {cat.count} +
+
+ +
+
+ ))} +
+ )} + {issues.length > 0 && ( +
+

+ Top Issues +

+ {issues.map((issue) => ( +
+
+ {issue.label} + {issue.count} +
+
+ +
+
+ ))} +
+ )} +
+ )} +
+
+
+
+
) } @@ -934,21 +984,16 @@ function DashboardSkeleton() { return ( <> {/* Header skeleton */} -
- - -
+ - {/* Stats grid skeleton */} -
- {[...Array(4)].map((_, i) => ( - - - - - - - + {/* Stats row skeleton */} +
+ {[...Array(5)].map((_, i) => ( + + + + + ))} @@ -956,24 +1001,24 @@ function DashboardSkeleton() { {/* Two-column content skeleton */}
-
+
- - + +
{[...Array(3)].map((_, i) => ( - + ))}
- - + +
@@ -984,42 +1029,27 @@ function DashboardSkeleton() {
-
- - - -
- {[...Array(2)].map((_, i) => ( - - ))} -
-
-
- - - -
- {[...Array(4)].map((_, i) => ( - - ))} -
-
-
- - - -
- {[...Array(2)].map((_, i) => ( - - ))} -
-
-
+
+ {[...Array(4)].map((_, i) => ( + + + +
+ {[...Array(3)].map((_, j) => ( + + ))} +
+
+
+ ))}
- {/* Map skeleton */} - + {/* Bottom skeleton */} +
+ + +
) } diff --git a/src/app/(admin)/admin/rounds/[roundId]/page.tsx b/src/app/(admin)/admin/rounds/[roundId]/page.tsx index 75ac3cd..64b2153 100644 --- a/src/app/(admin)/admin/rounds/[roundId]/page.tsx +++ b/src/app/(admin)/admin/rounds/[roundId]/page.tsx @@ -71,6 +71,7 @@ import { FileText, Trophy, Clock, + Upload, Send, Download, Plus, @@ -78,7 +79,24 @@ import { ArrowRight, RotateCcw, X, + Check, + ChevronsUpDown, + Search, } from 'lucide-react' +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { ScrollArea } from '@/components/ui/scroll-area' import { RoundConfigForm } from '@/components/admin/competition/round-config-form' import { ProjectStatesTable } from '@/components/admin/round/project-states-table' import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager' @@ -696,7 +714,7 @@ export default function RoundDetailPage() { ...(isEvaluation ? [{ value: 'assignments', label: 'Assignments', icon: ClipboardList }] : []), { value: 'jury', label: 'Jury', icon: Users }, { value: 'config', label: 'Config', icon: Settings }, - { value: 'windows', label: 'Submissions', icon: Clock }, + { value: 'windows', label: 'Document Windows', icon: Upload }, { value: 'awards', label: 'Awards', icon: Trophy }, ].map((tab) => ( {/* Individual Assignments Table */} - + {/* Unassigned Queue */} @@ -1635,6 +1653,12 @@ export default function RoundDetailPage() { {/* ═══════════ SUBMISSION WINDOWS TAB ═══════════ */} +
+

Document Upload Windows

+

+ Configure when applicants can upload documents for each phase of this round. These windows control the submission periods independently of the round's active status. +

+
@@ -1969,10 +1993,18 @@ function ExportEvaluationsDialog({ // ── Individual Assignments Table ───────────────────────────────────────── -function IndividualAssignmentsTable({ roundId }: { roundId: string }) { +function IndividualAssignmentsTable({ + roundId, + projectStates, +}: { + roundId: string + projectStates: any[] | undefined +}) { const [addDialogOpen, setAddDialogOpen] = useState(false) - const [newUserId, setNewUserId] = useState('') - const [newProjectId, setNewProjectId] = useState('') + const [selectedJurorId, setSelectedJurorId] = useState('') + const [selectedProjectIds, setSelectedProjectIds] = useState>(new Set()) + const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false) + const [projectSearch, setProjectSearch] = useState('') const utils = trpc.useUtils() const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery( @@ -1980,9 +2012,15 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) { { refetchInterval: 15_000 }, ) + const { data: juryMembers } = trpc.user.getJuryMembers.useQuery( + { roundId }, + { enabled: addDialogOpen }, + ) + const deleteMutation = trpc.assignment.delete.useMutation({ onSuccess: () => { utils.assignment.listByStage.invalidate({ roundId }) + utils.roundEngine.getProjectStates.invalidate({ roundId }) toast.success('Assignment removed') }, onError: (err) => toast.error(err.message), @@ -1991,14 +2029,102 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) { const createMutation = trpc.assignment.create.useMutation({ onSuccess: () => { utils.assignment.listByStage.invalidate({ roundId }) + utils.roundEngine.getProjectStates.invalidate({ roundId }) + utils.user.getJuryMembers.invalidate({ roundId }) toast.success('Assignment created') - setAddDialogOpen(false) - setNewUserId('') - setNewProjectId('') + resetDialog() }, onError: (err) => toast.error(err.message), }) + const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({ + onSuccess: (result) => { + utils.assignment.listByStage.invalidate({ roundId }) + utils.roundEngine.getProjectStates.invalidate({ roundId }) + utils.user.getJuryMembers.invalidate({ roundId }) + toast.success(`${result.created} assignment(s) created`) + resetDialog() + }, + onError: (err) => toast.error(err.message), + }) + + const resetDialog = useCallback(() => { + setAddDialogOpen(false) + setSelectedJurorId('') + setSelectedProjectIds(new Set()) + setProjectSearch('') + }, []) + + const selectedJuror = useMemo( + () => juryMembers?.find((j: any) => j.id === selectedJurorId), + [juryMembers, selectedJurorId], + ) + + // Filter projects by search term + const filteredProjects = useMemo(() => { + const items = projectStates ?? [] + if (!projectSearch) return items + const q = projectSearch.toLowerCase() + return items.filter((ps: any) => + ps.project?.title?.toLowerCase().includes(q) || + ps.project?.teamName?.toLowerCase().includes(q) || + ps.project?.competitionCategory?.toLowerCase().includes(q) + ) + }, [projectStates, projectSearch]) + + // Existing assignments for the selected juror (to grey out already-assigned projects) + const jurorExistingProjectIds = useMemo(() => { + if (!selectedJurorId || !assignments) return new Set() + return new Set( + assignments + .filter((a: any) => a.userId === selectedJurorId) + .map((a: any) => a.projectId) + ) + }, [selectedJurorId, assignments]) + + const toggleProject = useCallback((projectId: string) => { + setSelectedProjectIds(prev => { + const next = new Set(prev) + if (next.has(projectId)) { + next.delete(projectId) + } else { + next.add(projectId) + } + return next + }) + }, []) + + const selectAllUnassigned = useCallback(() => { + const unassigned = filteredProjects + .filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id)) + .map((ps: any) => ps.project?.id) + .filter(Boolean) + setSelectedProjectIds(new Set(unassigned)) + }, [filteredProjects, jurorExistingProjectIds]) + + const handleCreate = useCallback(() => { + if (!selectedJurorId || selectedProjectIds.size === 0) return + + const projectIds = Array.from(selectedProjectIds) + if (projectIds.length === 1) { + createMutation.mutate({ + userId: selectedJurorId, + projectId: projectIds[0], + roundId, + }) + } else { + bulkCreateMutation.mutate({ + roundId, + assignments: projectIds.map(projectId => ({ + userId: selectedJurorId, + projectId, + })), + }) + } + }, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation]) + + const isMutating = createMutation.isPending || bulkCreateMutation.isPending + return ( @@ -2071,44 +2197,220 @@ function IndividualAssignmentsTable({ roundId }: { roundId: string }) {
{/* Add Assignment Dialog */} - - + { + if (!open) resetDialog() + else setAddDialogOpen(true) + }}> + Add Assignment - Manually assign a juror to evaluate a project + Select a juror and one or more projects to assign +
+ {/* Juror Selector */}
- - setNewUserId(e.target.value)} - /> + + + + + + + + + + No jury members found. + + {juryMembers?.map((juror: any) => { + const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0 + return ( + { + setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id) + setSelectedProjectIds(new Set()) + setJurorPopoverOpen(false) + }} + > + +
+
+

+ {juror.name || 'Unnamed'} +

+

+ {juror.email} +

+
+ + {juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'} + {atCapacity ? ' full' : ''} + +
+
+ ) + })} +
+
+
+
+
+ + {/* Project Multi-Select */}
- - setNewProjectId(e.target.value)} - /> +
+ + {selectedJurorId && ( +
+ + {selectedProjectIds.size > 0 && ( + + )} +
+ )} +
+ + {/* Search input */} +
+ + setProjectSearch(e.target.value)} + className="pl-9 h-9" + /> +
+ + {/* Project checklist */} + +
+ {!selectedJurorId ? ( +

+ Select a juror first +

+ ) : filteredProjects.length === 0 ? ( +

+ No projects found +

+ ) : ( + filteredProjects.map((ps: any) => { + const project = ps.project + if (!project) return null + const alreadyAssigned = jurorExistingProjectIds.has(project.id) + const isSelected = selectedProjectIds.has(project.id) + + return ( + + ) + }) + )} +
+
+ - +
diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index b3e0d5b..e4cc50c 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { SettingsContent } from '@/components/settings/settings-content' // Categories that only super admins can access -const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY']) +const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP']) async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) { const settings = await prisma.systemSettings.findMany({ diff --git a/src/components/admin/assignment/assignment-preview-sheet.tsx b/src/components/admin/assignment/assignment-preview-sheet.tsx index fb30e8b..ea40e14 100644 --- a/src/components/admin/assignment/assignment-preview-sheet.tsx +++ b/src/components/admin/assignment/assignment-preview-sheet.tsx @@ -45,6 +45,8 @@ export function AssignmentPreviewSheet({ toast.success(`Created ${result.created} assignments`) utils.roundAssignment.coverageReport.invalidate({ roundId }) utils.roundAssignment.unassignedQueue.invalidate({ roundId }) + utils.assignment.listByStage.invalidate({ roundId }) + utils.roundEngine.getProjectStates.invalidate({ roundId }) onOpenChange(false) }, onError: (err) => { diff --git a/src/components/admin/round/filtering-dashboard.tsx b/src/components/admin/round/filtering-dashboard.tsx index 7a6e72f..c9ceefe 100644 --- a/src/components/admin/round/filtering-dashboard.tsx +++ b/src/components/admin/round/filtering-dashboard.tsx @@ -198,6 +198,8 @@ export function FilteringDashboard({ competitionId, roundId }: FilteringDashboar onSuccess: (data) => { utils.filtering.getResults.invalidate() utils.filtering.getResultStats.invalidate({ roundId }) + utils.roundEngine.getProjectStates.invalidate({ roundId }) + utils.project.list.invalidate() toast.success( `Finalized: ${data.passed} passed, ${data.filteredOut} filtered out` + (data.advancedToStageName ? `. Next round: ${data.advancedToStageName}` : '') diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 64fa08d..331ec11 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -25,6 +25,7 @@ import { ShieldAlert, Globe, Webhook, + MessageCircle, } from 'lucide-react' import Link from 'next/link' import { AnimatedCard } from '@/components/shared/animated-container' @@ -103,8 +104,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin ]) const storageSettings = getSettingsByKeys([ + 'storage_provider', + 'local_storage_path', 'max_file_size_mb', + 'avatar_max_size_mb', 'allowed_file_types', + 'allowed_image_types', ]) const securitySettings = getSettingsByKeys([ @@ -147,6 +152,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin 'anomaly_off_hours_end', ]) + const whatsappSettings = getSettingsByKeys([ + 'whatsapp_enabled', + 'whatsapp_provider', + ]) + const localizationSettings = getSettingsByKeys([ 'localization_enabled_locales', 'localization_default_locale', @@ -183,6 +193,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin Digest + {isSuperAdmin && ( + + + WhatsApp + + )} {isSuperAdmin && ( @@ -259,6 +275,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin Digest + {isSuperAdmin && ( + + + WhatsApp + + )}
@@ -502,6 +524,24 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin + + {isSuperAdmin && ( + + + + + WhatsApp Notifications + + Configure WhatsApp messaging for notifications + + + + + + + + + )}
{/* end content area */}
{/* end lg:flex */} @@ -794,6 +834,29 @@ function AuditSettingsSection({ settings }: { settings: Record } ) } +function WhatsAppSettingsSection({ settings }: { settings: Record }) { + return ( +
+ + +
+ ) +} + function LocalizationSettingsSection({ settings }: { settings: Record }) { const mutation = useSettingsMutation() const enabledLocales = (settings.localization_enabled_locales || 'en').split(',') diff --git a/src/components/settings/storage-settings-form.tsx b/src/components/settings/storage-settings-form.tsx index 11fd922..206658b 100644 --- a/src/components/settings/storage-settings-form.tsx +++ b/src/components/settings/storage-settings-form.tsx @@ -22,6 +22,14 @@ import { } from '@/components/ui/form' // Note: Storage provider cache is cleared server-side when settings are updated +const COMMON_IMAGE_TYPES = [ + { value: 'image/png', label: 'PNG (.png)' }, + { value: 'image/jpeg', label: 'JPEG (.jpg, .jpeg)' }, + { value: 'image/webp', label: 'WebP (.webp)' }, + { value: 'image/gif', label: 'GIF (.gif)' }, + { value: 'image/svg+xml', label: 'SVG (.svg)' }, +] + const COMMON_FILE_TYPES = [ { value: 'application/pdf', label: 'PDF Documents (.pdf)' }, { value: 'video/mp4', label: 'MP4 Video (.mp4)' }, @@ -41,6 +49,7 @@ const formSchema = z.object({ max_file_size_mb: z.string().regex(/^\d+$/, 'Must be a number'), avatar_max_size_mb: z.string().regex(/^\d+$/, 'Must be a number'), allowed_file_types: z.array(z.string()).min(1, 'Select at least one file type'), + allowed_image_types: z.array(z.string()).min(1, 'Select at least one image type'), }) type FormValues = z.infer @@ -52,6 +61,7 @@ interface StorageSettingsFormProps { max_file_size_mb?: string avatar_max_size_mb?: string allowed_file_types?: string + allowed_image_types?: string } } @@ -68,6 +78,16 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) { allowedTypes = ['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg'] } + // Parse allowed image types from JSON string + let allowedImageTypes: string[] = [] + try { + allowedImageTypes = settings.allowed_image_types + ? JSON.parse(settings.allowed_image_types) + : ['image/png', 'image/jpeg', 'image/webp'] + } catch { + allowedImageTypes = ['image/png', 'image/jpeg', 'image/webp'] + } + const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -76,6 +96,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) { max_file_size_mb: settings.max_file_size_mb || '500', avatar_max_size_mb: settings.avatar_max_size_mb || '5', allowed_file_types: allowedTypes, + allowed_image_types: allowedImageTypes, }, }) @@ -99,6 +120,7 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) { { key: 'max_file_size_mb', value: data.max_file_size_mb }, { key: 'avatar_max_size_mb', value: data.avatar_max_size_mb }, { key: 'allowed_file_types', value: JSON.stringify(data.allowed_file_types) }, + { key: 'allowed_image_types', value: JSON.stringify(data.allowed_image_types) }, ], }) } @@ -255,6 +277,57 @@ export function StorageSettingsForm({ settings }: StorageSettingsFormProps) { )} /> + ( + +
+ Allowed Image Types (Avatars/Logos) + + Select which image formats can be used for profile pictures and project logos + +
+
+ {COMMON_IMAGE_TYPES.map((type) => ( + { + return ( + + + { + return checked + ? field.onChange([...field.value, type.value]) + : field.onChange( + field.value?.filter( + (value) => value !== type.value + ) + ) + }} + /> + + + {type.label} + + + ) + }} + /> + ))} +
+ +
+ )} + /> + {storageProvider === 's3' && (