2026-03-03 19:14:41 +01:00
|
|
|
'use client'
|
|
|
|
|
|
2026-03-03 22:10:04 +01:00
|
|
|
import React, { useState, useMemo } from 'react'
|
2026-03-03 19:14:41 +01:00
|
|
|
import { trpc } from '@/lib/trpc/client'
|
|
|
|
|
import { toast } from 'sonner'
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
|
|
|
import { Badge } from '@/components/ui/badge'
|
|
|
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from '@/components/ui/select'
|
|
|
|
|
import {
|
|
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
AlertDialogTrigger,
|
|
|
|
|
} from '@/components/ui/alert-dialog'
|
|
|
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
|
|
|
import {
|
|
|
|
|
Clock,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
AlertTriangle,
|
|
|
|
|
ArrowRight,
|
|
|
|
|
Loader2,
|
|
|
|
|
Search,
|
|
|
|
|
ChevronDown,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Mail,
|
|
|
|
|
Send,
|
2026-03-03 22:57:52 +01:00
|
|
|
Eye,
|
2026-03-03 19:14:41 +01:00
|
|
|
} from 'lucide-react'
|
|
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
import { projectStateConfig } from '@/lib/round-config'
|
2026-03-03 22:57:52 +01:00
|
|
|
import { EmailPreviewDialog } from './email-preview-dialog'
|
2026-03-03 19:14:41 +01:00
|
|
|
|
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
interface FinalizationTabProps {
|
|
|
|
|
roundId: string
|
|
|
|
|
roundStatus: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ProposedOutcome = 'PASSED' | 'REJECTED'
|
|
|
|
|
|
|
|
|
|
const stateColors: Record<string, string> = Object.fromEntries(
|
|
|
|
|
Object.entries(projectStateConfig).map(([k, v]) => [k, v.bg])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const stateLabelColors: Record<string, string> = {
|
|
|
|
|
PENDING: 'bg-gray-100 text-gray-700',
|
|
|
|
|
IN_PROGRESS: 'bg-blue-100 text-blue-700',
|
|
|
|
|
COMPLETED: 'bg-indigo-100 text-indigo-700',
|
|
|
|
|
PASSED: 'bg-green-100 text-green-700',
|
|
|
|
|
REJECTED: 'bg-red-100 text-red-700',
|
|
|
|
|
WITHDRAWN: 'bg-yellow-100 text-yellow-700',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Main Component ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export function FinalizationTab({ roundId, roundStatus }: FinalizationTabProps) {
|
|
|
|
|
const utils = trpc.useUtils()
|
|
|
|
|
|
|
|
|
|
const { data: summary, isLoading } = trpc.roundEngine.getFinalizationSummary.useQuery(
|
|
|
|
|
{ roundId },
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const [search, setSearch] = useState('')
|
|
|
|
|
const [filterOutcome, setFilterOutcome] = useState<'all' | 'PASSED' | 'REJECTED' | 'none'>('all')
|
2026-03-03 22:10:04 +01:00
|
|
|
const [filterCategory, setFilterCategory] = useState<'all' | 'STARTUP' | 'BUSINESS_CONCEPT'>('all')
|
2026-03-03 19:14:41 +01:00
|
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
|
|
|
const [emailSectionOpen, setEmailSectionOpen] = useState(false)
|
|
|
|
|
const [advancementMessage, setAdvancementMessage] = useState('')
|
|
|
|
|
const [rejectionMessage, setRejectionMessage] = useState('')
|
2026-03-03 22:57:52 +01:00
|
|
|
const [advancePreviewOpen, setAdvancePreviewOpen] = useState(false)
|
|
|
|
|
const [rejectPreviewOpen, setRejectPreviewOpen] = useState(false)
|
|
|
|
|
const [advancePreviewMsg, setAdvancePreviewMsg] = useState<string | undefined>()
|
|
|
|
|
const [rejectPreviewMsg, setRejectPreviewMsg] = useState<string | undefined>()
|
2026-03-03 19:14:41 +01:00
|
|
|
|
|
|
|
|
// Mutations
|
|
|
|
|
const updateOutcome = trpc.roundEngine.updateProposedOutcome.useMutation({
|
|
|
|
|
onSuccess: () => utils.roundEngine.getFinalizationSummary.invalidate({ roundId }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const batchUpdate = trpc.roundEngine.batchUpdateProposedOutcomes.useMutation({
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
|
|
|
|
setSelectedIds(new Set())
|
|
|
|
|
toast.success('Proposed outcomes updated')
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const confirmMutation = trpc.roundEngine.confirmFinalization.useMutation({
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
|
|
|
|
toast.success(
|
|
|
|
|
`Finalized: ${data.advanced} advanced, ${data.rejected} rejected, ${data.emailsSent} emails sent`,
|
|
|
|
|
)
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => toast.error(err.message),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const endGraceMutation = trpc.roundEngine.endGracePeriod.useMutation({
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
|
|
|
|
toast.success('Grace period ended, projects processed')
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => toast.error(err.message),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const processProjectsMutation = trpc.roundEngine.processRoundProjects.useMutation({
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
utils.roundEngine.getFinalizationSummary.invalidate({ roundId })
|
|
|
|
|
toast.success(`Processed ${data.processed} projects — review proposed outcomes below`)
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => toast.error(err.message),
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-03 22:57:52 +01:00
|
|
|
// Email preview queries
|
|
|
|
|
const advancePreview = trpc.roundEngine.previewFinalizationAdvancementEmail.useQuery(
|
|
|
|
|
{ roundId, customMessage: advancePreviewMsg },
|
|
|
|
|
{ enabled: advancePreviewOpen }
|
|
|
|
|
)
|
|
|
|
|
const rejectPreview = trpc.roundEngine.previewFinalizationRejectionEmail.useQuery(
|
|
|
|
|
{ roundId, customMessage: rejectPreviewMsg },
|
|
|
|
|
{ enabled: rejectPreviewOpen }
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
// Filtered projects
|
|
|
|
|
const filteredProjects = useMemo(() => {
|
|
|
|
|
if (!summary) return []
|
|
|
|
|
return summary.projects.filter((p) => {
|
|
|
|
|
const matchesSearch =
|
|
|
|
|
!search ||
|
|
|
|
|
p.title.toLowerCase().includes(search.toLowerCase()) ||
|
|
|
|
|
p.teamName?.toLowerCase().includes(search.toLowerCase()) ||
|
|
|
|
|
p.country?.toLowerCase().includes(search.toLowerCase())
|
|
|
|
|
|
|
|
|
|
const matchesFilter =
|
|
|
|
|
filterOutcome === 'all' ||
|
|
|
|
|
(filterOutcome === 'none' && !p.proposedOutcome) ||
|
|
|
|
|
p.proposedOutcome === filterOutcome
|
|
|
|
|
|
2026-03-03 22:10:04 +01:00
|
|
|
const matchesCategory =
|
|
|
|
|
filterCategory === 'all' || p.category === filterCategory
|
|
|
|
|
|
|
|
|
|
return matchesSearch && matchesFilter && matchesCategory
|
2026-03-03 19:14:41 +01:00
|
|
|
})
|
2026-03-03 22:10:04 +01:00
|
|
|
}, [summary, search, filterOutcome, filterCategory])
|
|
|
|
|
|
|
|
|
|
// Check if we have multiple categories (to decide whether to group)
|
|
|
|
|
const hasMultipleCategories = useMemo(() => {
|
|
|
|
|
if (!summary) return false
|
|
|
|
|
const cats = new Set(summary.projects.map((p) => p.category).filter(Boolean))
|
|
|
|
|
return cats.size > 1
|
|
|
|
|
}, [summary])
|
|
|
|
|
|
|
|
|
|
// Group filtered projects by category, sorted by rank within each group
|
|
|
|
|
const groupedProjects = useMemo(() => {
|
|
|
|
|
if (!hasMultipleCategories || filterCategory !== 'all') return null
|
|
|
|
|
const groups: { category: string; label: string; projects: typeof filteredProjects }[] = []
|
|
|
|
|
const startups = filteredProjects.filter((p) => p.category === 'STARTUP')
|
|
|
|
|
const concepts = filteredProjects.filter((p) => p.category === 'BUSINESS_CONCEPT')
|
|
|
|
|
const other = filteredProjects.filter((p) => p.category !== 'STARTUP' && p.category !== 'BUSINESS_CONCEPT')
|
|
|
|
|
if (startups.length > 0) groups.push({ category: 'STARTUP', label: 'Startups', projects: startups })
|
|
|
|
|
if (concepts.length > 0) groups.push({ category: 'BUSINESS_CONCEPT', label: 'Business Concepts', projects: concepts })
|
|
|
|
|
if (other.length > 0) groups.push({ category: 'OTHER', label: 'Other', projects: other })
|
|
|
|
|
return groups
|
|
|
|
|
}, [filteredProjects, hasMultipleCategories, filterCategory])
|
2026-03-03 19:14:41 +01:00
|
|
|
|
|
|
|
|
// Counts
|
|
|
|
|
const passedCount = summary?.projects.filter((p) => p.proposedOutcome === 'PASSED').length ?? 0
|
|
|
|
|
const rejectedCount = summary?.projects.filter((p) => p.proposedOutcome === 'REJECTED').length ?? 0
|
|
|
|
|
const undecidedCount = summary?.projects.filter((p) => !p.proposedOutcome).length ?? 0
|
|
|
|
|
|
|
|
|
|
// Select all toggle
|
|
|
|
|
const allSelected = filteredProjects.length > 0 && filteredProjects.every((p) => selectedIds.has(p.id))
|
|
|
|
|
const toggleSelectAll = () => {
|
|
|
|
|
if (allSelected) {
|
|
|
|
|
setSelectedIds(new Set())
|
|
|
|
|
} else {
|
|
|
|
|
setSelectedIds(new Set(filteredProjects.map((p) => p.id)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bulk set outcome
|
|
|
|
|
const handleBulkSetOutcome = (outcome: ProposedOutcome) => {
|
|
|
|
|
const outcomes: Record<string, ProposedOutcome> = {}
|
|
|
|
|
for (const id of selectedIds) {
|
|
|
|
|
outcomes[id] = outcome
|
|
|
|
|
}
|
|
|
|
|
batchUpdate.mutate({ roundId, outcomes })
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 22:10:04 +01:00
|
|
|
// Column count for colSpan
|
|
|
|
|
const colCount = (summary?.isFinalized ? 0 : 1) + 4 + (summary?.roundType === 'EVALUATION' ? 1 : 0) + 1
|
|
|
|
|
|
|
|
|
|
// Shared row renderer
|
|
|
|
|
const renderProjectRow = (project: (typeof filteredProjects)[number]) => (
|
|
|
|
|
<tr key={project.id} className="border-b last:border-0 hover:bg-muted/30">
|
|
|
|
|
{!summary?.isFinalized && (
|
|
|
|
|
<td className="px-3 py-2.5">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={selectedIds.has(project.id)}
|
|
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
const next = new Set(selectedIds)
|
|
|
|
|
if (checked) next.add(project.id)
|
|
|
|
|
else next.delete(project.id)
|
|
|
|
|
setSelectedIds(next)
|
|
|
|
|
}}
|
|
|
|
|
aria-label={`Select ${project.title}`}
|
|
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
|
|
|
|
<td className="px-3 py-2.5">
|
|
|
|
|
<div className="font-medium truncate max-w-[200px]">{project.title}</div>
|
|
|
|
|
{project.teamName && (
|
|
|
|
|
<div className="text-xs text-muted-foreground truncate">{project.teamName}</div>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-2.5 hidden sm:table-cell text-muted-foreground">
|
|
|
|
|
{project.category === 'STARTUP' ? 'Startup' : project.category === 'BUSINESS_CONCEPT' ? 'Concept' : project.category ?? '-'}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-2.5 hidden md:table-cell text-muted-foreground">
|
|
|
|
|
{project.country ?? '-'}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-2.5 text-center">
|
|
|
|
|
<Badge variant="secondary" className={cn('text-xs', stateLabelColors[project.currentState] ?? '')}>
|
|
|
|
|
{project.currentState.replace('_', ' ')}
|
|
|
|
|
</Badge>
|
|
|
|
|
</td>
|
|
|
|
|
{summary?.roundType === 'EVALUATION' && (
|
|
|
|
|
<td className="px-3 py-2.5 text-center hidden lg:table-cell text-muted-foreground">
|
|
|
|
|
{project.evaluationScore != null
|
|
|
|
|
? `${project.evaluationScore.toFixed(1)} (#${project.rankPosition ?? '-'})`
|
|
|
|
|
: '-'}
|
|
|
|
|
</td>
|
|
|
|
|
)}
|
|
|
|
|
<td className="px-3 py-2.5 text-center">
|
|
|
|
|
{summary?.isFinalized ? (
|
|
|
|
|
<Badge
|
|
|
|
|
variant="secondary"
|
|
|
|
|
className={cn(
|
|
|
|
|
'text-xs',
|
|
|
|
|
project.proposedOutcome === 'PASSED' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{project.proposedOutcome === 'PASSED' ? 'Advanced' : 'Rejected'}
|
|
|
|
|
</Badge>
|
|
|
|
|
) : (
|
|
|
|
|
<Select
|
|
|
|
|
value={project.proposedOutcome ?? 'undecided'}
|
|
|
|
|
onValueChange={(v) => {
|
|
|
|
|
if (v === 'undecided') return
|
|
|
|
|
updateOutcome.mutate({
|
|
|
|
|
roundId,
|
|
|
|
|
projectId: project.id,
|
|
|
|
|
proposedOutcome: v as 'PASSED' | 'REJECTED',
|
|
|
|
|
})
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-8 w-[130px] text-xs mx-auto',
|
|
|
|
|
project.proposedOutcome === 'PASSED' && 'border-green-300 bg-green-50 text-green-700',
|
|
|
|
|
project.proposedOutcome === 'REJECTED' && 'border-red-300 bg-red-50 text-red-700',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="undecided" disabled>Undecided</SelectItem>
|
|
|
|
|
<SelectItem value="PASSED">Pass</SelectItem>
|
|
|
|
|
<SelectItem value="REJECTED">Reject</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)
|
|
|
|
|
|
2026-03-03 19:14:41 +01:00
|
|
|
if (isLoading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<Skeleton className="h-20 w-full" />
|
|
|
|
|
<Skeleton className="h-12 w-full" />
|
|
|
|
|
<Skeleton className="h-96 w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!summary) return null
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{/* Grace Period Banner */}
|
|
|
|
|
{summary.isGracePeriodActive && (
|
|
|
|
|
<Card className="border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20">
|
|
|
|
|
<CardContent className="flex items-center justify-between py-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Clock className="h-5 w-5 text-amber-600" />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-medium text-amber-800 dark:text-amber-200">Grace Period Active</p>
|
|
|
|
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
|
|
|
|
Applicants can still submit until{' '}
|
|
|
|
|
{summary.gracePeriodEndsAt
|
|
|
|
|
? new Date(summary.gracePeriodEndsAt).toLocaleString()
|
|
|
|
|
: 'the grace period ends'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="border-amber-300 hover:bg-amber-100"
|
|
|
|
|
onClick={() => endGraceMutation.mutate({ roundId })}
|
|
|
|
|
disabled={endGraceMutation.isPending}
|
|
|
|
|
>
|
|
|
|
|
{endGraceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
|
|
|
|
End Grace Period
|
|
|
|
|
</Button>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Finalized Banner */}
|
|
|
|
|
{summary.isFinalized && (
|
|
|
|
|
<Card className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
|
|
|
|
|
<CardContent className="flex items-center gap-3 py-4">
|
|
|
|
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-medium text-green-800 dark:text-green-200">Round Finalized</p>
|
|
|
|
|
<p className="text-sm text-green-600 dark:text-green-400">
|
|
|
|
|
Finalized on{' '}
|
|
|
|
|
{summary.finalizedAt
|
|
|
|
|
? new Date(summary.finalizedAt).toLocaleString()
|
|
|
|
|
: 'unknown date'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Needs Processing Banner */}
|
|
|
|
|
{!summary.isFinalized && !summary.isGracePeriodActive && summary.projects.length > 0 && summary.projects.every((p) => !p.proposedOutcome) && (
|
|
|
|
|
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20">
|
|
|
|
|
<CardContent className="flex items-center justify-between py-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<AlertTriangle className="h-5 w-5 text-blue-600" />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-medium text-blue-800 dark:text-blue-200">Projects Need Processing</p>
|
|
|
|
|
<p className="text-sm text-blue-600 dark:text-blue-400">
|
|
|
|
|
{summary.projects.length} project{summary.projects.length !== 1 ? 's' : ''} in this round have no proposed outcome.
|
|
|
|
|
Click "Process" to auto-assign outcomes based on round type and project activity.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="border-blue-300 hover:bg-blue-100"
|
|
|
|
|
onClick={() => processProjectsMutation.mutate({ roundId })}
|
|
|
|
|
disabled={processProjectsMutation.isPending}
|
|
|
|
|
>
|
|
|
|
|
{processProjectsMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
|
|
|
|
Process Projects
|
|
|
|
|
</Button>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Summary Stats Bar */}
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
|
|
|
|
{([
|
|
|
|
|
['Pending', summary.stats.pending, 'bg-gray-100 text-gray-700'],
|
|
|
|
|
['In Progress', summary.stats.inProgress, 'bg-blue-100 text-blue-700'],
|
|
|
|
|
['Completed', summary.stats.completed, 'bg-indigo-100 text-indigo-700'],
|
|
|
|
|
['Passed', summary.stats.passed, 'bg-green-100 text-green-700'],
|
|
|
|
|
['Rejected', summary.stats.rejected, 'bg-red-100 text-red-700'],
|
|
|
|
|
['Withdrawn', summary.stats.withdrawn, 'bg-yellow-100 text-yellow-700'],
|
|
|
|
|
] as const).map(([label, count, cls]) => (
|
|
|
|
|
<div key={label} className="rounded-lg border p-3 text-center">
|
|
|
|
|
<div className="text-2xl font-bold">{count}</div>
|
|
|
|
|
<div className={cn('text-xs font-medium mt-1 inline-flex px-2 py-0.5 rounded-full', cls)}>{label}</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Category Target Progress */}
|
|
|
|
|
{(summary.categoryTargets.startupTarget != null || summary.categoryTargets.conceptTarget != null) && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<CardTitle className="text-base">Advancement Targets</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-3">
|
|
|
|
|
{summary.categoryTargets.startupTarget != null && (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex justify-between text-sm mb-1">
|
|
|
|
|
<span>Startup</span>
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{summary.categoryTargets.startupProposed} / {summary.categoryTargets.startupTarget}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-full rounded-full transition-all',
|
|
|
|
|
summary.categoryTargets.startupProposed > summary.categoryTargets.startupTarget
|
|
|
|
|
? 'bg-amber-500'
|
|
|
|
|
: 'bg-green-500',
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
width: `${Math.min(100, (summary.categoryTargets.startupProposed / summary.categoryTargets.startupTarget) * 100)}%`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{summary.categoryTargets.conceptTarget != null && (
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex justify-between text-sm mb-1">
|
|
|
|
|
<span>Business Concept</span>
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{summary.categoryTargets.conceptProposed} / {summary.categoryTargets.conceptTarget}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-2.5 rounded-full bg-muted overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'h-full rounded-full transition-all',
|
|
|
|
|
summary.categoryTargets.conceptProposed > summary.categoryTargets.conceptTarget
|
|
|
|
|
? 'bg-amber-500'
|
|
|
|
|
: 'bg-green-500',
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
width: `${Math.min(100, (summary.categoryTargets.conceptProposed / summary.categoryTargets.conceptTarget) * 100)}%`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Proposed Outcomes Table */}
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
|
|
|
<CardTitle className="text-base">Proposed Outcomes</CardTitle>
|
|
|
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
|
|
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
|
|
|
|
|
{passedCount} advancing
|
|
|
|
|
</Badge>
|
|
|
|
|
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
|
|
|
|
|
{rejectedCount} rejected
|
|
|
|
|
</Badge>
|
|
|
|
|
{undecidedCount > 0 && (
|
|
|
|
|
<Badge variant="outline">{undecidedCount} undecided</Badge>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
{/* Search + Filter */}
|
|
|
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
|
|
|
<div className="relative flex-1">
|
|
|
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<Input
|
|
|
|
|
placeholder="Search projects..."
|
|
|
|
|
value={search}
|
|
|
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
|
|
|
className="pl-9"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Select value={filterOutcome} onValueChange={(v) => setFilterOutcome(v as typeof filterOutcome)}>
|
|
|
|
|
<SelectTrigger className="w-[180px]">
|
|
|
|
|
<SelectValue placeholder="Filter by outcome" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
2026-03-03 22:10:04 +01:00
|
|
|
<SelectItem value="all">All outcomes</SelectItem>
|
2026-03-03 19:14:41 +01:00
|
|
|
<SelectItem value="PASSED">Proposed: Pass</SelectItem>
|
|
|
|
|
<SelectItem value="REJECTED">Proposed: Reject</SelectItem>
|
|
|
|
|
<SelectItem value="none">Undecided</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
2026-03-03 22:10:04 +01:00
|
|
|
{hasMultipleCategories && (
|
|
|
|
|
<Select value={filterCategory} onValueChange={(v) => setFilterCategory(v as typeof filterCategory)}>
|
|
|
|
|
<SelectTrigger className="w-[180px]">
|
|
|
|
|
<SelectValue placeholder="Filter by category" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="all">All categories</SelectItem>
|
|
|
|
|
<SelectItem value="STARTUP">Startups</SelectItem>
|
|
|
|
|
<SelectItem value="BUSINESS_CONCEPT">Business Concepts</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
)}
|
2026-03-03 19:14:41 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Bulk actions */}
|
|
|
|
|
{selectedIds.size > 0 && !summary.isFinalized && (
|
|
|
|
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
|
|
|
|
|
<span className="text-sm font-medium">{selectedIds.size} selected</span>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleBulkSetOutcome('PASSED')}
|
|
|
|
|
disabled={batchUpdate.isPending}
|
|
|
|
|
>
|
|
|
|
|
Set as Pass
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleBulkSetOutcome('REJECTED')}
|
|
|
|
|
disabled={batchUpdate.isPending}
|
|
|
|
|
className="text-destructive"
|
|
|
|
|
>
|
|
|
|
|
Set as Reject
|
|
|
|
|
</Button>
|
|
|
|
|
<Button variant="ghost" size="sm" onClick={() => setSelectedIds(new Set())}>
|
|
|
|
|
Clear
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Table */}
|
|
|
|
|
<div className="border rounded-lg overflow-hidden">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr className="border-b bg-muted/50">
|
|
|
|
|
{!summary.isFinalized && (
|
|
|
|
|
<th className="w-10 px-3 py-2.5">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={allSelected}
|
|
|
|
|
onCheckedChange={toggleSelectAll}
|
|
|
|
|
aria-label="Select all"
|
|
|
|
|
/>
|
|
|
|
|
</th>
|
|
|
|
|
)}
|
|
|
|
|
<th className="text-left px-3 py-2.5 font-medium">Project</th>
|
|
|
|
|
<th className="text-left px-3 py-2.5 font-medium hidden sm:table-cell">Category</th>
|
|
|
|
|
<th className="text-left px-3 py-2.5 font-medium hidden md:table-cell">Country</th>
|
|
|
|
|
<th className="text-center px-3 py-2.5 font-medium">Current State</th>
|
|
|
|
|
{summary.roundType === 'EVALUATION' && (
|
|
|
|
|
<th className="text-center px-3 py-2.5 font-medium hidden lg:table-cell">Score / Rank</th>
|
|
|
|
|
)}
|
|
|
|
|
<th className="text-center px-3 py-2.5 font-medium w-[160px]">Proposed Outcome</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
2026-03-03 22:10:04 +01:00
|
|
|
{groupedProjects ? (
|
|
|
|
|
groupedProjects.map((group) => (
|
|
|
|
|
<React.Fragment key={group.category}>
|
|
|
|
|
<tr className="bg-muted/70">
|
|
|
|
|
<td
|
|
|
|
|
colSpan={colCount}
|
|
|
|
|
className="px-3 py-2 font-semibold text-sm tracking-wide uppercase text-muted-foreground"
|
2026-03-03 19:14:41 +01:00
|
|
|
>
|
2026-03-03 22:10:04 +01:00
|
|
|
{group.label}
|
|
|
|
|
<span className="ml-2 text-xs font-normal normal-case">
|
|
|
|
|
({group.projects.filter((p) => p.proposedOutcome === 'PASSED').length} pass, {group.projects.filter((p) => p.proposedOutcome === 'REJECTED').length} reject, {group.projects.length} total)
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{group.projects.map((project) => renderProjectRow(project))}
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
))
|
|
|
|
|
) : (
|
|
|
|
|
filteredProjects.map((project) => renderProjectRow(project))
|
|
|
|
|
)}
|
2026-03-03 19:14:41 +01:00
|
|
|
{filteredProjects.length === 0 && (
|
|
|
|
|
<tr>
|
|
|
|
|
<td
|
2026-03-03 22:10:04 +01:00
|
|
|
colSpan={colCount}
|
2026-03-03 19:14:41 +01:00
|
|
|
className="px-3 py-8 text-center text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
No projects match your search/filter
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Email Customization + Confirm */}
|
|
|
|
|
{!summary.isFinalized && !summary.isGracePeriodActive && (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<button
|
|
|
|
|
className="flex items-center gap-2 text-left w-full"
|
|
|
|
|
onClick={() => setEmailSectionOpen(!emailSectionOpen)}
|
|
|
|
|
>
|
|
|
|
|
{emailSectionOpen ? (
|
|
|
|
|
<ChevronDown className="h-4 w-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
)}
|
|
|
|
|
<Mail className="h-4 w-4" />
|
|
|
|
|
<CardTitle className="text-base">Email Customization</CardTitle>
|
|
|
|
|
<span className="text-xs text-muted-foreground ml-2">(optional)</span>
|
|
|
|
|
</button>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
{emailSectionOpen && (
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
{/* Account stats */}
|
|
|
|
|
{(summary.accountStats.needsInvite > 0 || summary.accountStats.hasAccount > 0) && (
|
|
|
|
|
<div className="flex items-center gap-4 p-3 rounded-lg bg-muted/50 border text-sm">
|
|
|
|
|
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
|
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
|
|
|
|
{summary.accountStats.needsInvite > 0 && (
|
|
|
|
|
<span>
|
|
|
|
|
<strong>{summary.accountStats.needsInvite}</strong> project{summary.accountStats.needsInvite !== 1 ? 's' : ''} will receive a{' '}
|
|
|
|
|
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 text-xs">Create Account</Badge>{' '}
|
|
|
|
|
invite link
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{summary.accountStats.hasAccount > 0 && (
|
|
|
|
|
<span>
|
|
|
|
|
<strong>{summary.accountStats.hasAccount}</strong> project{summary.accountStats.hasAccount !== 1 ? 's' : ''} will receive a{' '}
|
|
|
|
|
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs">View Dashboard</Badge>{' '}
|
|
|
|
|
link
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div>
|
2026-03-03 22:57:52 +01:00
|
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
|
|
|
<label className="text-sm font-medium">Advancement Message</label>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setAdvancePreviewMsg(advancementMessage || undefined)
|
|
|
|
|
setAdvancePreviewOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Eye className="h-3.5 w-3.5 mr-1" />
|
|
|
|
|
Preview
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-03-03 19:14:41 +01:00
|
|
|
<Textarea
|
|
|
|
|
placeholder="Custom message for projects that are advancing (added to the standard email template)..."
|
|
|
|
|
value={advancementMessage}
|
|
|
|
|
onChange={(e) => setAdvancementMessage(e.target.value)}
|
|
|
|
|
rows={3}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-03-03 22:57:52 +01:00
|
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
|
|
|
<label className="text-sm font-medium">Rejection Message</label>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setRejectPreviewMsg(rejectionMessage || undefined)
|
|
|
|
|
setRejectPreviewOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Eye className="h-3.5 w-3.5 mr-1" />
|
|
|
|
|
Preview
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-03-03 19:14:41 +01:00
|
|
|
<Textarea
|
|
|
|
|
placeholder="Custom message for projects that are not advancing (added to the standard email template)..."
|
|
|
|
|
value={rejectionMessage}
|
|
|
|
|
onChange={(e) => setRejectionMessage(e.target.value)}
|
|
|
|
|
rows={3}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
)}
|
|
|
|
|
<CardContent className="pt-0">
|
|
|
|
|
<div className="flex items-center justify-between border-t pt-4">
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
{summary.nextRound ? (
|
|
|
|
|
<span>
|
|
|
|
|
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will advance to{' '}
|
|
|
|
|
<strong>{summary.nextRound.name}</strong>
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span>
|
|
|
|
|
<strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} will be marked as passed
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{' | '}
|
|
|
|
|
<strong>{rejectedCount}</strong> rejected
|
|
|
|
|
{undecidedCount > 0 && (
|
|
|
|
|
<span className="text-amber-600"> | {undecidedCount} undecided (will not be processed)</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<AlertDialog>
|
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
disabled={passedCount + rejectedCount === 0 || confirmMutation.isPending}
|
|
|
|
|
>
|
|
|
|
|
{confirmMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
|
|
|
|
|
<Send className="h-4 w-4 mr-1.5" />
|
|
|
|
|
Finalize Round
|
|
|
|
|
</Button>
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>Confirm Finalization</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription asChild>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<p>This will:</p>
|
|
|
|
|
<ul className="list-disc pl-5 space-y-1">
|
|
|
|
|
<li>Mark <strong>{passedCount}</strong> project{passedCount !== 1 ? 's' : ''} as <strong>PASSED</strong></li>
|
|
|
|
|
<li>Mark <strong>{rejectedCount}</strong> project{rejectedCount !== 1 ? 's' : ''} as <strong>REJECTED</strong></li>
|
|
|
|
|
{summary.nextRound && (
|
|
|
|
|
<li>Advance passed projects to <strong>{summary.nextRound.name}</strong></li>
|
|
|
|
|
)}
|
|
|
|
|
<li>Send email notifications to all affected teams</li>
|
|
|
|
|
</ul>
|
|
|
|
|
{undecidedCount > 0 && (
|
|
|
|
|
<p className="text-amber-600 flex items-center gap-1.5 mt-2">
|
|
|
|
|
<AlertTriangle className="h-4 w-4" />
|
|
|
|
|
{undecidedCount} project{undecidedCount !== 1 ? 's' : ''} with no proposed outcome will not be affected.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
<p className="font-medium mt-3">This action cannot be undone.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
|
|
|
|
onClick={() =>
|
|
|
|
|
confirmMutation.mutate({
|
|
|
|
|
roundId,
|
|
|
|
|
targetRoundId: summary.nextRound?.id,
|
|
|
|
|
advancementMessage: advancementMessage || undefined,
|
|
|
|
|
rejectionMessage: rejectionMessage || undefined,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
Yes, Finalize Round
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
2026-03-03 22:57:52 +01:00
|
|
|
|
|
|
|
|
{/* Email Preview Dialogs */}
|
|
|
|
|
<EmailPreviewDialog
|
|
|
|
|
open={advancePreviewOpen}
|
|
|
|
|
onOpenChange={setAdvancePreviewOpen}
|
|
|
|
|
title="Advancement Email Preview"
|
|
|
|
|
description="Preview of the email sent to advancing project teams"
|
|
|
|
|
recipientCount={advancePreview.data?.recipientCount ?? passedCount}
|
|
|
|
|
previewHtml={advancePreview.data?.html}
|
|
|
|
|
isPreviewLoading={advancePreview.isLoading}
|
|
|
|
|
onSend={() => setAdvancePreviewOpen(false)}
|
|
|
|
|
isSending={false}
|
|
|
|
|
showCustomMessage={false}
|
|
|
|
|
previewOnly
|
|
|
|
|
/>
|
|
|
|
|
<EmailPreviewDialog
|
|
|
|
|
open={rejectPreviewOpen}
|
|
|
|
|
onOpenChange={setRejectPreviewOpen}
|
|
|
|
|
title="Rejection Email Preview"
|
|
|
|
|
description="Preview of the email sent to non-advancing project teams"
|
|
|
|
|
recipientCount={rejectPreview.data?.recipientCount ?? rejectedCount}
|
|
|
|
|
previewHtml={rejectPreview.data?.html}
|
|
|
|
|
isPreviewLoading={rejectPreview.isLoading}
|
|
|
|
|
onSend={() => setRejectPreviewOpen(false)}
|
|
|
|
|
isSending={false}
|
|
|
|
|
showCustomMessage={false}
|
|
|
|
|
previewOnly
|
|
|
|
|
/>
|
2026-03-03 19:14:41 +01:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|