feat: multi-round messaging, login logo, applicant seed user
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s

- Communication hub now supports selecting multiple rounds when sending
  to Round Jury or Round Applicants (checkbox list instead of dropdown)
- Recipients are unioned across selected rounds with deduplication
- Recipient details grouped by round when multiple rounds selected
- Added MOPC logo above "Welcome back" on login page
- Added matt@letsbe.solutions as seeded APPLICANT account

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 12:22:01 +01:00
parent 3180bfa946
commit e7b99fff63
4 changed files with 205 additions and 71 deletions

View File

@@ -109,7 +109,7 @@ const STATE_BADGE_VARIANT: Record<string, 'default' | 'success' | 'destructive'
export default function MessagesPage() {
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
const [selectedRole, setSelectedRole] = useState('')
const [roundId, setRoundId] = useState('')
const [roundIds, setRoundIds] = useState<string[]>([])
const [selectedProgramId, setSelectedProgramId] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [subject, setSubject] = useState('')
@@ -155,14 +155,14 @@ export default function MessagesPage() {
{
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined,
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
},
{
enabled: recipientType === 'ROUND_APPLICANTS'
? !!roundId
? roundIds.length > 0
: recipientType === 'ROUND_JURY'
? !!roundId
? roundIds.length > 0
: recipientType === 'ROLE'
? !!selectedRole
: recipientType === 'USER'
@@ -179,15 +179,15 @@ export default function MessagesPage() {
{
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined,
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
},
{
enabled: showRecipientDetails && (
recipientType === 'ROUND_APPLICANTS'
? !!roundId
? roundIds.length > 0
: recipientType === 'ROUND_JURY'
? !!roundId
? roundIds.length > 0
: recipientType === 'ROLE'
? !!selectedRole
: recipientType === 'USER'
@@ -224,7 +224,7 @@ export default function MessagesPage() {
setBody('')
setSelectedTemplateId('')
setSelectedRole('')
setRoundId('')
setRoundIds([])
setSelectedProgramId('')
setSelectedUserId('')
setIsScheduled(false)
@@ -276,18 +276,24 @@ export default function MessagesPage() {
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
}
case 'ROUND_JURY': {
if (!roundId) return 'Stage Jury (none selected)'
const stage = rounds?.find((r) => r.id === roundId)
return stage
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
: 'Stage Jury'
if (roundIds.length === 0) return 'Stage Jury (none selected)'
const selectedJuryRounds = rounds?.filter((r) => roundIds.includes(r.id))
if (!selectedJuryRounds?.length) return 'Stage Jury'
if (selectedJuryRounds.length === 1) {
const s = selectedJuryRounds[0]
return `Jury of ${s.program ? `${s.program.name} - ` : ''}${s.name}`
}
return `Jury across ${selectedJuryRounds.length} rounds`
}
case 'ROUND_APPLICANTS': {
if (!roundId) return 'Round Applicants (none selected)'
const appRound = rounds?.find((r) => r.id === roundId)
return appRound
? `Applicants in ${appRound.program ? `${appRound.program.name} - ` : ''}${appRound.name}`
: 'Round Applicants'
if (roundIds.length === 0) return 'Round Applicants (none selected)'
const selectedAppRounds = rounds?.filter((r) => roundIds.includes(r.id))
if (!selectedAppRounds?.length) return 'Round Applicants'
if (selectedAppRounds.length === 1) {
const ar = selectedAppRounds[0]
return `Applicants in ${ar.program ? `${ar.program.name} - ` : ''}${ar.name}`
}
return `Applicants across ${selectedAppRounds.length} rounds`
}
case 'PROGRAM_TEAM': {
if (!selectedProgramId) return 'Program Team (none selected)'
@@ -324,8 +330,8 @@ export default function MessagesPage() {
toast.error('Please select a role')
return false
}
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) {
toast.error('Please select a round')
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && roundIds.length === 0) {
toast.error('Please select at least one round')
return false
}
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
@@ -348,7 +354,7 @@ export default function MessagesPage() {
sendMutation.mutate({
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundId: roundId || undefined,
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
subject: subject.trim(),
body: body.trim(),
@@ -412,7 +418,7 @@ export default function MessagesPage() {
onValueChange={(v) => {
setRecipientType(v as RecipientType)
setSelectedRole('')
setRoundId('')
setRoundIds([])
setSelectedProgramId('')
setSelectedUserId('')
}}
@@ -451,24 +457,44 @@ export default function MessagesPage() {
{(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && (
<div className="space-y-2">
<Label>Select Round</Label>
<Select value={roundId} onValueChange={setRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.program ? `${round.program.name} - ${round.name}` : round.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Label>Select Rounds</Label>
<div className="rounded-lg border p-3 space-y-2 max-h-48 overflow-y-auto">
{rounds?.length === 0 && (
<p className="text-sm text-muted-foreground">No rounds available</p>
)}
{rounds?.map((round) => {
const label = round.program ? `${round.program.name} - ${round.name}` : round.name
const isChecked = roundIds.includes(round.id)
return (
<div key={round.id} className="flex items-center gap-2">
<Checkbox
id={`round-${round.id}`}
checked={isChecked}
onCheckedChange={(checked) => {
setRoundIds((prev) =>
checked
? [...prev, round.id]
: prev.filter((id) => id !== round.id)
)
}}
/>
<label htmlFor={`round-${round.id}`} className="text-sm cursor-pointer">
{label}
</label>
</div>
)
})}
</div>
{roundIds.length > 0 && (
<p className="text-xs text-muted-foreground">
{roundIds.length} round{roundIds.length > 1 ? 's' : ''} selected
</p>
)}
</div>
)}
{/* Exclude filters for Round Applicants */}
{recipientType === 'ROUND_APPLICANTS' && roundId && (
{recipientType === 'ROUND_APPLICANTS' && roundIds.length > 0 && (
<div className="rounded-lg border p-3 space-y-2">
<Label className="text-sm font-medium">Exclude Project States</Label>
<div className="flex flex-wrap gap-4">
@@ -1119,6 +1145,8 @@ type RecipientDetailsData = {
id: string
title: string
state: string
roundId?: string
roundName?: string
members: Array<{ id: string; name: string | null; email: string }>
}>
users: Array<{
@@ -1129,6 +1157,7 @@ type RecipientDetailsData = {
projectNames?: string[]
projectName?: string | null
role?: string
roundNames?: string[]
}>
}
@@ -1162,12 +1191,38 @@ function RecipientDetailsList({
) : !data || (data.projects.length === 0 && data.users.length === 0) ? (
<p className="text-xs text-muted-foreground p-1">No recipients found.</p>
) : data.type === 'projects' ? (
// ROUND_APPLICANTS: projects with their members
data.projects.map((project) => (
<ProjectRecipientRow key={project.id} project={project} />
))
// ROUND_APPLICANTS: projects grouped by round (if multi-round)
(() => {
const roundGroups = new Map<string, { roundName: string; projects: typeof data.projects }>()
for (const project of data.projects) {
const key = project.roundId || '_unknown'
const existing = roundGroups.get(key)
if (existing) {
existing.projects.push(project)
} else {
roundGroups.set(key, {
roundName: project.roundName || 'Unknown Round',
projects: [project],
})
}
}
const groups = Array.from(roundGroups.values())
const isMultiRound = groups.length > 1
return groups.map((group) => (
<div key={group.roundName}>
{isMultiRound && (
<div className="sticky top-0 bg-muted/60 backdrop-blur-sm px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground border-b mb-1">
{group.roundName} ({group.projects.length})
</div>
)}
{group.projects.map((project) => (
<ProjectRecipientRow key={`${project.roundId}-${project.id}`} project={project} />
))}
</div>
))
})()
) : data.type === 'jurors' ? (
// ROUND_JURY: jurors with project counts
// ROUND_JURY: jurors with project counts and round info
data.users.map((user) => (
<div key={user.id} className="flex items-center justify-between rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="min-w-0">
@@ -1177,11 +1232,14 @@ function RecipientDetailsList({
>
{user.name || user.email}
</Link>
{user.projectCount !== undefined && (
<span className="text-muted-foreground">
{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned
</span>
)}
<span className="text-muted-foreground">
{user.projectCount !== undefined && (
<>{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned</>
)}
{user.roundNames && user.roundNames.length > 1 && (
<> &middot; {user.roundNames.length} rounds</>
)}
</span>
</div>
</div>
))
@@ -1219,6 +1277,8 @@ function ProjectRecipientRow({ project }: {
id: string
title: string
state: string
roundId?: string
roundName?: string
members: Array<{ id: string; name: string | null; email: string }>
}
}) {

View File

@@ -4,6 +4,7 @@ import { useState } from 'react'
import type { Route } from 'next'
import { useSearchParams, useRouter } from 'next/navigation'
import { signIn } from 'next-auth/react'
import Image from 'next/image'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -165,6 +166,15 @@ export default function LoginPage() {
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="flex justify-center mb-2">
<Image
src="/images/MOPC-blue-small.png"
alt="MOPC"
width={48}
height={48}
className="h-12 w-auto"
/>
</div>
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>
{mode === 'password'