Files
MOPC-Portal/src/app/(admin)/admin/messages/page.tsx
Matt e7b99fff63
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
feat: multi-round messaging, login logo, applicant seed user
- 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>
2026-03-06 12:22:01 +01:00

1317 lines
56 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '@/components/ui/tabs'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Send,
Mail,
Bell,
Clock,
Loader2,
LayoutTemplate,
AlertCircle,
Inbox,
CheckCircle2,
Eye,
Users,
FolderOpen,
XCircle,
ArrowRight,
ChevronDown,
ChevronRight,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate } from '@/lib/utils'
type RecipientType = 'ALL' | 'ROLE' | 'ROUND_JURY' | 'ROUND_APPLICANTS' | 'PROGRAM_TEAM' | 'USER'
const RECIPIENT_TYPE_OPTIONS: { value: RecipientType; label: string }[] = [
{ value: 'ALL', label: 'All Users' },
{ value: 'ROLE', label: 'By Role' },
{ value: 'ROUND_JURY', label: 'Round Jury' },
{ value: 'ROUND_APPLICANTS', label: 'Round Applicants' },
{ value: 'PROGRAM_TEAM', label: 'Program Team' },
{ value: 'USER', label: 'Specific User' },
]
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() {
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
const [selectedRole, setSelectedRole] = useState('')
const [roundIds, setRoundIds] = useState<string[]>([])
const [selectedProgramId, setSelectedProgramId] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [subject, setSubject] = useState('')
const [body, setBody] = useState('')
const [selectedTemplateId, setSelectedTemplateId] = useState('')
const [deliveryChannels, setDeliveryChannels] = useState<string[]>(['EMAIL', 'IN_APP'])
const [linkType, setLinkType] = useState<'NONE' | 'MESSAGES' | 'LOGIN' | 'INVITE'>('MESSAGES')
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
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 } }))
) || []
const { data: templates } = trpc.message.listTemplates.useQuery()
const { data: users } = trpc.user.list.useQuery(
{ page: 1, perPage: 100 },
{ enabled: recipientType === 'USER' }
)
// 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(),
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
},
{
enabled: recipientType === 'ROUND_APPLICANTS'
? roundIds.length > 0
: recipientType === 'ROUND_JURY'
? roundIds.length > 0
: recipientType === 'ROLE'
? !!selectedRole
: recipientType === 'USER'
? !!selectedUserId
: recipientType === 'PROGRAM_TEAM'
? !!selectedProgramId
: recipientType === 'ALL',
}
)
// Detailed recipient list (fetched on-demand when user expands section)
const [showRecipientDetails, setShowRecipientDetails] = useState(false)
const recipientDetails = trpc.message.listRecipientDetails.useQuery(
{
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
},
{
enabled: showRecipientDetails && (
recipientType === 'ROUND_APPLICANTS'
? roundIds.length > 0
: recipientType === 'ROUND_JURY'
? roundIds.length > 0
: 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 }
)
const sendTestMutation = trpc.message.sendTest.useMutation({
onSuccess: (data) => toast.success(`Test email sent to ${data.to}`),
onError: (e) => toast.error(e.message),
})
const sendMutation = trpc.message.send.useMutation({
onSuccess: (data) => {
const count = (data as Record<string, unknown>)?.recipientCount || ''
toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`)
resetForm()
utils.message.sent.invalidate()
},
onError: (e) => toast.error(e.message),
})
const resetForm = () => {
setSubject('')
setBody('')
setSelectedTemplateId('')
setSelectedRole('')
setRoundIds([])
setSelectedProgramId('')
setSelectedUserId('')
setIsScheduled(false)
setScheduledAt('')
setLinkType('MESSAGES')
setShowRecipientDetails(false)
}
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplateId(templateId)
if (templateId && templateId !== '__none__' && templates) {
const template = (templates as Array<Record<string, unknown>>).find(
(t) => String(t.id) === templateId
)
if (template) {
setSubject(String(template.subject || ''))
setBody(String(template.body || ''))
}
}
}
const toggleChannel = (channel: string) => {
setDeliveryChannels((prev) =>
prev.includes(channel)
? prev.filter((c) => c !== channel)
: [...prev, channel]
)
}
function buildRecipientFilterValue(): unknown {
switch (recipientType) {
case 'ROLE':
return selectedRole ? { role: selectedRole } : undefined
case 'USER':
return selectedUserId ? { userId: selectedUserId } : undefined
case 'PROGRAM_TEAM':
return selectedProgramId ? { programId: selectedProgramId } : undefined
default:
return undefined
}
}
const getRecipientDescription = (): string => {
switch (recipientType) {
case 'ALL':
return 'All platform users'
case 'ROLE': {
const roleLabel = selectedRole ? selectedRole.replace(/_/g, ' ') : ''
return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)'
}
case 'ROUND_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 (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)'
const program = (programs as Array<{ id: string; name: string }> | undefined)?.find(
(p) => p.id === selectedProgramId
)
return program ? `Team of ${program.name}` : 'Program Team'
}
case 'USER': {
if (!selectedUserId) return 'Specific User (none selected)'
const userList = (users as { users: Array<{ id: string; name: string | null; email: string }> } | undefined)?.users
const user = userList?.find((u) => u.id === selectedUserId)
return user ? (user.name || user.email) : 'Specific User'
}
default:
return 'Unknown'
}
}
const validateForm = (): boolean => {
if (!subject.trim()) {
toast.error('Subject is required')
return false
}
if (!body.trim()) {
toast.error('Message body is required')
return false
}
if (deliveryChannels.length === 0) {
toast.error('Select at least one delivery channel')
return false
}
if (recipientType === 'ROLE' && !selectedRole) {
toast.error('Please select a role')
return false
}
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) {
toast.error('Please select a program')
return false
}
if (recipientType === 'USER' && !selectedUserId) {
toast.error('Please select a user')
return false
}
return true
}
const handlePreview = () => {
if (!validateForm()) return
setShowPreview(true)
}
const handleActualSend = () => {
sendMutation.mutate({
recipientType,
recipientFilter: buildRecipientFilterValue(),
roundIds: roundIds.length > 0 ? roundIds : undefined,
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
subject: subject.trim(),
body: body.trim(),
deliveryChannels,
scheduledAt: isScheduled && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
templateId: selectedTemplateId && selectedTemplateId !== '__none__' ? selectedTemplateId : undefined,
linkType,
})
setShowPreview(false)
}
const preview = recipientPreview.data
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Communication Hub</h1>
<p className="text-muted-foreground">
Send messages and notifications to platform users
</p>
</div>
<Button variant="outline" asChild>
<Link href="/admin/messages/templates">
<LayoutTemplate className="mr-2 h-4 w-4" />
Templates
</Link>
</Button>
</div>
<Tabs defaultValue="compose">
<TabsList>
<TabsTrigger value="compose">
<Send className="mr-2 h-4 w-4" />
Compose
</TabsTrigger>
<TabsTrigger value="history">
<Inbox className="mr-2 h-4 w-4" />
Sent History
</TabsTrigger>
</TabsList>
<TabsContent value="compose" className="space-y-4 mt-4">
<div className="grid gap-4 lg:grid-cols-3">
{/* Left: Compose form */}
<div className="lg:col-span-2 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Compose Message</CardTitle>
<CardDescription>
Send a message via email, in-app notifications, or both
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Recipient type */}
<div className="space-y-2">
<Label>Recipient Type</Label>
<Select
value={recipientType}
onValueChange={(v) => {
setRecipientType(v as RecipientType)
setSelectedRole('')
setRoundIds([])
setSelectedProgramId('')
setSelectedUserId('')
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RECIPIENT_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Conditional sub-filters */}
{recipientType === 'ROLE' && (
<div className="space-y-2">
<Label>Select Role</Label>
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger>
<SelectValue placeholder="Choose a role..." />
</SelectTrigger>
<SelectContent>
{ROLES.map((role) => (
<SelectItem key={role} value={role}>
{role.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{(recipientType === 'ROUND_JURY' || recipientType === 'ROUND_APPLICANTS') && (
<div className="space-y-2">
<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' && 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">
<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' && (
<div className="space-y-2">
<Label>Select Program</Label>
<Select value={selectedProgramId} onValueChange={setSelectedProgramId}>
<SelectTrigger>
<SelectValue placeholder="Choose a program..." />
</SelectTrigger>
<SelectContent>
{(programs as Array<{ id: string; name: string }> | undefined)?.map((prog) => (
<SelectItem key={prog.id} value={prog.id}>
{prog.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{recipientType === 'USER' && (
<div className="space-y-2">
<Label>Select User</Label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger>
<SelectValue placeholder="Choose a user..." />
</SelectTrigger>
<SelectContent>
{(() => {
const userList = (users as { users: Array<{ id: string; name: string | null; email: string; role: string }> } | undefined)?.users
if (!userList) return null
const grouped = userList.reduce<Record<string, typeof userList>>((acc, u) => {
const role = u.role || 'OTHER'
if (!acc[role]) acc[role] = []
acc[role].push(u)
return acc
}, {})
const roleLabels: Record<string, string> = {
SUPER_ADMIN: 'Super Admins',
PROGRAM_ADMIN: 'Program Admins',
JURY_MEMBER: 'Jury Members',
MENTOR: 'Mentors',
OBSERVER: 'Observers',
APPLICANT: 'Applicants',
OTHER: 'Other',
}
const roleOrder = ['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'OTHER']
return roleOrder
.filter((role) => grouped[role]?.length)
.map((role) => (
<SelectGroup key={role}>
<SelectLabel>{roleLabels[role] || role}</SelectLabel>
{grouped[role]
.sort((a, b) => (a.name || a.email).localeCompare(b.name || b.email))
.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name || u.email}
</SelectItem>
))}
</SelectGroup>
))
})()}
</SelectContent>
</Select>
</div>
)}
{recipientType === 'ALL' && (
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 p-3 text-amber-700">
<AlertCircle className="h-4 w-4 shrink-0" />
<p className="text-sm">
This message will be sent to all platform users.
</p>
</div>
)}
{/* Template selector */}
{templates && (templates as unknown[]).length > 0 && (
<div className="space-y-2">
<Label>Template (optional)</Label>
<Select value={selectedTemplateId} onValueChange={handleTemplateSelect}>
<SelectTrigger>
<SelectValue placeholder="Load from template..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No template</SelectItem>
{(templates as Array<Record<string, unknown>>).map((t) => (
<SelectItem key={String(t.id)} value={String(t.id)}>
{String(t.name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Subject */}
<div className="space-y-2">
<Label>Subject</Label>
<Input
placeholder="Message subject..."
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
{/* Body */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Message Body</Label>
<span className="text-xs text-muted-foreground">
Variables: {'{{projectName}}'}, {'{{userName}}'}, {'{{deadline}}'}, {'{{roundName}}'}, {'{{programName}}'}
</span>
</div>
<Textarea
placeholder="Write your message..."
value={body}
onChange={(e) => setBody(e.target.value)}
rows={6}
/>
</div>
{/* Delivery channels */}
<div className="space-y-2">
<Label>Delivery Channels</Label>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Checkbox
id="channel-email"
checked={deliveryChannels.includes('EMAIL')}
onCheckedChange={() => toggleChannel('EMAIL')}
/>
<label htmlFor="channel-email" className="text-sm cursor-pointer flex items-center gap-1">
<Mail className="h-3 w-3" />
Email
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="channel-inapp"
checked={deliveryChannels.includes('IN_APP')}
onCheckedChange={() => toggleChannel('IN_APP')}
/>
<label htmlFor="channel-inapp" className="text-sm cursor-pointer flex items-center gap-1">
<Bell className="h-3 w-3" />
In-App
</label>
</div>
</div>
</div>
{/* Link in Email */}
{deliveryChannels.includes('EMAIL') && (
<div className="space-y-2">
<Label>Email Link Button</Label>
<Select
value={linkType}
onValueChange={(v) => setLinkType(v as typeof linkType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="NONE">No link button</SelectItem>
<SelectItem value="MESSAGES">Link to Messages</SelectItem>
<SelectItem value="LOGIN">Link to Login</SelectItem>
<SelectItem value="INVITE">Invite / Accept link (for new members)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{linkType === 'NONE'
? 'No button will appear in the email.'
: linkType === 'MESSAGES'
? 'Recipients see a "View Details" button linking to their messages inbox.'
: linkType === 'LOGIN'
? 'Recipients see a button linking to the login page.'
: 'New members get their personal invite link; existing members get the login page.'}
</p>
</div>
)}
{/* Schedule */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<Switch
id="schedule-toggle"
checked={isScheduled}
onCheckedChange={setIsScheduled}
/>
<label htmlFor="schedule-toggle" className="text-sm cursor-pointer flex items-center gap-1">
<Clock className="h-3 w-3" />
Schedule for later
</label>
</div>
{isScheduled && (
<Input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
/>
)}
</div>
{/* Preview button */}
<div className="flex justify-end">
<Button onClick={handlePreview} disabled={sendMutation.isPending}>
{sendMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Eye className="mr-2 h-4 w-4" />
)}
{isScheduled ? 'Preview & Schedule' : 'Preview & Send'}
</Button>
</div>
</CardContent>
</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>
)}
{/* Expandable recipient details */}
<RecipientDetailsList
open={showRecipientDetails}
onOpenChange={setShowRecipientDetails}
data={recipientDetails.data}
isLoading={recipientDetails.isLoading}
recipientType={recipientType}
/>
</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 value="history" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">Sent Messages</CardTitle>
<CardDescription>
Recent messages sent through the platform
</CardDescription>
</CardHeader>
<CardContent>
{loadingSent ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-6 w-24" />
<Skeleton className="h-4 w-32 ml-auto" />
</div>
))}
</div>
) : sentMessages && sentMessages.items.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Subject</TableHead>
<TableHead className="hidden md:table-cell">Recipients</TableHead>
<TableHead className="hidden md:table-cell">Channels</TableHead>
<TableHead className="hidden lg:table-cell">Status</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sentMessages.items.map((msg: any) => {
const channels = (msg.deliveryChannels as string[]) || []
const recipientCount = msg._count?.recipients ?? 0
const isSent = !!msg.sentAt
return (
<TableRow key={msg.id}>
<TableCell>
<span className="font-medium">
{msg.subject || 'No subject'}
</span>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{msg.recipientType === 'ALL'
? 'All users'
: msg.recipientType === 'ROLE'
? `By role`
: msg.recipientType === 'ROUND_JURY'
? 'Round jury'
: msg.recipientType === 'ROUND_APPLICANTS'
? 'Round applicants'
: msg.recipientType === 'USER'
? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
: msg.recipientType}
{recipientCount > 0 && ` (${recipientCount})`}
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex gap-1">
{channels.includes('EMAIL') && (
<Badge variant="outline" className="text-xs">
<Mail className="mr-1 h-3 w-3" />Email
</Badge>
)}
{channels.includes('IN_APP') && (
<Badge variant="outline" className="text-xs">
<Bell className="mr-1 h-3 w-3" />In-App
</Badge>
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
{isSent ? (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Sent
</Badge>
) : msg.scheduledAt ? (
<Badge variant="default" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
Scheduled
</Badge>
) : (
<Badge variant="outline" className="text-xs">
Draft
</Badge>
)}
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{msg.sentAt
? formatDate(msg.sentAt)
: msg.scheduledAt
? formatDate(msg.scheduledAt)
: ''}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
) : (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Inbox className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No messages yet</p>
<p className="text-sm text-muted-foreground">
Sent messages will appear here.
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Preview & Send Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-xl">Review & Send</DialogTitle>
<DialogDescription>Verify everything looks correct before sending</DialogDescription>
</DialogHeader>
<div className="grid gap-6 md:grid-cols-5">
{/* Left: Message details */}
<div className="md:col-span-3 space-y-4">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Subject</p>
<p className="text-base font-semibold mt-1">{subject}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">Email Preview</p>
<div className="rounded-lg border overflow-hidden bg-gray-50">
{emailPreview.data?.html ? (
<iframe
srcDoc={emailPreview.data.html}
sandbox="allow-same-origin"
className="w-full h-[400px] border-0"
title="Email Preview"
/>
) : (
<div className="p-4">
<p className="text-sm whitespace-pre-wrap">{body}</p>
</div>
)}
</div>
</div>
</div>
{/* 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') && (
<Badge variant="outline" className="text-xs">
<Mail className="mr-1 h-3 w-3" />
Email
</Badge>
)}
{deliveryChannels.includes('IN_APP') && (
<Badge variant="outline" className="text-xs">
<Bell className="mr-1 h-3 w-3" />
In-App
</Badge>
)}
</div>
{isScheduled && scheduledAt && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground mt-1">
<Clock className="h-3.5 w-3.5" />
Scheduled: {formatDate(new Date(scheduledAt))}
</div>
)}
{deliveryChannels.includes('EMAIL') && (
<p className="text-xs text-muted-foreground mt-1">
Link: {linkType === 'NONE' ? 'None' : linkType === 'MESSAGES' ? 'Messages inbox' : linkType === 'LOGIN' ? 'Login page' : 'Invite / Accept link'}
</p>
)}
</div>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2 mt-4 border-t pt-4">
<Button
variant="ghost"
size="sm"
onClick={() => sendTestMutation.mutate({ subject, body })}
disabled={sendTestMutation.isPending}
className="sm:mr-auto"
>
{sendTestMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Send Test to Me
</Button>
<Button variant="outline" onClick={() => setShowPreview(false)}>
Back to Edit
</Button>
<Button onClick={handleActualSend} disabled={sendMutation.isPending} size="lg">
{sendMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{isScheduled ? 'Confirm & Schedule' : `Send to ${preview?.totalApplicants ?? '...'} Recipients`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// =============================================================================
// Expandable recipient details
// =============================================================================
type RecipientDetailsData = {
type: 'projects' | 'jurors' | 'users'
projects: Array<{
id: string
title: string
state: string
roundId?: string
roundName?: string
members: Array<{ id: string; name: string | null; email: string }>
}>
users: Array<{
id: string
name: string | null
email: string
projectCount?: number
projectNames?: string[]
projectName?: string | null
role?: string
roundNames?: string[]
}>
}
function RecipientDetailsList({
open,
onOpenChange,
data,
isLoading,
recipientType,
}: {
open: boolean
onOpenChange: (open: boolean) => void
data?: RecipientDetailsData
isLoading: boolean
recipientType: string
}) {
return (
<Collapsible open={open} onOpenChange={onOpenChange}>
<CollapsibleTrigger className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-full pt-2">
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
View Recipients
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 max-h-[300px] overflow-y-auto rounded-md border bg-muted/20 p-2 space-y-1">
{isLoading ? (
<div className="space-y-2 p-1">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
) : !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 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 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">
<Link
href={`/admin/members/${user.id}`}
className="font-medium hover:underline text-primary truncate block"
>
{user.name || user.email}
</Link>
<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>
))
) : (
// ALL, ROLE, USER, PROGRAM_TEAM: plain user list
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">
<Link
href={`/admin/members/${user.id}`}
className="font-medium hover:underline text-primary truncate block"
>
{user.name || user.email}
</Link>
{user.projectName && (
<span className="text-muted-foreground truncate block">{user.projectName}</span>
)}
</div>
{user.role && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 shrink-0 ml-2">
{user.role.replace(/_/g, ' ')}
</Badge>
)}
</div>
))
)}
</div>
</CollapsibleContent>
</Collapsible>
)
}
function ProjectRecipientRow({ project }: {
project: {
id: string
title: string
state: string
roundId?: string
roundName?: string
members: Array<{ id: string; name: string | null; email: string }>
}
}) {
const [open, setOpen] = useState(false)
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full rounded px-2 py-1.5 text-xs hover:bg-muted/50">
<div className="flex items-center gap-1.5 min-w-0">
{open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
<span className="font-medium truncate">{project.title}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Badge variant={STATE_BADGE_VARIANT[project.state] || 'secondary'} className="text-[10px] px-1.5 py-0">
{STATE_LABELS[project.state] || project.state}
</Badge>
<span className="text-muted-foreground">{project.members.length}</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="ml-5 space-y-0.5 pb-1">
{project.members.map((member) => (
<Link
key={member.id}
href={`/admin/members/${member.id}`}
className="block text-xs px-2 py-0.5 text-primary hover:underline truncate"
>
{member.name || member.email}
</Link>
))}
</div>
</CollapsibleContent>
</Collapsible>
)
}