Observer dashboard extraction, PDF reports, jury UX overhaul, and miscellaneous improvements

- Extract observer dashboard to client component, add PDF export button
- Add PDF report generator with jsPDF for analytics reports
- Overhaul jury evaluation page with improved layout and UX
- Add new analytics endpoints for observer/admin reports
- Improve round creation/edit forms with better settings
- Fix filtering rules page, CSV export dialog, notification bell
- Update auth, prisma schema, and various type fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 23:08:00 +01:00
parent 5c8d22ac11
commit d787a24921
31 changed files with 2565 additions and 930 deletions

View File

@@ -142,7 +142,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: { in: ['ACTIVE', 'INVITED'] },
status: { in: ['ACTIVE', 'INVITED', 'NONE'] },
assignments: { some: { round: { programId: editionId } } },
},
}),
@@ -751,7 +751,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-accent transition-all"
className="h-full rounded-full bg-brand-teal transition-all"
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
/>
</div>

View File

@@ -25,7 +25,10 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { TagInput } from '@/components/shared/tag-input'
import { CountrySelect } from '@/components/ui/country-select'
import { PhoneInput } from '@/components/ui/phone-input'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import { toast } from 'sonner'
import {
ArrowLeft,
@@ -33,8 +36,52 @@ import {
Loader2,
AlertCircle,
FolderPlus,
Users,
UserPlus,
Trash2,
Mail,
} from 'lucide-react'
type TeamMemberEntry = {
id: string
name: string
email: string
role: 'LEAD' | 'MEMBER' | 'ADVISOR'
title: string
phone: string
sendInvite: boolean
}
const ROLE_COLORS: Record<string, string> = {
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
}
const ROLE_AVATAR_COLORS: Record<string, string> = {
LEAD: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
MEMBER: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
ADVISOR: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
}
const ROLE_LABELS: Record<string, string> = {
LEAD: 'Lead',
MEMBER: 'Member',
ADVISOR: 'Advisor',
}
function getInitials(name: string): string {
return name
.split(' ')
.map((w) => w[0])
.filter(Boolean)
.slice(0, 2)
.join('')
.toUpperCase()
}
const ROLE_SORT_ORDER: Record<string, number> = { LEAD: 0, MEMBER: 1, ADVISOR: 2 }
function NewProjectPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
@@ -49,15 +96,20 @@ function NewProjectPageContent() {
const [teamName, setTeamName] = useState('')
const [description, setDescription] = useState('')
const [tags, setTags] = useState<string[]>([])
const [contactEmail, setContactEmail] = useState('')
const [contactName, setContactName] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [country, setCountry] = useState('')
const [city, setCity] = useState('')
const [institution, setInstitution] = useState('')
const [competitionCategory, setCompetitionCategory] = useState<string>('')
const [oceanIssue, setOceanIssue] = useState<string>('')
// Team members state
const [teamMembers, setTeamMembers] = useState<TeamMemberEntry[]>([])
const [memberName, setMemberName] = useState('')
const [memberEmail, setMemberEmail] = useState('')
const [memberRole, setMemberRole] = useState<'LEAD' | 'MEMBER' | 'ADVISOR'>('MEMBER')
const [memberTitle, setMemberTitle] = useState('')
const [memberPhone, setMemberPhone] = useState('')
const [memberSendInvite, setMemberSendInvite] = useState(false)
// Fetch programs
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
@@ -92,6 +144,66 @@ function NewProjectPageContent() {
const categoryOptions = wizardConfig?.competitionCategories || []
const oceanIssueOptions = wizardConfig?.oceanIssues || []
const handleAddMember = () => {
if (!memberName.trim()) {
toast.error('Please enter a member name')
return
}
if (!memberEmail.trim()) {
toast.error('Please enter a member email')
return
}
// Basic email validation
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(memberEmail.trim())) {
toast.error('Please enter a valid email address')
return
}
// Check for duplicates
if (teamMembers.some((m) => m.email.toLowerCase() === memberEmail.trim().toLowerCase())) {
toast.error('A member with this email already exists')
return
}
if (teamMembers.length >= 10) {
toast.error('Maximum 10 team members allowed')
return
}
setTeamMembers((prev) => [
...prev,
{
id: crypto.randomUUID(),
name: memberName.trim(),
email: memberEmail.trim(),
role: memberRole,
title: memberTitle.trim(),
phone: memberPhone.trim(),
sendInvite: memberSendInvite,
},
])
// Reset form
setMemberName('')
setMemberEmail('')
setMemberRole('MEMBER')
setMemberTitle('')
setMemberPhone('')
setMemberSendInvite(false)
}
const handleRemoveMember = (id: string) => {
setTeamMembers((prev) => prev.filter((m) => m.id !== id))
}
const handleToggleInvite = (id: string) => {
setTeamMembers((prev) =>
prev.map((m) => (m.id === id ? { ...m, sendInvite: !m.sendInvite } : m))
)
}
const sortedMembers = [...teamMembers].sort(
(a, b) => (ROLE_SORT_ORDER[a.role] ?? 9) - (ROLE_SORT_ORDER[b.role] ?? 9)
)
const handleSubmit = () => {
if (!title.trim()) {
toast.error('Please enter a project title')
@@ -113,10 +225,16 @@ function NewProjectPageContent() {
competitionCategory: competitionCategory as 'STARTUP' | 'BUSINESS_CONCEPT' | undefined || undefined,
oceanIssue: oceanIssue as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | undefined || undefined,
institution: institution.trim() || undefined,
contactPhone: contactPhone.trim() || undefined,
contactEmail: contactEmail.trim() || undefined,
contactName: contactName.trim() || undefined,
city: city.trim() || undefined,
teamMembers: teamMembers.length > 0
? teamMembers.map(({ name, email, role, title: t, phone, sendInvite }) => ({
name,
email,
role,
title: t || undefined,
phone: phone || undefined,
sendInvite,
}))
: undefined,
})
}
@@ -304,47 +422,6 @@ function NewProjectPageContent() {
placeholder="e.g., University of Monaco"
/>
</div>
</CardContent>
</Card>
{/* Contact Info */}
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
<CardDescription>
Contact details for the project team
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="contactName">Contact Name</Label>
<Input
id="contactName"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="e.g., John Smith"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contactEmail">Contact Email</Label>
<Input
id="contactEmail"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="e.g., john@example.com"
/>
</div>
<div className="space-y-2">
<Label>Contact Phone</Label>
<PhoneInput
value={contactPhone}
onChange={setContactPhone}
defaultCountry="MC"
/>
</div>
<div className="space-y-2">
<Label>Country</Label>
@@ -353,16 +430,171 @@ function NewProjectPageContent() {
onChange={setCountry}
/>
</div>
</CardContent>
</Card>
<div className="space-y-2">
<Label htmlFor="city">City</Label>
<Input
id="city"
value={city}
onChange={(e) => setCity(e.target.value)}
placeholder="e.g., Monaco"
/>
{/* Team Members */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Team Members</CardTitle>
<Badge variant="secondary">{teamMembers.length} / 10</Badge>
</div>
<CardDescription>
Add team members and optionally invite them to the platform
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{teamMembers.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-8 text-center">
<Users className="h-10 w-10 text-muted-foreground/40" />
<p className="mt-2 text-sm font-medium">No team members yet</p>
<p className="text-xs text-muted-foreground">
Add members below to link them to this project
</p>
</div>
) : (
<div className="space-y-2">
{sortedMembers.map((member) => (
<div
key={member.id}
className="flex items-center gap-3 rounded-lg border p-3"
>
<Avatar className={`h-9 w-9 ${ROLE_AVATAR_COLORS[member.role]}`}>
<AvatarFallback className={ROLE_AVATAR_COLORS[member.role]}>
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{member.name}</span>
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${ROLE_COLORS[member.role]}`}>
{ROLE_LABELS[member.role]}
</Badge>
{member.title && (
<span className="hidden truncate text-xs text-muted-foreground sm:inline">
{member.title}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">{member.email}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleToggleInvite(member.id)}
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
member.sendInvite
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground'
}`}
title={member.sendInvite ? 'Invitation will be sent' : 'Click to send invitation'}
>
<Mail className="h-3.5 w-3.5" />
<span className="hidden sm:inline">
{member.sendInvite ? 'Invite' : 'No invite'}
</span>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => handleRemoveMember(member.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{teamMembers.length > 0 && teamMembers.length < 10 && <Separator />}
{/* Add member form */}
{teamMembers.length < 10 && (
<div className="space-y-3">
<p className="text-sm font-medium flex items-center gap-1.5">
<UserPlus className="h-4 w-4" />
Add Member
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="memberName" className="text-xs">Name *</Label>
<Input
id="memberName"
value={memberName}
onChange={(e) => setMemberName(e.target.value)}
placeholder="Full name"
className="h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="memberEmail" className="text-xs">Email *</Label>
<Input
id="memberEmail"
type="email"
value={memberEmail}
onChange={(e) => setMemberEmail(e.target.value)}
placeholder="email@example.com"
className="h-9"
/>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label className="text-xs">Role</Label>
<Select value={memberRole} onValueChange={(v) => setMemberRole(v as 'LEAD' | 'MEMBER' | 'ADVISOR')}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LEAD">Lead</SelectItem>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="ADVISOR">Advisor</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="memberTitle" className="text-xs">Title (optional)</Label>
<Input
id="memberTitle"
value={memberTitle}
onChange={(e) => setMemberTitle(e.target.value)}
placeholder="e.g., CEO, CTO"
className="h-9"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
id="memberSendInvite"
checked={memberSendInvite}
onCheckedChange={(checked) => setMemberSendInvite(checked === true)}
/>
<Label htmlFor="memberSendInvite" className="text-xs cursor-pointer">
Send platform invitation
</Label>
</div>
<Button
type="button"
size="sm"
onClick={handleAddMember}
>
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
Add
</Button>
</div>
</div>
)}
{teamMembers.length >= 10 && (
<p className="text-center text-xs text-muted-foreground">
Maximum of 10 team members reached
</p>
)}
</CardContent>
</Card>
</div>

View File

@@ -37,12 +37,10 @@ import {
Users,
ClipboardList,
CheckCircle2,
PieChart,
TrendingUp,
GitCompare,
UserCheck,
Globe,
Printer,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
@@ -57,6 +55,7 @@ import {
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
function ReportsOverview() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
@@ -631,6 +630,19 @@ function DiversityTab() {
}
export default function ReportsPage() {
const [pdfRoundId, setPdfRoundId] = useState<string | null>(null)
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeRounds: true })
const pdfRounds = pdfPrograms?.flatMap((p) =>
p.rounds.map((r) => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
) || []
if (pdfRounds.length && !pdfRoundId) {
setPdfRoundId(pdfRounds[0].id)
}
const selectedPdfRound = pdfRounds.find((r) => r.id === pdfRoundId)
return (
<div className="space-y-6">
{/* Header */}
@@ -666,16 +678,27 @@ export default function ReportsPage() {
Diversity
</TabsTrigger>
</TabsList>
<Button
variant="outline"
size="sm"
onClick={() => {
window.print()
}}
>
<Printer className="mr-2 h-4 w-4" />
Export PDF
</Button>
<div className="flex items-center gap-2">
<Select value={pdfRoundId || ''} onValueChange={setPdfRoundId}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="Select round for PDF" />
</SelectTrigger>
<SelectContent>
{pdfRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
{pdfRoundId && (
<ExportPdfButton
roundId={pdfRoundId}
roundName={selectedPdfRound?.name}
programName={selectedPdfRound?.programName}
/>
)}
</div>
</div>
<TabsContent value="overview">

View File

@@ -72,7 +72,7 @@ interface PageProps {
const updateRoundSchema = z
.object({
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
requiredReviews: z.number().int().min(0).max(10),
minAssignmentsPerJuror: z.number().int().min(1).max(50),
maxAssignmentsPerJuror: z.number().int().min(1).max(100),
votingStartAt: z.date().nullable().optional(),
@@ -206,7 +206,7 @@ function EditRoundContent({ roundId }: { roundId: string }) {
await updateRound.mutateAsync({
id: roundId,
name: data.name,
requiredReviews: data.requiredReviews,
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
minAssignmentsPerJuror: data.minAssignmentsPerJuror,
maxAssignmentsPerJuror: data.maxAssignmentsPerJuror,
roundType,
@@ -301,30 +301,32 @@ function EditRoundContent({ roundId }: { roundId: string }) {
)}
/>
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{roundType !== 'FILTERING' && (
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid gap-4 sm:grid-cols-2">
<FormField

View File

@@ -105,6 +105,8 @@ export default function FilteringResultsPage({
: undefined,
page,
perPage,
}, {
staleTime: 0, // Always refetch - results change after filtering runs
})
const utils = trpc.useUtils()

View File

@@ -109,6 +109,7 @@ export default function FilteringRulesPage({
// AI screening config state
const [criteriaText, setCriteriaText] = useState('')
const [aiAction, setAiAction] = useState<'REJECT' | 'FLAG'>('REJECT')
const [aiBatchSize, setAiBatchSize] = useState('20')
const [aiParallelBatches, setAiParallelBatches] = useState('1')
@@ -144,7 +145,7 @@ export default function FilteringRulesPage({
} else if (newRuleType === 'AI_SCREENING') {
configJson = {
criteriaText,
action: 'FLAG',
action: aiAction,
batchSize: parseInt(aiBatchSize) || 20,
parallelBatches: parseInt(aiParallelBatches) || 1,
}
@@ -418,9 +419,23 @@ export default function FilteringRulesPage({
placeholder="Describe the criteria for AI to evaluate projects against..."
rows={4}
/>
</div>
<div className="space-y-2">
<Label>Action for Non-Matching Projects</Label>
<Select value={aiAction} onValueChange={(v) => setAiAction(v as 'REJECT' | 'FLAG')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="REJECT">Auto Filter Out</SelectItem>
<SelectItem value="FLAG">Flag for Review</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
AI screening always flags projects for human review, never
auto-rejects.
{aiAction === 'REJECT'
? 'Projects that don\'t meet criteria will be automatically filtered out.'
: 'Projects that don\'t meet criteria will be flagged for human review.'}
</p>
</div>

View File

@@ -50,7 +50,7 @@ const TEAM_NOTIFICATION_OPTIONS = [
const createRoundSchema = z.object({
programId: z.string().min(1, 'Please select a program'),
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
requiredReviews: z.number().int().min(0).max(10),
votingStartAt: z.date().nullable().optional(),
votingEndAt: z.date().nullable().optional(),
}).refine((data) => {
@@ -128,7 +128,7 @@ function CreateRoundContent() {
programId: data.programId,
name: data.name,
roundType,
requiredReviews: data.requiredReviews,
requiredReviews: roundType === 'FILTERING' ? 0 : data.requiredReviews,
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ?? undefined,
votingEndAt: data.votingEndAt ?? undefined,
@@ -291,28 +291,30 @@ function CreateRoundContent() {
)}
/>
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{roundType !== 'FILTERING' && (
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</CardContent>
</Card>