feat(02-02): implement full RankingDashboard component
- Replace stub with complete drag-and-drop ranked project list (DASH-01, DASH-02) - localOrder in useState with useRef init guard prevents snap-back (DASH-03) - Per-category DndContext (STARTUP / BUSINESS_CONCEPT) with SortableProjectRow - AI-order rows show dark-blue rank badge; admin-reordered show amber '(override)' badge - Sheet panel lazy-loads trpc.project.getFullDetail on row click (DASH-04) - Per-juror evaluation breakdown with score, binary decision, feedback text - 'Run Ranking' button in header triggers triggerAutoRank mutation - Empty categories show placeholder message (no empty drag zone) - Zero TypeScript errors; build passes
This commit is contained in:
@@ -1,14 +1,497 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { 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 {
|
||||||
|
GripVertical,
|
||||||
|
BarChart3,
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type RankingDashboardProps = {
|
type RankingDashboardProps = {
|
||||||
competitionId: string
|
competitionId: string
|
||||||
roundId: string
|
roundId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RankingDashboard({ competitionId: _competitionId, roundId: _roundId }: RankingDashboardProps) {
|
type SortableProjectRowProps = {
|
||||||
|
projectId: string
|
||||||
|
currentRank: number
|
||||||
|
entry: RankedProjectEntry | undefined
|
||||||
|
onSelect: () => void
|
||||||
|
isSelected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sub-component: SortableProjectRow ────────────────────────────────────────
|
||||||
|
|
||||||
|
function SortableProjectRow({
|
||||||
|
projectId,
|
||||||
|
currentRank,
|
||||||
|
entry,
|
||||||
|
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
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-muted-foreground text-sm p-4">
|
<div
|
||||||
Ranking dashboard coming soon...
|
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 identifier */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
Project …{projectId.slice(-6)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
{entry && (
|
||||||
|
<div className="flex items-center gap-4 flex-shrink-0 text-xs text-muted-foreground">
|
||||||
|
<span title="Composite score">
|
||||||
|
<BarChart3 className="inline h-3 w-3 mr-0.5" />
|
||||||
|
{Math.round(entry.compositeScore * 100)}%
|
||||||
|
</span>
|
||||||
|
{entry.avgGlobalScore !== null && (
|
||||||
|
<span title="Average global score">
|
||||||
|
Avg {entry.avgGlobalScore.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span title="Pass rate">
|
||||||
|
Pass {Math.round(entry.passRate * 100)}%
|
||||||
|
</span>
|
||||||
|
<span title="Evaluator count">
|
||||||
|
{entry.evaluatorCount} juror{entry.evaluatorCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
</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)
|
||||||
|
|
||||||
|
// ─── 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: projectDetail, isLoading: detailLoading } = trpc.project.getFullDetail.useQuery(
|
||||||
|
{ id: selectedProjectId! },
|
||||||
|
{ enabled: !!selectedProjectId },
|
||||||
|
)
|
||||||
|
|
||||||
|
// ─── tRPC mutations ───────────────────────────────────────────────────────
|
||||||
|
const utils = trpc.useUtils()
|
||||||
|
|
||||||
|
const saveReorderMutation = trpc.ranking.saveReorder.useMutation({
|
||||||
|
onError: (err) => toast.error(`Failed to save order: ${err.message}`),
|
||||||
|
// Do NOT invalidate getSnapshot — would reset localOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Ranking complete. Reload to see results.')
|
||||||
|
initialized.current = false // allow re-init on next snapshot load
|
||||||
|
void utils.ranking.listSnapshots.invalidate({ roundId })
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── 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])
|
||||||
|
|
||||||
|
// ─── 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])
|
||||||
|
|
||||||
|
// ─── 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex flex-col items-center justify-center gap-4 py-12 text-center">
|
||||||
|
<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}
|
||||||
|
>
|
||||||
|
{triggerRankMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Run Ranking Now
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => triggerRankMutation.mutate({ roundId })}
|
||||||
|
disabled={triggerRankMutation.isPending}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
{triggerRankMutation.isPending ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Run Ranking
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
</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]}
|
||||||
|
</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) => (
|
||||||
|
<motion.div
|
||||||
|
key={projectId}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
>
|
||||||
|
<SortableProjectRow
|
||||||
|
projectId={projectId}
|
||||||
|
currentRank={index + 1}
|
||||||
|
entry={rankingMap.get(projectId)}
|
||||||
|
onSelect={() => setSelectedProjectId(projectId)}
|
||||||
|
isSelected={selectedProjectId === projectId}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
</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) => (
|
||||||
|
<div key={a.id} className="rounded-lg border p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
Score: {a.evaluation.globalScore.toFixed(1)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
'text-xs',
|
||||||
|
a.evaluation.binaryDecision
|
||||||
|
? 'bg-green-100 text-green-700 hover:bg-green-100'
|
||||||
|
: 'bg-red-100 text-red-700 hover:bg-red-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{a.evaluation?.feedbackText && (
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{a.evaluation.feedbackText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user