feat: audit log clickable links, communication hub recipient details & link options
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:
2026-03-06 11:49:49 +01:00
parent 2e8ab91e07
commit d4c946470a
4 changed files with 455 additions and 25 deletions

View File

@@ -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>
)
}