Award shortlist UX improvements + configurable invite link expiry
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s

Award shortlist:
- Expandable reasoning text (click to toggle, hover hint)
- Bulk select/deselect all checkbox in header
- Top N projects highlighted with amber background
- New bulkToggleShortlisted backend mutation

Invite link expiry:
- New "Invitation Link Expiry (hours)" field in Security Settings
- Reads from systemSettings `invite_link_expiry_hours` (default 72h / 3 days)
- Email template dynamically shows "X hours" or "X days" based on setting
- All 3 invite paths (bulk create, single invite, bulk resend) use setting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:05:58 +01:00
parent 8a7da0fd93
commit d02b0b91b9
6 changed files with 156 additions and 24 deletions

View File

@@ -4,7 +4,6 @@ import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
@@ -26,15 +25,14 @@ import {
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Award,
ChevronDown,
ChevronUp,
Loader2,
CheckCircle2,
Play,
Star,
Trophy,
AlertTriangle,
ChevronsUpDown,
} from 'lucide-react'
type AwardShortlistProps = {
@@ -61,6 +59,7 @@ export function AwardShortlist({
jobDone,
}: AwardShortlistProps) {
const [expanded, setExpanded] = useState(false)
const [expandedReasoning, setExpandedReasoning] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
@@ -93,6 +92,15 @@ export function AwardShortlist({
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const bulkToggleMutation = trpc.specialAward.bulkToggleShortlisted.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(`${data.updated} projects ${data.shortlisted ? 'added to' : 'removed from'} shortlist`)
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
{ awardId },
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
@@ -119,6 +127,27 @@ export function AwardShortlist({
: 0
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
const someShortlisted = shortlistedCount > 0 && !allShortlisted
const toggleReasoning = (id: string) => {
setExpandedReasoning((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}
const handleBulkToggle = () => {
if (!shortlist) return
const projectIds = shortlist.eligibilities.map((e) => e.project.id)
const newValue = !allShortlisted
bulkToggleMutation.mutate({ awardId, projectIds, shortlisted: newValue })
}
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
@@ -257,23 +286,41 @@ export function AwardShortlist({
<th className="px-3 py-2 text-left w-8">#</th>
<th className="px-3 py-2 text-left">Project</th>
<th className="px-3 py-2 text-left w-24">Score</th>
<th className="px-3 py-2 text-left w-32">Reasoning</th>
<th className="px-3 py-2 text-center w-20">Shortlist</th>
<th className="px-3 py-2 text-left w-44">Reasoning</th>
<th className="px-3 py-2 text-center w-20">
<div className="flex items-center justify-center gap-1">
<Checkbox
checked={allShortlisted ? true : someShortlisted ? 'indeterminate' : false}
onCheckedChange={handleBulkToggle}
disabled={bulkToggleMutation.isPending}
aria-label="Select all"
/>
<span className="text-xs">All</span>
</div>
</th>
</tr>
</thead>
<tbody>
{shortlist.eligibilities.map((e, i) => {
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
const isTop5 = i < shortlistSize
const isReasoningExpanded = expandedReasoning.has(e.id)
return (
<tr key={e.id} className={`border-t ${e.shortlisted ? 'bg-amber-50/50' : ''}`}>
<tr key={e.id} className={`border-t ${isTop5 ? 'bg-amber-50/50' : ''}`}>
<td className="px-3 py-2 text-muted-foreground font-mono">
{i + 1}
{isTop5 ? (
<span className="text-amber-600 font-semibold">{i + 1}</span>
) : (
i + 1
)}
</td>
<td className="px-3 py-2">
<div>
<p className="font-medium">{e.project.title}</p>
<p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}>
{e.project.title}
</p>
<p className="text-xs text-muted-foreground">
{e.project.teamName || e.project.country || e.project.competitionCategory || '—'}
{[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}
</p>
</div>
</td>
@@ -290,9 +337,18 @@ export function AwardShortlist({
</td>
<td className="px-3 py-2">
{reasoning ? (
<p className="text-xs text-muted-foreground line-clamp-2" title={reasoning}>
{reasoning}
</p>
<button
onClick={() => toggleReasoning(e.id)}
className="text-left w-full group"
>
<p className={`text-xs text-muted-foreground ${isReasoningExpanded ? '' : 'line-clamp-2'}`}>
{reasoning}
</p>
<span className="text-xs text-blue-600 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-0.5 mt-0.5">
<ChevronsUpDown className="h-3 w-3" />
{isReasoningExpanded ? 'Collapse' : 'Expand'}
</span>
</button>
) : (
<span className="text-xs text-muted-foreground"></span>
)}