From ffe12a9e85d6a996f86de3e1ce391f62855dadff Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Mar 2026 17:08:19 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20applicant=20dashboard=20=E2=80=94=20tea?= =?UTF-8?q?m=20cards,=20editable=20description,=20feedback=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace flat team names list with proper cards showing roles and badges - Hide TeamMembers from metadata display, remove Withdraw from header - Add inline-editable project description (admin-toggleable setting) - Move applicant feedback visibility from per-round config to admin settings - Support EVALUATION, LIVE_FINAL, DELIBERATION round types in feedback - Backwards-compatible: falls back to old per-round config if no settings exist - Add observer team tab toggle and 10 new SystemSettings seed entries Co-Authored-By: Claude Opus 4.6 --- prisma/seed.ts | 1 + src/app/(applicant)/applicant/page.tsx | 172 ++++++++++++++----- src/components/settings/settings-content.tsx | 10 ++ src/server/routers/applicant.ts | 129 +++++++++++--- src/server/routers/settings.ts | 2 + 5 files changed, 247 insertions(+), 67 deletions(-) diff --git a/prisma/seed.ts b/prisma/seed.ts index 8496807..2ec3d3d 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -927,6 +927,7 @@ async function main() { { key: 'applicant_show_livefinal_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show individual jury scores from live finals' }, { key: 'applicant_show_deliberation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show deliberation results to applicants' }, { key: 'applicant_hide_feedback_from_rejected', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Hide feedback from rejected projects' }, + { key: 'applicant_allow_description_edit', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Allow applicants to edit their project description' }, ] for (const s of visibilitySettings) { await prisma.systemSettings.upsert({ diff --git a/src/app/(applicant)/applicant/page.tsx b/src/app/(applicant)/applicant/page.tsx index b585089..5b67cde 100644 --- a/src/app/(applicant)/applicant/page.tsx +++ b/src/app/(applicant)/applicant/page.tsx @@ -1,5 +1,6 @@ 'use client' +import { useState } from 'react' import { useSession } from 'next-auth/react' import Link from 'next/link' import type { Route } from 'next' @@ -13,9 +14,8 @@ import { CardTitle, } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' -import { StatusTracker } from '@/components/shared/status-tracker' +import { Textarea } from '@/components/ui/textarea' import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline' -import { WithdrawButton } from '@/components/applicant/withdraw-button' import { MentoringRequestCard } from '@/components/applicant/mentoring-request-card' import { AnimatedCard } from '@/components/shared/animated-container' import { ProjectLogoUpload } from '@/components/shared/project-logo-upload' @@ -31,7 +31,12 @@ import { Star, AlertCircle, Pencil, + Loader2, + Check, + X, + UserCircle, } from 'lucide-react' +import { toast } from 'sonner' const statusColors: Record = { DRAFT: 'secondary', @@ -44,6 +49,9 @@ const statusColors: Record @@ -118,10 +130,11 @@ export default function ApplicantDashboardPage() { const programYear = project.program?.year const programName = project.program?.name const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0 + const canEditDescription = flags?.applicantAllowDescriptionEdit && !isRejected return (
- {/* Header */} + {/* Header — no withdraw button here */}
{/* Project logo — clickable for any team member to change */} @@ -163,9 +176,6 @@ export default function ApplicantDashboardPage() {

- {project.isTeamLead && currentStatus !== 'REJECTED' && (currentStatus as string) !== 'WINNER' && ( - - )}
@@ -184,12 +194,19 @@ export default function ApplicantDashboardPage() {

{project.teamName}

)} - {project.description && ( + {/* Description — editable if admin allows */} + {project.description && !canEditDescription && (

Description

{project.description}

)} + {canEditDescription && ( + + )} {project.tags && project.tags.length > 0 && (

Tags

@@ -203,22 +220,27 @@ export default function ApplicantDashboardPage() {
)} - {/* Metadata */} - {project.metadataJson && Object.keys(project.metadataJson as Record).length > 0 && ( -
-

Additional Information

-
- {Object.entries(project.metadataJson as Record).map(([key, value]) => ( -
-
- {key.replace(/_/g, ' ')} -
-
{String(value)}
-
- ))} -
-
- )} + {/* Metadata — filter out team members (shown in sidebar) */} + {project.metadataJson && (() => { + const entries = Object.entries(project.metadataJson as Record) + .filter(([key]) => !HIDDEN_METADATA_KEYS.has(key)) + if (entries.length === 0) return null + return ( +
+

Additional Information

+
+ {entries.map(([key, value]) => ( +
+
+ {key.replace(/_/g, ' ')} +
+
{String(value)}
+
+ ))} +
+
+ ) + })()} {/* Meta info row */}
@@ -338,7 +360,7 @@ export default function ApplicantDashboardPage() { {/* Sidebar */}
- {/* Competition timeline or status tracker */} + {/* Competition timeline */} @@ -350,7 +372,7 @@ export default function ApplicantDashboardPage() { - {/* Mentoring Request Card — show when there's an active MENTORING round */} + {/* Mentoring Request Card */} {project.isTeamLead && openRounds.filter((r) => r.roundType === 'MENTORING').map((mentoringRound) => (
- -

- {totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '} - {evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}. -

+ + {evaluations?.map((round) => ( +
+ {round.roundName} + + {round.evaluationCount} review{round.evaluationCount !== 1 ? 's' : ''} + +
+ ))}
)} - {/* Team overview */} + {/* Team overview — proper cards */} @@ -404,27 +430,25 @@ export default function ApplicantDashboardPage() {
- + {project.teamMembers.length > 0 ? ( project.teamMembers.slice(0, 5).map((member) => ( -
-
+
+
{member.role === 'LEAD' ? ( - + ) : ( - - {member.user.name?.charAt(0).toUpperCase() || '?'} - + )}

{member.user.name || member.user.email}

-

- {member.role === 'LEAD' ? 'Team Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} -

+ + {member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'} +
)) ) : ( @@ -494,3 +518,69 @@ export default function ApplicantDashboardPage() {
) } + +function EditableDescription({ projectId, initialDescription }: { projectId: string; initialDescription: string }) { + const [isEditing, setIsEditing] = useState(false) + const [description, setDescription] = useState(initialDescription) + const utils = trpc.useUtils() + + const mutation = trpc.applicant.updateDescription.useMutation({ + onSuccess: () => { + utils.applicant.getMyDashboard.invalidate() + setIsEditing(false) + toast.success('Description updated') + }, + onError: (e) => toast.error(e.message), + }) + + const handleSave = () => { + mutation.mutate({ projectId, description }) + } + + const handleCancel = () => { + setDescription(initialDescription) + setIsEditing(false) + } + + if (!isEditing) { + return ( +
+
+

Description

+ +
+

{initialDescription || No description yet. Click Edit to add one.}

+
+ ) + } + + return ( +
+

Description

+