feat: Email Team button + custom-email dialog on project page

Adds a PROJECT_TEAM recipient type to the message router (resolver
returns team members + project lead) and an "Email Team" button on
the admin project detail page that opens a self-contained dialog
matching the look of /admin/messages: subject, body (pre-filled
with "Hello [Project Title] team,\n\n"), live HTML preview iframe,
"Send test to me" + "Send to N" actions.

The composer reuses the existing message.previewEmail and
message.send tRPC procedures end-to-end — no parallel email
infrastructure introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt
2026-04-28 14:29:42 +02:00
parent 16156111a6
commit b867c45114
5 changed files with 465 additions and 9 deletions

View File

@@ -75,7 +75,9 @@ import {
Eye,
Plus,
X,
Mail,
} from 'lucide-react'
import { ProjectEmailDialog } from '@/components/admin/project-email-dialog'
import { toast } from 'sonner'
import { formatDateOnly } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
@@ -161,6 +163,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// State for remove member confirmation
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
const [emailDialogOpen, setEmailDialogOpen] = useState(false)
const addTeamMember = trpc.project.addTeamMember.useMutation({
onSuccess: () => {
@@ -269,14 +272,29 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</div>
</div>
<Button variant="outline" asChild>
<Link href={`/admin/projects/${projectId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setEmailDialogOpen(true)}>
<Mail className="mr-2 h-4 w-4" />
Email Team
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/projects/${projectId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</div>
{project && (
<ProjectEmailDialog
open={emailDialogOpen}
onClose={() => setEmailDialogOpen(false)}
projectId={project.id}
projectTitle={project.title}
/>
)}
<Separator />
{/* Stats Grid */}

View File

@@ -0,0 +1,177 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog'
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 { Mail, Send, Eye } from 'lucide-react'
interface Props {
open: boolean
onClose: () => void
projectId: string
projectTitle: string
}
function useDebounced<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const t = setTimeout(() => setDebounced(value), delayMs)
return () => clearTimeout(t)
}, [value, delayMs])
return debounced
}
export function ProjectEmailDialog({ open, onClose, projectId, projectTitle }: Props) {
const initialBody = useMemo(() => `Hello ${projectTitle} team,\n\n`, [projectTitle])
const [subject, setSubject] = useState('')
const [body, setBody] = useState(initialBody)
const [showPreview, setShowPreview] = useState(false)
// Reset state whenever the dialog opens for a new project
useEffect(() => {
if (open) {
setSubject('')
setBody(initialBody)
setShowPreview(false)
}
}, [open, initialBody])
const debouncedSubject = useDebounced(subject, 300)
const debouncedBody = useDebounced(body, 300)
const recipientPreview = trpc.message.previewRecipients.useQuery(
{ recipientType: 'PROJECT_TEAM', recipientFilter: { projectId } },
{ enabled: open }
)
const emailPreview = trpc.message.previewEmail.useQuery(
{ subject: debouncedSubject, body: debouncedBody },
{ enabled: showPreview && debouncedSubject.length > 0 && debouncedBody.length > 0 }
)
const sendTestMutation = trpc.message.sendTest.useMutation({
onSuccess: ({ to }) => toast.success(`Test email sent to ${to}`),
onError: (e) => toast.error(e.message),
})
const sendMutation = trpc.message.send.useMutation({
onSuccess: () => {
toast.success(`Email sent to ${recipientPreview.data?.totalApplicants ?? 0} team members`)
onClose()
},
onError: (e) => toast.error(e.message),
})
const recipientCount = recipientPreview.data?.totalApplicants ?? 0
const canSend = subject.length > 0 && body.length > 0 && recipientCount > 0
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Email Team {projectTitle}
</DialogTitle>
<DialogDescription>
Compose a custom email to all members of this project&apos;s team.
{recipientPreview.isLoading
? ' Loading recipients…'
: ` Will be sent to ${recipientCount} team member${recipientCount === 1 ? '' : 's'}.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email-subject">Subject</Label>
<Input
id="email-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Subject of your email"
maxLength={500}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email-body">Body</Label>
<Textarea
id="email-body"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={10}
className="font-mono text-sm"
placeholder={initialBody}
/>
<p className="text-xs text-muted-foreground">
The greeting is pre-filled edit freely. The full email is wrapped in the standard
MOPC styled template when sent. Click &quot;Show preview&quot; to see exactly what recipients will see.
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPreview((s) => !s)}
disabled={subject.length === 0 || body.length === 0}
>
<Eye className="mr-2 h-4 w-4" />
{showPreview ? 'Hide preview' : 'Show preview'}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => sendTestMutation.mutate({ subject, body })}
disabled={!canSend || sendTestMutation.isPending}
>
<Send className="mr-2 h-4 w-4" />
{sendTestMutation.isPending ? 'Sending…' : 'Send test to me'}
</Button>
</div>
{showPreview && emailPreview.data && (
<div className="border rounded-md overflow-hidden">
<div className="bg-muted text-xs px-3 py-2 border-b">Email preview</div>
<iframe
title="Email preview"
srcDoc={emailPreview.data.html}
className="w-full h-96 bg-white"
sandbox=""
/>
</div>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
onClick={() =>
sendMutation.mutate({
recipientType: 'PROJECT_TEAM',
recipientFilter: { projectId },
subject,
body,
deliveryChannels: ['EMAIL'],
linkType: 'NONE',
})
}
disabled={!canSend || sendMutation.isPending}
>
<Send className="mr-2 h-4 w-4" />
{sendMutation.isPending ? 'Sending…' : `Send to ${recipientCount}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -14,7 +14,7 @@ export const messageRouter = router({
send: adminProcedure
.input(
z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
roundIds: z.array(z.string()).optional(),
@@ -482,7 +482,7 @@ export const messageRouter = router({
*/
previewRecipients: adminProcedure
.input(z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
roundIds: z.array(z.string()).optional(),
@@ -556,7 +556,7 @@ export const messageRouter = router({
*/
listRecipientDetails: adminProcedure
.input(z.object({
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'ALL']),
recipientType: z.enum(['USER', 'ROLE', 'ROUND_JURY', 'ROUND_APPLICANTS', 'PROGRAM_TEAM', 'PROJECT_TEAM', 'ALL']),
recipientFilter: z.any().optional(),
roundId: z.string().optional(),
roundIds: z.array(z.string()).optional(),
@@ -839,6 +839,25 @@ async function resolveRecipients(
return [...ids]
}
case 'PROJECT_TEAM': {
const projectId = filter?.projectId as string
if (!projectId) return []
const [teamMembers, project] = await Promise.all([
prisma.teamMember.findMany({
where: { projectId },
select: { userId: true },
}),
prisma.project.findUnique({
where: { id: projectId },
select: { submittedByUserId: true },
}),
])
const ids = new Set<string>()
for (const tm of teamMembers) ids.add(tm.userId)
if (project?.submittedByUserId) ids.add(project.submittedByUserId)
return [...ids]
}
case 'ALL': {
const users = await prisma.user.findMany({
where: { status: 'ACTIVE' },