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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user