feat: add email preview to award notification and finalization tab
- Award "Notify Pool" dialog now uses EmailPreviewDialog with live preview - Button shows eligible project count: "Notify Pool (38)" - Finalization tab email section has "Preview" buttons for both advancement and rejection messages - EmailPreviewDialog supports previewOnly mode (close button, no send) - Backend: previewAwardSelectionEmail, previewFinalizationAdvancementEmail, previewFinalizationRejectionEmail queries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,11 +53,11 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -158,7 +158,7 @@ export default function AwardDetailPage({
|
||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
||||
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
|
||||
const [notifyCustomMessage, setNotifyCustomMessage] = useState('')
|
||||
const [notifyCustomMessage, setNotifyCustomMessage] = useState<string | undefined>()
|
||||
|
||||
// Pagination for eligibility list
|
||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||
@@ -287,15 +287,15 @@ export default function AwardDetailPage({
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const { data: notifyStats } = trpc.specialAward.getNotificationStats.useQuery(
|
||||
{ awardId },
|
||||
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
|
||||
{ awardId, customMessage: notifyCustomMessage },
|
||||
{ enabled: notifyDialogOpen }
|
||||
)
|
||||
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
|
||||
setNotifyDialogOpen(false)
|
||||
setNotifyCustomMessage('')
|
||||
setNotifyCustomMessage(undefined)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
@@ -486,63 +486,22 @@ export default function AwardDetailPage({
|
||||
)}
|
||||
{award.status === 'NOMINATIONS_OPEN' && (
|
||||
<>
|
||||
<Dialog open={notifyDialogOpen} onOpenChange={setNotifyDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" disabled={award.eligibleCount === 0}>
|
||||
<Button variant="outline" disabled={award.eligibleCount === 0} onClick={() => setNotifyDialogOpen(true)}>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Notify Pool
|
||||
Notify Pool ({award.eligibleCount})
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notify Eligible Projects</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send "Selected for {award.name}" emails to all {award.eligibleCount} eligible projects.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{notifyStats && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{notifyStats.needsInvite > 0 && (
|
||||
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-700">
|
||||
{notifyStats.needsInvite} will receive Create Account link
|
||||
</Badge>
|
||||
)}
|
||||
{notifyStats.hasAccount > 0 && (
|
||||
<Badge variant="outline" className="border-emerald-300 bg-emerald-50 text-emerald-700">
|
||||
{notifyStats.hasAccount} will receive Dashboard link
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Custom message (optional)</Label>
|
||||
<Textarea
|
||||
placeholder="Add a personal message to include in the email..."
|
||||
value={notifyCustomMessage}
|
||||
onChange={(e) => setNotifyCustomMessage(e.target.value)}
|
||||
rows={4}
|
||||
<EmailPreviewDialog
|
||||
open={notifyDialogOpen}
|
||||
onOpenChange={setNotifyDialogOpen}
|
||||
title="Notify Eligible Projects"
|
||||
description={`Send "Selected for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`}
|
||||
recipientCount={notifyPreview.data?.recipientCount ?? 0}
|
||||
previewHtml={notifyPreview.data?.html}
|
||||
isPreviewLoading={notifyPreview.isLoading}
|
||||
onSend={(msg) => notifyEligible.mutate({ awardId, customMessage: msg })}
|
||||
isSending={notifyEligible.isPending}
|
||||
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setNotifyDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={() => notifyEligible.mutate({
|
||||
awardId,
|
||||
customMessage: notifyCustomMessage.trim() || undefined,
|
||||
})}
|
||||
disabled={notifyEligible.isPending}
|
||||
>
|
||||
{notifyEligible.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Sending...</>
|
||||
) : (
|
||||
<><Mail className="mr-2 h-4 w-4" />Send {award.eligibleCount} Emails</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button
|
||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||
disabled={updateStatus.isPending}
|
||||
|
||||
@@ -26,6 +26,7 @@ interface EmailPreviewDialogProps {
|
||||
isSending: boolean
|
||||
showCustomMessage?: boolean
|
||||
onRefreshPreview?: (customMessage?: string) => void
|
||||
previewOnly?: boolean
|
||||
}
|
||||
|
||||
export function EmailPreviewDialog({
|
||||
@@ -40,6 +41,7 @@ export function EmailPreviewDialog({
|
||||
isSending,
|
||||
showCustomMessage = true,
|
||||
onRefreshPreview,
|
||||
previewOnly = false,
|
||||
}: EmailPreviewDialogProps) {
|
||||
const [customMessage, setCustomMessage] = useState('')
|
||||
|
||||
@@ -104,6 +106,10 @@ export function EmailPreviewDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
{previewOnly ? (
|
||||
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSending}>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -123,6 +129,8 @@ export function EmailPreviewDialog({
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -39,9 +39,11 @@ import {
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Send,
|
||||
Eye,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { projectStateConfig } from '@/lib/round-config'
|
||||
import { EmailPreviewDialog } from './email-preview-dialog'
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -81,6 +83,10 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
|
||||
const [advancementMessage, setAdvancementMessage] = useState('')
|
||||
const [rejectionMessage, setRejectionMessage] = useState('')
|
||||
const [advancePreviewOpen, setAdvancePreviewOpen] = useState(false)
|
||||
const [rejectPreviewOpen, setRejectPreviewOpen] = useState(false)
|
||||
const [advancePreviewMsg, setAdvancePreviewMsg] = useState<string | undefined>()
|
||||
const [rejectPreviewMsg, setRejectPreviewMsg] = useState<string | undefined>()
|
||||
|
||||
// Mutations
|
||||
const updateOutcome = trpc.roundEngine.updateProposedOutcome.useMutation({
|
||||
@@ -121,6 +127,16 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
// Email preview queries
|
||||
const advancePreview = trpc.roundEngine.previewFinalizationAdvancementEmail.useQuery(
|
||||
{ roundId, customMessage: advancePreviewMsg },
|
||||
{ enabled: advancePreviewOpen }
|
||||
)
|
||||
const rejectPreview = trpc.roundEngine.previewFinalizationRejectionEmail.useQuery(
|
||||
{ roundId, customMessage: rejectPreviewMsg },
|
||||
{ enabled: rejectPreviewOpen }
|
||||
)
|
||||
|
||||
// Filtered projects
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!summary) return []
|
||||
@@ -624,7 +640,21 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Advancement Message</label>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-medium">Advancement Message</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
setAdvancePreviewMsg(advancementMessage || undefined)
|
||||
setAdvancePreviewOpen(true)
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
|
||||
value={advancementMessage}
|
||||
@@ -633,7 +663,21 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Rejection Message</label>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-medium">Rejection Message</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => {
|
||||
setRejectPreviewMsg(rejectionMessage || undefined)
|
||||
setRejectPreviewOpen(true)
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Custom message for projects that are not advancing (added to the standard email template)..."
|
||||
value={rejectionMessage}
|
||||
@@ -718,6 +762,34 @@ export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps)
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Email Preview Dialogs */}
|
||||
<EmailPreviewDialog
|
||||
open={advancePreviewOpen}
|
||||
onOpenChange={setAdvancePreviewOpen}
|
||||
title="Advancement Email Preview"
|
||||
description="Preview of the email sent to advancing project teams"
|
||||
recipientCount={advancePreview.data?.recipientCount ?? passedCount}
|
||||
previewHtml={advancePreview.data?.html}
|
||||
isPreviewLoading={advancePreview.isLoading}
|
||||
onSend={() => setAdvancePreviewOpen(false)}
|
||||
isSending={false}
|
||||
showCustomMessage={false}
|
||||
previewOnly
|
||||
/>
|
||||
<EmailPreviewDialog
|
||||
open={rejectPreviewOpen}
|
||||
onOpenChange={setRejectPreviewOpen}
|
||||
title="Rejection Email Preview"
|
||||
description="Preview of the email sent to non-advancing project teams"
|
||||
recipientCount={rejectPreview.data?.recipientCount ?? rejectedCount}
|
||||
previewHtml={rejectPreview.data?.html}
|
||||
isPreviewLoading={rejectPreview.isLoading}
|
||||
onSend={() => setRejectPreviewOpen(false)}
|
||||
isSending={false}
|
||||
showCustomMessage={false}
|
||||
previewOnly
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ import {
|
||||
getFinalizationSummary,
|
||||
confirmFinalization,
|
||||
} from '../services/round-finalization'
|
||||
import {
|
||||
getAdvancementNotificationTemplate,
|
||||
getRejectionNotificationTemplate,
|
||||
} from '@/lib/email'
|
||||
|
||||
const projectRoundStateEnum = z.enum([
|
||||
'PENDING',
|
||||
@@ -367,6 +371,66 @@ export const roundEngineRouter = router({
|
||||
return { updated }
|
||||
}),
|
||||
|
||||
previewFinalizationAdvancementEmail: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
customMessage: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: {
|
||||
name: true,
|
||||
competition: {
|
||||
select: { rounds: { select: { id: true, name: true, sortOrder: true }, orderBy: { sortOrder: 'asc' } } },
|
||||
},
|
||||
},
|
||||
})
|
||||
const rounds = round.competition.rounds
|
||||
const currentIdx = rounds.findIndex((r) => r.id === input.roundId)
|
||||
const nextRound = rounds[currentIdx + 1]
|
||||
const toRoundName = nextRound?.name ?? 'Next Round'
|
||||
|
||||
const passedCount = await ctx.prisma.projectRoundState.count({
|
||||
where: { roundId: input.roundId, proposedOutcome: 'PASSED' },
|
||||
})
|
||||
|
||||
const template = getAdvancementNotificationTemplate(
|
||||
'Team Member',
|
||||
'Your Project',
|
||||
round.name,
|
||||
toRoundName,
|
||||
input.customMessage || undefined,
|
||||
)
|
||||
|
||||
return { html: template.html, subject: template.subject, recipientCount: passedCount }
|
||||
}),
|
||||
|
||||
previewFinalizationRejectionEmail: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
customMessage: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const round = await ctx.prisma.round.findUniqueOrThrow({
|
||||
where: { id: input.roundId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
const rejectedCount = await ctx.prisma.projectRoundState.count({
|
||||
where: { roundId: input.roundId, proposedOutcome: 'REJECTED' },
|
||||
})
|
||||
|
||||
const template = getRejectionNotificationTemplate(
|
||||
'Team Member',
|
||||
'Your Project',
|
||||
round.name,
|
||||
input.customMessage || undefined,
|
||||
)
|
||||
|
||||
return { html: template.html, subject: template.subject, recipientCount: rejectedCount }
|
||||
}),
|
||||
|
||||
confirmFinalization: adminProcedure
|
||||
.input(z.object({
|
||||
roundId: z.string(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Prisma } from '@prisma/client'
|
||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||
import { logAudit } from '../utils/audit'
|
||||
import { processEligibilityJob } from '../services/award-eligibility-job'
|
||||
import { sendStyledNotificationEmail } from '@/lib/email'
|
||||
import { sendStyledNotificationEmail, getAwardSelectionNotificationTemplate } from '@/lib/email'
|
||||
import { generateInviteToken, getInviteExpiryMs } from '@/server/utils/invite'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
@@ -1229,6 +1229,31 @@ export const specialAwardRouter = router({
|
||||
return { needsInvite, hasAccount, totalProjects: eligibilities.length }
|
||||
}),
|
||||
|
||||
previewAwardSelectionEmail: adminProcedure
|
||||
.input(z.object({
|
||||
awardId: z.string(),
|
||||
customMessage: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
select: { name: true },
|
||||
})
|
||||
|
||||
const eligibleCount = await ctx.prisma.awardEligibility.count({
|
||||
where: { awardId: input.awardId, eligible: true },
|
||||
})
|
||||
|
||||
const template = getAwardSelectionNotificationTemplate(
|
||||
'Team Member',
|
||||
'Your Project',
|
||||
award.name,
|
||||
input.customMessage || undefined,
|
||||
)
|
||||
|
||||
return { html: template.html, subject: template.subject, recipientCount: eligibleCount }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Notify eligible projects that they've been selected for an award.
|
||||
* Generates invite tokens for passwordless users.
|
||||
|
||||
Reference in New Issue
Block a user