From 34fc0b81e07f409ccc6614c7bc28e53a01dcfc2f Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 6 Mar 2026 10:32:03 +0100 Subject: [PATCH] feat: revamp communication hub with recipient preview and state filtering - New previewRecipients query shows live project/applicant counts as you compose, with per-state breakdown for Round Applicants - Exclude Rejected/Withdrawn checkboxes filter out terminal-state projects - Compose form now has 2/3 + 1/3 layout with always-visible recipient summary sidebar showing project counts, applicant counts, state badges - Preview dialog enlarged (max-w-3xl) with split layout: email preview on left, recipient/delivery summary on right - Send button now shows recipient count ("Send to N Recipients") - resolveRecipients accepts excludeStates param for ROUND_APPLICANTS Co-Authored-By: Claude Opus 4.6 --- src/app/(admin)/admin/messages/page.tsx | 923 +++++++++++++++--------- src/server/routers/message.ts | 81 ++- 2 files changed, 672 insertions(+), 332 deletions(-) diff --git a/src/app/(admin)/admin/messages/page.tsx b/src/app/(admin)/admin/messages/page.tsx index 50b6733..9470976 100644 --- a/src/app/(admin)/admin/messages/page.tsx +++ b/src/app/(admin)/admin/messages/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState } from 'react' +import { useState, useMemo } from 'react' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { @@ -60,6 +60,10 @@ import { Inbox, CheckCircle2, Eye, + Users, + FolderOpen, + XCircle, + ArrowRight, } from 'lucide-react' import { toast } from 'sonner' import { formatDate } from '@/lib/utils' @@ -77,6 +81,24 @@ const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'] +const STATE_LABELS: Record = { + PENDING: 'Pending', + IN_PROGRESS: 'Active', + COMPLETED: 'Completed', + PASSED: 'Passed', + REJECTED: 'Rejected', + WITHDRAWN: 'Withdrawn', +} + +const STATE_BADGE_VARIANT: Record = { + PENDING: 'secondary', + IN_PROGRESS: 'default', + COMPLETED: 'default', + PASSED: 'success', + REJECTED: 'destructive', + WITHDRAWN: 'secondary', +} + export default function MessagesPage() { const [recipientType, setRecipientType] = useState('ALL') const [selectedRole, setSelectedRole] = useState('') @@ -90,11 +112,12 @@ export default function MessagesPage() { const [isScheduled, setIsScheduled] = useState(false) const [scheduledAt, setScheduledAt] = useState('') const [showPreview, setShowPreview] = useState(false) + const [excludeRejected, setExcludeRejected] = useState(true) + const [excludeWithdrawn, setExcludeWithdrawn] = useState(true) const utils = trpc.useUtils() // Fetch supporting data - // Get programs with stages const { data: programs } = trpc.program.list.useQuery({ includeStages: true }) const rounds = programs?.flatMap((p) => ((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } })) @@ -105,12 +128,43 @@ export default function MessagesPage() { { enabled: recipientType === 'USER' } ) - // Fetch sent messages for history (messages sent BY this admin) + // Fetch sent messages for history const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery( { page: 1, pageSize: 50 }, { refetchInterval: 30_000 } ) + // Compute exclude states list + const excludeStates = useMemo(() => { + const states: string[] = [] + if (excludeRejected) states.push('REJECTED') + if (excludeWithdrawn) states.push('WITHDRAWN') + return states + }, [excludeRejected, excludeWithdrawn]) + + // Live recipient preview — fetches whenever selection changes + const recipientPreview = trpc.message.previewRecipients.useQuery( + { + recipientType, + recipientFilter: buildRecipientFilterValue(), + roundId: roundId || undefined, + excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined, + }, + { + enabled: recipientType === 'ROUND_APPLICANTS' + ? !!roundId + : recipientType === 'ROUND_JURY' + ? !!roundId + : recipientType === 'ROLE' + ? !!selectedRole + : recipientType === 'USER' + ? !!selectedUserId + : recipientType === 'PROGRAM_TEAM' + ? !!selectedProgramId + : recipientType === 'ALL', + } + ) + const emailPreview = trpc.message.previewEmail.useQuery( { subject, body }, { enabled: showPreview && subject.length > 0 && body.length > 0 } @@ -164,7 +218,7 @@ export default function MessagesPage() { ) } - const buildRecipientFilter = (): unknown => { + function buildRecipientFilterValue(): unknown { switch (recipientType) { case 'ROLE': return selectedRole ? { role: selectedRole } : undefined @@ -187,9 +241,7 @@ export default function MessagesPage() { } case 'ROUND_JURY': { if (!roundId) return 'Stage Jury (none selected)' - const stage = rounds?.find( - (r) => r.id === roundId - ) + const stage = rounds?.find((r) => r.id === roundId) return stage ? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}` : 'Stage Jury' @@ -219,44 +271,49 @@ export default function MessagesPage() { } } - const handlePreview = () => { + const validateForm = (): boolean => { if (!subject.trim()) { toast.error('Subject is required') - return + return false } if (!body.trim()) { toast.error('Message body is required') - return + return false } if (deliveryChannels.length === 0) { toast.error('Select at least one delivery channel') - return + return false } if (recipientType === 'ROLE' && !selectedRole) { toast.error('Please select a role') - return + return false } if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) { toast.error('Please select a round') - return + return false } if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) { toast.error('Please select a program') - return + return false } if (recipientType === 'USER' && !selectedUserId) { toast.error('Please select a user') - return + return false } + return true + } + const handlePreview = () => { + if (!validateForm()) return setShowPreview(true) } const handleActualSend = () => { sendMutation.mutate({ recipientType, - recipientFilter: buildRecipientFilter(), + recipientFilter: buildRecipientFilterValue(), roundId: roundId || undefined, + excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined, subject: subject.trim(), body: body.trim(), deliveryChannels, @@ -266,6 +323,8 @@ export default function MessagesPage() { setShowPreview(false) } + const preview = recipientPreview.data + return (
{/* Header */} @@ -297,266 +356,408 @@ export default function MessagesPage() { - {/* Compose Form */} - - - Compose Message - - Send a message via email, in-app notifications, or both - - - - {/* Recipient type */} -
- - -
- - {/* Conditional sub-filters */} - {recipientType === 'ROLE' && ( -
- - -
- )} - - {(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && ( -
- - -
- )} - - {recipientType === 'PROGRAM_TEAM' && ( -
- - -
- )} - - {recipientType === 'USER' && ( -
- - -
- )} - - {recipientType === 'ALL' && ( -
- -

- This message will be sent to all platform users. -

-
- )} - - {/* Template selector */} - {templates && (templates as unknown[]).length > 0 && ( -
- - -
- )} - - {/* Subject */} -
- - setSubject(e.target.value)} - /> -
- - {/* Body */} -
-
- - - Variables: {'{{projectName}}'}, {'{{userName}}'}, {'{{deadline}}'}, {'{{roundName}}'}, {'{{programName}}'} - -
-