All checks were successful
Build and Push Docker Image / build (push) Successful in 9m16s
- Add criteriaWeights to EvaluationConfig for per-criterion weight assignment (0-10) - Rewrite ai-ranking service: fetch eval form criteria, compute per-criterion averages, z-score normalize juror scores to correct grading bias, send weighted criteria to AI - Update AI prompts with criteria_definitions and per-project criteria_scores - compositeScore uses weighted criteria when configured, falls back to globalScore - Add collapsible ranking config section to dashboard (criteria text + weight sliders) - Move rankingCriteria textarea from eval config tab to ranking dashboard - Store criteriaWeights in ranking snapshot parsedRulesJson for audit - Enhance projectScores CSV export with per-criterion averages, category, country - Add Export CSV button to ranking dashboard header - Add threshold-based advancement mode (decimal score threshold, e.g. 6.5) alongside existing top-N mode in advance dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1140 lines
47 KiB
TypeScript
1140 lines
47 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
|
import { trpc } from '@/lib/trpc/client'
|
|
import { toast } from 'sonner'
|
|
import { cn } from '@/lib/utils'
|
|
import {
|
|
DndContext,
|
|
closestCenter,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
type DragEndEvent,
|
|
} from '@dnd-kit/core'
|
|
import {
|
|
arrayMove,
|
|
SortableContext,
|
|
sortableKeyboardCoordinates,
|
|
useSortable,
|
|
verticalListSortingStrategy,
|
|
} from '@dnd-kit/sortable'
|
|
import { CSS } from '@dnd-kit/utilities'
|
|
import { AnimatePresence, motion } from 'motion/react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
Sheet,
|
|
SheetContent,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
SheetDescription,
|
|
} from '@/components/ui/sheet'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Label } from '@/components/ui/label'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { Slider } from '@/components/ui/slider'
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible'
|
|
import {
|
|
GripVertical,
|
|
BarChart3,
|
|
Loader2,
|
|
RefreshCw,
|
|
Trophy,
|
|
ExternalLink,
|
|
ChevronDown,
|
|
Settings2,
|
|
Download,
|
|
} from 'lucide-react'
|
|
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
type RankingDashboardProps = {
|
|
competitionId: string
|
|
roundId: string
|
|
}
|
|
|
|
type ProjectInfo = {
|
|
title: string
|
|
teamName: string | null
|
|
country: string | null
|
|
}
|
|
|
|
type JurorScore = {
|
|
jurorName: string
|
|
globalScore: number | null
|
|
decision: boolean | null
|
|
}
|
|
|
|
type SortableProjectRowProps = {
|
|
projectId: string
|
|
currentRank: number
|
|
entry: RankedProjectEntry | undefined
|
|
projectInfo: ProjectInfo | undefined
|
|
jurorScores: JurorScore[] | undefined
|
|
onSelect: () => void
|
|
isSelected: boolean
|
|
}
|
|
|
|
// ─── Sub-component: SortableProjectRow ────────────────────────────────────────
|
|
|
|
function SortableProjectRow({
|
|
projectId,
|
|
currentRank,
|
|
entry,
|
|
projectInfo,
|
|
jurorScores,
|
|
onSelect,
|
|
isSelected,
|
|
}: SortableProjectRowProps) {
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({ id: projectId })
|
|
|
|
const style = {
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
}
|
|
|
|
// isOverridden: current position differs from AI-assigned rank
|
|
const isOverridden = entry !== undefined && currentRank !== entry.rank
|
|
|
|
// Compute yes count from juror scores
|
|
const yesCount = jurorScores?.filter((j) => j.decision === true).length ?? 0
|
|
const totalJurors = jurorScores?.length ?? entry?.evaluatorCount ?? 0
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
onClick={onSelect}
|
|
className={cn(
|
|
'flex items-center gap-3 rounded-lg border bg-card p-3 cursor-pointer transition-all hover:shadow-sm',
|
|
isDragging && 'opacity-50 shadow-lg ring-2 ring-[#de0f1e]/30',
|
|
isSelected && 'ring-2 ring-[#de0f1e]',
|
|
)}
|
|
>
|
|
{/* Drag handle */}
|
|
<button
|
|
className="cursor-grab touch-none text-muted-foreground hover:text-foreground flex-shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
{...attributes}
|
|
{...listeners}
|
|
>
|
|
<GripVertical className="h-4 w-4" />
|
|
</button>
|
|
|
|
{/* Rank badge */}
|
|
{isOverridden ? (
|
|
<Badge className="flex-shrink-0 bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200 text-xs font-semibold">
|
|
#{currentRank} (override)
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
className="flex-shrink-0 text-xs font-semibold"
|
|
style={{ backgroundColor: '#053d57', color: '#fefefe' }}
|
|
>
|
|
#{currentRank}
|
|
</Badge>
|
|
)}
|
|
|
|
{/* Project info */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium truncate">
|
|
{projectInfo?.title ?? `Project …${projectId.slice(-6)}`}
|
|
</p>
|
|
{projectInfo?.teamName && (
|
|
<p className="text-xs text-muted-foreground truncate">
|
|
{projectInfo.teamName}
|
|
{projectInfo.country ? ` · ${projectInfo.country}` : ''}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Juror scores + advance decision */}
|
|
<div className="flex items-center gap-3 flex-shrink-0">
|
|
{/* Individual juror score pills */}
|
|
{jurorScores && jurorScores.length > 0 ? (
|
|
<div className="flex items-center gap-1" title={jurorScores.map((j) => `${j.jurorName}: ${j.globalScore ?? '—'}/10`).join('\n')}>
|
|
{jurorScores.map((j, i) => (
|
|
<span
|
|
key={i}
|
|
className={cn(
|
|
'inline-flex items-center justify-center rounded-md px-1.5 py-0.5 text-xs font-medium border',
|
|
j.globalScore != null && j.globalScore >= 8
|
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
|
: j.globalScore != null && j.globalScore >= 6
|
|
? 'bg-blue-50 text-blue-700 border-blue-200'
|
|
: j.globalScore != null && j.globalScore >= 4
|
|
? 'bg-amber-50 text-amber-700 border-amber-200'
|
|
: 'bg-red-50 text-red-700 border-red-200',
|
|
)}
|
|
title={`${j.jurorName}: ${j.globalScore ?? '—'}/10`}
|
|
>
|
|
{j.globalScore ?? '—'}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : entry?.avgGlobalScore !== null && entry?.avgGlobalScore !== undefined ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
Avg {entry.avgGlobalScore.toFixed(1)}
|
|
</span>
|
|
) : null}
|
|
|
|
{/* Average score */}
|
|
{entry?.avgGlobalScore !== null && entry?.avgGlobalScore !== undefined && jurorScores && jurorScores.length > 1 && (
|
|
<span className="text-xs font-medium text-muted-foreground" title="Average score">
|
|
= {entry.avgGlobalScore.toFixed(1)}
|
|
</span>
|
|
)}
|
|
|
|
{/* Advance decision indicator */}
|
|
<div className={cn(
|
|
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium',
|
|
yesCount > 0
|
|
? 'bg-emerald-100 text-emerald-700'
|
|
: 'bg-gray-100 text-gray-500',
|
|
)}>
|
|
{yesCount > 0 ? (
|
|
<>{yesCount}/{totalJurors} Yes</>
|
|
) : (
|
|
<>{totalJurors} juror{totalJurors !== 1 ? 's' : ''}</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main component ────────────────────────────────────────────────────────────
|
|
|
|
export function RankingDashboard({ competitionId: _competitionId, roundId }: RankingDashboardProps) {
|
|
// ─── State ────────────────────────────────────────────────────────────────
|
|
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
|
|
const [localOrder, setLocalOrder] = useState<Record<'STARTUP' | 'BUSINESS_CONCEPT', string[]>>({
|
|
STARTUP: [],
|
|
BUSINESS_CONCEPT: [],
|
|
})
|
|
const initialized = useRef(false)
|
|
const pendingReorderCount = useRef(0)
|
|
|
|
// ─── Advance dialog state ─────────────────────────────────────────────────
|
|
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
|
|
const [advanceMode, setAdvanceMode] = useState<'top_n' | 'threshold'>('top_n')
|
|
const [topNStartup, setTopNStartup] = useState(3)
|
|
const [topNConceptual, setTopNConceptual] = useState(3)
|
|
const [scoreThreshold, setScoreThreshold] = useState(5)
|
|
const [includeReject, setIncludeReject] = useState(false)
|
|
|
|
// ─── Export state ──────────────────────────────────────────────────────────
|
|
const [exportLoading, setExportLoading] = useState(false)
|
|
|
|
// ─── Expandable review state ──────────────────────────────────────────────
|
|
const [expandedReviews, setExpandedReviews] = useState<Set<string>>(new Set())
|
|
|
|
// ─── Criteria weights state ────────────────────────────────────────────────
|
|
const [weightsOpen, setWeightsOpen] = useState(false)
|
|
const [localWeights, setLocalWeights] = useState<Record<string, number>>({})
|
|
const [localCriteriaText, setLocalCriteriaText] = useState<string>('')
|
|
const weightsInitialized = useRef(false)
|
|
|
|
// ─── Sensors ──────────────────────────────────────────────────────────────
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor),
|
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
)
|
|
|
|
// ─── tRPC queries ─────────────────────────────────────────────────────────
|
|
const { data: snapshots, isLoading: snapshotsLoading } = trpc.ranking.listSnapshots.useQuery(
|
|
{ roundId },
|
|
{ refetchInterval: 30_000 },
|
|
)
|
|
|
|
const latestSnapshotId = snapshots?.[0]?.id ?? null
|
|
const latestSnapshot = snapshots?.[0] ?? null
|
|
|
|
const { data: snapshot, isLoading: snapshotLoading } = trpc.ranking.getSnapshot.useQuery(
|
|
{ snapshotId: latestSnapshotId! },
|
|
{ enabled: !!latestSnapshotId },
|
|
)
|
|
|
|
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
|
|
{ roundId },
|
|
)
|
|
|
|
const { data: projectDetail, isLoading: detailLoading } = trpc.project.getFullDetail.useQuery(
|
|
{ id: selectedProjectId! },
|
|
{ enabled: !!selectedProjectId },
|
|
)
|
|
|
|
const { data: roundData } = trpc.round.getById.useQuery({ id: roundId })
|
|
|
|
const { data: evalScores } = trpc.ranking.roundEvaluationScores.useQuery(
|
|
{ roundId },
|
|
)
|
|
|
|
const { data: evalForm } = trpc.evaluation.getStageForm.useQuery(
|
|
{ roundId },
|
|
)
|
|
|
|
// ─── tRPC mutations ───────────────────────────────────────────────────────
|
|
const utils = trpc.useUtils()
|
|
|
|
const saveReorderMutation = trpc.ranking.saveReorder.useMutation({
|
|
onMutate: () => { pendingReorderCount.current++ },
|
|
onSettled: () => { pendingReorderCount.current-- },
|
|
onError: (err) => toast.error(`Failed to save order: ${err.message}`),
|
|
// Do NOT invalidate getSnapshot — would reset localOrder
|
|
})
|
|
|
|
const updateRoundMutation = trpc.round.update.useMutation({
|
|
onSuccess: () => {
|
|
toast.success('Ranking config saved')
|
|
void utils.round.getById.invalidate({ id: roundId })
|
|
},
|
|
onError: (err) => toast.error(`Failed to save: ${err.message}`),
|
|
})
|
|
|
|
const [rankingInProgress, setRankingInProgress] = useState(false)
|
|
|
|
const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
|
|
onMutate: () => setRankingInProgress(true),
|
|
onSuccess: () => {
|
|
toast.success('Ranking complete!')
|
|
initialized.current = false // allow re-init on next snapshot load
|
|
void utils.ranking.listSnapshots.invalidate({ roundId })
|
|
void utils.ranking.getSnapshot.invalidate()
|
|
setRankingInProgress(false)
|
|
},
|
|
onError: (err) => {
|
|
toast.error(err.message)
|
|
setRankingInProgress(false)
|
|
},
|
|
})
|
|
|
|
const advanceMutation = trpc.round.advanceProjects.useMutation({
|
|
onSuccess: (data) => {
|
|
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
|
|
void utils.roundEngine.getProjectStates.invalidate({ roundId })
|
|
setAdvanceDialogOpen(false)
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
const batchRejectMutation = trpc.roundEngine.batchTransition.useMutation({
|
|
onSuccess: (data) => {
|
|
// MEMORY.md: use .length, not direct value comparison
|
|
toast.success(`Rejected ${data.succeeded.length} project(s)`)
|
|
if (data.failed.length > 0) {
|
|
toast.warning(`${data.failed.length} project(s) could not be rejected`)
|
|
}
|
|
void utils.roundEngine.getProjectStates.invalidate({ roundId })
|
|
setAdvanceDialogOpen(false)
|
|
},
|
|
onError: (err) => toast.error(err.message),
|
|
})
|
|
|
|
// ─── evalConfig (advancement counts from round config) ────────────────────
|
|
const evalConfig = useMemo(() => {
|
|
if (!roundData?.configJson) return null
|
|
try {
|
|
const config = roundData.configJson as Record<string, unknown>
|
|
const advConfig = config.advancementConfig as Record<string, unknown> | undefined
|
|
return {
|
|
startupAdvanceCount: (advConfig?.startupCount ?? config.startupAdvanceCount ?? 0) as number,
|
|
conceptAdvanceCount: (advConfig?.conceptCount ?? config.conceptAdvanceCount ?? 0) as number,
|
|
}
|
|
} catch { return null }
|
|
}, [roundData])
|
|
|
|
// ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
|
|
const rankingMap = useMemo(() => {
|
|
const map = new Map<string, RankedProjectEntry>()
|
|
if (!snapshot) return map
|
|
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
|
|
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
|
|
;[...startup, ...concept].forEach((entry) => map.set(entry.projectId, entry))
|
|
return map
|
|
}, [snapshot])
|
|
|
|
// ─── projectInfoMap (O(1) lookup by projectId) ────────────────────────────
|
|
const projectInfoMap = useMemo(() => {
|
|
const map = new Map<string, ProjectInfo>()
|
|
if (!projectStates) return map
|
|
for (const ps of projectStates) {
|
|
map.set(ps.project.id, {
|
|
title: ps.project.title,
|
|
teamName: ps.project.teamName,
|
|
country: ps.project.country,
|
|
})
|
|
}
|
|
return map
|
|
}, [projectStates])
|
|
|
|
// ─── localOrder init (once, with useRef guard) ────────────────────────────
|
|
useEffect(() => {
|
|
if (!initialized.current && snapshot) {
|
|
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
|
|
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
|
|
setLocalOrder({
|
|
STARTUP: startup.map((r) => r.projectId),
|
|
BUSINESS_CONCEPT: concept.map((r) => r.projectId),
|
|
})
|
|
initialized.current = true
|
|
}
|
|
}, [snapshot])
|
|
|
|
// ─── numericCriteria from eval form ─────────────────────────────────────
|
|
const numericCriteria = useMemo(() => {
|
|
if (!evalForm?.criteriaJson) return []
|
|
return (evalForm.criteriaJson as Array<{ id: string; label: string; type?: string; scale?: number | string }>)
|
|
.filter((c) => !c.type || c.type === 'numeric')
|
|
}, [evalForm])
|
|
|
|
// ─── Init local weights + criteriaText from round config ──────────────────
|
|
useEffect(() => {
|
|
if (!weightsInitialized.current && roundData?.configJson) {
|
|
const cfg = roundData.configJson as Record<string, unknown>
|
|
const saved = (cfg.criteriaWeights ?? {}) as Record<string, number>
|
|
setLocalWeights(saved)
|
|
setLocalCriteriaText((cfg.rankingCriteria as string) ?? '')
|
|
weightsInitialized.current = true
|
|
}
|
|
}, [roundData])
|
|
|
|
// ─── Save weights + criteria text to round config ─────────────────────────
|
|
const saveRankingConfig = () => {
|
|
if (!roundData?.configJson) return
|
|
const cfg = roundData.configJson as Record<string, unknown>
|
|
updateRoundMutation.mutate({
|
|
id: roundId,
|
|
configJson: { ...cfg, criteriaWeights: localWeights, rankingCriteria: localCriteriaText },
|
|
})
|
|
}
|
|
|
|
// ─── sync advance dialog defaults from config ────────────────────────────
|
|
useEffect(() => {
|
|
if (evalConfig) {
|
|
if (evalConfig.startupAdvanceCount > 0) setTopNStartup(evalConfig.startupAdvanceCount)
|
|
if (evalConfig.conceptAdvanceCount > 0) setTopNConceptual(evalConfig.conceptAdvanceCount)
|
|
}
|
|
}, [evalConfig])
|
|
|
|
// ─── handleDragEnd ────────────────────────────────────────────────────────
|
|
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
|
|
const { active, over } = event
|
|
if (!over || active.id === over.id) return
|
|
setLocalOrder((prev) => {
|
|
const ids = prev[category]
|
|
const newIds = arrayMove(
|
|
ids,
|
|
ids.indexOf(active.id as string),
|
|
ids.indexOf(over.id as string),
|
|
)
|
|
saveReorderMutation.mutate({
|
|
snapshotId: latestSnapshotId!,
|
|
category,
|
|
orderedProjectIds: newIds,
|
|
})
|
|
return { ...prev, [category]: newIds }
|
|
})
|
|
}
|
|
|
|
// ─── Compute threshold-based project IDs ──────────────────────────────────
|
|
const thresholdAdvanceIds = useMemo(() => {
|
|
if (advanceMode !== 'threshold') return { ids: [] as string[], startupCount: 0, conceptCount: 0 }
|
|
const ids: string[] = []
|
|
let startupCount = 0
|
|
let conceptCount = 0
|
|
for (const cat of ['STARTUP', 'BUSINESS_CONCEPT'] as const) {
|
|
for (const projectId of localOrder[cat]) {
|
|
const entry = rankingMap.get(projectId)
|
|
if (entry?.avgGlobalScore != null && entry.avgGlobalScore >= scoreThreshold) {
|
|
ids.push(projectId)
|
|
if (cat === 'STARTUP') startupCount++
|
|
else conceptCount++
|
|
}
|
|
}
|
|
}
|
|
return { ids, startupCount, conceptCount }
|
|
}, [advanceMode, scoreThreshold, localOrder, rankingMap])
|
|
|
|
// ─── handleAdvance ────────────────────────────────────────────────────────
|
|
function handleAdvance() {
|
|
let advanceIds: string[]
|
|
if (advanceMode === 'threshold') {
|
|
advanceIds = thresholdAdvanceIds.ids
|
|
} else {
|
|
advanceIds = [
|
|
...localOrder.STARTUP.slice(0, topNStartup),
|
|
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
|
|
]
|
|
}
|
|
const advanceSet = new Set(advanceIds)
|
|
|
|
advanceMutation.mutate({ roundId, projectIds: advanceIds })
|
|
|
|
if (includeReject) {
|
|
const rejectIds = [...localOrder.STARTUP, ...localOrder.BUSINESS_CONCEPT].filter(
|
|
(id) => !advanceSet.has(id),
|
|
)
|
|
if (rejectIds.length > 0) {
|
|
batchRejectMutation.mutate({ projectIds: rejectIds, roundId, newState: 'REJECTED' })
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── handleExport ──────────────────────────────────────────────────────────
|
|
async function handleExportScores() {
|
|
setExportLoading(true)
|
|
try {
|
|
const result = await utils.export.projectScores.fetch({ roundId })
|
|
if (!result.data || result.data.length === 0) {
|
|
toast.error('No data to export')
|
|
return
|
|
}
|
|
const headers = result.columns
|
|
const csvRows = [
|
|
headers.join(','),
|
|
...result.data.map((row: Record<string, unknown>) =>
|
|
headers.map((h: string) => {
|
|
const val = row[h]
|
|
if (val == null) return ''
|
|
const str = String(val)
|
|
return str.includes(',') || str.includes('"') || str.includes('\n')
|
|
? `"${str.replace(/"/g, '""')}"`
|
|
: str
|
|
}).join(','),
|
|
),
|
|
]
|
|
const blob = new Blob([csvRows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `round-scores-${roundId.slice(-8)}.csv`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
toast.success('CSV exported')
|
|
} catch (err) {
|
|
toast.error(`Export failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
|
} finally {
|
|
setExportLoading(false)
|
|
}
|
|
}
|
|
|
|
// ─── Loading state ────────────────────────────────────────────────────────
|
|
if (snapshotsLoading || snapshotLoading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-24 w-full rounded-lg" />
|
|
<Skeleton className="h-48 w-full rounded-lg" />
|
|
<Skeleton className="h-48 w-full rounded-lg" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Empty state ──────────────────────────────────────────────────────────
|
|
if (!latestSnapshotId) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardContent className="flex flex-col items-center justify-center gap-4 py-12 text-center">
|
|
{rankingInProgress ? (
|
|
<>
|
|
<Loader2 className="h-10 w-10 text-blue-500 animate-spin" />
|
|
<div>
|
|
<p className="font-medium">AI ranking in progress…</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
This may take a minute. You can continue working — results will appear automatically.
|
|
</p>
|
|
</div>
|
|
<div className="h-2 w-48 rounded-full bg-blue-100 dark:bg-blue-900 overflow-hidden">
|
|
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<BarChart3 className="h-10 w-10 text-muted-foreground" />
|
|
<div>
|
|
<p className="font-medium">No ranking available yet</p>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Run ranking from the Config tab to generate results, or trigger it now.
|
|
</p>
|
|
</div>
|
|
<Button
|
|
onClick={() => triggerRankMutation.mutate({ roundId })}
|
|
disabled={triggerRankMutation.isPending}
|
|
>
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
Run Ranking Now
|
|
</Button>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main content ─────────────────────────────────────────────────────────
|
|
const categoryLabels: Record<'STARTUP' | 'BUSINESS_CONCEPT', string> = {
|
|
STARTUP: 'Startups',
|
|
BUSINESS_CONCEPT: 'Business Concepts',
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-6">
|
|
{/* Header card */}
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
|
<div className="flex-1 min-w-0">
|
|
<CardTitle className="text-base">Latest Ranking Snapshot</CardTitle>
|
|
{latestSnapshot && (
|
|
<CardDescription className="mt-1 space-y-0.5">
|
|
<span>
|
|
Created{' '}
|
|
{new Date(latestSnapshot.createdAt).toLocaleString(undefined, {
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short',
|
|
})}
|
|
{latestSnapshot.triggeredBy?.name && ` by ${latestSnapshot.triggeredBy.name}`}
|
|
{' · '}
|
|
{latestSnapshot.triggerType}
|
|
</span>
|
|
{latestSnapshot.criteriaText && (
|
|
<span className="block truncate text-xs">
|
|
Criteria: {latestSnapshot.criteriaText.slice(0, 120)}
|
|
{latestSnapshot.criteriaText.length > 120 ? '…' : ''}
|
|
</span>
|
|
)}
|
|
</CardDescription>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleExportScores}
|
|
disabled={exportLoading}
|
|
>
|
|
{exportLoading ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Download className="mr-2 h-4 w-4" />
|
|
)}
|
|
Export CSV
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => triggerRankMutation.mutate({ roundId })}
|
|
disabled={rankingInProgress}
|
|
>
|
|
{rankingInProgress ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Ranking…
|
|
</>
|
|
) : (
|
|
<>
|
|
<RefreshCw className="mr-2 h-4 w-4" />
|
|
Run Ranking
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
disabled={saveReorderMutation.isPending || advanceMutation.isPending || !latestSnapshotId}
|
|
onClick={() => setAdvanceDialogOpen(true)}
|
|
className="bg-[#053d57] hover:bg-[#053d57]/90"
|
|
>
|
|
{advanceMutation.isPending ? (
|
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...</>
|
|
) : (
|
|
<><Trophy className="h-4 w-4 mr-2" /> Advance Top N</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
{/* Ranking Configuration: criteria text + weights */}
|
|
<Collapsible open={weightsOpen} onOpenChange={setWeightsOpen}>
|
|
<Card>
|
|
<CollapsibleTrigger asChild>
|
|
<CardHeader className="cursor-pointer flex flex-row items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
|
|
<div className="flex items-center gap-2">
|
|
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
|
<div>
|
|
<CardTitle className="text-base">Ranking Configuration</CardTitle>
|
|
<CardDescription className="mt-0.5">Criteria text, per-criterion weights, and bias correction</CardDescription>
|
|
</div>
|
|
</div>
|
|
<ChevronDown className={cn('h-4 w-4 text-muted-foreground transition-transform', weightsOpen && 'rotate-180')} />
|
|
</CardHeader>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<CardContent className="space-y-5 pt-0">
|
|
{/* Ranking criteria text */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="rankingCriteria">Ranking Criteria (natural language)</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Describe how projects should be ranked. The AI will parse this into rules.
|
|
</p>
|
|
<Textarea
|
|
id="rankingCriteria"
|
|
rows={3}
|
|
placeholder='E.g. "Prioritize innovation and ocean impact. Filter out projects with pass rate below 50%."'
|
|
value={localCriteriaText}
|
|
onChange={(e) => setLocalCriteriaText(e.target.value)}
|
|
className="resize-y"
|
|
/>
|
|
</div>
|
|
|
|
{/* Per-criterion weights */}
|
|
{numericCriteria.length > 0 && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label>Criteria Weights</Label>
|
|
<p className="text-xs text-muted-foreground">
|
|
Set relative importance of each evaluation criterion (0 = ignore, 10 = highest priority)
|
|
</p>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{numericCriteria.map((c) => (
|
|
<div key={c.id} className="flex items-center gap-4">
|
|
<span className="text-sm w-40 truncate flex-shrink-0" title={c.label}>{c.label}</span>
|
|
<Slider
|
|
min={0}
|
|
max={10}
|
|
step={1}
|
|
value={[localWeights[c.id] ?? 1]}
|
|
onValueChange={([v]) => setLocalWeights((prev) => ({ ...prev, [c.id]: v }))}
|
|
className="flex-1"
|
|
/>
|
|
<span className="text-sm font-mono w-6 text-right">{localWeights[c.id] ?? 1}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2 pt-2 border-t">
|
|
<Button
|
|
size="sm"
|
|
onClick={saveRankingConfig}
|
|
disabled={updateRoundMutation.isPending}
|
|
>
|
|
{updateRoundMutation.isPending ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
|
Save Configuration
|
|
</Button>
|
|
<p className="text-xs text-muted-foreground">
|
|
Weights are applied when ranking is run. Z-score normalization corrects for juror bias automatically.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</CollapsibleContent>
|
|
</Card>
|
|
</Collapsible>
|
|
|
|
{/* Ranking in-progress banner */}
|
|
{rankingInProgress && (
|
|
<Card className="border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30">
|
|
<CardContent className="flex items-center gap-3 py-4">
|
|
<Loader2 className="h-5 w-5 animate-spin text-blue-600 dark:text-blue-400 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-blue-900 dark:text-blue-200">
|
|
AI ranking in progress…
|
|
</p>
|
|
<p className="text-xs text-blue-700 dark:text-blue-400">
|
|
This may take a minute. You can continue working — results will appear automatically.
|
|
</p>
|
|
</div>
|
|
<div className="h-1.5 w-32 rounded-full bg-blue-200 dark:bg-blue-800 overflow-hidden flex-shrink-0">
|
|
<div className="h-full w-full rounded-full bg-blue-500 animate-pulse" />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Per-category sections */}
|
|
{(['STARTUP', 'BUSINESS_CONCEPT'] as const).map((category) => (
|
|
<Card key={category}>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
|
{categoryLabels[category]}
|
|
{evalConfig && (category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount) > 0 && (
|
|
<span className="ml-2 text-xs font-normal normal-case">
|
|
(Top {category === 'STARTUP' ? evalConfig.startupAdvanceCount : evalConfig.conceptAdvanceCount} advance)
|
|
</span>
|
|
)}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{localOrder[category].length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.
|
|
</p>
|
|
) : (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragEnd={(event) => handleDragEnd(category, event)}
|
|
>
|
|
<SortableContext
|
|
items={localOrder[category]}
|
|
strategy={verticalListSortingStrategy}
|
|
>
|
|
<AnimatePresence initial={false}>
|
|
<div className="space-y-2">
|
|
{localOrder[category].map((projectId, index) => {
|
|
const advanceCount = category === 'STARTUP'
|
|
? (evalConfig?.startupAdvanceCount ?? 0)
|
|
: (evalConfig?.conceptAdvanceCount ?? 0)
|
|
const isAdvancing = advanceCount > 0 && index < advanceCount
|
|
const isCutoffRow = advanceCount > 0 && index === advanceCount - 1
|
|
|
|
return (
|
|
<React.Fragment key={projectId}>
|
|
<motion.div
|
|
layout
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className={isAdvancing ? 'rounded-lg bg-emerald-50 dark:bg-emerald-950/20' : ''}
|
|
>
|
|
<SortableProjectRow
|
|
projectId={projectId}
|
|
currentRank={index + 1}
|
|
entry={rankingMap.get(projectId)}
|
|
projectInfo={projectInfoMap.get(projectId)}
|
|
jurorScores={evalScores?.[projectId]}
|
|
onSelect={() => setSelectedProjectId(projectId)}
|
|
isSelected={selectedProjectId === projectId}
|
|
/>
|
|
</motion.div>
|
|
{isCutoffRow && (
|
|
<div className="flex items-center gap-2 py-1">
|
|
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
|
|
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400 whitespace-nowrap">
|
|
Advancement cutoff — Top {advanceCount}
|
|
</span>
|
|
<div className="flex-1 border-t-2 border-dashed border-emerald-400/60" />
|
|
</div>
|
|
)}
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
</div>
|
|
</AnimatePresence>
|
|
</SortableContext>
|
|
</DndContext>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Advance dialog */}
|
|
<Dialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>Advance Projects</DialogTitle>
|
|
<DialogDescription>
|
|
Choose how to select which projects advance to the next round.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
{/* Mode toggle */}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant={advanceMode === 'top_n' ? 'default' : 'outline'}
|
|
onClick={() => setAdvanceMode('top_n')}
|
|
className={advanceMode === 'top_n' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
|
|
>
|
|
Top N per category
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={advanceMode === 'threshold' ? 'default' : 'outline'}
|
|
onClick={() => setAdvanceMode('threshold')}
|
|
className={advanceMode === 'threshold' ? 'bg-[#053d57] hover:bg-[#053d57]/90' : ''}
|
|
>
|
|
Score threshold
|
|
</Button>
|
|
</div>
|
|
|
|
{advanceMode === 'top_n' ? (
|
|
<>
|
|
{/* Top N for STARTUP */}
|
|
{localOrder.STARTUP.length > 0 && (
|
|
<div className="flex items-center gap-3">
|
|
<Label className="w-40 text-sm">Startups to advance</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
max={localOrder.STARTUP.length}
|
|
value={topNStartup}
|
|
onChange={(e) =>
|
|
setTopNStartup(
|
|
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
|
|
)
|
|
}
|
|
className="w-24"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Top N for BUSINESS_CONCEPT */}
|
|
{localOrder.BUSINESS_CONCEPT.length > 0 && (
|
|
<div className="flex items-center gap-3">
|
|
<Label className="w-40 text-sm">Concepts to advance</Label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
max={localOrder.BUSINESS_CONCEPT.length}
|
|
value={topNConceptual}
|
|
onChange={(e) =>
|
|
setTopNConceptual(
|
|
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
|
|
)
|
|
}
|
|
className="w-24"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<Label className="w-40 text-sm">Minimum avg score</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
max={10}
|
|
step={0.1}
|
|
value={scoreThreshold}
|
|
onChange={(e) => setScoreThreshold(Math.max(0, Math.min(10, parseFloat(e.target.value) || 5)))}
|
|
className="w-24"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">out of 10</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
All projects with an average global score at or above this threshold will advance, regardless of category.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Optional: also batch-reject non-advanced */}
|
|
<div className="flex items-center gap-2 pt-2 border-t">
|
|
<input
|
|
type="checkbox"
|
|
id="includeReject"
|
|
checked={includeReject}
|
|
onChange={(e) => setIncludeReject(e.target.checked)}
|
|
className="h-4 w-4 accent-[#de0f1e]"
|
|
/>
|
|
<Label htmlFor="includeReject" className="text-sm cursor-pointer">
|
|
Also batch-reject non-advanced projects
|
|
</Label>
|
|
</div>
|
|
|
|
{/* Preview */}
|
|
{(() => {
|
|
const advCount = advanceMode === 'top_n'
|
|
? topNStartup + topNConceptual
|
|
: thresholdAdvanceIds.ids.length
|
|
const totalProjects = localOrder.STARTUP.length + localOrder.BUSINESS_CONCEPT.length
|
|
return (
|
|
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
|
|
<p>
|
|
Advancing: {advCount} project{advCount !== 1 ? 's' : ''}
|
|
{advanceMode === 'threshold' && (
|
|
<> ({thresholdAdvanceIds.startupCount} startups, {thresholdAdvanceIds.conceptCount} concepts)</>
|
|
)}
|
|
</p>
|
|
{includeReject && (
|
|
<p>Rejecting: {totalProjects - advCount} project{totalProjects - advCount !== 1 ? 's' : ''}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setAdvanceDialogOpen(false)}
|
|
disabled={advanceMutation.isPending || batchRejectMutation.isPending}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleAdvance}
|
|
disabled={
|
|
advanceMutation.isPending ||
|
|
batchRejectMutation.isPending ||
|
|
(advanceMode === 'top_n' ? topNStartup + topNConceptual === 0 : thresholdAdvanceIds.ids.length === 0)
|
|
}
|
|
className="bg-[#053d57] hover:bg-[#053d57]/90"
|
|
>
|
|
{advanceMutation.isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...
|
|
</>
|
|
) : (
|
|
`Advance ${advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length} Project${(advanceMode === 'top_n' ? topNStartup + topNConceptual : thresholdAdvanceIds.ids.length) !== 1 ? 's' : ''}`
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Side panel Sheet */}
|
|
<Sheet
|
|
open={!!selectedProjectId}
|
|
onOpenChange={(open) => {
|
|
if (!open) setSelectedProjectId(null)
|
|
}}
|
|
>
|
|
<SheetContent className="w-[480px] sm:max-w-[480px] overflow-y-auto">
|
|
<SheetHeader>
|
|
<SheetTitle>{projectDetail?.project.title ?? 'Project Details'}</SheetTitle>
|
|
<SheetDescription>
|
|
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
|
|
</SheetDescription>
|
|
{selectedProjectId && (
|
|
<a
|
|
href={`/admin/projects/${selectedProjectId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 text-sm text-primary hover:underline mt-1"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
View Project Page
|
|
</a>
|
|
)}
|
|
</SheetHeader>
|
|
|
|
{detailLoading ? (
|
|
<div className="mt-6 space-y-3">
|
|
<Skeleton className="h-16 w-full" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-24 w-full" />
|
|
</div>
|
|
) : projectDetail ? (
|
|
<div className="mt-6 space-y-6">
|
|
{/* Stats summary */}
|
|
{projectDetail.stats && (
|
|
<div className="grid grid-cols-3 gap-3">
|
|
<div className="rounded-lg border p-3 text-center">
|
|
<p className="text-xs text-muted-foreground">Avg Score</p>
|
|
<p className="mt-1 text-lg font-semibold">
|
|
{projectDetail.stats.averageGlobalScore?.toFixed(1) ?? '—'}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border p-3 text-center">
|
|
<p className="text-xs text-muted-foreground">Pass Rate</p>
|
|
<p className="mt-1 text-lg font-semibold">
|
|
{projectDetail.stats.totalEvaluations > 0
|
|
? `${Math.round((projectDetail.stats.yesVotes / projectDetail.stats.totalEvaluations) * 100)}%`
|
|
: '—'}
|
|
</p>
|
|
</div>
|
|
<div className="rounded-lg border p-3 text-center">
|
|
<p className="text-xs text-muted-foreground">Evaluators</p>
|
|
<p className="mt-1 text-lg font-semibold">
|
|
{projectDetail.stats.totalEvaluations}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Per-juror evaluations */}
|
|
<div>
|
|
<h4 className="mb-3 text-sm font-semibold">Juror Evaluations</h4>
|
|
{(() => {
|
|
const submitted = projectDetail.assignments.filter(
|
|
(a) => a.evaluation?.status === 'SUBMITTED' && a.round.id === roundId,
|
|
)
|
|
if (submitted.length === 0) {
|
|
return (
|
|
<p className="text-sm text-muted-foreground">
|
|
No submitted evaluations for this round.
|
|
</p>
|
|
)
|
|
}
|
|
return (
|
|
<div className="space-y-3">
|
|
{submitted.map((a) => {
|
|
const isExpanded = expandedReviews.has(a.id)
|
|
return (
|
|
<div
|
|
key={a.id}
|
|
className="rounded-lg border p-3 cursor-pointer hover:bg-muted/50 transition-colors"
|
|
onClick={() => setExpandedReviews(prev => {
|
|
const next = new Set(prev)
|
|
next.has(a.id) ? next.delete(a.id) : next.add(a.id)
|
|
return next
|
|
})}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="font-medium text-sm">{a.user?.name ?? a.user?.email ?? 'Unknown'}</span>
|
|
<div className="flex items-center gap-2">
|
|
{a.evaluation?.binaryDecision != null && (
|
|
<Badge
|
|
variant={a.evaluation.binaryDecision ? 'default' : 'destructive'}
|
|
className={a.evaluation.binaryDecision ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100' : ''}
|
|
>
|
|
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
|
|
</Badge>
|
|
)}
|
|
<Badge variant="outline">Score: {a.evaluation?.globalScore?.toFixed(1) ?? '—'}</Badge>
|
|
</div>
|
|
</div>
|
|
{isExpanded && a.evaluation?.feedbackText && (
|
|
<p className="mt-2 text-sm text-muted-foreground whitespace-pre-wrap border-t pt-2">
|
|
{a.evaluation.feedbackText}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</SheetContent>
|
|
</Sheet>
|
|
</>
|
|
)
|
|
}
|