feat: revamp communication hub with recipient preview and state filtering
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
import {
|
import {
|
||||||
@@ -60,6 +60,10 @@ import {
|
|||||||
Inbox,
|
Inbox,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Eye,
|
Eye,
|
||||||
|
Users,
|
||||||
|
FolderOpen,
|
||||||
|
XCircle,
|
||||||
|
ArrowRight,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { formatDate } from '@/lib/utils'
|
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 ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN']
|
||||||
|
|
||||||
|
const STATE_LABELS: Record<string, string> = {
|
||||||
|
PENDING: 'Pending',
|
||||||
|
IN_PROGRESS: 'Active',
|
||||||
|
COMPLETED: 'Completed',
|
||||||
|
PASSED: 'Passed',
|
||||||
|
REJECTED: 'Rejected',
|
||||||
|
WITHDRAWN: 'Withdrawn',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE_BADGE_VARIANT: Record<string, 'default' | 'success' | 'destructive' | 'secondary'> = {
|
||||||
|
PENDING: 'secondary',
|
||||||
|
IN_PROGRESS: 'default',
|
||||||
|
COMPLETED: 'default',
|
||||||
|
PASSED: 'success',
|
||||||
|
REJECTED: 'destructive',
|
||||||
|
WITHDRAWN: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
export default function MessagesPage() {
|
export default function MessagesPage() {
|
||||||
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
|
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
|
||||||
const [selectedRole, setSelectedRole] = useState('')
|
const [selectedRole, setSelectedRole] = useState('')
|
||||||
@@ -90,11 +112,12 @@ export default function MessagesPage() {
|
|||||||
const [isScheduled, setIsScheduled] = useState(false)
|
const [isScheduled, setIsScheduled] = useState(false)
|
||||||
const [scheduledAt, setScheduledAt] = useState('')
|
const [scheduledAt, setScheduledAt] = useState('')
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
|
const [excludeRejected, setExcludeRejected] = useState(true)
|
||||||
|
const [excludeWithdrawn, setExcludeWithdrawn] = useState(true)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
// Fetch supporting data
|
// Fetch supporting data
|
||||||
// Get programs with stages
|
|
||||||
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||||
const rounds = programs?.flatMap((p) =>
|
const rounds = programs?.flatMap((p) =>
|
||||||
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, program: { name: p.name } }))
|
((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' }
|
{ 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(
|
const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery(
|
||||||
{ page: 1, pageSize: 50 },
|
{ page: 1, pageSize: 50 },
|
||||||
{ refetchInterval: 30_000 }
|
{ 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(
|
const emailPreview = trpc.message.previewEmail.useQuery(
|
||||||
{ subject, body },
|
{ subject, body },
|
||||||
{ enabled: showPreview && subject.length > 0 && body.length > 0 }
|
{ enabled: showPreview && subject.length > 0 && body.length > 0 }
|
||||||
@@ -164,7 +218,7 @@ export default function MessagesPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildRecipientFilter = (): unknown => {
|
function buildRecipientFilterValue(): unknown {
|
||||||
switch (recipientType) {
|
switch (recipientType) {
|
||||||
case 'ROLE':
|
case 'ROLE':
|
||||||
return selectedRole ? { role: selectedRole } : undefined
|
return selectedRole ? { role: selectedRole } : undefined
|
||||||
@@ -187,9 +241,7 @@ export default function MessagesPage() {
|
|||||||
}
|
}
|
||||||
case 'ROUND_JURY': {
|
case 'ROUND_JURY': {
|
||||||
if (!roundId) return 'Stage Jury (none selected)'
|
if (!roundId) return 'Stage Jury (none selected)'
|
||||||
const stage = rounds?.find(
|
const stage = rounds?.find((r) => r.id === roundId)
|
||||||
(r) => r.id === roundId
|
|
||||||
)
|
|
||||||
return stage
|
return stage
|
||||||
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
|
? `Jury of ${stage.program ? `${stage.program.name} - ` : ''}${stage.name}`
|
||||||
: 'Stage Jury'
|
: 'Stage Jury'
|
||||||
@@ -219,44 +271,49 @@ export default function MessagesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePreview = () => {
|
const validateForm = (): boolean => {
|
||||||
if (!subject.trim()) {
|
if (!subject.trim()) {
|
||||||
toast.error('Subject is required')
|
toast.error('Subject is required')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (!body.trim()) {
|
if (!body.trim()) {
|
||||||
toast.error('Message body is required')
|
toast.error('Message body is required')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (deliveryChannels.length === 0) {
|
if (deliveryChannels.length === 0) {
|
||||||
toast.error('Select at least one delivery channel')
|
toast.error('Select at least one delivery channel')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (recipientType === 'ROLE' && !selectedRole) {
|
if (recipientType === 'ROLE' && !selectedRole) {
|
||||||
toast.error('Please select a role')
|
toast.error('Please select a role')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) {
|
if ((recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && !roundId) {
|
||||||
toast.error('Please select a round')
|
toast.error('Please select a round')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
|
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
|
||||||
toast.error('Please select a program')
|
toast.error('Please select a program')
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
if (recipientType === 'USER' && !selectedUserId) {
|
if (recipientType === 'USER' && !selectedUserId) {
|
||||||
toast.error('Please select a user')
|
toast.error('Please select a user')
|
||||||
return
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handlePreview = () => {
|
||||||
|
if (!validateForm()) return
|
||||||
setShowPreview(true)
|
setShowPreview(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleActualSend = () => {
|
const handleActualSend = () => {
|
||||||
sendMutation.mutate({
|
sendMutation.mutate({
|
||||||
recipientType,
|
recipientType,
|
||||||
recipientFilter: buildRecipientFilter(),
|
recipientFilter: buildRecipientFilterValue(),
|
||||||
roundId: roundId || undefined,
|
roundId: roundId || undefined,
|
||||||
|
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
|
||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
body: body.trim(),
|
body: body.trim(),
|
||||||
deliveryChannels,
|
deliveryChannels,
|
||||||
@@ -266,6 +323,8 @@ export default function MessagesPage() {
|
|||||||
setShowPreview(false)
|
setShowPreview(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preview = recipientPreview.data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -297,7 +356,9 @@ export default function MessagesPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="compose" className="space-y-4 mt-4">
|
<TabsContent value="compose" className="space-y-4 mt-4">
|
||||||
{/* Compose Form */}
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
{/* Left: Compose form */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Compose Message</CardTitle>
|
<CardTitle className="text-lg">Compose Message</CardTitle>
|
||||||
@@ -369,6 +430,37 @@ export default function MessagesPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Exclude filters for Round Applicants */}
|
||||||
|
{recipientType === 'ROUND_APPLICANTS' && roundId && (
|
||||||
|
<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">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="exclude-rejected"
|
||||||
|
checked={excludeRejected}
|
||||||
|
onCheckedChange={(v) => setExcludeRejected(!!v)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="exclude-rejected" className="text-sm cursor-pointer flex items-center gap-1.5">
|
||||||
|
<XCircle className="h-3.5 w-3.5 text-destructive" />
|
||||||
|
Exclude Rejected
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="exclude-withdrawn"
|
||||||
|
checked={excludeWithdrawn}
|
||||||
|
onCheckedChange={(v) => setExcludeWithdrawn(!!v)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="exclude-withdrawn" className="text-sm cursor-pointer flex items-center gap-1.5">
|
||||||
|
<XCircle className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
Exclude Withdrawn
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{recipientType === 'PROGRAM_TEAM' && (
|
{recipientType === 'PROGRAM_TEAM' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Select Program</Label>
|
<Label>Select Program</Label>
|
||||||
@@ -544,7 +636,7 @@ export default function MessagesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Send button */}
|
{/* Preview button */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={handlePreview} disabled={sendMutation.isPending}>
|
<Button onClick={handlePreview} disabled={sendMutation.isPending}>
|
||||||
{sendMutation.isPending ? (
|
{sendMutation.isPending ? (
|
||||||
@@ -557,6 +649,115 @@ export default function MessagesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right sidebar: Recipient Summary */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-blue-500" />
|
||||||
|
Recipient Summary
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{recipientPreview.isLoading ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-32" />
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</div>
|
||||||
|
) : preview ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{getRecipientDescription()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{preview.totalProjects > 0 && (
|
||||||
|
<div className="rounded-lg border p-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||||
|
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Projects</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold tabular-nums">{preview.totalProjects}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`rounded-lg border p-3 text-center ${preview.totalProjects > 0 ? '' : 'col-span-2'}`}>
|
||||||
|
<div className="flex items-center justify-center gap-1.5 mb-1">
|
||||||
|
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs text-muted-foreground">Recipients</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold tabular-nums">{preview.totalApplicants}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State breakdown for Round Applicants */}
|
||||||
|
{recipientType === 'ROUND_APPLICANTS' && Object.keys(preview.stateBreakdown).length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Projects by Status
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{Object.entries(preview.stateBreakdown)
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
const order = ['IN_PROGRESS', 'PENDING', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
|
||||||
|
return order.indexOf(a) - order.indexOf(b)
|
||||||
|
})
|
||||||
|
.map(([state, count]) => {
|
||||||
|
const isExcluded = excludeStates.includes(state)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={state}
|
||||||
|
className={`flex items-center justify-between rounded-md px-2.5 py-1.5 text-sm ${
|
||||||
|
isExcluded ? 'bg-muted/50 opacity-50 line-through' : 'bg-muted/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={STATE_BADGE_VARIANT[state] || 'secondary'}
|
||||||
|
className="text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{STATE_LABELS[state] || state}
|
||||||
|
</Badge>
|
||||||
|
{isExcluded && (
|
||||||
|
<span className="text-[10px] text-muted-foreground">(excluded)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold tabular-nums">{count}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Included vs excluded summary */}
|
||||||
|
{excludeStates.length > 0 && (
|
||||||
|
<div className="rounded-lg bg-blue-500/5 border border-blue-200/50 p-2.5 mt-2">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
Sending to <span className="font-semibold">{preview.totalApplicants}</span> applicants
|
||||||
|
{' '}across{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
{preview.totalProjects - Object.entries(preview.stateBreakdown)
|
||||||
|
.filter(([s]) => excludeStates.includes(s))
|
||||||
|
.reduce((sum, [, c]) => sum + c, 0)}
|
||||||
|
</span>{' '}
|
||||||
|
projects (excluding{' '}
|
||||||
|
{excludeStates.map((s) => STATE_LABELS[s] || s).join(' & ').toLowerCase()}).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Select a recipient type{recipientType !== 'ALL' ? ' and filter' : ''} to see a summary.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="history" className="mt-4">
|
<TabsContent value="history" className="mt-4">
|
||||||
@@ -609,6 +810,8 @@ export default function MessagesPage() {
|
|||||||
? `By role`
|
? `By role`
|
||||||
: msg.recipientType === 'ROUND_JURY'
|
: msg.recipientType === 'ROUND_JURY'
|
||||||
? 'Round jury'
|
? 'Round jury'
|
||||||
|
: msg.recipientType === 'ROUND_APPLICANTS'
|
||||||
|
? 'Round applicants'
|
||||||
: msg.recipientType === 'USER'
|
: msg.recipientType === 'USER'
|
||||||
? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
|
? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
|
||||||
: msg.recipientType}
|
: msg.recipientType}
|
||||||
@@ -671,30 +874,30 @@ export default function MessagesPage() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Preview Dialog */}
|
{/* Preview & Send Dialog */}
|
||||||
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
<Dialog open={showPreview} onOpenChange={setShowPreview}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Preview Message</DialogTitle>
|
<DialogTitle className="text-xl">Review & Send</DialogTitle>
|
||||||
<DialogDescription>Review your message before sending</DialogDescription>
|
<DialogDescription>Verify everything looks correct before sending</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
<div className="grid gap-6 md:grid-cols-5">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Recipients</p>
|
{/* Left: Message details */}
|
||||||
<p className="text-sm mt-1">{getRecipientDescription()}</p>
|
<div className="md:col-span-3 space-y-4">
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Subject</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Subject</p>
|
||||||
<p className="text-sm font-medium mt-1">{subject}</p>
|
<p className="text-base font-semibold mt-1">{subject}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email Preview</p>
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">Email Preview</p>
|
||||||
<div className="mt-1 rounded-lg border overflow-hidden bg-gray-50">
|
<div className="rounded-lg border overflow-hidden bg-gray-50">
|
||||||
{emailPreview.data?.html ? (
|
{emailPreview.data?.html ? (
|
||||||
<iframe
|
<iframe
|
||||||
srcDoc={emailPreview.data.html}
|
srcDoc={emailPreview.data.html}
|
||||||
sandbox="allow-same-origin"
|
sandbox="allow-same-origin"
|
||||||
className="w-full h-[500px] border-0"
|
className="w-full h-[400px] border-0"
|
||||||
title="Email Preview"
|
title="Email Preview"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -704,9 +907,71 @@ export default function MessagesPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Delivery Channels</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
{/* Right: Recipient & delivery summary */}
|
||||||
|
<div className="md:col-span-2 space-y-4">
|
||||||
|
<div className="rounded-lg border p-4 space-y-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Recipients</p>
|
||||||
|
<p className="text-sm font-medium">{getRecipientDescription()}</p>
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{preview.totalProjects > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
Projects
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold tabular-nums">
|
||||||
|
{preview.totalProjects - Object.entries(preview.stateBreakdown)
|
||||||
|
.filter(([s]) => excludeStates.includes(s))
|
||||||
|
.reduce((sum, [, c]) => sum + c, 0)}
|
||||||
|
{excludeStates.length > 0 && (
|
||||||
|
<span className="text-muted-foreground font-normal"> / {preview.totalProjects}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Applicants
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold tabular-nums">{preview.totalApplicants}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* State breakdown in preview */}
|
||||||
|
{recipientType === 'ROUND_APPLICANTS' && Object.keys(preview.stateBreakdown).length > 0 && (
|
||||||
|
<div className="pt-2 border-t space-y-1">
|
||||||
|
{Object.entries(preview.stateBreakdown)
|
||||||
|
.sort(([a], [b]) => {
|
||||||
|
const order = ['IN_PROGRESS', 'PENDING', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
|
||||||
|
return order.indexOf(a) - order.indexOf(b)
|
||||||
|
})
|
||||||
|
.map(([state, count]) => {
|
||||||
|
const isExcluded = excludeStates.includes(state)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={state}
|
||||||
|
className={`flex items-center justify-between text-xs ${isExcluded ? 'opacity-40 line-through' : ''}`}
|
||||||
|
>
|
||||||
|
<Badge variant={STATE_BADGE_VARIANT[state] || 'secondary'} className="text-[10px] px-1.5 py-0">
|
||||||
|
{STATE_LABELS[state] || state}
|
||||||
|
</Badge>
|
||||||
|
<span className="tabular-nums">{count} projects</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border p-4 space-y-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Delivery</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{deliveryChannels.includes('EMAIL') && (
|
{deliveryChannels.includes('EMAIL') && (
|
||||||
<Badge variant="outline" className="text-xs">
|
<Badge variant="outline" className="text-xs">
|
||||||
<Mail className="mr-1 h-3 w-3" />
|
<Mail className="mr-1 h-3 w-3" />
|
||||||
@@ -720,15 +985,17 @@ export default function MessagesPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{isScheduled && scheduledAt && (
|
{isScheduled && scheduledAt && (
|
||||||
<div>
|
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1">
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Scheduled For</p>
|
<Clock className="h-3.5 w-3.5" />
|
||||||
<p className="text-sm mt-1">{formatDate(new Date(scheduledAt))}</p>
|
Scheduled: {formatDate(new Date(scheduledAt))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex-col sm:flex-row gap-2">
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-col sm:flex-row gap-2 mt-4 border-t pt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -744,15 +1011,15 @@ export default function MessagesPage() {
|
|||||||
Send Test to Me
|
Send Test to Me
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowPreview(false)}>
|
<Button variant="outline" onClick={() => setShowPreview(false)}>
|
||||||
Edit
|
Back to Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleActualSend} disabled={sendMutation.isPending}>
|
<Button onClick={handleActualSend} disabled={sendMutation.isPending} size="lg">
|
||||||
{sendMutation.isPending ? (
|
{sendMutation.isPending ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Send className="mr-2 h-4 w-4" />
|
<Send className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{isScheduled ? 'Confirm & Schedule' : 'Confirm & Send'}
|
{isScheduled ? 'Confirm & Schedule' : `Send to ${preview?.totalApplicants ?? '...'} Recipients`}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const messageRouter = router({
|
|||||||
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
||||||
recipientFilter: z.any().optional(),
|
recipientFilter: z.any().optional(),
|
||||||
roundId: z.string().optional(),
|
roundId: z.string().optional(),
|
||||||
|
excludeStates: z.array(z.string()).optional(),
|
||||||
subject: z.string().min(1).max(500),
|
subject: z.string().min(1).max(500),
|
||||||
body: z.string().min(1),
|
body: z.string().min(1),
|
||||||
deliveryChannels: z.array(z.string()).min(1),
|
deliveryChannels: z.array(z.string()).min(1),
|
||||||
@@ -30,7 +31,8 @@ export const messageRouter = router({
|
|||||||
ctx.prisma,
|
ctx.prisma,
|
||||||
input.recipientType,
|
input.recipientType,
|
||||||
input.recipientFilter,
|
input.recipientFilter,
|
||||||
input.roundId
|
input.roundId,
|
||||||
|
input.excludeStates
|
||||||
)
|
)
|
||||||
|
|
||||||
if (recipientUserIds.length === 0) {
|
if (recipientUserIds.length === 0) {
|
||||||
@@ -385,6 +387,72 @@ export const messageRouter = router({
|
|||||||
return { html: getEmailPreviewHtml(input.subject, input.body) }
|
return { html: getEmailPreviewHtml(input.subject, input.body) }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preview recipient counts for a given recipient type + filters.
|
||||||
|
* Returns project breakdown by state for ROUND_APPLICANTS, or total user count for others.
|
||||||
|
*/
|
||||||
|
previewRecipients: adminProcedure
|
||||||
|
.input(z.object({
|
||||||
|
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
|
||||||
|
recipientFilter: z.any().optional(),
|
||||||
|
roundId: z.string().optional(),
|
||||||
|
excludeStates: z.array(z.string()).optional(),
|
||||||
|
}))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
// For ROUND_APPLICANTS, return a breakdown by project state
|
||||||
|
if (input.recipientType === 'ROUND_APPLICANTS' && input.roundId) {
|
||||||
|
const projectStates = await ctx.prisma.projectRoundState.findMany({
|
||||||
|
where: { roundId: input.roundId },
|
||||||
|
select: {
|
||||||
|
state: true,
|
||||||
|
projectId: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
submittedByUserId: true,
|
||||||
|
teamMembers: { select: { userId: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Count projects per state
|
||||||
|
const stateBreakdown: Record<string, number> = {}
|
||||||
|
for (const ps of projectStates) {
|
||||||
|
stateBreakdown[ps.state] = (stateBreakdown[ps.state] || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute total unique users respecting exclusions
|
||||||
|
const excludeSet = new Set(input.excludeStates ?? [])
|
||||||
|
const includedUserIds = new Set<string>()
|
||||||
|
for (const ps of projectStates) {
|
||||||
|
if (excludeSet.has(ps.state)) continue
|
||||||
|
if (ps.project.submittedByUserId) includedUserIds.add(ps.project.submittedByUserId)
|
||||||
|
for (const tm of ps.project.teamMembers) includedUserIds.add(tm.userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalProjects: projectStates.length,
|
||||||
|
totalApplicants: includedUserIds.size,
|
||||||
|
stateBreakdown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other recipient types, just count resolved users
|
||||||
|
const userIds = await resolveRecipients(
|
||||||
|
ctx.prisma,
|
||||||
|
input.recipientType,
|
||||||
|
input.recipientFilter,
|
||||||
|
input.roundId,
|
||||||
|
input.excludeStates
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalProjects: 0,
|
||||||
|
totalApplicants: userIds.length,
|
||||||
|
stateBreakdown: {} as Record<string, number>,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a test email to the currently logged-in admin.
|
* Send a test email to the currently logged-in admin.
|
||||||
*/
|
*/
|
||||||
@@ -415,7 +483,8 @@ async function resolveRecipients(
|
|||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
recipientType: string,
|
recipientType: string,
|
||||||
recipientFilter: unknown,
|
recipientFilter: unknown,
|
||||||
roundId?: string
|
roundId?: string,
|
||||||
|
excludeStates?: string[]
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const filter = recipientFilter as Record<string, unknown> | undefined
|
const filter = recipientFilter as Record<string, unknown> | undefined
|
||||||
|
|
||||||
@@ -454,9 +523,13 @@ async function resolveRecipients(
|
|||||||
case 'ROUND_APPLICANTS': {
|
case 'ROUND_APPLICANTS': {
|
||||||
const targetRoundId = roundId || (filter?.roundId as string)
|
const targetRoundId = roundId || (filter?.roundId as string)
|
||||||
if (!targetRoundId) return []
|
if (!targetRoundId) return []
|
||||||
// Get all projects in this round
|
// Get all projects in this round, optionally excluding certain states
|
||||||
|
const stateWhere: Record<string, unknown> = { roundId: targetRoundId }
|
||||||
|
if (excludeStates && excludeStates.length > 0) {
|
||||||
|
stateWhere.state = { notIn: excludeStates }
|
||||||
|
}
|
||||||
const projectStates = await prisma.projectRoundState.findMany({
|
const projectStates = await prisma.projectRoundState.findMany({
|
||||||
where: { roundId: targetRoundId },
|
where: stateWhere,
|
||||||
select: { projectId: true },
|
select: { projectId: true },
|
||||||
})
|
})
|
||||||
const projectIds = projectStates.map((ps) => ps.projectId)
|
const projectIds = projectStates.map((ps) => ps.projectId)
|
||||||
|
|||||||
Reference in New Issue
Block a user