Observer: fix round history, match admin project info, add AI rejection reason
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
- Round history infers rejection round when ProjectRoundState lacks explicit REJECTED state; shows red XCircle + badge, dims unreached rounds - Project info section now matches admin: description, location, founded, submission links, expertise tags, internal notes, created/updated dates - Fetch FilteringResult for rejected projects; display AI reasoning + confidence - Remove cross-round comparison from reports, replace with scoring/criteria insights - Remove unused AI synthesis placeholder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Toggle } from '@/components/ui/toggle'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import {
|
import {
|
||||||
@@ -36,7 +35,6 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Users,
|
Users,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
GitCompare,
|
|
||||||
Download,
|
Download,
|
||||||
Clock,
|
Clock,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -46,7 +44,6 @@ import {
|
|||||||
EvaluationTimelineChart,
|
EvaluationTimelineChart,
|
||||||
StatusBreakdownChart,
|
StatusBreakdownChart,
|
||||||
CriteriaScoresChart,
|
CriteriaScoresChart,
|
||||||
CrossStageComparisonChart,
|
|
||||||
} from '@/components/charts'
|
} from '@/components/charts'
|
||||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||||
@@ -621,29 +618,8 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||||
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
|
trpc.analytics.getCriteriaScores.useQuery(queryInput, { enabled: hasSelection })
|
||||||
|
|
||||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
|
const { data: overviewStats } =
|
||||||
|
trpc.analytics.getOverviewStats.useQuery(queryInput, { enabled: hasSelection })
|
||||||
const crossStageRounds = programs?.flatMap(p =>
|
|
||||||
((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({
|
|
||||||
id: s.id,
|
|
||||||
name: s.name,
|
|
||||||
programName: `${p.year} Edition`,
|
|
||||||
}))
|
|
||||||
) ?? []
|
|
||||||
|
|
||||||
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
|
|
||||||
|
|
||||||
const { data: comparison, isLoading: comparisonLoading } =
|
|
||||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
|
||||||
{ roundIds: selectedRoundIds },
|
|
||||||
{ enabled: selectedRoundIds.length >= 2 }
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleRound = (roundId: string) => {
|
|
||||||
setSelectedRoundIds((prev) =>
|
|
||||||
prev.includes(roundId) ? prev.filter((id) => id !== roundId) : [...prev, roundId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const [csvOpen, setCsvOpen] = useState(false)
|
const [csvOpen, setCsvOpen] = useState(false)
|
||||||
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
const [csvData, setCsvData] = useState<{ data: Record<string, unknown>[]; columns: string[] } | undefined>()
|
||||||
@@ -665,12 +641,63 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
return result
|
return result
|
||||||
}, [criteriaScores])
|
}, [criteriaScores])
|
||||||
|
|
||||||
|
// Derived scoring insights
|
||||||
|
const scoringInsights = (() => {
|
||||||
|
if (!scoreDistribution?.distribution?.length) return null
|
||||||
|
const dist = scoreDistribution.distribution
|
||||||
|
const totalScores = dist.reduce((sum, d) => sum + d.count, 0)
|
||||||
|
if (totalScores === 0) return null
|
||||||
|
|
||||||
|
// Find score with highest count
|
||||||
|
const peakBucket = dist.reduce((a, b) => (b.count > a.count ? b : a), dist[0])
|
||||||
|
// Find highest and lowest scores that have counts
|
||||||
|
const scoredBuckets = dist.filter(d => d.count > 0)
|
||||||
|
const highScore = scoredBuckets.length > 0 ? Math.max(...scoredBuckets.map(d => d.score)) : 0
|
||||||
|
const lowScore = scoredBuckets.length > 0 ? Math.min(...scoredBuckets.map(d => d.score)) : 0
|
||||||
|
// Median: walk through distribution to find the middle
|
||||||
|
let cumulative = 0
|
||||||
|
let median = 0
|
||||||
|
for (const d of dist) {
|
||||||
|
cumulative += d.count
|
||||||
|
if (cumulative >= totalScores / 2) {
|
||||||
|
median = d.score
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scores ≥ 7 count
|
||||||
|
const highScoreCount = dist.filter(d => d.score >= 7).reduce((sum, d) => sum + d.count, 0)
|
||||||
|
const highScorePct = Math.round((highScoreCount / totalScores) * 100)
|
||||||
|
|
||||||
|
return { peakScore: peakBucket.score, highScore, lowScore, median, totalScores, highScorePct }
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Criteria insights
|
||||||
|
const criteriaInsights = (() => {
|
||||||
|
if (!criteriaScores?.length) return null
|
||||||
|
const sorted = [...criteriaScores].sort((a, b) => b.averageScore - a.averageScore)
|
||||||
|
return {
|
||||||
|
strongest: sorted[0],
|
||||||
|
weakest: sorted[sorted.length - 1],
|
||||||
|
spread: sorted[0].averageScore - sorted[sorted.length - 1].averageScore,
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Status insights
|
||||||
|
const statusInsights = (() => {
|
||||||
|
if (!statusBreakdown?.length) return null
|
||||||
|
const total = statusBreakdown.reduce((sum, s) => sum + s.count, 0)
|
||||||
|
const reviewed = statusBreakdown
|
||||||
|
.filter(s => !['SUBMITTED', 'ELIGIBLE', 'DRAFT'].includes(s.status))
|
||||||
|
.reduce((sum, s) => sum + s.count, 0)
|
||||||
|
return { total, reviewed, reviewedPct: total > 0 ? Math.round((reviewed / total) * 100) : 0 }
|
||||||
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-semibold">Scores & Analytics</h2>
|
<h2 className="text-base font-semibold">Scores & Analytics</h2>
|
||||||
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and cross-round comparison</p>
|
<p className="text-sm text-muted-foreground">Score distributions, criteria breakdown and insights</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -692,6 +719,48 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
onRequestData={handleRequestCsvData}
|
onRequestData={handleRequestCsvData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Scoring Insight Tiles */}
|
||||||
|
{hasSelection && scoringInsights && (
|
||||||
|
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||||
|
<AnimatedCard index={0}>
|
||||||
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Median Score</p>
|
||||||
|
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.median}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">of 10</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
<AnimatedCard index={1}>
|
||||||
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Score Range</p>
|
||||||
|
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.lowScore}–{scoringInsights.highScore}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">lowest to highest</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
<AnimatedCard index={2}>
|
||||||
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Most Common</p>
|
||||||
|
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.peakScore}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">peak score</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
<AnimatedCard index={3}>
|
||||||
|
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">High Scores (7+)</p>
|
||||||
|
<p className="text-2xl font-bold mt-1 tabular-nums">{scoringInsights.highScorePct}%</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">of {scoringInsights.totalScores} scores</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Score Distribution & Status Breakdown */}
|
{/* Score Distribution & Status Breakdown */}
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
{scoreLoading ? (
|
{scoreLoading ? (
|
||||||
@@ -736,54 +805,84 @@ function ScoresTab({ selectedValue }: { selectedValue: string }) {
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Cross-Round Comparison */}
|
{/* Criteria & Coverage Insights */}
|
||||||
<Card>
|
{hasSelection && (criteriaInsights || statusInsights || overviewStats) && (
|
||||||
<CardHeader>
|
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<CardTitle className="flex items-center gap-2">
|
{criteriaInsights && (
|
||||||
<GitCompare className="h-4 w-4" />
|
<>
|
||||||
Cross-Round Comparison
|
<AnimatedCard index={4}>
|
||||||
</CardTitle>
|
<Card className="border-l-4 border-l-emerald-500">
|
||||||
<CardDescription>Select at least 2 rounds to compare metrics</CardDescription>
|
<CardContent className="p-4">
|
||||||
</CardHeader>
|
<p className="text-xs font-medium text-muted-foreground">Strongest Criterion</p>
|
||||||
<CardContent className="space-y-4">
|
<p className="font-semibold mt-1 leading-tight">{criteriaInsights.strongest.name}</p>
|
||||||
{programsLoading ? (
|
<p className="text-lg font-bold tabular-nums text-emerald-600 mt-1">
|
||||||
<Skeleton className="h-10" />
|
{criteriaInsights.strongest.averageScore.toFixed(2)}
|
||||||
) : (
|
</p>
|
||||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Select rounds to compare">
|
</CardContent>
|
||||||
{crossStageRounds.map((stage) => (
|
</Card>
|
||||||
<Toggle
|
</AnimatedCard>
|
||||||
key={stage.id}
|
<AnimatedCard index={5}>
|
||||||
variant="outline"
|
<Card className="border-l-4 border-l-amber-500">
|
||||||
size="sm"
|
<CardContent className="p-4">
|
||||||
pressed={selectedRoundIds.includes(stage.id)}
|
<p className="text-xs font-medium text-muted-foreground">Weakest Criterion</p>
|
||||||
onPressedChange={() => toggleRound(stage.id)}
|
<p className="font-semibold mt-1 leading-tight">{criteriaInsights.weakest.name}</p>
|
||||||
aria-label={stage.name}
|
<p className="text-lg font-bold tabular-nums text-amber-600 mt-1">
|
||||||
>
|
{criteriaInsights.weakest.averageScore.toFixed(2)}
|
||||||
{stage.name}
|
</p>
|
||||||
</Toggle>
|
</CardContent>
|
||||||
))}
|
</Card>
|
||||||
</div>
|
</AnimatedCard>
|
||||||
|
<AnimatedCard index={6}>
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Criteria Spread</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums mt-1">{criteriaInsights.spread.toFixed(2)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">points between strongest and weakest</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{selectedRoundIds.length < 2 && (
|
{overviewStats && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<>
|
||||||
Select at least 2 rounds to enable comparison
|
<AnimatedCard index={7}>
|
||||||
</p>
|
<Card className="border-l-4 border-l-violet-500">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Total Evaluations</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums mt-1">{overviewStats.evaluationCount}</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{overviewStats.projectCount > 0
|
||||||
|
? `~${(overviewStats.evaluationCount / overviewStats.projectCount).toFixed(1)} per project`
|
||||||
|
: 'submitted'}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
<AnimatedCard index={8}>
|
||||||
|
<Card className="border-l-4 border-l-teal-500">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Completion Rate</p>
|
||||||
|
<p className="text-2xl font-bold tabular-nums mt-1">{overviewStats.completionRate}%</p>
|
||||||
|
<Progress value={overviewStats.completionRate} className="mt-2 h-1.5" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
{statusInsights && (
|
||||||
</Card>
|
<AnimatedCard index={9}>
|
||||||
|
<Card className="border-l-4 border-l-cyan-500">
|
||||||
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
|
<CardContent className="p-4">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Review Progress</p>
|
||||||
{comparison && (
|
<p className="text-2xl font-bold tabular-nums mt-1">{statusInsights.reviewed}/{statusInsights.total}</p>
|
||||||
<CrossStageComparisonChart data={comparison as Array<{
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
roundId: string
|
{statusInsights.reviewedPct}% of projects reviewed
|
||||||
roundName: string
|
</p>
|
||||||
projectCount: number
|
</CardContent>
|
||||||
evaluationCount: number
|
</Card>
|
||||||
completionRate: number
|
</AnimatedCard>
|
||||||
averageScore: number | null
|
)}
|
||||||
scoreDistribution: { score: number; count: number }[]
|
</div>
|
||||||
}>} />
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Circle,
|
Circle,
|
||||||
|
XCircle,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
@@ -36,7 +37,6 @@ import {
|
|||||||
GraduationCap,
|
GraduationCap,
|
||||||
Heart,
|
Heart,
|
||||||
Clock,
|
Clock,
|
||||||
Sparkles,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn, formatDate, formatDateOnly } from '@/lib/utils'
|
import { cn, formatDate, formatDateOnly } from '@/lib/utils'
|
||||||
@@ -85,7 +85,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { project, assignments, stats, competitionRounds, projectRoundStates, allRequirements } =
|
const { project, assignments, stats, competitionRounds, projectRoundStates, allRequirements, filteringResult } =
|
||||||
data
|
data
|
||||||
|
|
||||||
const roundStateMap = new Map(
|
const roundStateMap = new Map(
|
||||||
@@ -301,19 +301,50 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Synthesis placeholder */}
|
{/* AI Rejection Reason */}
|
||||||
<AnimatedCard index={1}>
|
{project.status === 'REJECTED' && filteringResult?.aiScreeningJson && (() => {
|
||||||
<Card className="border-dashed">
|
const screening = filteringResult.aiScreeningJson as Record<string, Record<string, unknown>>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
|
// Extract reasoning from the first rule's result
|
||||||
<Sparkles className="h-8 w-8 text-muted-foreground/40" />
|
const firstRule = Object.values(screening)[0]
|
||||||
<p className="mt-3 text-sm font-medium text-muted-foreground">
|
const reasoning = firstRule?.reasoning as string | undefined
|
||||||
AI synthesis will appear here when available
|
const confidence = firstRule?.confidence as number | undefined
|
||||||
</p>
|
if (!reasoning) return null
|
||||||
</CardContent>
|
return (
|
||||||
</Card>
|
<AnimatedCard index={1}>
|
||||||
</AnimatedCard>
|
<Card className="border-red-200 bg-red-50/50">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center gap-2.5 text-lg text-red-700">
|
||||||
|
<div className="rounded-lg bg-red-100 p-1.5">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-600" />
|
||||||
|
</div>
|
||||||
|
AI Screening — Rejected
|
||||||
|
{filteringResult.round && (
|
||||||
|
<span className="text-sm font-normal text-red-500 ml-auto">
|
||||||
|
at {filteringResult.round.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-red-800 whitespace-pre-wrap">{reasoning}</p>
|
||||||
|
{confidence != null && (
|
||||||
|
<p className="mt-2 text-xs text-red-500">
|
||||||
|
AI Confidence: {Math.round(confidence * 100)}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{filteringResult.overrideReason && (
|
||||||
|
<div className="mt-3 border-t border-red-200 pt-3">
|
||||||
|
<p className="text-xs font-medium text-red-600">Override Reason</p>
|
||||||
|
<p className="text-sm text-red-800">{filteringResult.overrideReason}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Project Info */}
|
{/* Project Info — matches admin layout */}
|
||||||
<AnimatedCard index={2}>
|
<AnimatedCard index={2}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -324,76 +355,101 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
Project Information
|
Project Information
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-2">
|
{/* Category & Ocean Issue badges */}
|
||||||
<div>
|
<div className="flex flex-wrap gap-2">
|
||||||
<p className="text-xs text-muted-foreground">Submitted</p>
|
{project.competitionCategory && (
|
||||||
<p className="text-sm font-medium">
|
<Badge variant="outline" className="gap-1">
|
||||||
{formatDateOnly(project.createdAt)}
|
<GraduationCap className="h-3 w-3" />
|
||||||
</p>
|
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
|
||||||
</div>
|
</Badge>
|
||||||
<div>
|
)}
|
||||||
<p className="text-xs text-muted-foreground">Category</p>
|
{project.oceanIssue && (
|
||||||
<p className="text-sm font-medium">
|
<Badge variant="outline" className="gap-1">
|
||||||
{project.competitionCategory
|
<Waves className="h-3 w-3" />
|
||||||
? project.competitionCategory === 'STARTUP'
|
{project.oceanIssue.replace(/_/g, ' ')}
|
||||||
? 'Start-up'
|
</Badge>
|
||||||
: 'Business Concept'
|
)}
|
||||||
: '-'}
|
{project.wantsMentorship && (
|
||||||
</p>
|
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Country</p>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{project.country || project.geographicZone || '-'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">AI Score</p>
|
|
||||||
<p className="text-sm font-medium">-</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Last Updated
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{formatDateOnly(project.updatedAt)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{project.wantsMentorship && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="gap-1 border-pink-200 bg-pink-50 text-pink-600"
|
|
||||||
>
|
|
||||||
<Heart className="h-3 w-3" />
|
<Heart className="h-3 w-3" />
|
||||||
Wants Mentorship
|
Wants Mentorship
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{project.description && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-1">Description</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Expertise Tags */}
|
{/* Location, Institution, Founded */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{(project.country || project.geographicZone) && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
||||||
|
<p className="text-sm">{project.geographicZone || project.country}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.institution && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Institution</p>
|
||||||
|
<p className="text-sm">{project.institution}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.foundedAt && (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Founded</p>
|
||||||
|
<p className="text-sm">{formatDateOnly(project.foundedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submission URLs */}
|
||||||
|
{(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Submission Links</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.phase1SubmissionUrl && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={project.phase1SubmissionUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
Phase 1 Submission
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{project.phase2SubmissionUrl && (
|
||||||
|
<Button variant="outline" size="sm" asChild>
|
||||||
|
<a href={project.phase2SubmissionUrl} target="_blank" rel="noopener noreferrer">
|
||||||
|
Phase 2 Submission
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI-Assigned Expertise Tags */}
|
||||||
{project.projectTags && project.projectTags.length > 0 && (
|
{project.projectTags && project.projectTags.length > 0 && (
|
||||||
<div className="mt-4">
|
<div>
|
||||||
<p className="mb-2 text-xs text-muted-foreground">
|
<p className="text-sm font-medium text-muted-foreground mb-2">Expertise Tags</p>
|
||||||
Expertise Tags
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{project.projectTags.map((pt) => (
|
{project.projectTags.map((pt) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={pt.tag.id}
|
key={pt.tag.id}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
style={
|
style={pt.tag.color ? { backgroundColor: `${pt.tag.color}20`, borderColor: pt.tag.color } : undefined}
|
||||||
pt.tag.color
|
|
||||||
? {
|
|
||||||
backgroundColor: `${pt.tag.color}20`,
|
|
||||||
borderColor: pt.tag.color,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{pt.tag.name}
|
{pt.tag.name}
|
||||||
{pt.confidence < 1 && (
|
{pt.confidence < 1 && (
|
||||||
@@ -406,6 +462,56 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Simple Tags (legacy) */}
|
||||||
|
{project.tags && project.tags.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-2">Tags</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{project.tags.map((tag: string) => (
|
||||||
|
<Badge key={tag} variant="secondary">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Internal Info */}
|
||||||
|
{(project.internalComments || project.applicationStatus || project.referralSource) && (
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground mb-3">Internal Notes</p>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{project.applicationStatus && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Application Status</p>
|
||||||
|
<p className="text-sm">{project.applicationStatus}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{project.referralSource && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Referral Source</p>
|
||||||
|
<p className="text-sm">{project.referralSource}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{project.internalComments && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-xs text-muted-foreground">Comments</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{project.internalComments}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-6 text-sm pt-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Created:</span>{' '}
|
||||||
|
{formatDateOnly(project.createdAt)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Updated:</span>{' '}
|
||||||
|
{formatDateOnly(project.updatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
@@ -416,10 +522,46 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
const s = roundStateMap.get(r.id)
|
const s = roundStateMap.get(r.id)
|
||||||
return s && (s.state === 'PASSED' || s.state === 'COMPLETED')
|
return s && (s.state === 'PASSED' || s.state === 'COMPLETED')
|
||||||
}).length
|
}).length
|
||||||
const rejectedRound = competitionRounds.find((r) => {
|
|
||||||
|
// Find the rejection round — either explicit REJECTED state or inferred
|
||||||
|
const isProjectRejected = project.status === 'REJECTED'
|
||||||
|
const explicitRejectedRound = competitionRounds.find((r) => {
|
||||||
const s = roundStateMap.get(r.id)
|
const s = roundStateMap.get(r.id)
|
||||||
return s?.state === 'REJECTED'
|
return s?.state === 'REJECTED'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If project is globally rejected but no round has explicit REJECTED state,
|
||||||
|
// infer the rejection round as the furthest round the project reached
|
||||||
|
let inferredRejectionRoundId: string | null = null
|
||||||
|
if (isProjectRejected && !explicitRejectedRound) {
|
||||||
|
// Find the last round that has any state record (furthest the project got)
|
||||||
|
for (let i = competitionRounds.length - 1; i >= 0; i--) {
|
||||||
|
const s = roundStateMap.get(competitionRounds[i].id)
|
||||||
|
if (s) {
|
||||||
|
// If it's PASSED/COMPLETED, rejection happened at the next round
|
||||||
|
if (s.state === 'PASSED' || s.state === 'COMPLETED') {
|
||||||
|
if (i + 1 < competitionRounds.length) {
|
||||||
|
inferredRejectionRoundId = competitionRounds[i + 1].id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// PENDING/IN_PROGRESS in this round means rejected here
|
||||||
|
inferredRejectionRoundId = competitionRounds[i].id
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rejectedRound = explicitRejectedRound
|
||||||
|
?? (inferredRejectionRoundId
|
||||||
|
? competitionRounds.find((r) => r.id === inferredRejectionRoundId)
|
||||||
|
: null)
|
||||||
|
|
||||||
|
// Determine which rounds are "not reached" (after rejection point)
|
||||||
|
const rejectedRoundIdx = rejectedRound
|
||||||
|
? competitionRounds.findIndex((r) => r.id === rejectedRound.id)
|
||||||
|
: -1
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatedCard index={3}>
|
<AnimatedCard index={3}>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -438,9 +580,18 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ol className="space-y-4">
|
<ol className="space-y-4">
|
||||||
{competitionRounds.map((round) => {
|
{competitionRounds.map((round, idx) => {
|
||||||
const roundState = roundStateMap.get(round.id)
|
const roundState = roundStateMap.get(round.id)
|
||||||
const state = roundState?.state
|
const rawState = roundState?.state
|
||||||
|
|
||||||
|
// Override state for inferred rejection
|
||||||
|
const isRejectionRound = round.id === rejectedRound?.id
|
||||||
|
const isNotReached = rejectedRoundIdx >= 0 && idx > rejectedRoundIdx
|
||||||
|
const effectiveState = isRejectionRound && !explicitRejectedRound
|
||||||
|
? 'REJECTED'
|
||||||
|
: isNotReached
|
||||||
|
? 'NOT_REACHED'
|
||||||
|
: rawState
|
||||||
|
|
||||||
const roundAssignments = assignments.filter(
|
const roundAssignments = assignments.filter(
|
||||||
(a) => a.roundId === round.id,
|
(a) => a.roundId === round.id,
|
||||||
@@ -448,13 +599,16 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
|
|
||||||
let icon: React.ReactNode
|
let icon: React.ReactNode
|
||||||
let statusLabel: string | null = null
|
let statusLabel: string | null = null
|
||||||
if (state === 'PASSED' || state === 'COMPLETED') {
|
let labelClass = 'text-muted-foreground'
|
||||||
|
|
||||||
|
if (effectiveState === 'PASSED' || effectiveState === 'COMPLETED') {
|
||||||
icon = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
|
icon = <CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-500" />
|
||||||
statusLabel = 'Passed'
|
statusLabel = 'Passed'
|
||||||
} else if (state === 'REJECTED') {
|
} else if (effectiveState === 'REJECTED') {
|
||||||
icon = <AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
icon = <XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
|
||||||
statusLabel = 'Rejected at this round'
|
statusLabel = 'Rejected at this round'
|
||||||
} else if (state === 'IN_PROGRESS') {
|
labelClass = 'text-red-600 font-medium'
|
||||||
|
} else if (effectiveState === 'IN_PROGRESS') {
|
||||||
icon = (
|
icon = (
|
||||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center">
|
||||||
<span className="relative flex h-3 w-3">
|
<span className="relative flex h-3 w-3">
|
||||||
@@ -464,7 +618,11 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
statusLabel = 'Active'
|
statusLabel = 'Active'
|
||||||
} else if (state === 'PENDING') {
|
} else if (effectiveState === 'NOT_REACHED') {
|
||||||
|
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/15" />
|
||||||
|
statusLabel = 'Not reached'
|
||||||
|
labelClass = 'text-muted-foreground/50 italic'
|
||||||
|
} else if (effectiveState === 'PENDING') {
|
||||||
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
|
icon = <Circle className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/40" />
|
||||||
statusLabel = 'Pending'
|
statusLabel = 'Pending'
|
||||||
} else {
|
} else {
|
||||||
@@ -472,15 +630,18 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={round.id} className="flex items-start gap-3">
|
<li key={round.id} className={cn(
|
||||||
|
'flex items-start gap-3',
|
||||||
|
effectiveState === 'NOT_REACHED' && 'opacity-50',
|
||||||
|
)}>
|
||||||
{icon}
|
{icon}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium">{round.name}</p>
|
<p className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
effectiveState === 'NOT_REACHED' && 'text-muted-foreground',
|
||||||
|
)}>{round.name}</p>
|
||||||
{statusLabel && (
|
{statusLabel && (
|
||||||
<p className={cn(
|
<p className={cn('text-xs', labelClass)}>
|
||||||
'text-xs',
|
|
||||||
state === 'REJECTED' ? 'text-destructive' : 'text-muted-foreground',
|
|
||||||
)}>
|
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -490,7 +651,7 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{state === 'IN_PROGRESS' && (
|
{effectiveState === 'IN_PROGRESS' && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs"
|
className="ml-auto shrink-0 border-blue-200 bg-blue-50 text-blue-600 text-xs"
|
||||||
@@ -498,6 +659,14 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
|||||||
Active
|
Active
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{effectiveState === 'REJECTED' && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="ml-auto shrink-0 text-xs"
|
||||||
|
>
|
||||||
|
Rejected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1317,6 +1317,21 @@ export const analyticsRouter = router({
|
|||||||
select: { roundId: true, state: true, enteredAt: true, exitedAt: true },
|
select: { roundId: true, state: true, enteredAt: true, exitedAt: true },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Get filtering result (AI screening) for rejected projects
|
||||||
|
const filteringResult = projectRaw.status === 'REJECTED'
|
||||||
|
? await ctx.prisma.filteringResult.findFirst({
|
||||||
|
where: { projectId: input.id },
|
||||||
|
select: {
|
||||||
|
outcome: true,
|
||||||
|
finalOutcome: true,
|
||||||
|
aiScreeningJson: true,
|
||||||
|
overrideReason: true,
|
||||||
|
round: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
// Get file requirements for all rounds
|
// Get file requirements for all rounds
|
||||||
let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = []
|
let allRequirements: { id: string; roundId: string; name: string; description: string | null; isRequired: boolean; acceptedMimeTypes: string[]; maxSizeMB: number | null }[] = []
|
||||||
if (competitionRounds.length > 0) {
|
if (competitionRounds.length > 0) {
|
||||||
@@ -1360,6 +1375,7 @@ export const analyticsRouter = router({
|
|||||||
competitionRounds,
|
competitionRounds,
|
||||||
projectRoundStates,
|
projectRoundStates,
|
||||||
allRequirements,
|
allRequirements,
|
||||||
|
filteringResult,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user