feat: audit log clickable links, communication hub recipient details & link options
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
- Audit log: user names link to /admin/members/{id}, entity IDs link to
relevant detail pages (projects, rounds, awards, users)
- Communication hub: expandable "View Recipients" section in sidebar shows
actual users/projects that will receive the message, with collapsible
project-level detail for applicants and juror assignment counts
- Email link type selector: choose between no link, messages inbox, login
page, or invite/accept link (auto-detects new vs existing members)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,11 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
Send,
|
||||
Mail,
|
||||
@@ -64,6 +69,8 @@ import {
|
||||
FolderOpen,
|
||||
XCircle,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
@@ -109,6 +116,7 @@ export default function MessagesPage() {
|
||||
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)
|
||||
@@ -165,6 +173,32 @@ export default function MessagesPage() {
|
||||
}
|
||||
)
|
||||
|
||||
// Detailed recipient list (fetched on-demand when user expands section)
|
||||
const [showRecipientDetails, setShowRecipientDetails] = useState(false)
|
||||
const recipientDetails = trpc.message.listRecipientDetails.useQuery(
|
||||
{
|
||||
recipientType,
|
||||
recipientFilter: buildRecipientFilterValue(),
|
||||
roundId: roundId || undefined,
|
||||
excludeStates: recipientType === 'ROUND_APPLICANTS' ? excludeStates : undefined,
|
||||
},
|
||||
{
|
||||
enabled: showRecipientDetails && (
|
||||
recipientType === 'ROUND_APPLICANTS'
|
||||
? !!roundId
|
||||
: recipientType === 'ROUND_JURY'
|
||||
? !!roundId
|
||||
: recipientType === 'ROLE'
|
||||
? !!selectedRole
|
||||
: recipientType === 'USER'
|
||||
? !!selectedUserId
|
||||
: recipientType === 'PROGRAM_TEAM'
|
||||
? !!selectedProgramId
|
||||
: recipientType === 'ALL'
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
const emailPreview = trpc.message.previewEmail.useQuery(
|
||||
{ subject, body },
|
||||
{ enabled: showPreview && subject.length > 0 && body.length > 0 }
|
||||
@@ -195,6 +229,8 @@ export default function MessagesPage() {
|
||||
setSelectedUserId('')
|
||||
setIsScheduled(false)
|
||||
setScheduledAt('')
|
||||
setLinkType('MESSAGES')
|
||||
setShowRecipientDetails(false)
|
||||
}
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
@@ -319,6 +355,7 @@ export default function MessagesPage() {
|
||||
deliveryChannels,
|
||||
scheduledAt: isScheduled && scheduledAt ? new Date(scheduledAt).toISOString() : undefined,
|
||||
templateId: selectedTemplateId && selectedTemplateId !== '__none__' ? selectedTemplateId : undefined,
|
||||
linkType,
|
||||
})
|
||||
setShowPreview(false)
|
||||
}
|
||||
@@ -614,6 +651,36 @@ export default function MessagesPage() {
|
||||
</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">
|
||||
@@ -748,6 +815,15 @@ export default function MessagesPage() {
|
||||
)}
|
||||
</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">
|
||||
@@ -991,6 +1067,11 @@ export default function MessagesPage() {
|
||||
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>
|
||||
@@ -1027,3 +1108,149 @@ export default function MessagesPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Expandable recipient details
|
||||
// =============================================================================
|
||||
|
||||
type RecipientDetailsData = {
|
||||
type: 'projects' | 'jurors' | 'users'
|
||||
projects: Array<{
|
||||
id: string
|
||||
title: string
|
||||
state: 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
|
||||
}>
|
||||
}
|
||||
|
||||
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 with their members
|
||||
data.projects.map((project) => (
|
||||
<ProjectRecipientRow key={project.id} project={project} />
|
||||
))
|
||||
) : data.type === 'jurors' ? (
|
||||
// ROUND_JURY: jurors with project counts
|
||||
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.projectCount !== undefined && (
|
||||
<span className="text-muted-foreground">
|
||||
{user.projectCount} project{user.projectCount !== 1 ? 's' : ''} assigned
|
||||
</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
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user