Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening

UI overhaul applying jury dashboard design patterns across all pages:
- Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports
- Card section headers with color-coded icon pills throughout
- Hover lift effects (translate-y + shadow) on cards and list items
- Gradient progress bars (brand-teal to brand-blue) platform-wide
- AnimatedCard stagger animations on all dashboard sections
- Auth pages with gradient accent strip and polished icon containers
- EmptyState component upgraded with rounded icon pill containers
- Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files
- Removed gradient overlay from jury dashboard header
- Quick actions restyled as card links with group hover effects

Backend improvements:
- Team member invite emails with account setup flow and notification logging
- Analytics routers accept edition-wide queries (programId) in addition to roundId
- Round detail endpoint returns inline progress data (eliminates extra getProgress call)
- Award voting endpoints parallelized with Promise.all
- Bulk invite supports optional sendInvitation flag
- AwardVote composite index migration for query performance

Infrastructure:
- Docker entrypoint with migration retry loop (configurable retries/delay)
- docker-compose pull_policy: always for automatic image refresh
- Simplified deploy/update scripts using docker compose up -d --pull always
- Updated deployment documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 13:20:52 +01:00
parent 98f4a957cc
commit ce4069bf92
59 changed files with 1949 additions and 913 deletions

View File

@@ -55,6 +55,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { UserAvatar } from '@/components/shared/user-avatar'
import { AnimatedCard } from '@/components/shared/animated-container'
import { Pagination } from '@/components/shared/pagination'
import { toast } from 'sonner'
import {
@@ -66,14 +67,13 @@ import {
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ArrowLeft,
Trophy,
Users,
CheckCircle2,
Brain,
ListChecks,
BarChart3,
Loader2,
Crown,
@@ -151,19 +151,29 @@ export default function AwardDetailPage({
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState('eligibility')
// Core queries
// Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1)
const eligibilityPerPage = 25
// Core queries — lazy-load tab-specific data based on activeTab
const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId })
const { data: eligibilityData, refetch: refetchEligibility } =
trpc.specialAward.listEligible.useQuery({
awardId,
page: 1,
perPage: 100,
page: eligibilityPage,
perPage: eligibilityPerPage,
}, {
enabled: activeTab === 'eligibility',
})
const { data: jurors, refetch: refetchJurors } =
trpc.specialAward.listJurors.useQuery({ awardId })
trpc.specialAward.listJurors.useQuery({ awardId }, {
enabled: activeTab === 'jurors',
})
const { data: voteResults } =
trpc.specialAward.getVoteResults.useQuery({ awardId })
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
enabled: activeTab === 'results',
})
// Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery(
@@ -539,8 +549,9 @@ export default function AwardDetailPage({
</div>
{/* Stats Cards */}
<AnimatedCard index={0}>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card className="border-l-4 border-l-emerald-500">
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
@@ -553,7 +564,7 @@ export default function AwardDetailPage({
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-blue-500">
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
@@ -561,12 +572,12 @@ export default function AwardDetailPage({
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
<Brain className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-violet-500">
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
@@ -579,7 +590,7 @@ export default function AwardDetailPage({
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500">
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
@@ -593,8 +604,10 @@ export default function AwardDetailPage({
</CardContent>
</Card>
</div>
</AnimatedCard>
{/* Tabs */}
<AnimatedCard index={1}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="eligibility">
@@ -637,7 +650,7 @@ export default function AwardDetailPage({
{runEligibility.isPending || isPollingJob ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Brain className="mr-2 h-4 w-4" />
<ListChecks className="mr-2 h-4 w-4" />
)}
{isPollingJob ? 'Processing...' : 'Run AI Eligibility'}
</Button>
@@ -779,6 +792,7 @@ export default function AwardDetailPage({
? ((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100
: 0
}
gradient
/>
</div>
</div>
@@ -841,15 +855,22 @@ export default function AwardDetailPage({
})
}} asChild>
<>
<TableRow className={`${!e.eligible ? 'opacity-50' : ''} ${hasReasoning ? 'cursor-pointer' : ''}`}>
<TableRow
className={`${!e.eligible ? 'opacity-50' : ''} ${hasReasoning ? 'cursor-pointer hover:bg-muted/50' : ''}`}
onClick={() => {
if (!hasReasoning) return
setExpandedRows((prev) => {
const next = new Set(prev)
if (next.has(e.id)) next.delete(e.id)
else next.add(e.id)
return next
})
}}
>
<TableCell>
<div className="flex items-center gap-2">
{hasReasoning && (
<CollapsibleTrigger asChild>
<button className="flex-shrink-0 p-0.5 rounded hover:bg-muted transition-colors">
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 flex-shrink-0 ${isExpanded ? 'rotate-180' : ''}`} />
)}
<div>
<p className="font-medium">{e.project.title}</p>
@@ -892,7 +913,7 @@ export default function AwardDetailPage({
)}
</TableCell>
)}
<TableCell>
<TableCell onClick={(ev) => ev.stopPropagation()}>
<Switch
checked={e.eligible}
onCheckedChange={(checked) =>
@@ -900,7 +921,7 @@ export default function AwardDetailPage({
}
/>
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right" onClick={(ev) => ev.stopPropagation()}>
<Button
variant="ghost"
size="sm"
@@ -917,7 +938,7 @@ export default function AwardDetailPage({
<td colSpan={award.useAiEligibility ? 7 : 6} className="p-0">
<div className="border-t bg-muted/30 px-6 py-3">
<div className="flex items-start gap-2">
<Brain className="h-4 w-4 text-brand-teal mt-0.5 flex-shrink-0" />
<ListChecks className="h-4 w-4 text-brand-teal mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Reasoning</p>
<p className="text-sm leading-relaxed">{aiReasoning?.reasoning}</p>
@@ -934,12 +955,23 @@ export default function AwardDetailPage({
})}
</TableBody>
</Table>
{eligibilityData.totalPages > 1 && (
<div className="p-4 border-t">
<Pagination
page={eligibilityData.page}
totalPages={eligibilityData.totalPages}
total={eligibilityData.total}
perPage={eligibilityPerPage}
onPageChange={setEligibilityPage}
/>
</div>
)}
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<Brain className="h-8 w-8 text-muted-foreground/60" />
<ListChecks className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="text-lg font-medium">No eligibility data yet</p>
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
@@ -950,7 +982,7 @@ export default function AwardDetailPage({
<div className="flex gap-2 mt-4">
<Button onClick={handleRunEligibility} disabled={runEligibility.isPending || isPollingJob} size="sm">
{award.useAiEligibility ? (
<><Brain className="mr-2 h-4 w-4" />Run AI Eligibility</>
<><ListChecks className="mr-2 h-4 w-4" />Run AI Eligibility</>
) : (
<><CheckCircle2 className="mr-2 h-4 w-4" />Load Projects</>
)}
@@ -1185,6 +1217,7 @@ export default function AwardDetailPage({
)}
</TabsContent>
</Tabs>
</AnimatedCard>
</div>
)
}

View File

@@ -23,6 +23,7 @@ import {
SelectValue,
} from '@/components/ui/select'
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DRAFT: 'secondary',
@@ -156,9 +157,10 @@ export default function AwardsListPage() {
{/* Awards Grid */}
{filteredAwards.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredAwards.map((award) => (
<Link key={award.id} href={`/admin/awards/${award.id}`}>
<Card className="transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
{filteredAwards.map((award, index) => (
<AnimatedCard key={award.id} index={index}>
<Link href={`/admin/awards/${award.id}`}>
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<CardTitle className="text-lg flex items-center gap-2">
@@ -202,6 +204,7 @@ export default function AwardsListPage() {
</CardContent>
</Card>
</Link>
</AnimatedCard>
))}
</div>
) : awards && awards.length > 0 ? (

View File

@@ -52,48 +52,228 @@ type DashboardContentProps = {
sessionName: string
}
function formatEntity(entityType: string | null): string {
if (!entityType) return 'record'
// Insert space before uppercase letters (PascalCase → words), then lowercase
return entityType
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/_/g, ' ')
.toLowerCase()
}
function formatAction(action: string, entityType: string | null): string {
const entity = entityType?.toLowerCase() || 'record'
const entity = formatEntity(entityType)
const actionMap: Record<string, string> = {
// Generic CRUD
CREATE: `created a ${entity}`,
UPDATE: `updated a ${entity}`,
DELETE: `deleted a ${entity}`,
LOGIN: 'logged in',
EXPORT: `exported ${entity} data`,
SUBMIT: `submitted an ${entity}`,
ASSIGN: `assigned a ${entity}`,
INVITE: `invited a user`,
STATUS_CHANGE: `changed ${entity} status`,
BULK_UPDATE: `bulk updated ${entity}s`,
IMPORT: `imported ${entity}s`,
EXPORT: `exported ${entity} data`,
REORDER: `reordered ${entity}s`,
// Auth
LOGIN: 'logged in',
LOGIN_SUCCESS: 'logged in',
LOGIN_FAILED: 'failed to log in',
PASSWORD_SET: 'set their password',
PASSWORD_CHANGED: 'changed their password',
REQUEST_PASSWORD_RESET: 'requested a password reset',
COMPLETE_ONBOARDING: 'completed onboarding',
DELETE_OWN_ACCOUNT: 'deleted their account',
// Evaluations
EVALUATION_SUBMITTED: 'submitted an evaluation',
COI_DECLARED: 'declared a conflict of interest',
COI_REVIEWED: 'reviewed a COI declaration',
REMINDERS_TRIGGERED: 'triggered evaluation reminders',
DISCUSSION_COMMENT_ADDED: 'added a discussion comment',
DISCUSSION_CLOSED: 'closed a discussion',
// Assignments
ASSIGN: `assigned a ${entity}`,
BULK_CREATE: `bulk created ${entity}s`,
BULK_ASSIGN: 'bulk assigned users',
BULK_DELETE: `bulk deleted ${entity}s`,
BULK_UPDATE: `bulk updated ${entity}s`,
BULK_UPDATE_STATUS: 'bulk updated statuses',
APPLY_SUGGESTIONS: 'applied assignment suggestions',
ASSIGN_PROJECTS_TO_ROUND: 'assigned projects to round',
REMOVE_PROJECTS_FROM_ROUND: 'removed projects from round',
ADVANCE_PROJECTS: 'advanced projects to next round',
BULK_ASSIGN_TO_ROUND: 'bulk assigned to round',
REORDER_ROUNDS: 'reordered rounds',
// Status
STATUS_CHANGE: `changed ${entity} status`,
UPDATE_STATUS: `updated ${entity} status`,
ROLE_CHANGED: 'changed a user role',
// Invitations
INVITE: 'invited a user',
SEND_INVITATION: 'sent an invitation',
BULK_SEND_INVITATIONS: 'sent bulk invitations',
// Files
UPLOAD_FILE: 'uploaded a file',
DELETE_FILE: 'deleted a file',
REPLACE_FILE: 'replaced a file',
FILE_DOWNLOADED: 'downloaded a file',
// Filtering
EXECUTE_FILTERING: 'ran project filtering',
FINALIZE_FILTERING: 'finalized filtering results',
OVERRIDE: `overrode a ${entity} result`,
BULK_OVERRIDE: 'bulk overrode filtering results',
REINSTATE: 'reinstated a project',
BULK_REINSTATE: 'bulk reinstated projects',
// AI
AI_TAG: 'ran AI tagging',
START_AI_TAG_JOB: 'started AI tagging job',
EVALUATION_SUMMARY: 'generated an AI summary',
AWARD_ELIGIBILITY: 'ran award eligibility check',
PROJECT_TAGGING: 'ran project tagging',
FILTERING: 'ran AI filtering',
MENTOR_MATCHING: 'ran mentor matching',
// Tags
ADD_TAG: 'added a tag',
REMOVE_TAG: 'removed a tag',
BULK_CREATE_TAGS: 'bulk created tags',
// Mentor
MENTOR_ASSIGN: 'assigned a mentor',
MENTOR_UNASSIGN: 'unassigned a mentor',
MENTOR_AUTO_ASSIGN: 'auto-assigned mentors',
MENTOR_BULK_ASSIGN: 'bulk assigned mentors',
CREATE_MENTOR_NOTE: 'created a mentor note',
COMPLETE_MILESTONE: 'completed a milestone',
// Messages & Webhooks
SEND_MESSAGE: 'sent a message',
CREATE_MESSAGE_TEMPLATE: 'created a message template',
UPDATE_MESSAGE_TEMPLATE: 'updated a message template',
DELETE_MESSAGE_TEMPLATE: 'deleted a message template',
CREATE_WEBHOOK: 'created a webhook',
UPDATE_WEBHOOK: 'updated a webhook',
DELETE_WEBHOOK: 'deleted a webhook',
TEST_WEBHOOK: 'tested a webhook',
REGENERATE_WEBHOOK_SECRET: 'regenerated a webhook secret',
// Settings
UPDATE_SETTING: 'updated a setting',
UPDATE_SETTINGS_BATCH: 'updated settings',
UPDATE_NOTIFICATION_PREFERENCES: 'updated notification preferences',
UPDATE_DIGEST_SETTINGS: 'updated digest settings',
UPDATE_ANALYTICS_SETTINGS: 'updated analytics settings',
UPDATE_AUDIT_SETTINGS: 'updated audit settings',
UPDATE_LOCALIZATION_SETTINGS: 'updated localization settings',
UPDATE_RETENTION_CONFIG: 'updated retention config',
// Live Voting
START_VOTING: 'started live voting',
END_SESSION: 'ended a live voting session',
UPDATE_SESSION_CONFIG: 'updated session config',
// Round Templates
CREATE_ROUND_TEMPLATE: 'created a round template',
CREATE_ROUND_TEMPLATE_FROM_ROUND: 'saved round as template',
UPDATE_ROUND_TEMPLATE: 'updated a round template',
DELETE_ROUND_TEMPLATE: 'deleted a round template',
UPDATE_EVALUATION_FORM: 'updated the evaluation form',
// Grace Period
GRANT_GRACE_PERIOD: 'granted a grace period',
UPDATE_GRACE_PERIOD: 'updated a grace period',
REVOKE_GRACE_PERIOD: 'revoked a grace period',
BULK_GRANT_GRACE_PERIOD: 'bulk granted grace periods',
// Awards
SET_AWARD_WINNER: 'set an award winner',
// Reports & Applications
REPORT_GENERATED: 'generated a report',
DRAFT_SUBMITTED: 'submitted a draft application',
SUBMIT: `submitted a ${entity}`,
}
return actionMap[action] || `${action.toLowerCase()} ${entity}`
if (actionMap[action]) return actionMap[action]
// Fallback: convert ACTION_NAME to readable text
return action.toLowerCase().replace(/_/g, ' ')
}
function getActionIcon(action: string) {
switch (action) {
case 'CREATE': return <Plus className="h-3.5 w-3.5" />
case 'UPDATE': return <FileEdit className="h-3.5 w-3.5" />
case 'DELETE': return <Trash2 className="h-3.5 w-3.5" />
case 'LOGIN': return <LogIn className="h-3.5 w-3.5" />
case 'EXPORT': return <ArrowRight className="h-3.5 w-3.5" />
case 'SUBMIT': return <Send className="h-3.5 w-3.5" />
case 'ASSIGN': return <Users className="h-3.5 w-3.5" />
case 'INVITE': return <UserPlus className="h-3.5 w-3.5" />
default: return <Eye className="h-3.5 w-3.5" />
case 'CREATE':
case 'BULK_CREATE':
return <Plus className="h-3.5 w-3.5" />
case 'UPDATE':
case 'UPDATE_STATUS':
case 'BULK_UPDATE':
case 'BULK_UPDATE_STATUS':
case 'STATUS_CHANGE':
case 'ROLE_CHANGED':
return <FileEdit className="h-3.5 w-3.5" />
case 'DELETE':
case 'BULK_DELETE':
return <Trash2 className="h-3.5 w-3.5" />
case 'LOGIN':
case 'LOGIN_SUCCESS':
case 'LOGIN_FAILED':
case 'PASSWORD_SET':
case 'PASSWORD_CHANGED':
case 'COMPLETE_ONBOARDING':
return <LogIn className="h-3.5 w-3.5" />
case 'EXPORT':
case 'REPORT_GENERATED':
return <ArrowRight className="h-3.5 w-3.5" />
case 'SUBMIT':
case 'EVALUATION_SUBMITTED':
case 'DRAFT_SUBMITTED':
return <Send className="h-3.5 w-3.5" />
case 'ASSIGN':
case 'BULK_ASSIGN':
case 'APPLY_SUGGESTIONS':
case 'ASSIGN_PROJECTS_TO_ROUND':
case 'MENTOR_ASSIGN':
case 'MENTOR_BULK_ASSIGN':
return <Users className="h-3.5 w-3.5" />
case 'INVITE':
case 'SEND_INVITATION':
case 'BULK_SEND_INVITATIONS':
return <UserPlus className="h-3.5 w-3.5" />
case 'IMPORT':
return <Upload className="h-3.5 w-3.5" />
default:
return <Eye className="h-3.5 w-3.5" />
}
}
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading } = trpc.dashboard.getStats.useQuery(
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
{ editionId },
{ enabled: !!editionId }
{ enabled: !!editionId, retry: 1 }
)
if (isLoading) {
return <DashboardSkeleton />
}
if (error) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertTriangle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Failed to load dashboard</p>
<p className="text-sm text-muted-foreground">
{error.message || 'An unexpected error occurred. Please try refreshing the page.'}
</p>
</CardContent>
</Card>
)
}
if (!data) {
return (
<Card>
@@ -204,69 +384,85 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Rounds</CardTitle>
<CircleDot className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalRoundCount}</div>
<p className="text-xs text-muted-foreground">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Rounds</p>
<p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<CircleDot className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projectCount}</div>
<p className="text-xs text-muted-foreground">
{newProjectsThisWeek > 0
? `${newProjectsThisWeek} new this week`
: 'In this edition'}
</p>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Projects</p>
<p className="text-2xl font-bold mt-1">{projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{newProjectsThisWeek > 0
? `${newProjectsThisWeek} new this week`
: 'In this edition'}
</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalJurors}</div>
<p className="text-xs text-muted-foreground">
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
</p>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
<p className="text-2xl font-bold mt-1">{totalJurors}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{submittedCount}
{totalAssignments > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {totalAssignments}
</span>
)}
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">
{submittedCount}
{totalAssignments > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {totalAssignments}
</span>
)}
</p>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
</div>
</div>
<div className="mt-2">
<Progress value={completionRate} className="h-2" />
<Progress value={completionRate} className="h-2" gradient />
<p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate
</p>
@@ -277,25 +473,34 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/admin/rounds/new">
<Plus className="mr-1.5 h-3.5 w-3.5" />
New Round
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/projects/new">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import Projects
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/admin/members">
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
Invite Jury
</Link>
</Button>
<div className="grid gap-3 sm:grid-cols-3">
<Link href="/admin/rounds/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
<Plus className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium">New Round</p>
<p className="text-xs text-muted-foreground">Create a voting round</p>
</div>
</Link>
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
<div className="rounded-xl bg-emerald-50 p-2.5 transition-colors group-hover:bg-emerald-100">
<Upload className="h-4 w-4 text-emerald-600" />
</div>
<div>
<p className="text-sm font-medium">Import Projects</p>
<p className="text-xs text-muted-foreground">Upload a CSV file</p>
</div>
</Link>
<Link href="/admin/members" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-violet-500/30 hover:bg-violet-500/5">
<div className="rounded-xl bg-violet-50 p-2.5 transition-colors group-hover:bg-violet-100">
<UserPlus className="h-4 w-4 text-violet-600" />
</div>
<div>
<p className="text-sm font-medium">Invite Jury</p>
<p className="text-xs text-muted-foreground">Add jury members</p>
</div>
</Link>
</div>
{/* Two-Column Content */}
@@ -303,11 +508,17 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Left Column */}
<div className="space-y-6 lg:col-span-7">
{/* Rounds Card (enhanced) */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Rounds</CardTitle>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<CircleDot className="h-4 w-4 text-blue-500" />
</div>
Rounds
</CardTitle>
<CardDescription>
Voting rounds in {edition.name}
</CardDescription>
@@ -363,7 +574,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
</div>
{round.totalEvals > 0 && (
<Progress value={round.evalPercent} className="mt-3 h-1.5" />
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
)}
</div>
</Link>
@@ -372,13 +583,20 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Latest Projects Card */}
<AnimatedCard index={5}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Latest Projects</CardTitle>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-500" />
</div>
Latest Projects
</CardTitle>
<CardDescription>Recently submitted projects</CardDescription>
</div>
<Link
@@ -453,15 +671,19 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Right Column */}
<div className="space-y-6 lg:col-span-5">
{/* Pending Actions Card */}
<AnimatedCard index={6}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<AlertTriangle className="h-4 w-4 text-amber-500" />
</div>
Pending Actions
</CardTitle>
</CardHeader>
@@ -503,12 +725,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Evaluation Progress Card */}
<AnimatedCard index={7}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<TrendingUp className="h-4 w-4 text-brand-teal" />
</div>
Evaluation Progress
</CardTitle>
</CardHeader>
@@ -532,7 +758,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{round.evalPercent}%
</span>
</div>
<Progress value={round.evalPercent} className="h-2" />
<Progress value={round.evalPercent} className="h-2" gradient />
<p className="text-xs text-muted-foreground">
{round.submittedEvals} of {round.totalEvals} evaluations submitted
</p>
@@ -542,12 +768,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Category Breakdown Card */}
<AnimatedCard index={8}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="h-4 w-4" />
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Layers className="h-4 w-4 text-violet-500" />
</div>
Project Categories
</CardTitle>
</CardHeader>
@@ -607,12 +837,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Recent Activity Card */}
<AnimatedCard index={9}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Activity className="h-4 w-4 text-blue-500" />
</div>
Recent Activity
</CardTitle>
</CardHeader>
@@ -646,12 +880,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Upcoming Deadlines Card */}
<AnimatedCard index={10}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<Calendar className="h-4 w-4 text-rose-500" />
</div>
Upcoming Deadlines
</CardTitle>
</CardHeader>
@@ -688,6 +926,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
</div>

View File

@@ -50,6 +50,7 @@ import {
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Switch } from '@/components/ui/switch'
import {
ArrowLeft,
ArrowRight,
@@ -65,6 +66,8 @@ import {
ChevronDown,
Check,
Tags,
Mail,
MailX,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -257,10 +260,12 @@ export default function MemberInvitePage() {
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
const [sendProgress, setSendProgress] = useState(0)
const [sendInvitation, setSendInvitation] = useState(true)
const [result, setResult] = useState<{
created: number
skipped: number
assignmentsCreated?: number
invitationSent?: boolean
} | null>(null)
// Pre-assignment state
@@ -505,6 +510,7 @@ export default function MemberInvitePage() {
expertiseTags: u.expertiseTags,
assignments: u.assignments,
})),
sendInvitation,
})
setSendProgress(100)
setResult(result)
@@ -520,6 +526,7 @@ export default function MemberInvitePage() {
setParsedUsers([])
setResult(null)
setSendProgress(0)
setSendInvitation(true)
}
const hasManualData = rows.some((r) => r.email.trim() || r.name.trim())
@@ -793,6 +800,32 @@ export default function MemberInvitePage() {
</div>
)}
{/* Invitation toggle */}
<div className="rounded-lg border border-dashed p-4 bg-muted/30">
<div className="flex items-center gap-3">
{sendInvitation ? (
<Mail className="h-5 w-5 text-primary shrink-0" />
) : (
<MailX className="h-5 w-5 text-muted-foreground shrink-0" />
)}
<div className="flex-1 min-w-0">
<Label htmlFor="send-invitation" className="text-sm font-medium cursor-pointer">
Send platform invitation immediately
</Label>
<p className="text-xs text-muted-foreground">
{sendInvitation
? 'Members will receive an email invitation to create their account'
: 'Members will be created without notification — you can send invitations later from the Members page'}
</p>
</div>
<Switch
id="send-invitation"
checked={sendInvitation}
onCheckedChange={setSendInvitation}
/>
</div>
</div>
{/* Actions */}
<div className="flex justify-between pt-4">
<Button variant="outline" asChild>
@@ -844,6 +877,18 @@ export default function MemberInvitePage() {
</div>
</div>
{!sendInvitation && (
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400">
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium">No invitations will be sent</p>
<p className="text-sm opacity-80">
Members will be created with &ldquo;Not Invited&rdquo; status. You can send invitations later from the Members page.
</p>
</div>
</div>
)}
{summary.invalid > 0 && (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700">
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
@@ -924,10 +969,12 @@ export default function MemberInvitePage() {
>
{bulkCreate.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : sendInvitation ? (
<Mail className="mr-2 h-4 w-4" />
) : (
<Users className="mr-2 h-4 w-4" />
)}
Create & Invite {summary.valid} Member
{sendInvitation ? 'Create & Invite' : 'Create'} {summary.valid} Member
{summary.valid !== 1 ? 's' : ''}
</Button>
</div>
@@ -948,7 +995,7 @@ export default function MemberInvitePage() {
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 font-medium">
Creating members and sending invitations...
{sendInvitation ? 'Creating members and sending invitations...' : 'Creating members...'}
</p>
<Progress value={sendProgress} className="mt-4 w-48" />
</CardContent>
@@ -963,23 +1010,28 @@ export default function MemberInvitePage() {
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<p className="mt-4 text-xl font-semibold">
Invitations Sent!
{result?.invitationSent ? 'Members Created & Invited!' : 'Members Created!'}
</p>
<p className="text-muted-foreground text-center max-w-sm mt-2">
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
created and invited.
{result?.invitationSent ? 'created and invited' : 'created'}.
{result?.skipped
? ` ${result.skipped} skipped (already exist).`
: ''}
{result?.assignmentsCreated && result.assignmentsCreated > 0
? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.`
: ''}
{!result?.invitationSent && (
<span className="block mt-1">
You can send invitations from the Members page when ready.
</span>
)}
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" asChild>
<Link href="/admin/members">View Members</Link>
</Button>
<Button onClick={resetForm}>Invite More</Button>
<Button onClick={resetForm}>Add More</Button>
</div>
</CardContent>
</Card>

View File

@@ -34,7 +34,7 @@ import {
FolderKanban,
Eye,
Pencil,
Wand2,
Copy,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
@@ -150,7 +150,7 @@ async function ProgramsContent() {
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
<Wand2 className="mr-2 h-4 w-4" />
<Copy className="mr-2 h-4 w-4" />
Apply Settings
</Link>
</DropdownMenuItem>
@@ -204,7 +204,7 @@ async function ProgramsContent() {
</Button>
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
<Wand2 className="mr-2 h-4 w-4" />
<Copy className="mr-2 h-4 w-4" />
Apply
</Link>
</Button>

View File

@@ -19,10 +19,10 @@ import { Progress } from '@/components/ui/progress'
import {
ArrowLeft,
Loader2,
Sparkles,
Users,
User,
Check,
Wand2,
RefreshCw,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
@@ -199,7 +199,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<Users className="h-5 w-5 text-primary" />
AI-Suggested Mentors
</CardTitle>
<CardDescription>
@@ -225,7 +225,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
{autoAssignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Wand2 className="mr-2 h-4 w-4" />
<RefreshCw className="mr-2 h-4 w-4" />
)}
Auto-Assign Best Match
</Button>

View File

@@ -28,6 +28,7 @@ import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { UserAvatar } from '@/components/shared/user-avatar'
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
ArrowLeft,
Edit,
@@ -184,13 +185,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{/* Stats Grid */}
{stats && (
<AnimatedCard index={0}>
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Average Score
</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<BarChart3 className="h-4 w-4 text-brand-teal" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
@@ -202,12 +206,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardContent>
</Card>
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Recommendations
</CardTitle>
<ThumbsUp className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ThumbsUp className="h-4 w-4 text-emerald-500" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
@@ -219,12 +225,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</CardContent>
</Card>
</div>
</AnimatedCard>
)}
{/* Project Info */}
<AnimatedCard index={1}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Information</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileText className="h-4 w-4 text-emerald-500" />
</div>
Project Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
@@ -393,14 +406,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Team Members Section */}
{project.teamMembers && project.teamMembers.length > 0 && (
<AnimatedCard index={2}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Team Members ({project.teamMembers.length})
</CardTitle>
</div>
@@ -437,15 +454,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Mentor Assignment Section */}
{project.wantsMentorship && (
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Heart className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<Heart className="h-4 w-4 text-rose-500" />
</div>
Mentor Assignment
</CardTitle>
{!project.mentorAssignment && (
@@ -487,12 +508,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Files Section */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Files</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<FileText className="h-4 w-4 text-rose-500" />
</div>
Files
</CardTitle>
<CardDescription>
Project documents and materials
</CardDescription>
@@ -535,14 +563,21 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Assignments Section */}
{assignments && assignments.length > 0 && (
<AnimatedCard index={5}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Jury Assignments</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Jury Assignments
</CardTitle>
<CardDescription>
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED')
.length}{' '}
@@ -649,6 +684,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</Table>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* AI Evaluation Summary */}

View File

@@ -60,7 +60,6 @@ import {
Search,
Trash2,
Loader2,
Sparkles,
Tags,
Clock,
CheckCircle2,
@@ -98,6 +97,7 @@ import {
ProjectFiltersBar,
type ProjectFilters,
} from './project-filters'
import { AnimatedCard } from '@/components/shared/animated-container'
const statusColors: Record<
string,
@@ -584,7 +584,7 @@ export default function ProjectsPage() {
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" />
<Tags className="mr-2 h-4 w-4" />
AI Tags
</Button>
<Button variant="outline" asChild>
@@ -983,7 +983,7 @@ export default function ProjectsPage() {
/>
</div>
<Link href={`/admin/projects/${project.id}`} className="block">
<Card className="transition-colors hover:bg-muted/50">
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-8">
<ProjectLogo project={project} size="md" fallback="initials" />
@@ -1051,7 +1051,7 @@ export default function ProjectsPage() {
/>
</div>
<Link href={`/admin/projects/${project.id}`} className="block">
<Card className={`transition-colors hover:bg-muted/50 h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
<CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-7">
<ProjectLogo project={project} size="lg" fallback="initials" />
@@ -1483,7 +1483,7 @@ export default function ProjectsPage() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-amber-400 to-orange-500">
<Sparkles className="h-5 w-5 text-white" />
<Tags className="h-5 w-5 text-white" />
</div>
<div>
<span>AI Tag Generator</span>
@@ -1723,7 +1723,7 @@ export default function ProjectsPage() {
{taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
<Tags className="mr-2 h-4 w-4" />
)}
{taggingInProgress ? 'Processing...' : 'Generate Tags'}
</Button>

View File

@@ -56,6 +56,7 @@ import {
DiversityMetricsChart,
} from '@/components/charts'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
function ReportsOverview() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
@@ -96,62 +97,91 @@ function ReportsOverview() {
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Programs</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalPrograms}</div>
<p className="text-xs text-muted-foreground">
{activeRounds} active round{activeRounds !== 1 ? 's' : ''}
</p>
</CardContent>
</Card>
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Programs</p>
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeRounds} active round{activeRounds !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<CheckCircle2 className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProjects}</div>
<p className="text-xs text-muted-foreground">Across all programs</p>
</CardContent>
</Card>
<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-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground mt-1">Across all programs</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{jurorCount}</div>
<p className="text-xs text-muted-foreground">Active jurors</p>
</CardContent>
</Card>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
<p className="text-2xl font-bold mt-1">{jurorCount}</p>
<p className="text-xs text-muted-foreground mt-1">Active jurors</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{submittedEvaluations}</div>
<p className="text-xs text-muted-foreground">
{totalEvaluations > 0
? `${completionRate}% completion rate`
: 'No assignments yet'}
</p>
</CardContent>
</Card>
<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-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
<p className="text-xs text-muted-foreground mt-1">
{totalEvaluations > 0
? `${completionRate}% completion rate`
: 'No assignments yet'}
</p>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<BarChart3 className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Score Distribution (if any evaluations exist) */}
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
<Card>
<CardHeader>
<CardTitle>Score Distribution</CardTitle>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-blue-600" />
</div>
Score Distribution
</CardTitle>
<CardDescription>Overall score distribution across all evaluations</CardDescription>
</CardHeader>
<CardContent>
@@ -162,7 +192,7 @@ function ReportsOverview() {
<div key={bucket.label} className="flex items-center gap-3">
<span className="w-10 text-sm font-medium text-right">{bucket.label}</span>
<div className="flex-1">
<Progress value={(bucket.count / maxCount) * 100} className="h-6" />
<Progress value={(bucket.count / maxCount) * 100} className="h-6" gradient />
</div>
<span className="w-8 text-sm text-muted-foreground text-right">{bucket.count}</span>
</div>
@@ -176,7 +206,12 @@ function ReportsOverview() {
{/* Rounds Table */}
<Card>
<CardHeader>
<CardTitle>Round Reports</CardTitle>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileSpreadsheet className="h-4 w-4 text-emerald-600" />
</div>
Round Reports
</CardTitle>
<CardDescription>
View progress and export data for each round
</CardDescription>
@@ -263,60 +298,73 @@ function ReportsOverview() {
)
}
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { roundId: value }
}
function RoundAnalytics() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Flatten rounds from all programs with program name
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programName: `${p.year} Edition` }))) || []
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
// Set default selected round
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ roundId: selectedRoundId!, limit: 15 },
{ enabled: !!selectedRoundId }
{ ...queryInput, limit: 15 },
{ enabled: hasSelection }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
const selectedRound = rounds.find((r) => r.id === selectedValue)
const geoInput = queryInput.programId
? { programId: queryInput.programId }
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
{ programId: selectedRound?.programId || '', roundId: selectedRoundId! },
{ enabled: !!selectedRoundId && !!selectedRound?.programId }
geoInput,
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
)
if (roundsLoading) {
@@ -350,11 +398,16 @@ function RoundAnalytics() {
{/* Round Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
@@ -364,7 +417,7 @@ function RoundAnalytics() {
</Select>
</div>
{selectedRoundId && (
{hasSelection && (
<div className="space-y-6">
{/* Row 1: Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
@@ -537,22 +590,25 @@ function CrossRoundTab() {
}
function JurorConsistencyTab() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: consistency, isLoading: consistencyLoading } =
trpc.analytics.getJurorConsistency.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
if (programsLoading) {
@@ -563,11 +619,16 @@ function JurorConsistencyTab() {
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
@@ -601,22 +662,25 @@ function JurorConsistencyTab() {
}
function DiversityTab() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
}
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading: diversityLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
if (programsLoading) {
@@ -627,11 +691,16 @@ function DiversityTab() {
<div className="space-y-6">
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}

View File

@@ -64,14 +64,14 @@ import {
CheckCircle2,
Clock,
AlertCircle,
Sparkles,
Shuffle,
Loader2,
Plus,
Trash2,
RefreshCw,
UserPlus,
Cpu,
Brain,
Calculator,
Workflow,
Search,
ChevronsUpDown,
Check,
@@ -829,7 +829,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
<Shuffle className="h-5 w-5 text-amber-500" />
Smart Assignment Suggestions
</CardTitle>
<CardDescription>
@@ -844,7 +844,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
<div className="flex items-center justify-between mb-4">
<TabsList>
<TabsTrigger value="algorithm" className="gap-2">
<Cpu className="h-4 w-4" />
<Calculator className="h-4 w-4" />
Algorithm
{algorithmicSuggestions && algorithmicSuggestions.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
@@ -853,7 +853,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
)}
</TabsTrigger>
<TabsTrigger value="ai" className="gap-2" disabled={!isAIAvailable && !hasStoredAISuggestions}>
<Brain className="h-4 w-4" />
<Workflow className="h-4 w-4" />
AI Powered
{aiSuggestions.length > 0 && (
<Badge variant="secondary" className="ml-1 text-xs">
@@ -983,7 +983,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
/>
) : !hasStoredAISuggestions ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Brain className="h-12 w-12 text-muted-foreground/50" />
<Workflow className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No AI analysis yet</p>
<p className="text-sm text-muted-foreground mb-4">
Click &quot;Start Analysis&quot; to generate AI-powered suggestions
@@ -995,7 +995,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
{startAIJob.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Brain className="mr-2 h-4 w-4" />
<Workflow className="mr-2 h-4 w-4" />
)}
Start AI Analysis
</Button>

View File

@@ -41,7 +41,7 @@ import {
GripVertical,
Loader2,
FileCheck,
Brain,
SlidersHorizontal,
Filter,
} from 'lucide-react'
@@ -56,7 +56,7 @@ const RULE_TYPE_LABELS: Record<RuleType, string> = {
const RULE_TYPE_ICONS: Record<RuleType, React.ReactNode> = {
FIELD_BASED: <Filter className="h-4 w-4" />,
DOCUMENT_CHECK: <FileCheck className="h-4 w-4" />,
AI_SCREENING: <Brain className="h-4 w-4" />,
AI_SCREENING: <SlidersHorizontal className="h-4 w-4" />,
}
const FIELD_OPTIONS = [

View File

@@ -81,13 +81,17 @@ import {
AlertTriangle,
ListChecks,
ClipboardCheck,
Sparkles,
FileSearch,
LayoutTemplate,
ShieldCheck,
Download,
RotateCcw,
Zap,
QrCode,
ExternalLink,
} from 'lucide-react'
import { toast } from 'sonner'
import { AnimatedCard } from '@/components/shared/animated-container'
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
@@ -126,6 +130,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const [advanceOpen, setAdvanceOpen] = useState(false)
const [removeOpen, setRemoveOpen] = useState(false)
const [activeJobId, setActiveJobId] = useState<string | null>(null)
const [jobPollInterval, setJobPollInterval] = useState(2000)
// Inline filtering results state
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
@@ -140,7 +145,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const [showExportDialog, setShowExportDialog] = useState(false)
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
// Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy)
const progress = round?.progress
// Check if this is a filtering round - roundType is stored directly on the round
const isFilteringRound = round?.roundType === 'FILTERING'
@@ -149,7 +155,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
trpc.filtering.getResultStats.useQuery(
{ roundId },
{ enabled: isFilteringRound, staleTime: 0 }
{ enabled: isFilteringRound, staleTime: 30_000 }
)
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
{ roundId },
@@ -162,31 +168,41 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const { data: latestJob, refetch: refetchLatestJob } =
trpc.filtering.getLatestJob.useQuery(
{ roundId },
{ enabled: isFilteringRound, staleTime: 0 }
{ enabled: isFilteringRound, staleTime: 30_000 }
)
// Poll for job status when there's an active job
// Poll for job status with exponential backoff (2s → 4s → 8s → 15s cap)
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
{ jobId: activeJobId! },
{
enabled: !!activeJobId,
refetchInterval: activeJobId ? 2000 : false,
refetchInterval: activeJobId ? jobPollInterval : false,
refetchIntervalInBackground: false,
staleTime: 0,
}
)
// Increase polling interval over time (exponential backoff)
useEffect(() => {
if (!activeJobId) {
setJobPollInterval(2000)
return
}
const timer = setTimeout(() => {
setJobPollInterval((prev) => Math.min(prev * 2, 15000))
}, jobPollInterval)
return () => clearTimeout(timer)
}, [activeJobId, jobPollInterval])
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
utils.round.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
},
})
const deleteRound = trpc.round.delete.useMutation({
onSuccess: () => {
toast.success('Round deleted')
utils.program.list.invalidate()
utils.round.list.invalidate()
router.push('/admin/rounds')
},
@@ -200,7 +216,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const finalizeResults = trpc.filtering.finalizeResults.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
utils.project.list.invalidate()
},
})
@@ -218,7 +233,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
},
{
enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0,
staleTime: 0,
staleTime: 30_000,
}
)
const overrideResult = trpc.filtering.overrideResult.useMutation()
@@ -286,6 +301,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
const handleStartFiltering = async () => {
try {
const result = await startJob.mutateAsync({ roundId })
setJobPollInterval(2000)
setActiveJobId(result.jobId)
toast.info('Filtering job started. Progress will update automatically.')
} catch (error) {
@@ -309,8 +325,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
}
refetchFilteringStats()
refetchRound()
utils.project.list.invalidate()
utils.program.list.invalidate({ includeRounds: true })
utils.round.get.invalidate({ id: roundId })
} catch (error) {
toast.error(
@@ -340,7 +354,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
setOverrideReason('')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to override result')
}
@@ -352,7 +365,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
toast.success('Project reinstated')
refetchResults()
refetchFilteringStats()
utils.project.list.invalidate()
} catch {
toast.error('Failed to reinstate project')
}
@@ -548,11 +560,14 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<Separator />
{/* Stats Grid */}
<AnimatedCard index={0}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileText className="h-4 w-4 text-emerald-500" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.projects}</div>
@@ -562,10 +577,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.assignments}</div>
@@ -577,10 +594,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-blue-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-blue-500" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round.requiredReviews}</div>
@@ -588,10 +607,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
@@ -603,12 +624,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</CardContent>
</Card>
</div>
</AnimatedCard>
{/* Progress */}
{progress && progress.totalAssignments > 0 && (
<AnimatedCard index={1}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<BarChart3 className="h-4 w-4 text-brand-teal" />
</div>
Evaluation Progress
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
@@ -616,7 +644,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
<span>Overall Completion</span>
<span>{progress.completionPercentage}%</span>
</div>
<Progress value={progress.completionPercentage} />
<Progress value={progress.completionPercentage} gradient />
</div>
<div className="grid gap-4 sm:grid-cols-4">
@@ -631,12 +659,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Voting Window */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Clock className="h-4 w-4 text-blue-500" />
</div>
Voting Window
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
@@ -723,15 +758,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Filtering Section (for FILTERING rounds) */}
{isFilteringRound && (
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Filter className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<Filter className="h-4 w-4 text-amber-500" />
</div>
Project Filtering
</CardTitle>
<CardDescription>
@@ -782,7 +821,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{progressPercent}%
</span>
</div>
<Progress value={progressPercent} className="h-2" />
<Progress value={progressPercent} className="h-2" gradient />
</div>
</div>
</div>
@@ -1226,12 +1265,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Quick Actions */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Quick Actions</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<Zap className="h-4 w-4 text-amber-500" />
</div>
Quick Actions
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Project Management */}
@@ -1275,6 +1321,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
Jury Assignments
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${round.id}/live-voting`}>
<Zap className="mr-2 h-4 w-4" />
Live Voting
</Link>
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -1287,7 +1339,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
{bulkSummaries.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
<FileSearch className="mr-2 h-4 w-4" />
)}
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
</Button>
@@ -1319,6 +1371,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Dialogs */}
<AssignProjectsDialog

View File

@@ -66,6 +66,7 @@ import {
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
import { cn } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container'
type RoundData = {
id: string
@@ -108,8 +109,10 @@ function RoundsContent() {
return (
<div className="space-y-6">
{programs.map((program) => (
<ProgramRounds key={program.id} program={program} />
{programs.map((program, index) => (
<AnimatedCard key={program.id} index={index}>
<ProgramRounds program={program} />
</AnimatedCard>
))}
</div>
)
@@ -485,7 +488,7 @@ function SortableRoundRow({
ref={setNodeRef}
style={style}
className={cn(
'rounded-lg border bg-card transition-all',
'rounded-lg border bg-card transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
isReordering && !isDragging && 'opacity-50'
)}

View File

@@ -16,6 +16,7 @@ import {
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { StatusTracker } from '@/components/shared/status-tracker'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
FileText,
Calendar,
@@ -79,16 +80,20 @@ export default function ApplicantDashboardPage() {
Your applicant dashboard
</p>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h2 className="text-xl font-semibold mb-2">No Project Yet</h2>
<p className="text-muted-foreground text-center max-w-md">
You haven&apos;t submitted a project yet. Check for open application rounds
on the MOPC website.
</p>
</CardContent>
</Card>
<AnimatedCard index={0}>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-2xl bg-muted/60 p-4 mb-4">
<FileText className="h-8 w-8 text-muted-foreground/70" />
</div>
<h2 className="text-xl font-semibold mb-2">No Project Yet</h2>
<p className="text-muted-foreground text-center max-w-md">
You haven&apos;t submitted a project yet. Check for open application rounds
on the MOPC website.
</p>
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}
@@ -132,6 +137,7 @@ export default function ApplicantDashboardPage() {
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Project details */}
<AnimatedCard index={0}>
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
@@ -203,65 +209,57 @@ export default function ApplicantDashboardPage() {
</div>
</CardContent>
</Card>
</AnimatedCard>
{/* Quick actions */}
<div className="grid gap-4 sm:grid-cols-3">
<Card className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<Link href={"/applicant/documents" as Route} className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Documents</p>
<p className="text-xs text-muted-foreground">
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</CardContent>
</Card>
<AnimatedCard index={1}>
<div className="grid gap-4 sm:grid-cols-3">
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Documents</p>
<p className="text-xs text-muted-foreground">
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
<Card className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<Link href={"/applicant/team" as Route} className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900/30">
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Team</p>
<p className="text-xs text-muted-foreground">
{project.teamMembers.length} member(s)
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</CardContent>
</Card>
<Link href={"/applicant/team" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-purple-500/30 hover:bg-purple-500/5">
<div className="rounded-xl bg-purple-500/10 p-2.5 transition-colors group-hover:bg-purple-500/20">
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Team</p>
<p className="text-xs text-muted-foreground">
{project.teamMembers.length} member(s)
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
<Card className="hover:border-primary/50 transition-colors">
<CardContent className="p-4">
<Link href={"/applicant/mentor" as Route} className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Mentor</p>
<p className="text-xs text-muted-foreground">
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</CardContent>
</Card>
</div>
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Mentor</p>
<p className="text-xs text-muted-foreground">
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</div>
</AnimatedCard>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Status timeline */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle>Status Timeline</CardTitle>
@@ -273,8 +271,10 @@ export default function ApplicantDashboardPage() {
/>
</CardContent>
</Card>
</AnimatedCard>
{/* Team overview */}
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -324,8 +324,10 @@ export default function ApplicantDashboardPage() {
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Key dates */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
@@ -353,6 +355,7 @@ export default function ApplicantDashboardPage() {
)}
</CardContent>
</Card>
</AnimatedCard>
</div>
</div>
</div>

View File

@@ -99,8 +99,12 @@ export default function ApplicantTeamPage() {
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member invited!')
onSuccess: (result) => {
if (result.requiresAccountSetup) {
toast.success('Invitation email sent to team member')
} else {
toast.success('Team member added and notified by email')
}
setIsInviteOpen(false)
refetch()
},

View File

@@ -13,6 +13,7 @@ import {
} from '@/components/ui/card'
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
@@ -134,12 +135,15 @@ function AcceptInviteContent() {
// Loading state
if (state === 'loading') {
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">Verifying your invitation...</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
@@ -147,9 +151,11 @@ function AcceptInviteContent() {
if (state === 'error') {
const errorContent = getErrorContent()
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
{errorContent.icon}
</div>
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
@@ -167,15 +173,18 @@ function AcceptInviteContent() {
</Button>
</CardContent>
</Card>
</AnimatedCard>
)
}
// Valid invitation - show welcome
const user = data?.user
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-xl">
@@ -213,18 +222,22 @@ function AcceptInviteContent() {
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
// Loading fallback for Suspense
function LoadingCard() {
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="mt-4 text-sm text-muted-foreground">Loading...</p>
</CardContent>
</Card>
</AnimatedCard>
)
}

View File

@@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo'
import { AlertCircle } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
const errorMessages: Record<string, string> = {
Configuration: 'There is a problem with the server configuration.',
@@ -20,12 +21,14 @@ export default function AuthErrorPage() {
const message = errorMessages[error] || errorMessages.Default
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4">
<Logo variant="small" />
</div>
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
<AlertCircle className="h-6 w-6 text-destructive" />
</div>
<CardTitle className="text-xl">Authentication Error</CardTitle>
@@ -42,5 +45,6 @@ export default function AuthErrorPage() {
</div>
</CardContent>
</Card>
</AnimatedCard>
)
}

View File

@@ -14,6 +14,7 @@ import {
CardTitle,
} from '@/components/ui/card'
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
type LoginMode = 'password' | 'magic-link'
@@ -102,9 +103,11 @@ export default function LoginPage() {
// Success state after sending magic link
if (isSent) {
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 animate-in zoom-in-50 duration-300">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-50 animate-in zoom-in-50 duration-300">
<Mail className="h-8 w-8 text-green-600" />
</div>
<CardTitle className="text-xl">Check your email</CardTitle>
@@ -137,11 +140,14 @@ export default function LoginPage() {
</div>
</CardContent>
</Card>
</AnimatedCard>
)
}
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription>
@@ -299,5 +305,6 @@ export default function LoginPage() {
</div>
</CardContent>
</Card>
</AnimatedCard>
)
}

View File

@@ -41,6 +41,7 @@ import {
Globe,
FileText,
} from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete'
@@ -181,19 +182,24 @@ export default function OnboardingPage() {
if (sessionStatus === 'loading' || userLoading || !initialized) {
return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-lg shadow-2xl">
<AnimatedCard>
<Card className="w-full max-w-lg shadow-2xl overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Loading your profile...</p>
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}
return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto shadow-2xl">
<AnimatedCard>
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-hidden shadow-2xl">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
{/* Progress indicator */}
<div className="px-6 pt-6">
<div className="flex items-center gap-2">
@@ -570,7 +576,7 @@ export default function OnboardingPage() {
{/* Step 7: Complete */}
{step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-full bg-green-100 p-4 mb-4 animate-in zoom-in-50 duration-500">
<div className="rounded-2xl bg-emerald-50 p-4 mb-4 animate-in zoom-in-50 duration-500">
<CheckCircle className="h-12 w-12 text-green-600" />
</div>
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
@@ -584,6 +590,7 @@ export default function OnboardingPage() {
</CardContent>
)}
</Card>
</AnimatedCard>
</div>
)
}

View File

@@ -17,6 +17,7 @@ import { Progress } from '@/components/ui/progress'
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
import Image from 'next/image'
import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
export default function SetPasswordPage() {
const [password, setPassword] = useState('')
@@ -116,20 +117,25 @@ export default function SetPasswordPage() {
// Loading state while checking session
if (session === undefined) {
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</AnimatedCard>
)
}
// Success state
if (isSuccess) {
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-xl">Password Set Successfully</CardTitle>
@@ -144,13 +150,16 @@ export default function SetPasswordPage() {
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-sm border">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
</div>
<CardTitle className="text-xl">Set Your Password</CardTitle>
@@ -294,5 +303,6 @@ export default function SetPasswordPage() {
</form>
</CardContent>
</Card>
</AnimatedCard>
)
}

View File

@@ -1,11 +1,14 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Mail } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
export default function VerifyEmailPage() {
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-teal/10">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-teal/10">
<Mail className="h-6 w-6 text-brand-teal" />
</div>
<CardTitle className="text-xl">Check your email</CardTitle>
@@ -23,5 +26,6 @@ export default function VerifyEmailPage() {
</p>
</CardContent>
</Card>
</AnimatedCard>
)
}

View File

@@ -2,12 +2,15 @@ import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CheckCircle2 } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
export default function VerifyPage() {
return (
<Card className="w-full max-w-md">
<AnimatedCard>
<Card className="w-full max-w-md overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
<CardTitle className="text-xl">Check your email</CardTitle>
@@ -24,5 +27,6 @@ export default function VerifyPage() {
</div>
</CardContent>
</Card>
</AnimatedCard>
)
}

View File

@@ -178,7 +178,7 @@ async function AssignmentsContent({
</div>
</div>
<div className="ml-auto">
<Progress value={overallProgress} className="h-2 w-32" />
<Progress value={overallProgress} className="h-2 w-32" gradient />
<p className="text-xs text-muted-foreground mt-1">{overallProgress}% complete</p>
</div>
</div>
@@ -210,7 +210,7 @@ async function AssignmentsContent({
new Date(assignment.round.votingEndAt) >= now
return (
<TableRow key={assignment.id}>
<TableRow key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm">
<TableCell>
<Link
href={`/jury/projects/${assignment.project.id}`}
@@ -328,7 +328,7 @@ async function AssignmentsContent({
new Date(assignment.round.votingEndAt) >= now
return (
<Card key={assignment.id}>
<Card key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<Link

View File

@@ -743,16 +743,13 @@ export default async function JuryDashboardPage() {
return (
<div className="space-y-4">
{/* Header */}
<div className="relative">
<div className="absolute -top-6 -left-6 -right-6 h-32 bg-gradient-to-b from-brand-blue/[0.03] to-transparent dark:from-brand-blue/[0.06] pointer-events-none rounded-xl" />
<div className="relative">
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
{getGreeting()}, {session?.user?.name || 'Juror'}
</h1>
<p className="text-muted-foreground mt-0.5">
Here&apos;s an overview of your evaluation progress
</p>
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
{getGreeting()}, {session?.user?.name || 'Juror'}
</h1>
<p className="text-muted-foreground mt-0.5">
Here&apos;s an overview of your evaluation progress
</p>
</div>
{/* Content */}

View File

@@ -27,6 +27,7 @@ import {
Star,
AlertCircle,
} from 'lucide-react'
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
import { format } from 'date-fns'
interface PageProps {
@@ -83,6 +84,7 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
id: true,
title: true,
teamName: true,
_count: { select: { files: true } },
},
})
@@ -223,6 +225,13 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
<Separator />
{/* Project Documents */}
<CollapsibleFilesSection
projectId={project.id}
roundId={round.id}
fileCount={project._count?.files ?? 0}
/>
{/* Criteria scores */}
{criteria.length > 0 && (
<Card>

View File

@@ -240,7 +240,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
{/* Description */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Description</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileText className="h-4 w-4 text-emerald-500" />
</div>
Project Description
</CardTitle>
</CardHeader>
<CardContent>
{project.description ? (
@@ -266,7 +271,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
{/* Round Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Round Details</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<Calendar className="h-4 w-4 text-blue-500" />
</div>
Round Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
@@ -310,7 +320,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
{evaluation && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Your Evaluation</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-brand-teal/10 p-1.5">
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
</div>
Your Evaluation
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">

View File

@@ -39,6 +39,7 @@ import {
Search,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container'
// Status badge colors
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -117,63 +118,72 @@ export default function MentorDashboard() {
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Assigned Projects
</CardTitle>
<Briefcase className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{projects.length}</div>
<p className="text-xs text-muted-foreground">
Projects you are mentoring
</p>
</CardContent>
</Card>
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Assigned Projects</p>
<p className="text-2xl font-bold mt-1">{projects.length}</p>
<p className="text-xs text-muted-foreground mt-1">Projects you are mentoring</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<Briefcase className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Completed
</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{completedCount}</div>
<div className="flex items-center gap-2 mt-1">
{projects.length > 0 && (
<Progress
value={(completedCount / projects.length) * 100}
className="h-1.5 flex-1"
/>
)}
<span className="text-xs text-muted-foreground">
{projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}%
</span>
</div>
</CardContent>
</Card>
<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-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Completed</p>
<p className="text-2xl font-bold mt-1">{completedCount}</p>
<div className="flex items-center gap-2 mt-1">
{projects.length > 0 && (
<Progress
value={(completedCount / projects.length) * 100}
className="h-1.5 flex-1"
gradient
/>
)}
<span className="text-xs text-muted-foreground">
{projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}%
</span>
</div>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Total Team Members
</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{projects.reduce(
(acc, a) => acc + (a.project.teamMembers?.length || 0),
0
)}
</div>
<p className="text-xs text-muted-foreground">
Across all assigned projects
</p>
</CardContent>
</Card>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Team Members</p>
<p className="text-2xl font-bold mt-1">
{projects.reduce(
(acc, a) => acc + (a.project.teamMembers?.length || 0),
0
)}
</p>
<p className="text-xs text-muted-foreground mt-1">Across all assigned projects</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Quick Actions */}
@@ -219,8 +229,8 @@ export default function MentorDashboard() {
{projects.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<Users className="h-6 w-6 text-muted-foreground" />
<div className="rounded-2xl bg-brand-teal/10 p-4">
<Users className="h-8 w-8 text-brand-teal" />
</div>
<p className="mt-4 font-medium">No assigned projects yet</p>
<p className="text-sm text-muted-foreground mt-1">
@@ -248,7 +258,7 @@ export default function MentorDashboard() {
</Card>
) : (
<div className="grid gap-4">
{filteredProjects.map((assignment) => {
{filteredProjects.map((assignment, index) => {
const project = assignment.project
const teamLead = project.teamMembers?.find(
(m) => m.role === 'LEAD'
@@ -256,7 +266,8 @@ export default function MentorDashboard() {
const badge = completionBadge[assignment.completionStatus] || completionBadge.in_progress
return (
<Card key={assignment.id}>
<AnimatedCard key={assignment.id} index={index}>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
@@ -376,6 +387,7 @@ export default function MentorDashboard() {
</div>
</CardContent>
</Card>
</AnimatedCard>
)
})}
</div>

View File

@@ -28,6 +28,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { AnimatedCard } from '@/components/shared/animated-container'
import { FileViewer } from '@/components/shared/file-viewer'
import { MentorChat } from '@/components/shared/mentor-chat'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
@@ -194,21 +195,31 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{/* Milestones Section */}
{programId && mentorAssignmentId && (
<AnimatedCard index={0}>
<MilestonesSection
programId={programId}
mentorAssignmentId={mentorAssignmentId}
/>
</AnimatedCard>
)}
{/* Private Notes Section */}
{mentorAssignmentId && (
<NotesSection mentorAssignmentId={mentorAssignmentId} />
<AnimatedCard index={1}>
<NotesSection mentorAssignmentId={mentorAssignmentId} />
</AnimatedCard>
)}
{/* Project Info */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Information</CardTitle>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<FileText className="h-4 w-4 text-emerald-500" />
</div>
Project Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
@@ -299,12 +310,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Team Members Section */}
<AnimatedCard index={3}>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Users className="h-4 w-4 text-violet-500" />
</div>
Team Members ({project.teamMembers?.length || 0})
</CardTitle>
<CardDescription>
@@ -392,12 +407,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Files Section */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<FileText className="h-4 w-4 text-rose-500" />
</div>
Project Files
</CardTitle>
<CardDescription>
@@ -426,12 +445,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Messaging Section */}
<AnimatedCard index={5}>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-blue-500/10 p-1.5">
<MessageSquare className="h-4 w-4 text-blue-500" />
</div>
Messages
</CardTitle>
<CardDescription>
@@ -450,6 +473,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
/>
</CardContent>
</Card>
</AnimatedCard>
</div>
)
}
@@ -529,8 +553,10 @@ function MilestonesSection({
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Target className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<Target className="h-4 w-4 text-amber-500" />
</div>
Milestones
</CardTitle>
<Badge variant="secondary">
@@ -552,7 +578,7 @@ function MilestonesSection({
return (
<div
key={milestone.id}
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : ''
}`}
>
@@ -676,8 +702,10 @@ function NotesSection({ mentorAssignmentId }: { mentorAssignmentId: string }) {
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<StickyNote className="h-5 w-5" />
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<StickyNote className="h-4 w-4 text-amber-500" />
</div>
Private Notes
</CardTitle>
{!isAdding && !editingId && (

View File

@@ -52,8 +52,16 @@ import {
DiversityMetricsChart,
} from '@/components/charts'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { roundId: value }
}
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
@@ -63,10 +71,13 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
}))
) || []
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: overviewStats, isLoading: statsLoading } =
trpc.analytics.getOverviewStats.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
if (isLoading) {
@@ -97,55 +108,79 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Rounds</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rounds.length}</div>
<p className="text-xs text-muted-foreground">
{activeRounds} active
</p>
</CardContent>
</Card>
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Rounds</p>
<p className="text-2xl font-bold mt-1">{rounds.length}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeRounds} active
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<BarChart3 className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProjects}</div>
<p className="text-xs text-muted-foreground">Across all rounds</p>
</CardContent>
</Card>
<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-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground mt-1">Across all rounds</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeRounds}</div>
<p className="text-xs text-muted-foreground">Currently active</p>
</CardContent>
</Card>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Active Rounds</p>
<p className="text-2xl font-bold mt-1">{activeRounds}</p>
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Programs</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalPrograms}</div>
<p className="text-xs text-muted-foreground">Total programs</p>
</CardContent>
</Card>
<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-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Programs</p>
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
<p className="text-xs text-muted-foreground mt-1">Total programs</p>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Round-specific overview stats */}
{selectedRoundId && (
{/* Round/edition-specific overview stats */}
{hasSelection && (
<>
{statsLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
@@ -163,7 +198,7 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
</div>
) : overviewStats ? (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Selected Round Details</h3>
<h3 className="text-lg font-semibold">{queryInput.programId ? 'Edition Overview' : 'Selected Round Details'}</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -207,7 +242,7 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
<Progress value={overviewStats.completionRate} className="mt-2 h-2" />
<Progress value={overviewStats.completionRate} className="mt-2 h-2" gradient />
</CardContent>
</Card>
</div>
@@ -304,41 +339,44 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
)
}
function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) {
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ roundId: selectedRoundId, limit: 15 },
{ enabled: !!selectedRoundId }
{ ...queryInput, limit: 15 },
{ enabled: hasSelection }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
return (
@@ -483,11 +521,14 @@ function CrossRoundTab() {
)
}
function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) {
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: consistency, isLoading } =
trpc.analytics.getJurorConsistency.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
if (isLoading) return <Skeleton className="h-[400px]" />
@@ -508,11 +549,14 @@ function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) {
)
}
function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) {
function DiversityTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
{ roundId: selectedRoundId },
{ enabled: !!selectedRoundId }
queryInput,
{ enabled: hasSelection }
)
if (isLoading) return <Skeleton className="h-[400px]" />
@@ -533,22 +577,26 @@ function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) {
}
export default function ObserverReportsPage() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const [selectedValue, setSelectedValue] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
const rounds = programs?.flatMap(p =>
p.rounds.map(r => ({
...r,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
// Set default selected round
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
}
const hasSelection = !!selectedValue
const selectedRound = rounds.find((r) => r.id === selectedValue)
return (
<div className="space-y-6">
{/* Header */}
@@ -565,11 +613,16 @@ export default function ObserverReportsPage() {
{roundsLoading ? (
<Skeleton className="h-10 w-full sm:w-[300px]" />
) : rounds.length > 0 ? (
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Rounds
</SelectItem>
))}
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
@@ -590,7 +643,7 @@ export default function ObserverReportsPage() {
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
<TabsTrigger value="analytics" className="gap-2" disabled={!hasSelection}>
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
@@ -598,38 +651,38 @@ export default function ObserverReportsPage() {
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
<TabsTrigger value="consistency" className="gap-2" disabled={!selectedRoundId}>
<TabsTrigger value="consistency" className="gap-2" disabled={!hasSelection}>
<UserCheck className="h-4 w-4" />
Juror Consistency
</TabsTrigger>
<TabsTrigger value="diversity" className="gap-2" disabled={!selectedRoundId}>
<TabsTrigger value="diversity" className="gap-2" disabled={!hasSelection}>
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger>
</TabsList>
{selectedRoundId && (
{selectedValue && !selectedValue.startsWith('all:') && (
<ExportPdfButton
roundId={selectedRoundId}
roundName={rounds.find((r) => r.id === selectedRoundId)?.name}
programName={rounds.find((r) => r.id === selectedRoundId)?.programName}
roundId={selectedValue}
roundName={selectedRound?.name}
programName={selectedRound?.programName}
/>
)}
</div>
<TabsContent value="overview">
<OverviewTab selectedRoundId={selectedRoundId} />
<OverviewTab selectedValue={selectedValue} />
</TabsContent>
<TabsContent value="analytics">
{selectedRoundId ? (
<AnalyticsTab selectedRoundId={selectedRoundId} />
{hasSelection ? (
<AnalyticsTab selectedValue={selectedValue!} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round from the dropdown above to view analytics
Choose a round or edition from the dropdown above to view analytics
</p>
</CardContent>
</Card>
@@ -641,15 +694,15 @@ export default function ObserverReportsPage() {
</TabsContent>
<TabsContent value="consistency">
{selectedRoundId ? (
<JurorConsistencyTab selectedRoundId={selectedRoundId} />
{hasSelection ? (
<JurorConsistencyTab selectedValue={selectedValue!} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<UserCheck className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round above to view juror consistency metrics
Choose a round or edition above to view juror consistency metrics
</p>
</CardContent>
</Card>
@@ -657,15 +710,15 @@ export default function ObserverReportsPage() {
</TabsContent>
<TabsContent value="diversity">
{selectedRoundId ? (
<DiversityTab selectedRoundId={selectedRoundId} />
{hasSelection ? (
<DiversityTab selectedValue={selectedValue!} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Globe className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round above to view diversity metrics
Choose a round or edition above to view diversity metrics
</p>
</CardContent>
</Card>

View File

@@ -98,8 +98,12 @@ export default function TeamManagementPage() {
)
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
onSuccess: () => {
toast.success('Team member invited!')
onSuccess: (result) => {
if (result.requiresAccountSetup) {
toast.success('Invitation email sent to team member')
} else {
toast.success('Team member added and notified by email')
}
setIsInviteOpen(false)
refetch()
},