Apply full refactor updates plus pipeline/email UX confirmations
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m33s

This commit is contained in:
Matt
2026-02-14 15:26:42 +01:00
parent e56e143a40
commit b5425e705e
374 changed files with 116737 additions and 111969 deletions

View File

@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
@@ -103,6 +104,7 @@ const fileTypeIcons: Record<string, React.ReactNode> = {
function EditProjectContent({ projectId }: { projectId: string }) {
const router = useRouter()
const [tagInput, setTagInput] = useState('')
const [statusNotificationConfirmed, setStatusNotificationConfirmed] = useState(false)
// Fetch project data
const { data: project, isLoading } = trpc.project.get.useQuery({
@@ -172,6 +174,24 @@ function EditProjectContent({ projectId }: { projectId: string }) {
}, [project, form])
const tags = form.watch('tags')
const selectedStatus = form.watch('status')
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
const statusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
const requiresStatusNotificationConfirmation = Boolean(
project && selectedStatus !== previousStatus && statusTriggersNotifications
)
const notificationRecipientEmails = Array.from(
new Set(
(project?.teamMembers ?? [])
.map((member) => member.user?.email?.toLowerCase().trim() ?? '')
.filter((email) => email.length > 0)
)
)
useEffect(() => {
setStatusNotificationConfirmed(false)
form.clearErrors('status')
}, [selectedStatus, form])
// Add tag
const addTag = useCallback(() => {
@@ -194,6 +214,14 @@ function EditProjectContent({ projectId }: { projectId: string }) {
)
const onSubmit = async (data: UpdateProjectForm) => {
if (requiresStatusNotificationConfirmation && !statusNotificationConfirmed) {
form.setError('status', {
type: 'manual',
message: 'Confirm participant notifications before saving this status change.',
})
return
}
await updateProject.mutateAsync({
id: projectId,
title: data.title,
@@ -370,6 +398,39 @@ function EditProjectContent({ projectId }: { projectId: string }) {
<SelectItem value="REJECTED">Rejected</SelectItem>
</SelectContent>
</Select>
{requiresStatusNotificationConfirmation && (
<div className="space-y-2 rounded-md border bg-muted/20 p-3">
<p className="text-xs font-medium">
Participant Notification Check
</p>
<p className="text-xs text-muted-foreground">
Saving this status will send automated notifications.
</p>
<p className="text-xs text-muted-foreground">
Recipients ({notificationRecipientEmails.length}):{' '}
{notificationRecipientEmails.length > 0
? notificationRecipientEmails.slice(0, 8).join(', ')
: 'No linked participant accounts found'}
{notificationRecipientEmails.length > 8 ? ', ...' : ''}
</p>
<div className="flex items-start gap-2">
<Checkbox
id="confirm-status-notifications"
checked={statusNotificationConfirmed}
onCheckedChange={(checked) => {
const confirmed = checked === true
setStatusNotificationConfirmed(confirmed)
if (confirmed) {
form.clearErrors('status')
}
}}
/>
<FormLabel htmlFor="confirm-status-notifications" className="text-sm font-normal leading-5">
I verified participant recipients and approve sending automated notifications.
</FormLabel>
</div>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -557,7 +618,10 @@ function EditProjectContent({ projectId }: { projectId: string }) {
<Button type="button" variant="outline" asChild>
<Link href={`/admin/projects/${projectId}`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isPending}>
<Button
type="submit"
disabled={isPending || (requiresStatusNotificationConfirmation && !statusNotificationConfirmed)}
>
{updateProject.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}

View File

@@ -1,393 +1,393 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import {
ArrowLeft,
Loader2,
Users,
User,
Check,
RefreshCw,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Type for mentor suggestion from the API
interface MentorSuggestion {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
mentor: {
id: string
name: string | null
email: string
expertiseTags: string[]
assignmentCount: number
} | null
}
function MentorAssignmentContent({ projectId }: { projectId: string }) {
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
const utils = trpc.useUtils()
// Fetch project
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch suggestions
const { data: suggestions, isLoading: suggestionsLoading, refetch } = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment }
)
// Assign mentor mutation
const assignMutation = trpc.mentor.assign.useMutation({
onSuccess: () => {
toast.success('Mentor assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Auto-assign mutation
const autoAssignMutation = trpc.mentor.autoAssign.useMutation({
onSuccess: () => {
toast.success('Mentor auto-assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Unassign mutation
const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => {
toast.success('Mentor removed')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
const handleAssign = (mentorId: string, suggestion?: MentorSuggestion) => {
assignMutation.mutate({
projectId,
mentorId,
method: suggestion ? 'AI_SUGGESTED' : 'MANUAL',
aiConfidenceScore: suggestion?.confidenceScore,
expertiseMatchScore: suggestion?.expertiseMatchScore,
aiReasoning: suggestion?.reasoning,
})
}
if (projectLoading) {
return <MentorAssignmentSkeleton />
}
if (!project) {
return (
<Card>
<CardContent className="py-12 text-center">
<p>Project not found</p>
</CardContent>
</Card>
)
}
const hasMentor = !!project.mentorAssignment
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor Assignment</h1>
<p className="text-muted-foreground">{project.title}</p>
</div>
{/* Current Assignment */}
{hasMentor && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Mentor</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback>
{getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{project.mentorAssignment!.mentor.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
</div>
</div>
<div className="text-right">
<Badge variant="outline" className="mb-2">
{project.mentorAssignment!.method.replace(/_/g, ' ')}
</Badge>
<div>
<Button
variant="destructive"
size="sm"
onClick={() => unassignMutation.mutate({ projectId })}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Remove'
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* AI Suggestions */}
{!hasMentor && (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
AI-Suggested Mentors
</CardTitle>
<CardDescription>
Mentors matched based on expertise and project needs
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => refetch()}
disabled={suggestionsLoading}
>
{suggestionsLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Refresh'
)}
</Button>
<Button
onClick={() => autoAssignMutation.mutate({ projectId, useAI: true })}
disabled={autoAssignMutation.isPending}
>
{autoAssignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Auto-Assign Best Match
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{suggestionsLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : suggestions?.suggestions.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No mentor suggestions available. Try adding more users with expertise tags.
</p>
) : (
<div className="space-y-4">
{suggestions?.suggestions.map((suggestion, index) => (
<div
key={suggestion.mentorId}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedMentorId === suggestion.mentorId
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="relative">
<Avatar className="h-12 w-12">
<AvatarFallback>
{suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'}
</AvatarFallback>
</Avatar>
{index === 0 && (
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{suggestion.mentor?.name || 'Unnamed'}</p>
<Badge variant="outline" className="text-xs">
{suggestion.mentor?.assignmentCount || 0} projects
</Badge>
</div>
<p className="text-sm text-muted-foreground">{suggestion.mentor?.email}</p>
{/* Expertise tags */}
{suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{suggestion.mentor.expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* Match scores */}
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Confidence:</span>
<Progress value={suggestion.confidenceScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.confidenceScore * 100).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Expertise Match:</span>
<Progress value={suggestion.expertiseMatchScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.expertiseMatchScore * 100).toFixed(0)}%</span>
</div>
</div>
{/* AI Reasoning */}
{suggestion.reasoning && (
<p className="mt-2 text-sm text-muted-foreground italic">
&quot;{suggestion.reasoning}&quot;
</p>
)}
</div>
</div>
<Button
onClick={() => handleAssign(suggestion.mentorId, suggestion)}
disabled={assignMutation.isPending}
variant={selectedMentorId === suggestion.mentorId ? 'default' : 'outline'}
>
{assignMutation.isPending && selectedMentorId === suggestion.mentorId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" />
Assign
</>
)}
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Manual Assignment */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5" />
Manual Assignment
</CardTitle>
<CardDescription>
Search and select a mentor manually
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Use the AI suggestions above or search for a specific user in the Users section
to assign them as a mentor manually.
</p>
</CardContent>
</Card>
</>
)}
</div>
)
}
function MentorAssignmentSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-48" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function MentorAssignmentPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<MentorAssignmentSkeleton />}>
<MentorAssignmentContent projectId={id} />
</Suspense>
)
}
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import {
ArrowLeft,
Loader2,
Users,
User,
Check,
RefreshCw,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Type for mentor suggestion from the API
interface MentorSuggestion {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
mentor: {
id: string
name: string | null
email: string
expertiseTags: string[]
assignmentCount: number
} | null
}
function MentorAssignmentContent({ projectId }: { projectId: string }) {
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
const utils = trpc.useUtils()
// Fetch project
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch suggestions
const { data: suggestions, isLoading: suggestionsLoading, refetch } = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment }
)
// Assign mentor mutation
const assignMutation = trpc.mentor.assign.useMutation({
onSuccess: () => {
toast.success('Mentor assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Auto-assign mutation
const autoAssignMutation = trpc.mentor.autoAssign.useMutation({
onSuccess: () => {
toast.success('Mentor auto-assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Unassign mutation
const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => {
toast.success('Mentor removed')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
const handleAssign = (mentorId: string, suggestion?: MentorSuggestion) => {
assignMutation.mutate({
projectId,
mentorId,
method: suggestion ? 'AI_SUGGESTED' : 'MANUAL',
aiConfidenceScore: suggestion?.confidenceScore,
expertiseMatchScore: suggestion?.expertiseMatchScore,
aiReasoning: suggestion?.reasoning,
})
}
if (projectLoading) {
return <MentorAssignmentSkeleton />
}
if (!project) {
return (
<Card>
<CardContent className="py-12 text-center">
<p>Project not found</p>
</CardContent>
</Card>
)
}
const hasMentor = !!project.mentorAssignment
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor Assignment</h1>
<p className="text-muted-foreground">{project.title}</p>
</div>
{/* Current Assignment */}
{hasMentor && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Mentor</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback>
{getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{project.mentorAssignment!.mentor.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
</div>
</div>
<div className="text-right">
<Badge variant="outline" className="mb-2">
{project.mentorAssignment!.method.replace(/_/g, ' ')}
</Badge>
<div>
<Button
variant="destructive"
size="sm"
onClick={() => unassignMutation.mutate({ projectId })}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Remove'
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* AI Suggestions */}
{!hasMentor && (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
AI-Suggested Mentors
</CardTitle>
<CardDescription>
Mentors matched based on expertise and project needs
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => refetch()}
disabled={suggestionsLoading}
>
{suggestionsLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Refresh'
)}
</Button>
<Button
onClick={() => autoAssignMutation.mutate({ projectId, useAI: true })}
disabled={autoAssignMutation.isPending}
>
{autoAssignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Auto-Assign Best Match
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{suggestionsLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : suggestions?.suggestions.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No mentor suggestions available. Try adding more users with expertise tags.
</p>
) : (
<div className="space-y-4">
{suggestions?.suggestions.map((suggestion, index) => (
<div
key={suggestion.mentorId}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedMentorId === suggestion.mentorId
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="relative">
<Avatar className="h-12 w-12">
<AvatarFallback>
{suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'}
</AvatarFallback>
</Avatar>
{index === 0 && (
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{suggestion.mentor?.name || 'Unnamed'}</p>
<Badge variant="outline" className="text-xs">
{suggestion.mentor?.assignmentCount || 0} projects
</Badge>
</div>
<p className="text-sm text-muted-foreground">{suggestion.mentor?.email}</p>
{/* Expertise tags */}
{suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{suggestion.mentor.expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* Match scores */}
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Confidence:</span>
<Progress value={suggestion.confidenceScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.confidenceScore * 100).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Expertise Match:</span>
<Progress value={suggestion.expertiseMatchScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.expertiseMatchScore * 100).toFixed(0)}%</span>
</div>
</div>
{/* AI Reasoning */}
{suggestion.reasoning && (
<p className="mt-2 text-sm text-muted-foreground italic">
&quot;{suggestion.reasoning}&quot;
</p>
)}
</div>
</div>
<Button
onClick={() => handleAssign(suggestion.mentorId, suggestion)}
disabled={assignMutation.isPending}
variant={selectedMentorId === suggestion.mentorId ? 'default' : 'outline'}
>
{assignMutation.isPending && selectedMentorId === suggestion.mentorId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" />
Assign
</>
)}
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Manual Assignment */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5" />
Manual Assignment
</CardTitle>
<CardDescription>
Search and select a mentor manually
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Use the AI suggestions above or search for a specific user in the Users section
to assign them as a mentor manually.
</p>
</CardContent>
</Card>
</>
)}
</div>
)
}
function MentorAssignmentSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-48" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function MentorAssignmentPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<MentorAssignmentSkeleton />}>
<MentorAssignmentContent projectId={id} />
</Suspense>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,242 +1,242 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { CSVImportForm } from '@/components/forms/csv-import-form'
import { NotionImportForm } from '@/components/forms/notion-import-form'
import { TypeformImportForm } from '@/components/forms/typeform-import-form'
import { ArrowLeft, FileSpreadsheet, AlertCircle, Database, FileText } from 'lucide-react'
function ImportPageContent() {
const router = useRouter()
const utils = trpc.useUtils()
const searchParams = useSearchParams()
const stageIdParam = searchParams.get('stage')
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
// Fetch active programs with stages
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeStages: true,
})
// Get all stages from programs
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
if (loadingPrograms) {
return <ImportPageSkeleton />
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Import Projects</h1>
<p className="text-muted-foreground">
Import projects from a CSV file into a stage
</p>
</div>
{/* Stage selection */}
{!selectedStageId && (
<Card>
<CardHeader>
<CardTitle>Select Stage</CardTitle>
<CardDescription>
Choose the stage you want to import projects into
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{stages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Stages</p>
<p className="text-sm text-muted-foreground">
Create a stage first before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
</Button>
</div>
) : (
<>
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
<SelectTrigger>
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
<div className="flex flex-col">
<span>{stage.name}</span>
<span className="text-xs text-muted-foreground">
{stage.programName}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => {
if (selectedStageId) {
router.push(`/admin/projects/import?stage=${selectedStageId}`)
}
}}
disabled={!selectedStageId}
>
Continue
</Button>
</>
)}
</CardContent>
</Card>
)}
{/* Import form */}
{selectedStageId && selectedStage && (
<div className="space-y-4">
<div className="flex items-center gap-4">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Importing into: {selectedStage.name}</p>
<p className="text-sm text-muted-foreground">
{selectedStage.programName}
</p>
</div>
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => {
setSelectedStageId('')
router.push('/admin/projects/import')
}}
>
Change Stage
</Button>
</div>
<Tabs defaultValue="csv" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="csv" className="flex items-center gap-2">
<FileSpreadsheet className="h-4 w-4" />
CSV
</TabsTrigger>
<TabsTrigger value="notion" className="flex items-center gap-2">
<Database className="h-4 w-4" />
Notion
</TabsTrigger>
<TabsTrigger value="typeform" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Typeform
</TabsTrigger>
</TabsList>
<TabsContent value="csv" className="mt-4">
<CSVImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>
<TabsContent value="notion" className="mt-4">
<NotionImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>
<TabsContent value="typeform" className="mt-4">
<TypeformImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>
</Tabs>
</div>
)}
</div>
)
}
function ImportPageSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-36" />
</div>
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-24" />
</CardContent>
</Card>
</div>
)
}
export default function ImportProjectsPage() {
return (
<Suspense fallback={<ImportPageSkeleton />}>
<ImportPageContent />
</Suspense>
)
}
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { CSVImportForm } from '@/components/forms/csv-import-form'
import { NotionImportForm } from '@/components/forms/notion-import-form'
import { TypeformImportForm } from '@/components/forms/typeform-import-form'
import { ArrowLeft, FileSpreadsheet, AlertCircle, Database, FileText } from 'lucide-react'
function ImportPageContent() {
const router = useRouter()
const utils = trpc.useUtils()
const searchParams = useSearchParams()
const stageIdParam = searchParams.get('stage')
const [selectedStageId, setSelectedStageId] = useState<string>(stageIdParam || '')
// Fetch active programs with stages
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeStages: true,
})
// Get all stages from programs
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
const selectedStage = stages.find((s: { id: string }) => s.id === selectedStageId)
if (loadingPrograms) {
return <ImportPageSkeleton />
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Import Projects</h1>
<p className="text-muted-foreground">
Import projects from a CSV file into a stage
</p>
</div>
{/* Stage selection */}
{!selectedStageId && (
<Card>
<CardHeader>
<CardTitle>Select Stage</CardTitle>
<CardDescription>
Choose the stage you want to import projects into
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{stages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Stages</p>
<p className="text-sm text-muted-foreground">
Create a stage first before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new-pipeline">Create Pipeline</Link>
</Button>
</div>
) : (
<>
<Select value={selectedStageId} onValueChange={setSelectedStageId}>
<SelectTrigger>
<SelectValue placeholder="Select a stage" />
</SelectTrigger>
<SelectContent>
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
<div className="flex flex-col">
<span>{stage.name}</span>
<span className="text-xs text-muted-foreground">
{stage.programName}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => {
if (selectedStageId) {
router.push(`/admin/projects/import?stage=${selectedStageId}`)
}
}}
disabled={!selectedStageId}
>
Continue
</Button>
</>
)}
</CardContent>
</Card>
)}
{/* Import form */}
{selectedStageId && selectedStage && (
<div className="space-y-4">
<div className="flex items-center gap-4">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Importing into: {selectedStage.name}</p>
<p className="text-sm text-muted-foreground">
{selectedStage.programName}
</p>
</div>
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => {
setSelectedStageId('')
router.push('/admin/projects/import')
}}
>
Change Stage
</Button>
</div>
<Tabs defaultValue="csv" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="csv" className="flex items-center gap-2">
<FileSpreadsheet className="h-4 w-4" />
CSV
</TabsTrigger>
<TabsTrigger value="notion" className="flex items-center gap-2">
<Database className="h-4 w-4" />
Notion
</TabsTrigger>
<TabsTrigger value="typeform" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Typeform
</TabsTrigger>
</TabsList>
<TabsContent value="csv" className="mt-4">
<CSVImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>
<TabsContent value="notion" className="mt-4">
<NotionImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>
<TabsContent value="typeform" className="mt-4">
<TypeformImportForm
programId={selectedStage.programId}
stageName={selectedStage.name}
onSuccess={() => {
utils.project.list.invalidate()
utils.program.get.invalidate()
}}
/>
</TabsContent>
</Tabs>
</div>
)}
</div>
)
}
function ImportPageSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-36" />
</div>
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-24" />
</CardContent>
</Card>
</div>
)
}
export default function ImportProjectsPage() {
return (
<Suspense fallback={<ImportPageSkeleton />}>
<ImportPageContent />
</Suspense>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -392,11 +392,14 @@ export default function ProjectsPage() {
const [allMatchingSelected, setAllMatchingSelected] = useState(false)
const [bulkStatus, setBulkStatus] = useState<string>('')
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false)
const [bulkNotificationsConfirmed, setBulkNotificationsConfirmed] = useState(false)
const [bulkAction, setBulkAction] = useState<'status' | 'assign' | 'delete'>('status')
const [bulkAssignStageId, setBulkAssignStageId] = useState('')
const [bulkAssignDialogOpen, setBulkAssignDialogOpen] = useState(false)
const [bulkDeleteConfirmOpen, setBulkDeleteConfirmOpen] = useState(false)
const bulkStatusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(bulkStatus)
// Query for fetching all matching IDs (used for "select all across pages")
const allIdsQuery = trpc.project.listAllIds.useQuery(
{
@@ -452,6 +455,26 @@ export default function ProjectsPage() {
},
})
const bulkNotificationPreview = trpc.project.previewStatusNotificationRecipients.useQuery(
{
ids: Array.from(selectedIds),
status: (bulkStatus || 'SUBMITTED') as
| 'SUBMITTED'
| 'ELIGIBLE'
| 'ASSIGNED'
| 'SEMIFINALIST'
| 'FINALIST'
| 'REJECTED',
},
{
enabled:
bulkConfirmOpen &&
selectedIds.size > 0 &&
bulkStatusTriggersNotifications,
staleTime: 30_000,
}
)
const bulkAssignToStage = trpc.projectPool.assignToStage.useMutation({
onSuccess: (result) => {
toast.success(`${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} assigned to stage`)
@@ -524,15 +547,21 @@ export default function ProjectsPage() {
setSelectedIds(new Set())
setAllMatchingSelected(false)
setBulkStatus('')
setBulkNotificationsConfirmed(false)
}
const handleBulkApply = () => {
if (!bulkStatus || selectedIds.size === 0) return
setBulkNotificationsConfirmed(false)
setBulkConfirmOpen(true)
}
const handleBulkConfirm = () => {
if (!bulkStatus || selectedIds.size === 0) return
if (bulkStatusTriggersNotifications && !bulkNotificationsConfirmed) {
toast.error('Confirm participant recipients before sending notifications')
return
}
bulkUpdateStatus.mutate({
ids: Array.from(selectedIds),
status: bulkStatus as 'SUBMITTED' | 'ELIGIBLE' | 'ASSIGNED' | 'SEMIFINALIST' | 'FINALIST' | 'REJECTED',
@@ -1283,7 +1312,15 @@ export default function ProjectsPage() {
)}
{/* Bulk Status Update Confirmation Dialog */}
<AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
<AlertDialog
open={bulkConfirmOpen}
onOpenChange={(open) => {
setBulkConfirmOpen(open)
if (!open) {
setBulkNotificationsConfirmed(false)
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Update Project Status</AlertDialogTitle>
@@ -1302,6 +1339,64 @@ export default function ProjectsPage() {
</p>
</div>
)}
{bulkStatusTriggersNotifications && (
<div className="space-y-3 rounded-md border bg-muted/20 p-3">
<p className="text-sm font-medium">Participant Notification Check</p>
<p className="text-xs text-muted-foreground">
Review recipients before automated emails are sent.
</p>
{bulkNotificationPreview.isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading recipients...
</div>
) : bulkNotificationPreview.data ? (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
{bulkNotificationPreview.data.totalRecipients} recipient
{bulkNotificationPreview.data.totalRecipients !== 1 ? 's' : ''} across{' '}
{bulkNotificationPreview.data.projectsWithRecipients} project
{bulkNotificationPreview.data.projectsWithRecipients !== 1 ? 's' : ''}.
</p>
<div className="max-h-44 space-y-2 overflow-auto rounded-md border bg-background p-2">
{bulkNotificationPreview.data.projects
.filter((project) => project.recipientCount > 0)
.slice(0, 8)
.map((project) => (
<div key={project.id} className="text-xs">
<p className="font-medium">
{project.title} ({project.recipientCount})
</p>
<p className="text-muted-foreground">
{project.recipientsPreview.join(', ')}
{project.hasMoreRecipients ? ', ...' : ''}
</p>
</div>
))}
{bulkNotificationPreview.data.projectsWithRecipients === 0 && (
<p className="text-xs text-amber-700">
No linked participant accounts found. Status will update, but no team notifications will be sent.
</p>
)}
</div>
</div>
) : null}
<div className="flex items-start gap-2">
<Checkbox
id="bulk-notification-confirm"
checked={bulkNotificationsConfirmed}
onCheckedChange={(checked) => setBulkNotificationsConfirmed(checked === true)}
/>
<Label htmlFor="bulk-notification-confirm" className="text-sm font-normal leading-5">
I verified the recipient list and want to send these automated notifications.
</Label>
</div>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
@@ -1310,12 +1405,12 @@ export default function ProjectsPage() {
<AlertDialogAction
onClick={handleBulkConfirm}
className={bulkStatus === 'REJECTED' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
disabled={bulkUpdateStatus.isPending}
disabled={bulkUpdateStatus.isPending || (bulkStatusTriggersNotifications && !bulkNotificationsConfirmed)}
>
{bulkUpdateStatus.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Update {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
{bulkStatusTriggersNotifications ? 'Update + Notify' : 'Update'} {selectedIds.size} Project{selectedIds.size !== 1 ? 's' : ''}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>

View File

@@ -1,350 +1,350 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
export default function ProjectPoolPage() {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [targetStageId, setTargetStageId] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [currentPage, setCurrentPage] = useState(1)
const perPage = 50
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId: selectedProgramId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
page: currentPage,
perPage,
},
{ enabled: !!selectedProgramId }
)
// Get stages from the selected program (program.list includes rounds/stages)
const { data: selectedProgramData, isLoading: isLoadingStages } = trpc.program.get.useQuery(
{ id: selectedProgramId },
{ enabled: !!selectedProgramId }
)
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
const utils = trpc.useUtils()
const assignMutation = trpc.projectPool.assignToStage.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.program.get.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetStageId('')
refetch()
},
onError: (error) => {
toast.error(error.message || 'Failed to assign projects')
},
})
const handleBulkAssign = () => {
if (selectedProjects.length === 0 || !targetStageId) return
assignMutation.mutate({
projectIds: selectedProjects,
stageId: targetStageId,
})
}
const handleQuickAssign = (projectId: string, stageId: string) => {
assignMutation.mutate({
projectIds: [projectId],
stageId,
})
}
const toggleSelectAll = () => {
if (!poolData?.projects) return
if (selectedProjects.length === poolData.projects.length) {
setSelectedProjects([])
} else {
setSelectedProjects(poolData.projects.map((p) => p.id))
}
}
const toggleSelectProject = (projectId: string) => {
if (selectedProjects.includes(projectId)) {
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
} else {
setSelectedProjects([...selectedProjects, projectId])
}
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
Assign unassigned projects to evaluation stages
</p>
</div>
{/* Program Selector */}
<Card className="p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Program</label>
<Select value={selectedProgramId} onValueChange={(value) => {
setSelectedProgramId(value)
setSelectedProjects([])
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue placeholder="Select program..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: any) => {
setCategoryFilter(value)
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Search</label>
<Input
placeholder="Project or team name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
/>
</div>
{selectedProjects.length > 0 && (
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
</Button>
)}
</div>
</Card>
{/* Projects Table */}
{selectedProgramId && (
<>
{isLoadingPool ? (
<Card className="p-4">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</Card>
) : poolData && poolData.total > 0 ? (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left">
<Checkbox
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="p-3 text-left font-medium">Project</th>
<th className="p-3 text-left font-medium">Category</th>
<th className="p-3 text-left font-medium">Country</th>
<th className="p-3 text-left font-medium">Submitted</th>
<th className="p-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody>
{poolData.projects.map((project) => (
<tr key={project.id} className="border-b hover:bg-muted/50">
<td className="p-3">
<Checkbox
checked={selectedProjects.includes(project.id)}
onCheckedChange={() => toggleSelectProject(project.id)}
/>
</td>
<td className="p-3">
<Link
href={`/admin/projects/${project.id}`}
className="hover:underline"
>
<div className="font-medium">{project.title}</div>
<div className="text-sm text-muted-foreground">{project.teamName}</div>
</Link>
</td>
<td className="p-3">
<Badge variant="outline">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.country || '-'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.submittedAt
? new Date(project.submittedAt).toLocaleDateString()
: '-'}
</td>
<td className="p-3">
{isLoadingStages ? (
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
disabled={assignMutation.isPending}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Assign to stage..." />
</SelectTrigger>
<SelectContent>
{stages?.map((stage: { id: string; name: string }) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Pagination */}
{poolData.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total} projects
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === poolData.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No unassigned projects found for this program
</Card>
)}
</>
)}
{!selectedProgramId && (
<Card className="p-8 text-center text-muted-foreground">
Select a program to view unassigned projects
</Card>
)}
{/* Bulk Assignment Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Projects to Stage</DialogTitle>
<DialogDescription>
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetStageId} onValueChange={setTargetStageId}>
<SelectTrigger>
<SelectValue placeholder="Select stage..." />
</SelectTrigger>
<SelectContent>
{stages?.map((stage: { id: string; name: string }) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleBulkAssign}
disabled={!targetStageId || assignMutation.isPending}
>
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
export default function ProjectPoolPage() {
const [selectedProgramId, setSelectedProgramId] = useState<string>('')
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [targetStageId, setTargetStageId] = useState<string>('')
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [currentPage, setCurrentPage] = useState(1)
const perPage = 50
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId: selectedProgramId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
page: currentPage,
perPage,
},
{ enabled: !!selectedProgramId }
)
// Get stages from the selected program (program.list includes rounds/stages)
const { data: selectedProgramData, isLoading: isLoadingStages } = trpc.program.get.useQuery(
{ id: selectedProgramId },
{ enabled: !!selectedProgramId }
)
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
const utils = trpc.useUtils()
const assignMutation = trpc.projectPool.assignToStage.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.program.get.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to stage`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetStageId('')
refetch()
},
onError: (error) => {
toast.error(error.message || 'Failed to assign projects')
},
})
const handleBulkAssign = () => {
if (selectedProjects.length === 0 || !targetStageId) return
assignMutation.mutate({
projectIds: selectedProjects,
stageId: targetStageId,
})
}
const handleQuickAssign = (projectId: string, stageId: string) => {
assignMutation.mutate({
projectIds: [projectId],
stageId,
})
}
const toggleSelectAll = () => {
if (!poolData?.projects) return
if (selectedProjects.length === poolData.projects.length) {
setSelectedProjects([])
} else {
setSelectedProjects(poolData.projects.map((p) => p.id))
}
}
const toggleSelectProject = (projectId: string) => {
if (selectedProjects.includes(projectId)) {
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
} else {
setSelectedProjects([...selectedProjects, projectId])
}
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
Assign unassigned projects to evaluation stages
</p>
</div>
{/* Program Selector */}
<Card className="p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Program</label>
<Select value={selectedProgramId} onValueChange={(value) => {
setSelectedProgramId(value)
setSelectedProjects([])
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue placeholder="Select program..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: any) => {
setCategoryFilter(value)
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Search</label>
<Input
placeholder="Project or team name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
/>
</div>
{selectedProjects.length > 0 && (
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
</Button>
)}
</div>
</Card>
{/* Projects Table */}
{selectedProgramId && (
<>
{isLoadingPool ? (
<Card className="p-4">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</Card>
) : poolData && poolData.total > 0 ? (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left">
<Checkbox
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="p-3 text-left font-medium">Project</th>
<th className="p-3 text-left font-medium">Category</th>
<th className="p-3 text-left font-medium">Country</th>
<th className="p-3 text-left font-medium">Submitted</th>
<th className="p-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody>
{poolData.projects.map((project) => (
<tr key={project.id} className="border-b hover:bg-muted/50">
<td className="p-3">
<Checkbox
checked={selectedProjects.includes(project.id)}
onCheckedChange={() => toggleSelectProject(project.id)}
/>
</td>
<td className="p-3">
<Link
href={`/admin/projects/${project.id}`}
className="hover:underline"
>
<div className="font-medium">{project.title}</div>
<div className="text-sm text-muted-foreground">{project.teamName}</div>
</Link>
</td>
<td className="p-3">
<Badge variant="outline">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.country || '-'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.submittedAt
? new Date(project.submittedAt).toLocaleDateString()
: '-'}
</td>
<td className="p-3">
{isLoadingStages ? (
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(stageId) => handleQuickAssign(project.id, stageId)}
disabled={assignMutation.isPending}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Assign to stage..." />
</SelectTrigger>
<SelectContent>
{stages?.map((stage: { id: string; name: string }) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Pagination */}
{poolData.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total} projects
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === poolData.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No unassigned projects found for this program
</Card>
)}
</>
)}
{!selectedProgramId && (
<Card className="p-8 text-center text-muted-foreground">
Select a program to view unassigned projects
</Card>
)}
{/* Bulk Assignment Dialog */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Projects to Stage</DialogTitle>
<DialogDescription>
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetStageId} onValueChange={setTargetStageId}>
<SelectTrigger>
<SelectValue placeholder="Select stage..." />
</SelectTrigger>
<SelectContent>
{stages?.map((stage: { id: string; name: string }) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleBulkAssign}
disabled={!targetStageId || assignMutation.isPending}
>
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,347 +1,347 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { ChevronDown, Filter, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
const ALL_STATUSES = [
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
] as const
const STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
ELIGIBLE: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
ASSIGNED: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200',
SEMIFINALIST: 'bg-green-100 text-green-700 hover:bg-green-200',
FINALIST: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200',
REJECTED: 'bg-red-100 text-red-700 hover:bg-red-200',
}
const ISSUE_LABELS: Record<string, string> = {
POLLUTION_REDUCTION: 'Pollution Reduction',
CLIMATE_MITIGATION: 'Climate Mitigation',
TECHNOLOGY_INNOVATION: 'Technology Innovation',
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
BLUE_CARBON: 'Blue Carbon',
HABITAT_RESTORATION: 'Habitat Restoration',
COMMUNITY_CAPACITY: 'Community Capacity',
SUSTAINABLE_FISHING: 'Sustainable Fishing',
CONSUMER_AWARENESS: 'Consumer Awareness',
OCEAN_ACIDIFICATION: 'Ocean Acidification',
OTHER: 'Other',
}
export interface ProjectFilters {
search: string
statuses: string[]
stageId: string
competitionCategory: string
oceanIssue: string
country: string
wantsMentorship: boolean | undefined
hasFiles: boolean | undefined
hasAssignments: boolean | undefined
}
export interface FilterOptions {
countries: string[]
categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }>
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
}
interface ProjectFiltersBarProps {
filters: ProjectFilters
filterOptions: FilterOptions | undefined
onChange: (filters: ProjectFilters) => void
}
export function ProjectFiltersBar({
filters,
filterOptions,
onChange,
}: ProjectFiltersBarProps) {
const [isOpen, setIsOpen] = useState(false)
const activeFilterCount = [
filters.statuses.length > 0,
filters.stageId !== '',
filters.competitionCategory !== '',
filters.oceanIssue !== '',
filters.country !== '',
filters.wantsMentorship !== undefined,
filters.hasFiles !== undefined,
filters.hasAssignments !== undefined,
].filter(Boolean).length
const toggleStatus = (status: string) => {
const next = filters.statuses.includes(status)
? filters.statuses.filter((s) => s !== status)
: [...filters.statuses, status]
onChange({ ...filters, statuses: next })
}
const clearAll = () => {
onChange({
search: filters.search,
statuses: [],
stageId: '',
competitionCategory: '',
oceanIssue: '',
country: '',
wantsMentorship: undefined,
hasFiles: undefined,
hasAssignments: undefined,
})
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Filter className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFilterCount}
</Badge>
)}
</CardTitle>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 duration-200">
<CardContent className="space-y-4 pt-0">
{/* Status toggles */}
<div className="space-y-2">
<Label className="text-sm font-medium">Status</Label>
<div className="flex flex-wrap gap-2">
{ALL_STATUSES.map((status) => (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
className={cn(
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
filters.statuses.includes(status)
? STATUS_COLORS[status]
: 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
>
{status.replace('_', ' ')}
</button>
))}
</div>
</div>
{/* Select filters grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-sm">Stage / Edition</Label>
<Select
value={filters.stageId || '_all'}
onValueChange={(v) =>
onChange({ ...filters, stageId: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All stages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All stages</SelectItem>
{filterOptions?.stages?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({s.programYear} {s.programName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Category</Label>
<Select
value={filters.competitionCategory || '_all'}
onValueChange={(v) =>
onChange({
...filters,
competitionCategory: v === '_all' ? '' : v,
})
}
>
<SelectTrigger>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All categories</SelectItem>
{filterOptions?.categories.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.value.replace('_', ' ')} ({c.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Ocean Issue</Label>
<Select
value={filters.oceanIssue || '_all'}
onValueChange={(v) =>
onChange({ ...filters, oceanIssue: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All issues" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All issues</SelectItem>
{filterOptions?.issues.map((i) => (
<SelectItem key={i.value} value={i.value}>
{ISSUE_LABELS[i.value] || i.value} ({i.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Country</Label>
<Select
value={filters.country || '_all'}
onValueChange={(v) =>
onChange({ ...filters, country: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All countries" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All countries</SelectItem>
{filterOptions?.countries
.map((c) => ({
code: c,
name: getCountryName(c),
flag: getCountryFlag(c),
}))
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) => (
<SelectItem key={c.code} value={c.code}>
<span className="flex items-center gap-2">
<span>{c.flag}</span>
<span>{c.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Boolean toggles */}
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-2">
<Switch
id="hasFiles"
checked={filters.hasFiles === true}
onCheckedChange={(checked) =>
onChange({
...filters,
hasFiles: checked ? true : undefined,
})
}
/>
<Label htmlFor="hasFiles" className="text-sm">
Has Documents
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="hasAssignments"
checked={filters.hasAssignments === true}
onCheckedChange={(checked) =>
onChange({
...filters,
hasAssignments: checked ? true : undefined,
})
}
/>
<Label htmlFor="hasAssignments" className="text-sm">
Has Assignments
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="wantsMentorship"
checked={filters.wantsMentorship === true}
onCheckedChange={(checked) =>
onChange({
...filters,
wantsMentorship: checked ? true : undefined,
})
}
/>
<Label htmlFor="wantsMentorship" className="text-sm">
Wants Mentorship
</Label>
</div>
</div>
{/* Clear all */}
{activeFilterCount > 0 && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={clearAll}
className="text-muted-foreground"
>
<X className="mr-1 h-3 w-3" />
Clear All Filters
</Button>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { ChevronDown, Filter, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getCountryName, getCountryFlag } from '@/lib/countries'
const ALL_STATUSES = [
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
] as const
const STATUS_COLORS: Record<string, string> = {
SUBMITTED: 'bg-gray-100 text-gray-700 hover:bg-gray-200',
ELIGIBLE: 'bg-blue-100 text-blue-700 hover:bg-blue-200',
ASSIGNED: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200',
SEMIFINALIST: 'bg-green-100 text-green-700 hover:bg-green-200',
FINALIST: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200',
REJECTED: 'bg-red-100 text-red-700 hover:bg-red-200',
}
const ISSUE_LABELS: Record<string, string> = {
POLLUTION_REDUCTION: 'Pollution Reduction',
CLIMATE_MITIGATION: 'Climate Mitigation',
TECHNOLOGY_INNOVATION: 'Technology Innovation',
SUSTAINABLE_SHIPPING: 'Sustainable Shipping',
BLUE_CARBON: 'Blue Carbon',
HABITAT_RESTORATION: 'Habitat Restoration',
COMMUNITY_CAPACITY: 'Community Capacity',
SUSTAINABLE_FISHING: 'Sustainable Fishing',
CONSUMER_AWARENESS: 'Consumer Awareness',
OCEAN_ACIDIFICATION: 'Ocean Acidification',
OTHER: 'Other',
}
export interface ProjectFilters {
search: string
statuses: string[]
stageId: string
competitionCategory: string
oceanIssue: string
country: string
wantsMentorship: boolean | undefined
hasFiles: boolean | undefined
hasAssignments: boolean | undefined
}
export interface FilterOptions {
countries: string[]
categories: Array<{ value: string; count: number }>
issues: Array<{ value: string; count: number }>
stages?: Array<{ id: string; name: string; programName: string; programYear: number }>
}
interface ProjectFiltersBarProps {
filters: ProjectFilters
filterOptions: FilterOptions | undefined
onChange: (filters: ProjectFilters) => void
}
export function ProjectFiltersBar({
filters,
filterOptions,
onChange,
}: ProjectFiltersBarProps) {
const [isOpen, setIsOpen] = useState(false)
const activeFilterCount = [
filters.statuses.length > 0,
filters.stageId !== '',
filters.competitionCategory !== '',
filters.oceanIssue !== '',
filters.country !== '',
filters.wantsMentorship !== undefined,
filters.hasFiles !== undefined,
filters.hasAssignments !== undefined,
].filter(Boolean).length
const toggleStatus = (status: string) => {
const next = filters.statuses.includes(status)
? filters.statuses.filter((s) => s !== status)
: [...filters.statuses, status]
onChange({ ...filters, statuses: next })
}
const clearAll = () => {
onChange({
search: filters.search,
statuses: [],
stageId: '',
competitionCategory: '',
oceanIssue: '',
country: '',
wantsMentorship: undefined,
hasFiles: undefined,
hasAssignments: undefined,
})
}
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors py-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Filter className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<Badge variant="secondary" className="ml-1">
{activeFilterCount}
</Badge>
)}
</CardTitle>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isOpen && 'rotate-180'
)}
/>
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:slide-in-from-top-2 data-[state=closed]:slide-out-to-top-2 duration-200">
<CardContent className="space-y-4 pt-0">
{/* Status toggles */}
<div className="space-y-2">
<Label className="text-sm font-medium">Status</Label>
<div className="flex flex-wrap gap-2">
{ALL_STATUSES.map((status) => (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
className={cn(
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
filters.statuses.includes(status)
? STATUS_COLORS[status]
: 'bg-muted text-muted-foreground hover:bg-muted/80'
)}
>
{status.replace('_', ' ')}
</button>
))}
</div>
</div>
{/* Select filters grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label className="text-sm">Stage / Edition</Label>
<Select
value={filters.stageId || '_all'}
onValueChange={(v) =>
onChange({ ...filters, stageId: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All stages" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All stages</SelectItem>
{filterOptions?.stages?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name} ({s.programYear} {s.programName})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Category</Label>
<Select
value={filters.competitionCategory || '_all'}
onValueChange={(v) =>
onChange({
...filters,
competitionCategory: v === '_all' ? '' : v,
})
}
>
<SelectTrigger>
<SelectValue placeholder="All categories" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All categories</SelectItem>
{filterOptions?.categories.map((c) => (
<SelectItem key={c.value} value={c.value}>
{c.value.replace('_', ' ')} ({c.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Ocean Issue</Label>
<Select
value={filters.oceanIssue || '_all'}
onValueChange={(v) =>
onChange({ ...filters, oceanIssue: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All issues" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All issues</SelectItem>
{filterOptions?.issues.map((i) => (
<SelectItem key={i.value} value={i.value}>
{ISSUE_LABELS[i.value] || i.value} ({i.count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-sm">Country</Label>
<Select
value={filters.country || '_all'}
onValueChange={(v) =>
onChange({ ...filters, country: v === '_all' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All countries" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_all">All countries</SelectItem>
{filterOptions?.countries
.map((c) => ({
code: c,
name: getCountryName(c),
flag: getCountryFlag(c),
}))
.sort((a, b) => a.name.localeCompare(b.name))
.map((c) => (
<SelectItem key={c.code} value={c.code}>
<span className="flex items-center gap-2">
<span>{c.flag}</span>
<span>{c.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Boolean toggles */}
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-2">
<Switch
id="hasFiles"
checked={filters.hasFiles === true}
onCheckedChange={(checked) =>
onChange({
...filters,
hasFiles: checked ? true : undefined,
})
}
/>
<Label htmlFor="hasFiles" className="text-sm">
Has Documents
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="hasAssignments"
checked={filters.hasAssignments === true}
onCheckedChange={(checked) =>
onChange({
...filters,
hasAssignments: checked ? true : undefined,
})
}
/>
<Label htmlFor="hasAssignments" className="text-sm">
Has Assignments
</Label>
</div>
<div className="flex items-center gap-2">
<Switch
id="wantsMentorship"
checked={filters.wantsMentorship === true}
onCheckedChange={(checked) =>
onChange({
...filters,
wantsMentorship: checked ? true : undefined,
})
}
/>
<Label htmlFor="wantsMentorship" className="text-sm">
Wants Mentorship
</Label>
</div>
</div>
{/* Clear all */}
{activeFilterCount > 0 && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={clearAll}
className="text-muted-foreground"
>
<X className="mr-1 h-3 w-3" />
Clear All Filters
</Button>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
)
}