Fix evaluation criteria, jury preferences, assignment config, and dashboard stats
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s
- Fix criteria not showing for jurors: fetch active form independently via
getStageForm query instead of relying on existing evaluation record
- Fix scoringMode default from 'global' to 'criteria' (matching schema)
- Parse scale string format ("1-10") into minScore/maxScore for criteria display
- Fix COI dialog dismissal: prevent outside click on evaluate page Dialog
- Fix requiredReviews hardcoded to 3: read from round configJson in 4 locations
- Add jury preferences banner for unconfirmed caps on jury dashboard
- Add updateJuryPreferences tRPC procedure for self-service cap/ratio
- Simplify onboarding: always show jury step, allow cap up to 50
- Add role/ratio/availability fields to jury member invite dialog
- Simplify jury group settings (keep only defaultMaxAssignments)
- Enforce deliberation showCollectiveRankings flag for non-admin users
- Redesign dashboard stat cards: editorial data strip on mobile,
clean grid layout on desktop (no more generic card pattern)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -30,9 +30,16 @@ export default function AssignmentsDashboardPage() {
|
||||
id: competitionId,
|
||||
})
|
||||
|
||||
const { data: selectedRound } = trpc.round.getById.useQuery(
|
||||
{ id: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
const requiredReviews = (selectedRound?.configJson as Record<string, unknown>)?.requiredReviewsPerProject as number || 3
|
||||
|
||||
const { data: unassignedQueue, isLoading: isLoadingQueue } =
|
||||
trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
{ roundId: selectedRoundId, requiredReviews: 3 },
|
||||
{ roundId: selectedRoundId, requiredReviews },
|
||||
{ enabled: !!selectedRoundId }
|
||||
)
|
||||
|
||||
@@ -111,7 +118,7 @@ export default function AssignmentsDashboardPage() {
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="coverage" className="mt-6">
|
||||
<CoverageReport roundId={selectedRoundId} />
|
||||
<CoverageReport roundId={selectedRoundId} requiredReviews={requiredReviews} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="unassigned" className="mt-6">
|
||||
@@ -119,7 +126,7 @@ export default function AssignmentsDashboardPage() {
|
||||
<CardHeader>
|
||||
<CardTitle>Unassigned Projects</CardTitle>
|
||||
<CardDescription>
|
||||
Projects with fewer than 3 assignments
|
||||
Projects with fewer than {requiredReviews} assignments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -143,7 +150,7 @@ export default function AssignmentsDashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{project.assignmentCount || 0} / 3 assignments
|
||||
{project.assignmentCount || 0} / {requiredReviews} assignments
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -162,6 +169,7 @@ export default function AssignmentsDashboardPage() {
|
||||
roundId={selectedRoundId}
|
||||
open={previewSheetOpen}
|
||||
onOpenChange={setPreviewSheetOpen}
|
||||
requiredReviews={requiredReviews}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Table,
|
||||
@@ -523,11 +522,6 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
defaultMaxAssignments: group.defaultMaxAssignments,
|
||||
defaultCapMode: group.defaultCapMode,
|
||||
softCapBuffer: group.softCapBuffer,
|
||||
categoryQuotasEnabled: group.categoryQuotasEnabled,
|
||||
allowJurorCapAdjustment: group.allowJurorCapAdjustment,
|
||||
allowJurorRatioAdjustment: group.allowJurorRatioAdjustment,
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
@@ -562,100 +556,21 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label>Default Max Assignments</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
value={formData.defaultMaxAssignments}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Cap Mode</Label>
|
||||
<Select
|
||||
value={formData.defaultCapMode}
|
||||
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="HARD">Hard Cap</SelectItem>
|
||||
<SelectItem value="SOFT">Soft Cap</SelectItem>
|
||||
<SelectItem value="NONE">No Cap</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.defaultCapMode === 'SOFT' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Soft Cap Buffer</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.softCapBuffer}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
|
||||
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) || 15 })
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Number of assignments allowed above the cap when in soft mode
|
||||
Suggested cap for new members. Per-member overrides and juror self-service preferences take priority.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Category Quotas Enabled</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable category-based assignment quotas
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.categoryQuotasEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, categoryQuotasEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Allow Juror Cap Adjustment</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jurors to set their own assignment cap during onboarding
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.allowJurorCapAdjustment}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, allowJurorCapAdjustment: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Allow Juror Ratio Adjustment</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow jurors to set their own startup/concept ratio during onboarding
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.allowJurorRatioAdjustment}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
|
||||
{isPending ? (
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { useState, useMemo, useCallback, useRef } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
@@ -165,7 +166,8 @@ export default function RoundDetailPage() {
|
||||
const roundId = params.roundId as string
|
||||
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const pendingSaveRef = useRef(false)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
|
||||
const [exportOpen, setExportOpen] = useState(false)
|
||||
@@ -215,8 +217,8 @@ export default function RoundDetailPage() {
|
||||
)
|
||||
const roundAwards = awards?.filter((a) => a.evaluationRoundId === roundId) ?? []
|
||||
|
||||
// Sync config from server when not dirty
|
||||
if (round && !hasChanges) {
|
||||
// Sync config from server when no pending save
|
||||
if (round && !pendingSaveRef.current) {
|
||||
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
|
||||
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
|
||||
setConfig(roundConfig)
|
||||
@@ -227,10 +229,15 @@ export default function RoundDetailPage() {
|
||||
const updateMutation = trpc.round.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.getById.invalidate({ id: roundId })
|
||||
toast.success('Round configuration saved')
|
||||
setHasChanges(false)
|
||||
pendingSaveRef.current = false
|
||||
setAutosaveStatus('saved')
|
||||
setTimeout(() => setAutosaveStatus('idle'), 2000)
|
||||
},
|
||||
onError: (err) => {
|
||||
pendingSaveRef.current = false
|
||||
setAutosaveStatus('error')
|
||||
toast.error(err.message)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const activateMutation = trpc.roundEngine.activate.useMutation({
|
||||
@@ -356,14 +363,17 @@ export default function RoundDetailPage() {
|
||||
|
||||
const isTransitioning = activateMutation.isPending || closeMutation.isPending || reopenMutation.isPending || archiveMutation.isPending
|
||||
|
||||
const debouncedSave = useDebouncedCallback((newConfig: Record<string, unknown>) => {
|
||||
setAutosaveStatus('saving')
|
||||
updateMutation.mutate({ id: roundId, configJson: newConfig })
|
||||
}, 1500)
|
||||
|
||||
const handleConfigChange = useCallback((newConfig: Record<string, unknown>) => {
|
||||
setConfig(newConfig)
|
||||
setHasChanges(true)
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
updateMutation.mutate({ id: roundId, configJson: config })
|
||||
}, [roundId, config, updateMutation])
|
||||
pendingSaveRef.current = true
|
||||
setAutosaveStatus('saving')
|
||||
debouncedSave(newConfig)
|
||||
}, [debouncedSave])
|
||||
|
||||
// ── Computed values ────────────────────────────────────────────────────
|
||||
const projectCount = round?._count?.projectRoundStates ?? 0
|
||||
@@ -565,15 +575,23 @@ export default function RoundDetailPage() {
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
{hasChanges && (
|
||||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending} className="bg-white text-[#053d57] hover:bg-white/90">
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1.5" />
|
||||
{autosaveStatus === 'saving' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-white/70">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
)}
|
||||
Save Config
|
||||
</Button>
|
||||
{autosaveStatus === 'saved' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-emerald-300">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
{autosaveStatus === 'error' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-red-300">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Save failed
|
||||
</span>
|
||||
)}
|
||||
<Link href={poolLink}>
|
||||
<Button variant="outline" size="sm" className="border-white/40 bg-white/15 text-white hover:bg-white/30 hover:text-white">
|
||||
@@ -1491,7 +1509,7 @@ export default function RoundDetailPage() {
|
||||
{isEvaluation && (
|
||||
<TabsContent value="assignments" className="space-y-6">
|
||||
{/* Coverage Report */}
|
||||
<CoverageReport roundId={roundId} />
|
||||
<CoverageReport roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
||||
|
||||
{/* Generate Assignments */}
|
||||
<Card>
|
||||
@@ -1554,13 +1572,14 @@ export default function RoundDetailPage() {
|
||||
<IndividualAssignmentsTable roundId={roundId} projectStates={projectStates} />
|
||||
|
||||
{/* Unassigned Queue */}
|
||||
<RoundUnassignedQueue roundId={roundId} />
|
||||
<RoundUnassignedQueue roundId={roundId} requiredReviews={(config.requiredReviewsPerProject as number) || 3} />
|
||||
|
||||
{/* Assignment Preview Sheet */}
|
||||
<AssignmentPreviewSheet
|
||||
roundId={roundId}
|
||||
open={previewSheetOpen}
|
||||
onOpenChange={setPreviewSheetOpen}
|
||||
requiredReviews={(config.requiredReviewsPerProject as number) || 3}
|
||||
/>
|
||||
|
||||
{/* CSV Export Dialog */}
|
||||
@@ -1822,9 +1841,9 @@ export default function RoundDetailPage() {
|
||||
|
||||
// ── Unassigned projects queue ────────────────────────────────────────────
|
||||
|
||||
function RoundUnassignedQueue({ roundId }: { roundId: string }) {
|
||||
function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: { roundId: string; requiredReviews?: number }) {
|
||||
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
|
||||
{ roundId, requiredReviews: 3 },
|
||||
{ roundId, requiredReviews },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
@@ -1832,7 +1851,7 @@ function RoundUnassignedQueue({ roundId }: { roundId: string }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Unassigned Projects</CardTitle>
|
||||
<CardDescription>Projects with fewer than 3 jury assignments</CardDescription>
|
||||
<CardDescription>Projects with fewer than {requiredReviews} jury assignments</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -51,10 +51,9 @@ type JuryPref = {
|
||||
juryGroupMemberId: string
|
||||
juryGroupName: string
|
||||
currentCap: number
|
||||
allowCapAdjustment: boolean
|
||||
allowRatioAdjustment: boolean
|
||||
selfServiceCap: number | null
|
||||
selfServiceRatio: number | null
|
||||
preferredStartupRatio: number | null
|
||||
}
|
||||
|
||||
export default function OnboardingPage() {
|
||||
@@ -530,13 +529,12 @@ export default function OnboardingPage() {
|
||||
{juryMemberships.map((m) => {
|
||||
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
|
||||
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
|
||||
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5
|
||||
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? m.preferredStartupRatio ?? 0.5
|
||||
|
||||
return (
|
||||
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
|
||||
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
|
||||
|
||||
{m.allowCapAdjustment && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Maximum assignments: {capValue}
|
||||
@@ -551,19 +549,17 @@ export default function OnboardingPage() {
|
||||
})
|
||||
}
|
||||
min={1}
|
||||
max={m.currentCap}
|
||||
max={50}
|
||||
step={1}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Admin default: {m.currentCap}. You may reduce this to match your availability.
|
||||
Admin suggestion: {m.currentCap}. Adjust to match your availability.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{m.allowRatioAdjustment && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
|
||||
Category preference: {Math.round(ratioValue * 100)}% Startups / {Math.round((1 - ratioValue) * 100)}% Business Concepts
|
||||
</Label>
|
||||
<Slider
|
||||
value={[ratioValue * 100]}
|
||||
@@ -576,14 +572,16 @@ export default function OnboardingPage() {
|
||||
}
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
step={10}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>More Business Concepts</span>
|
||||
<span>More Startups</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/70 italic">
|
||||
This is a preference, not a guarantee. Due to the number of projects, the system will try to match your preference but exact ratios cannot be ensured.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -70,6 +70,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
{ enabled: !!myAssignment?.id }
|
||||
)
|
||||
|
||||
// Fetch the active evaluation form for this round (independent of evaluation existence)
|
||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
// Start evaluation mutation (creates draft)
|
||||
const startMutation = trpc.evaluation.start.useMutation()
|
||||
|
||||
@@ -116,19 +122,31 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
|
||||
// Parse evaluation config from round
|
||||
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
|
||||
const scoringMode = evalConfig?.scoringMode ?? 'global'
|
||||
const scoringMode = evalConfig?.scoringMode ?? 'criteria'
|
||||
const requireFeedback = evalConfig?.requireFeedback ?? true
|
||||
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
|
||||
|
||||
// Get criteria from evaluation form
|
||||
const criteria = existingEvaluation?.form?.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
weight?: number
|
||||
minScore?: number
|
||||
maxScore?: number
|
||||
}> | undefined
|
||||
// Get criteria from the active evaluation form (independent of evaluation record)
|
||||
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
|
||||
// Parse scale string like "1-10" into minScore/maxScore
|
||||
let minScore = 1
|
||||
let maxScore = 10
|
||||
if (c.scale) {
|
||||
const parts = c.scale.split('-').map(Number)
|
||||
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
||||
minScore = parts[0]
|
||||
maxScore = parts[1]
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
weight: c.weight,
|
||||
minScore,
|
||||
maxScore,
|
||||
}
|
||||
})
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!myAssignment) {
|
||||
@@ -217,8 +235,12 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
// COI Dialog
|
||||
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
|
||||
return (
|
||||
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
|
||||
<DialogContent>
|
||||
<Dialog open={showCOIDialog} onOpenChange={() => { /* prevent dismissal */ }}>
|
||||
<DialogContent
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Conflict of Interest Declaration</DialogTitle>
|
||||
<DialogDescription className="space-y-3 pt-2">
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { CountdownTimer } from '@/components/shared/countdown-timer'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { JuryPreferencesBanner } from '@/components/jury/preferences-banner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function getGreeting(): string {
|
||||
@@ -757,6 +758,9 @@ export default async function JuryDashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
|
||||
<JuryPreferencesBanner />
|
||||
|
||||
{/* Content */}
|
||||
<Suspense fallback={<DashboardSkeleton />}>
|
||||
<JuryDashboardContent />
|
||||
|
||||
@@ -22,12 +22,14 @@ interface AssignmentPreviewSheetProps {
|
||||
roundId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
requiredReviews?: number
|
||||
}
|
||||
|
||||
export function AssignmentPreviewSheet({
|
||||
roundId,
|
||||
open,
|
||||
onOpenChange,
|
||||
requiredReviews = 3,
|
||||
}: AssignmentPreviewSheetProps) {
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
@@ -36,7 +38,7 @@ export function AssignmentPreviewSheet({
|
||||
isLoading,
|
||||
refetch,
|
||||
} = trpc.roundAssignment.preview.useQuery(
|
||||
{ roundId, honorIntents: true, requiredReviews: 3 },
|
||||
{ roundId, honorIntents: true, requiredReviews },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ import { AlertCircle, CheckCircle2, Users } from 'lucide-react'
|
||||
|
||||
interface CoverageReportProps {
|
||||
roundId: string
|
||||
requiredReviews?: number
|
||||
}
|
||||
|
||||
export function CoverageReport({ roundId }: CoverageReportProps) {
|
||||
export function CoverageReport({ roundId, requiredReviews = 3 }: CoverageReportProps) {
|
||||
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery(
|
||||
{ roundId, requiredReviews: 3 },
|
||||
{ roundId, requiredReviews },
|
||||
{ refetchInterval: 15_000 },
|
||||
)
|
||||
|
||||
@@ -71,7 +72,7 @@ export function CoverageReport({ roundId }: CoverageReportProps) {
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{unassignedCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects below 3 reviews
|
||||
Projects below {requiredReviews} reviews
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -38,12 +40,18 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('')
|
||||
const [maxAssignments, setMaxAssignments] = useState<string>('')
|
||||
const [capMode, setCapMode] = useState<string>('')
|
||||
const [role, setRole] = useState<string>('MEMBER')
|
||||
const [startupRatio, setStartupRatio] = useState<number | null>(null)
|
||||
const [availabilityNotes, setAvailabilityNotes] = useState('')
|
||||
|
||||
// Invite new user state
|
||||
const [inviteName, setInviteName] = useState('')
|
||||
const [inviteEmail, setInviteEmail] = useState('')
|
||||
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
|
||||
const [inviteCapMode, setInviteCapMode] = useState<string>('')
|
||||
const [inviteRole, setInviteRole] = useState<string>('MEMBER')
|
||||
const [inviteStartupRatio, setInviteStartupRatio] = useState<number | null>(null)
|
||||
const [inviteAvailabilityNotes, setInviteAvailabilityNotes] = useState('')
|
||||
const [inviteExpertise, setInviteExpertise] = useState('')
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
@@ -73,9 +81,11 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
addMember({
|
||||
juryGroupId,
|
||||
userId: newUser.id,
|
||||
role: 'MEMBER',
|
||||
role: inviteRole as 'CHAIR' | 'MEMBER' | 'OBSERVER',
|
||||
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
|
||||
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||
preferredStartupRatio: inviteStartupRatio,
|
||||
availabilityNotes: inviteAvailabilityNotes.trim() || null,
|
||||
})
|
||||
// Send invitation email
|
||||
sendInvitation({ userId: newUser.id, juryGroupId })
|
||||
@@ -101,10 +111,16 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
setSelectedUserId('')
|
||||
setMaxAssignments('')
|
||||
setCapMode('')
|
||||
setRole('MEMBER')
|
||||
setStartupRatio(null)
|
||||
setAvailabilityNotes('')
|
||||
setInviteName('')
|
||||
setInviteEmail('')
|
||||
setInviteMaxAssignments('')
|
||||
setInviteCapMode('')
|
||||
setInviteRole('MEMBER')
|
||||
setInviteStartupRatio(null)
|
||||
setInviteAvailabilityNotes('')
|
||||
setInviteExpertise('')
|
||||
}
|
||||
|
||||
@@ -119,9 +135,11 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
addMember({
|
||||
juryGroupId,
|
||||
userId: selectedUserId,
|
||||
role: 'MEMBER',
|
||||
role: role as 'CHAIR' | 'MEMBER' | 'OBSERVER',
|
||||
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
|
||||
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
|
||||
preferredStartupRatio: startupRatio,
|
||||
availabilityNotes: availabilityNotes.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -212,6 +230,19 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select value={role} onValueChange={setRole}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="CHAIR">Chair</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="capMode">Cap Mode</Label>
|
||||
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
|
||||
@@ -240,6 +271,38 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category Preference</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
|
||||
<Slider
|
||||
value={[startupRatio !== null ? startupRatio * 100 : 50]}
|
||||
onValueChange={([v]) => setStartupRatio(v / 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{startupRatio !== null
|
||||
? `~${Math.round(startupRatio * 100)}% startups / ~${Math.round((1 - startupRatio) * 100)}% concepts`
|
||||
: 'No preference set (balanced distribution)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="availabilityNotes">Availability Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="availabilityNotes"
|
||||
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
|
||||
rows={2}
|
||||
value={availabilityNotes}
|
||||
onChange={(e) => setAvailabilityNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
@@ -281,6 +344,19 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteRole">Role</Label>
|
||||
<Select value={inviteRole} onValueChange={setInviteRole}>
|
||||
<SelectTrigger id="inviteRole">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="CHAIR">Chair</SelectItem>
|
||||
<SelectItem value="OBSERVER">Observer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteCapMode">Cap Mode</Label>
|
||||
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
|
||||
@@ -309,6 +385,38 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Category Preference</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
|
||||
<Slider
|
||||
value={[inviteStartupRatio !== null ? inviteStartupRatio * 100 : 50]}
|
||||
onValueChange={([v]) => setInviteStartupRatio(v / 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{inviteStartupRatio !== null
|
||||
? `~${Math.round(inviteStartupRatio * 100)}% startups / ~${Math.round((1 - inviteStartupRatio) * 100)}% concepts`
|
||||
: 'No preference set (balanced distribution)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteAvailabilityNotes">Availability Notes (optional)</Label>
|
||||
<Textarea
|
||||
id="inviteAvailabilityNotes"
|
||||
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
|
||||
rows={2}
|
||||
value={inviteAvailabilityNotes}
|
||||
onChange={(e) => setInviteAvailabilityNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
|
||||
<Input
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
FileEdit,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
@@ -29,91 +22,73 @@ export function RoundStatsEvaluation({ round, activeJurors }: RoundStatsEvaluati
|
||||
const { assignmentCount, evalSubmitted, evalDraft, evalTotal } = round
|
||||
const completionPct = evalTotal > 0 ? ((evalSubmitted / evalTotal) * 100).toFixed(0) : '0'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: assignmentCount,
|
||||
label: 'Assignments',
|
||||
detail: 'Jury-project pairs',
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: `${evalSubmitted}/${evalTotal}`,
|
||||
label: 'Submitted',
|
||||
detail: `${completionPct}% complete`,
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: evalDraft,
|
||||
label: 'In draft',
|
||||
detail: evalDraft > 0 ? 'Not yet submitted' : 'No drafts',
|
||||
accent: evalDraft > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: activeJurors,
|
||||
label: 'Active jurors',
|
||||
detail: 'Evaluating',
|
||||
accent: 'text-brand-teal',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{round.name} — Evaluation
|
||||
<>
|
||||
{/* Round label */}
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
{round.name} — Evaluation
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Assignments</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{assignmentCount}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-blue-light">
|
||||
Jury-project pairs
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
|
||||
<ClipboardList className="h-5 w-5 text-brand-blue" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Submitted</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">
|
||||
{evalSubmitted}
|
||||
<span className="text-sm font-normal text-muted-foreground">/{evalTotal}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-emerald-600">
|
||||
{completionPct}% complete
|
||||
</p>
|
||||
{/* Mobile: horizontal data strip */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">In Draft</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{evalDraft}</p>
|
||||
<p className="mt-0.5 text-xs text-amber-600">
|
||||
{evalDraft > 0 ? 'Not yet submitted' : 'No drafts'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<FileEdit className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Active Jurors</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{activeJurors}</p>
|
||||
<p className="mt-0.5 text-xs text-violet-600">
|
||||
Evaluating this round
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-violet-500/10">
|
||||
<Users className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
{/* Desktop: editorial stat row */}
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
Filter,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
@@ -35,93 +28,80 @@ type RoundStatsFilteringProps = {
|
||||
|
||||
export function RoundStatsFiltering({ round }: RoundStatsFilteringProps) {
|
||||
const { filteringPassed, filteringRejected, filteringFlagged, projectStates } = round
|
||||
const passRate = projectStates.total > 0
|
||||
? ((filteringPassed / projectStates.total) * 100).toFixed(0)
|
||||
: '0'
|
||||
const rejectRate = projectStates.total > 0
|
||||
? ((filteringRejected / projectStates.total) * 100).toFixed(0)
|
||||
: '0'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: projectStates.total,
|
||||
label: 'To filter',
|
||||
detail: 'In pipeline',
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: filteringPassed,
|
||||
label: 'Passed',
|
||||
detail: `${passRate}% pass rate`,
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: filteringRejected,
|
||||
label: 'Rejected',
|
||||
detail: `${rejectRate}% rejected`,
|
||||
accent: 'text-red-600',
|
||||
},
|
||||
{
|
||||
value: filteringFlagged,
|
||||
label: 'Flagged',
|
||||
detail: filteringFlagged > 0 ? 'Manual review' : 'None flagged',
|
||||
accent: filteringFlagged > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{round.name} — Filtering
|
||||
<>
|
||||
{/* Round label */}
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
{round.name} — Filtering
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Projects to Filter</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.total}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-blue-light">
|
||||
In pipeline
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
|
||||
<Filter className="h-5 w-5 text-brand-blue" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">AI Passed</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringPassed}</p>
|
||||
<p className="mt-0.5 text-xs text-emerald-600">
|
||||
{projectStates.total > 0
|
||||
? `${((filteringPassed / projectStates.total) * 100).toFixed(0)}% pass rate`
|
||||
: 'No results yet'}
|
||||
</p>
|
||||
{/* Mobile: horizontal data strip */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-red-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">AI Rejected</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringRejected}</p>
|
||||
<p className="mt-0.5 text-xs text-red-600">
|
||||
{projectStates.total > 0
|
||||
? `${((filteringRejected / projectStates.total) * 100).toFixed(0)}% rejected`
|
||||
: 'No results yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-500/10">
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Flagged for Review</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{filteringFlagged}</p>
|
||||
<p className="mt-0.5 text-xs text-amber-600">
|
||||
{filteringFlagged > 0 ? 'Needs manual review' : 'None flagged'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
{/* Desktop: editorial stat row */}
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
ClipboardList,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
type RoundStatsGenericProps = {
|
||||
projectCount: number
|
||||
@@ -33,89 +26,72 @@ export function RoundStatsGeneric({
|
||||
const completionPct =
|
||||
totalAssignments > 0 ? ((submittedCount / totalAssignments) * 100).toFixed(0) : '0'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: projectCount,
|
||||
label: 'Projects',
|
||||
detail: newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : null,
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: totalJurors,
|
||||
label: 'Jurors',
|
||||
detail: `${activeJurors} active`,
|
||||
accent: 'text-brand-teal',
|
||||
},
|
||||
{
|
||||
value: `${submittedCount}/${totalAssignments}`,
|
||||
label: 'Evaluations',
|
||||
detail: `${completionPct}% complete`,
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: actionsCount,
|
||||
label: actionsCount === 1 ? 'Action' : 'Actions',
|
||||
detail: actionsCount > 0 ? 'Pending' : 'All clear',
|
||||
accent: actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Projects</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectCount}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-blue-light">
|
||||
{newProjectsThisWeek > 0 ? `+${newProjectsThisWeek} this week` : 'In edition'}
|
||||
</p>
|
||||
<>
|
||||
{/* Mobile: horizontal data strip */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
|
||||
<ClipboardList className="h-5 w-5 text-brand-blue" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Jury</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{totalJurors}</p>
|
||||
<p className="mt-0.5 text-xs text-violet-600">
|
||||
{activeJurors} active
|
||||
</p>
|
||||
{/* Desktop: editorial stat row */}
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-violet-500/10">
|
||||
<Users className="h-5 w-5 text-violet-500" />
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
{s.detail && (
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Evaluations</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">
|
||||
{submittedCount}
|
||||
<span className="text-sm font-normal text-muted-foreground">/{totalAssignments}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-emerald-600">
|
||||
{completionPct}% complete
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className={`border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md ${actionsCount > 0 ? 'border-l-amber-500' : 'border-l-emerald-400'}`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Actions Needed</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{actionsCount}</p>
|
||||
<p className={`mt-0.5 text-xs ${actionsCount > 0 ? 'text-amber-600' : 'text-emerald-600'}`}>
|
||||
{actionsCount > 0 ? 'Pending actions' : 'All clear'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${actionsCount > 0 ? 'bg-amber-500/10' : 'bg-emerald-400/10'}`}>
|
||||
{actionsCount > 0
|
||||
? <AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
: <CheckCircle2 className="h-5 w-5 text-emerald-400" />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'motion/react'
|
||||
|
||||
type PipelineRound = {
|
||||
id: string
|
||||
@@ -32,91 +25,77 @@ type RoundStatsIntakeProps = {
|
||||
|
||||
export function RoundStatsIntake({ round, newProjectsThisWeek }: RoundStatsIntakeProps) {
|
||||
const { projectStates } = round
|
||||
const completePct = projectStates.total > 0
|
||||
? ((projectStates.PASSED / projectStates.total) * 100).toFixed(0)
|
||||
: '0'
|
||||
|
||||
const stats = [
|
||||
{
|
||||
value: projectStates.total,
|
||||
label: 'Submitted',
|
||||
detail: 'Total projects',
|
||||
accent: 'text-brand-blue',
|
||||
},
|
||||
{
|
||||
value: projectStates.PASSED,
|
||||
label: 'Docs complete',
|
||||
detail: `${completePct}% of total`,
|
||||
accent: 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: projectStates.PENDING,
|
||||
label: 'Pending',
|
||||
detail: projectStates.PENDING > 0 ? 'Awaiting review' : 'All reviewed',
|
||||
accent: projectStates.PENDING > 0 ? 'text-amber-600' : 'text-emerald-600',
|
||||
},
|
||||
{
|
||||
value: newProjectsThisWeek,
|
||||
label: 'This week',
|
||||
detail: newProjectsThisWeek > 0 ? 'New submissions' : 'No new',
|
||||
accent: 'text-brand-teal',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{round.name} — Intake
|
||||
<>
|
||||
{/* Round label */}
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/70">
|
||||
{round.name} — Intake
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-brand-blue transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Projects</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.total}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-blue-light">
|
||||
In this round
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-blue/10">
|
||||
<ClipboardList className="h-5 w-5 text-brand-blue" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Documents Complete</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.PASSED}</p>
|
||||
<p className="mt-0.5 text-xs text-emerald-600">
|
||||
{projectStates.total > 0
|
||||
? `${((projectStates.PASSED / projectStates.total) * 100).toFixed(0)}% of total`
|
||||
: 'No projects yet'}
|
||||
</p>
|
||||
{/* Mobile: horizontal data strip */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="flex items-baseline justify-between border-b border-t py-3 md:hidden"
|
||||
>
|
||||
{stats.map((s, i) => (
|
||||
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
|
||||
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-500/10">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Pending Review</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{projectStates.PENDING}</p>
|
||||
<p className="mt-0.5 text-xs text-amber-600">
|
||||
{projectStates.PENDING > 0 ? 'Awaiting review' : 'All reviewed'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/10">
|
||||
<Clock className="h-5 w-5 text-amber-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">New This Week</p>
|
||||
<p className="mt-1 text-2xl font-bold tabular-nums">{newProjectsThisWeek}</p>
|
||||
<p className="mt-0.5 text-xs text-brand-teal">
|
||||
{newProjectsThisWeek > 0 ? 'Recently submitted' : 'No new submissions'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-teal/10">
|
||||
<TrendingUp className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
{/* Desktop: editorial stat row */}
|
||||
<div className="hidden md:block">
|
||||
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
|
||||
{stats.map((s, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: i * 0.06 }}
|
||||
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
|
||||
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
|
||||
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
144
src/components/jury/preferences-banner.tsx
Normal file
144
src/components/jury/preferences-banner.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Scale, CheckCircle2, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
|
||||
/**
|
||||
* Shows a blocking banner when a juror has jury group memberships
|
||||
* with unconfirmed preferences (selfServiceCap is null) linked to
|
||||
* active rounds. The juror must confirm before proceeding.
|
||||
*/
|
||||
export function JuryPreferencesBanner() {
|
||||
const { data: ctx, isLoading } = trpc.user.getOnboardingContext.useQuery()
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
const unconfirmed = (ctx?.memberships ?? []).filter(
|
||||
(m) => m.selfServiceCap === null,
|
||||
)
|
||||
|
||||
const [prefs, setPrefs] = useState<Map<string, { cap: number; ratio: number }>>(new Map())
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
|
||||
const updatePref = (id: string, field: 'cap' | 'ratio', value: number) => {
|
||||
setPrefs((prev) => {
|
||||
const next = new Map(prev)
|
||||
const existing = next.get(id) ?? { cap: 15, ratio: 0.5 }
|
||||
next.set(id, { ...existing, [field]: value })
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const confirmMutation = trpc.user.updateJuryPreferences.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Preferences confirmed')
|
||||
utils.user.getOnboardingContext.invalidate()
|
||||
setConfirming(false)
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message)
|
||||
setConfirming(false)
|
||||
},
|
||||
})
|
||||
|
||||
const handleConfirm = () => {
|
||||
setConfirming(true)
|
||||
const preferences = unconfirmed.map((m) => {
|
||||
const pref = prefs.get(m.juryGroupMemberId)
|
||||
return {
|
||||
juryGroupMemberId: m.juryGroupMemberId,
|
||||
selfServiceCap: pref?.cap ?? m.currentCap,
|
||||
selfServiceRatio: pref?.ratio ?? m.preferredStartupRatio ?? 0.5,
|
||||
}
|
||||
})
|
||||
confirmMutation.mutate({ preferences })
|
||||
}
|
||||
|
||||
if (isLoading || unconfirmed.length === 0) return null
|
||||
|
||||
return (
|
||||
<Card className="border-amber-300 bg-amber-50/50 dark:border-amber-800 dark:bg-amber-950/20">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scale className="h-5 w-5 text-amber-600" />
|
||||
<CardTitle className="text-base">Confirm Your Evaluation Preferences</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Please review and confirm your assignment preferences before evaluations begin.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{unconfirmed.map((m) => {
|
||||
const pref = prefs.get(m.juryGroupMemberId)
|
||||
const capValue = pref?.cap ?? m.currentCap
|
||||
const ratioValue = pref?.ratio ?? m.preferredStartupRatio ?? 0.5
|
||||
|
||||
return (
|
||||
<div key={m.juryGroupMemberId} className="rounded-lg border bg-background p-4 space-y-4">
|
||||
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Maximum assignments: {capValue}
|
||||
</Label>
|
||||
<Slider
|
||||
value={[capValue]}
|
||||
onValueChange={([v]) => updatePref(m.juryGroupMemberId, 'cap', v)}
|
||||
min={1}
|
||||
max={50}
|
||||
step={1}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Admin suggestion: {m.currentCap}. Adjust to match your availability.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
Category preference: {Math.round(ratioValue * 100)}% Startups / {Math.round((1 - ratioValue) * 100)}% Business Concepts
|
||||
</Label>
|
||||
<Slider
|
||||
value={[ratioValue * 100]}
|
||||
onValueChange={([v]) => updatePref(m.juryGroupMemberId, 'ratio', v / 100)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>More Business Concepts</span>
|
||||
<span>More Startups</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/70 italic">
|
||||
This is a preference, not a guarantee. Due to the number of projects, the system will try to match your preference but exact ratios cannot be ensured.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={confirming}
|
||||
className="w-full"
|
||||
>
|
||||
{confirming ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Confirming...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||
Confirm Preferences
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -186,7 +186,8 @@ export const deliberationRouter = router({
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get session with votes, results, and participants
|
||||
* Get session with votes, results, and participants.
|
||||
* Redacts juror identities for non-admin users when session flags are off.
|
||||
*/
|
||||
getSession: protectedProcedure
|
||||
.input(z.object({ sessionId: z.string() }))
|
||||
@@ -195,6 +196,37 @@ export const deliberationRouter = router({
|
||||
if (!session) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session not found' })
|
||||
}
|
||||
|
||||
const isAdmin = ctx.user.role === 'SUPER_ADMIN' || ctx.user.role === 'PROGRAM_ADMIN'
|
||||
if (isAdmin) return session
|
||||
|
||||
// Non-admin: enforce visibility flags
|
||||
if (!session.showCollectiveRankings) {
|
||||
// Anonymize juror identity on votes — only show own votes with identity
|
||||
session.votes = session.votes.map((v: any, i: number) => {
|
||||
const isOwn = v.juryMember?.user?.id === ctx.user.id
|
||||
if (isOwn) return v
|
||||
return {
|
||||
...v,
|
||||
juryMember: {
|
||||
...v.juryMember,
|
||||
user: { id: `anon-${i}`, name: `Juror ${i + 1}`, email: '' },
|
||||
},
|
||||
}
|
||||
})
|
||||
// Anonymize participants
|
||||
session.participants = session.participants.map((p: any, i: number) => {
|
||||
const isOwn = p.user?.user?.id === ctx.user.id
|
||||
if (isOwn) return p
|
||||
return {
|
||||
...p,
|
||||
user: p.user
|
||||
? { ...p.user, user: { id: `anon-${i}`, name: `Juror ${i + 1}`, email: '' } }
|
||||
: p.user,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return session
|
||||
}),
|
||||
|
||||
|
||||
@@ -1112,32 +1112,18 @@ export const userRouter = router({
|
||||
// Security: verify this member belongs to the current user
|
||||
const member = await tx.juryGroupMember.findUnique({
|
||||
where: { id: pref.juryGroupMemberId },
|
||||
include: { juryGroup: { select: { allowJurorCapAdjustment: true, allowJurorRatioAdjustment: true, defaultMaxAssignments: true } } },
|
||||
})
|
||||
if (!member || member.userId !== ctx.user.id) continue
|
||||
|
||||
const updateData: Record<string, unknown> = {}
|
||||
|
||||
// Only set selfServiceCap if group allows it
|
||||
if (pref.selfServiceCap != null && member.juryGroup.allowJurorCapAdjustment) {
|
||||
// Bound by admin max (override or group default)
|
||||
const adminMax = member.maxAssignmentsOverride ?? member.juryGroup.defaultMaxAssignments
|
||||
updateData.selfServiceCap = Math.min(pref.selfServiceCap, adminMax)
|
||||
}
|
||||
|
||||
// Only set selfServiceRatio if group allows it
|
||||
if (pref.selfServiceRatio != null && member.juryGroup.allowJurorRatioAdjustment) {
|
||||
updateData.selfServiceRatio = pref.selfServiceRatio
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await tx.juryGroupMember.update({
|
||||
where: { id: pref.juryGroupMemberId },
|
||||
data: updateData,
|
||||
data: {
|
||||
selfServiceCap: pref.selfServiceCap != null ? Math.min(pref.selfServiceCap, 50) : undefined,
|
||||
selfServiceRatio: pref.selfServiceRatio,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
@@ -1157,9 +1143,42 @@ export const userRouter = router({
|
||||
return user
|
||||
}),
|
||||
|
||||
/**
|
||||
* Update jury preferences outside of onboarding (e.g., when a new round opens).
|
||||
*/
|
||||
updateJuryPreferences: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
preferences: z.array(
|
||||
z.object({
|
||||
juryGroupMemberId: z.string(),
|
||||
selfServiceCap: z.number().int().min(1).max(50),
|
||||
selfServiceRatio: z.number().min(0).max(1),
|
||||
})
|
||||
),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
for (const pref of input.preferences) {
|
||||
const member = await ctx.prisma.juryGroupMember.findUnique({
|
||||
where: { id: pref.juryGroupMemberId },
|
||||
})
|
||||
if (!member || member.userId !== ctx.user.id) continue
|
||||
|
||||
await ctx.prisma.juryGroupMember.update({
|
||||
where: { id: pref.juryGroupMemberId },
|
||||
data: {
|
||||
selfServiceCap: pref.selfServiceCap,
|
||||
selfServiceRatio: pref.selfServiceRatio,
|
||||
},
|
||||
})
|
||||
}
|
||||
return { success: true }
|
||||
}),
|
||||
|
||||
/**
|
||||
* Get onboarding context for the current user.
|
||||
* Returns jury group memberships that allow self-service preferences.
|
||||
* Returns jury group memberships for self-service preferences.
|
||||
*/
|
||||
getOnboardingContext: protectedProcedure.query(async ({ ctx }) => {
|
||||
const memberships = await ctx.prisma.juryGroupMember.findMany({
|
||||
@@ -1170,29 +1189,20 @@ export const userRouter = router({
|
||||
id: true,
|
||||
name: true,
|
||||
defaultMaxAssignments: true,
|
||||
allowJurorCapAdjustment: true,
|
||||
allowJurorRatioAdjustment: true,
|
||||
categoryQuotasEnabled: true,
|
||||
defaultCategoryQuotas: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const selfServiceGroups = memberships.filter(
|
||||
(m) => m.juryGroup.allowJurorCapAdjustment || m.juryGroup.allowJurorRatioAdjustment,
|
||||
)
|
||||
|
||||
return {
|
||||
hasSelfServiceOptions: selfServiceGroups.length > 0,
|
||||
memberships: selfServiceGroups.map((m) => ({
|
||||
hasSelfServiceOptions: memberships.length > 0,
|
||||
memberships: memberships.map((m) => ({
|
||||
juryGroupMemberId: m.id,
|
||||
juryGroupName: m.juryGroup.name,
|
||||
currentCap: m.maxAssignmentsOverride ?? m.juryGroup.defaultMaxAssignments,
|
||||
allowCapAdjustment: m.juryGroup.allowJurorCapAdjustment,
|
||||
allowRatioAdjustment: m.juryGroup.allowJurorRatioAdjustment,
|
||||
selfServiceCap: m.selfServiceCap,
|
||||
selfServiceRatio: m.selfServiceRatio,
|
||||
preferredStartupRatio: m.preferredStartupRatio,
|
||||
})),
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user