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()
},

View File

@@ -23,7 +23,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Search, Loader2, Plus, Package } from 'lucide-react'
import { Search, Loader2, Plus, Package, CheckCircle2 } from 'lucide-react'
import { getCountryName } from '@/lib/countries'
interface AssignProjectsDialogProps {
@@ -65,7 +65,6 @@ export function AssignProjectsDialog({
const { data, isLoading } = trpc.project.list.useQuery(
{
programId,
notInRoundId: roundId,
search: debouncedSearch || undefined,
page: 1,
perPage: 5000,
@@ -87,23 +86,28 @@ export function AssignProjectsDialog({
})
const projects = data?.projects ?? []
const alreadyInRound = new Set(
projects.filter((p) => p.round?.id === roundId).map((p) => p.id)
)
const assignableProjects = projects.filter((p) => !alreadyInRound.has(p.id))
const toggleProject = useCallback((id: string) => {
if (alreadyInRound.has(id)) return
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
}, [alreadyInRound])
const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) {
if (selectedIds.size === assignableProjects.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(projects.map((p) => p.id)))
setSelectedIds(new Set(assignableProjects.map((p) => p.id)))
}
}, [selectedIds.size, projects])
}, [selectedIds.size, assignableProjects])
const handleAssign = () => {
if (selectedIds.size === 0) return
@@ -144,9 +148,9 @@ export function AssignProjectsDialog({
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No available projects</p>
<p className="mt-2 font-medium">No projects found</p>
<p className="text-sm text-muted-foreground">
All program projects are already in this round.
{debouncedSearch ? 'No projects match your search.' : 'This program has no projects yet.'}
</p>
</div>
) : (
@@ -154,11 +158,15 @@ export function AssignProjectsDialog({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.size === projects.length && projects.length > 0}
checked={assignableProjects.length > 0 && selectedIds.size === assignableProjects.length}
disabled={assignableProjects.length === 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
{selectedIds.size} of {assignableProjects.length} assignable selected
{alreadyInRound.size > 0 && (
<span className="ml-1">({alreadyInRound.size} already in round)</span>
)}
</span>
</div>
</div>
@@ -174,34 +182,54 @@ export function AssignProjectsDialog({
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
{project.country ? (
<Badge variant="outline" className="text-xs">
{getCountryName(project.country)}
</Badge>
) : '—'}
</TableCell>
</TableRow>
))}
{projects.map((project) => {
const isInRound = alreadyInRound.has(project.id)
return (
<TableRow
key={project.id}
className={
isInRound
? 'opacity-60'
: selectedIds.has(project.id)
? 'bg-muted/50'
: 'cursor-pointer'
}
onClick={() => toggleProject(project.id)}
>
<TableCell>
{isInRound ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
)}
</TableCell>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{project.title}
{isInRound && (
<Badge variant="secondary" className="text-xs">
In round
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
{project.country ? (
<Badge variant="outline" className="text-xs">
{getCountryName(project.country)}
</Badge>
) : '—'}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>

View File

@@ -24,7 +24,7 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Sparkles,
FileText,
RefreshCw,
Loader2,
CheckCircle2,
@@ -119,7 +119,7 @@ export function EvaluationSummaryCard({
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5" />
<FileText className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription>
@@ -128,7 +128,7 @@ export function EvaluationSummaryCard({
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-6 text-center">
<Sparkles className="h-10 w-10 text-muted-foreground/50 mb-3" />
<FileText className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">
No summary generated yet. Click below to analyze submitted evaluations.
</p>
@@ -136,7 +136,7 @@ export function EvaluationSummaryCard({
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
<FileText className="mr-2 h-4 w-4" />
)}
{isGenerating ? 'Generating...' : 'Generate Summary'}
</Button>
@@ -155,7 +155,7 @@ export function EvaluationSummaryCard({
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5" />
<FileText className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">

View File

@@ -27,7 +27,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { UserAvatar } from '@/components/shared/user-avatar'
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
import { Pagination } from '@/components/shared/pagination'
import { Plus, Users, Search } from 'lucide-react'
import { Plus, Users, Search, Mail, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { formatRelativeTime } from '@/lib/utils'
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
@@ -60,6 +61,39 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
SUPER_ADMIN: 'destructive' as 'default',
}
function InlineSendInvite({ userId, userEmail }: { userId: string; userEmail: string }) {
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation({
onSuccess: () => {
toast.success(`Invitation sent to ${userEmail}`)
utils.user.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to send invitation')
},
})
return (
<Button
variant="outline"
size="sm"
className="h-6 text-xs gap-1 px-2"
onClick={(e) => {
e.stopPropagation()
sendInvitation.mutate({ userId })
}}
disabled={sendInvitation.isPending}
>
{sendInvitation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Mail className="h-3 w-3" />
)}
Send Invite
</Button>
)
}
export function MembersContent() {
const searchParams = useSearchParams()
@@ -124,7 +158,7 @@ export function MembersContent() {
<Button asChild>
<Link href="/admin/members/invite">
<Plus className="mr-2 h-4 w-4" />
Invite Member
Add Member
</Link>
</Button>
</div>
@@ -223,9 +257,14 @@ export function MembersContent() {
</div>
</TableCell>
<TableCell>
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
<div className="flex items-center gap-2">
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
{user.status === 'NONE' && (
<InlineSendInvite userId={user.id} userEmail={user.email} />
)}
</div>
</TableCell>
<TableCell>
{user.lastLoginAt ? (
@@ -272,9 +311,14 @@ export function MembersContent() {
</CardDescription>
</div>
</div>
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
<div className="flex flex-col items-end gap-1.5">
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
{user.status === 'NONE' && (
<InlineSendInvite userId={user.id} userEmail={user.email} />
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">

View File

@@ -5,6 +5,7 @@ import Link from 'next/link'
import type { Route } from 'next'
import { usePathname } from 'next/navigation'
import { signOut } from 'next-auth/react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
@@ -33,7 +34,7 @@ import {
Trophy,
User,
MessageSquare,
Wand2,
LayoutTemplate,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
import { Logo } from '@/components/shared/logo'
@@ -41,6 +42,7 @@ import { EditionSelector } from '@/components/shared/edition-selector'
import { useEdition } from '@/contexts/edition-context'
import { UserAvatar } from '@/components/shared/user-avatar'
import { NotificationBell } from '@/components/shared/notification-bell'
import { LanguageSwitcher } from '@/components/shared/language-switcher'
import { useSession } from 'next-auth/react'
import { trpc } from '@/lib/trpc/client'
@@ -120,7 +122,7 @@ const adminNavigation: NavItem[] = [
{
name: 'Apply Page',
href: '/admin/programs',
icon: Wand2,
icon: LayoutTemplate,
activeMatch: 'apply-settings',
},
{
@@ -145,6 +147,7 @@ const roleLabels: Record<string, string> = {
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
const tAuth = useTranslations('auth')
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
@@ -170,6 +173,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden">
<Logo showText textSuffix="Admin" />
<div className="flex items-center gap-2">
<LanguageSwitcher />
<NotificationBell />
<Button
variant="ghost"
@@ -204,7 +208,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
{/* Logo + Notification */}
<div className="flex h-16 items-center justify-between border-b px-6">
<Logo showText textSuffix="Admin" />
<div className="hidden lg:block">
<div className="hidden lg:flex items-center gap-1">
<LanguageSwitcher />
<NotificationBell />
</div>
</div>
@@ -344,7 +349,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-destructive focus:bg-destructive/10 focus:text-destructive"
>
<LogOut className="h-4 w-4" />
<span>Sign out</span>
<span>{tAuth('signOut')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -2,35 +2,37 @@
import { Home, Users, FileText, MessageSquare } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/applicant',
icon: Home,
},
{
name: 'Team',
href: '/applicant/team',
icon: Users,
},
{
name: 'Documents',
href: '/applicant/documents',
icon: FileText,
},
{
name: 'Mentor',
href: '/applicant/mentor',
icon: MessageSquare,
},
]
import { useTranslations } from 'next-intl'
interface ApplicantNavProps {
user: RoleNavUser
}
export function ApplicantNav({ user }: ApplicantNavProps) {
const t = useTranslations('nav')
const navigation: NavItem[] = [
{
name: t('dashboard'),
href: '/applicant',
icon: Home,
},
{
name: t('team'),
href: '/applicant/team',
icon: Users,
},
{
name: t('documents'),
href: '/applicant/documents',
icon: FileText,
},
{
name: t('mentoring'),
href: '/applicant/mentor',
icon: MessageSquare,
},
]
return (
<RoleNav
navigation={navigation}

View File

@@ -1,32 +1,10 @@
'use client'
import { BookOpen, ClipboardList, GitCompare, Home } from 'lucide-react'
import { BookOpen, ClipboardList, GitCompare, Home, Trophy } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/jury',
icon: Home,
},
{
name: 'My Assignments',
href: '/jury/assignments',
icon: ClipboardList,
},
{
name: 'Compare',
href: '/jury/compare',
icon: GitCompare,
},
{
name: 'Learning Hub',
href: '/jury/learning',
icon: BookOpen,
},
]
import { useTranslations } from 'next-intl'
interface JuryNavProps {
user: RoleNavUser
@@ -65,6 +43,35 @@ function RemainingBadge() {
}
export function JuryNav({ user }: JuryNavProps) {
const t = useTranslations('nav')
const navigation: NavItem[] = [
{
name: t('dashboard'),
href: '/jury',
icon: Home,
},
{
name: t('assignments'),
href: '/jury/assignments',
icon: ClipboardList,
},
{
name: t('awards'),
href: '/jury/awards',
icon: Trophy,
},
{
name: t('compare'),
href: '/jury/compare',
icon: GitCompare,
},
{
name: t('learningHub'),
href: '/jury/learning',
icon: BookOpen,
},
]
return (
<RoleNav
navigation={navigation}

View File

@@ -2,30 +2,32 @@
import { BookOpen, Home, Users } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/mentor',
icon: Home,
},
{
name: 'My Mentees',
href: '/mentor/projects',
icon: Users,
},
{
name: 'Resources',
href: '/mentor/resources',
icon: BookOpen,
},
]
import { useTranslations } from 'next-intl'
interface MentorNavProps {
user: RoleNavUser
}
export function MentorNav({ user }: MentorNavProps) {
const t = useTranslations('nav')
const navigation: NavItem[] = [
{
name: t('dashboard'),
href: '/mentor',
icon: Home,
},
{
name: t('myProjects'),
href: '/mentor/projects',
icon: Users,
},
{
name: t('learningHub'),
href: '/mentor/resources',
icon: BookOpen,
},
]
return (
<RoleNav
navigation={navigation}

View File

@@ -2,25 +2,27 @@
import { BarChart3, Home } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
const navigation: NavItem[] = [
{
name: 'Dashboard',
href: '/observer',
icon: Home,
},
{
name: 'Reports',
href: '/observer/reports',
icon: BarChart3,
},
]
import { useTranslations } from 'next-intl'
interface ObserverNavProps {
user: RoleNavUser
}
export function ObserverNav({ user }: ObserverNavProps) {
const t = useTranslations('nav')
const navigation: NavItem[] = [
{
name: t('dashboard'),
href: '/observer',
icon: Home,
},
{
name: t('reports'),
href: '/observer/reports',
icon: BarChart3,
},
]
return (
<RoleNav
navigation={navigation}

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { signOut, useSession } from 'next-auth/react'
import { useTranslations } from 'next-intl'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { UserAvatar } from '@/components/shared/user-avatar'
@@ -21,6 +22,7 @@ import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Logo } from '@/components/shared/logo'
import { NotificationBell } from '@/components/shared/notification-bell'
import { LanguageSwitcher } from '@/components/shared/language-switcher'
export type NavItem = {
name: string
@@ -49,6 +51,8 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
const pathname = usePathname()
const tCommon = useTranslations('common')
const tAuth = useTranslations('auth')
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const { status: sessionStatus } = useSession()
const isAuthenticated = sessionStatus === 'authenticated'
@@ -107,6 +111,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
)}
</Button>
)}
<LanguageSwitcher />
<NotificationBell />
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -130,7 +135,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
<DropdownMenuItem asChild>
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
<Settings className="mr-2 h-4 w-4" />
Profile Settings
{tCommon('settings')}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -139,7 +144,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
className="text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
{tAuth('signOut')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -191,7 +196,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
onClick={() => signOut({ callbackUrl: '/login' })}
>
<LogOut className="mr-2 h-4 w-4" />
Sign out
{tAuth('signOut')}
</Button>
</div>
</nav>

View File

@@ -42,6 +42,7 @@ import {
ChevronRight,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { AnimatedCard } from '@/components/shared/animated-container'
import { useDebouncedCallback } from 'use-debounce'
const PER_PAGE_OPTIONS = [10, 20, 50]
@@ -121,9 +122,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</div>
{/* Observer Notice */}
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 px-4 py-3">
<div className="rounded-lg border border-blue-200 bg-blue-50/50 px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-100 p-2.5">
<Eye className="h-4 w-4 text-blue-600" />
</div>
<div>
@@ -175,65 +176,95 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</div>
) : stats ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<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">Programs</CardTitle>
<FolderKanban className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.programCount}</div>
<p className="text-xs text-muted-foreground">
{stats.activeRoundCount} active round{stats.activeRoundCount !== 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">{stats.programCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<FolderKanban className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<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">{stats.projectCount}</div>
<p className="text-xs text-muted-foreground">
{selectedRoundId !== 'all' ? 'In selected round' : '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">Projects</p>
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
<p className="text-xs text-muted-foreground mt-1">
{selectedRoundId !== 'all' ? 'In selected round' : '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 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">{stats.jurorCount}</div>
<p className="text-xs text-muted-foreground">Active members</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">{stats.jurorCount}</p>
<p className="text-xs text-muted-foreground mt-1">Active members</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 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">{stats.submittedEvaluations}</div>
<div className="mt-2">
<Progress value={stats.completionRate} className="h-2" />
<p className="mt-1 text-xs text-muted-foreground">
{stats.completionRate}% completion rate
</p>
</div>
</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">{stats.submittedEvaluations}</p>
<div className="mt-2">
<Progress value={stats.completionRate} className="h-2" gradient />
<p className="mt-1 text-xs text-muted-foreground">
{stats.completionRate}% completion rate
</p>
</div>
</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>
) : null}
{/* Projects Table */}
<AnimatedCard index={4}>
<Card>
<CardHeader>
<CardTitle>All 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>
All Projects
</CardTitle>
<CardDescription>
{projectsData ? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} found` : 'Loading projects...'}
</CardDescription>
@@ -395,12 +426,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
)}
</CardContent>
</Card>
</AnimatedCard>
{/* Score Distribution */}
{stats && stats.scoreDistribution.some((b) => b.count > 0) && (
<AnimatedCard index={5}>
<Card>
<CardHeader>
<CardTitle>Score Distribution</CardTitle>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-amber-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-amber-500" />
</div>
Score Distribution
</CardTitle>
<CardDescription>Distribution of global scores across evaluations</CardDescription>
</CardHeader>
<CardContent>
@@ -424,13 +462,20 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Recent Rounds */}
{recentRounds.length > 0 && (
<AnimatedCard index={6}>
<Card>
<CardHeader>
<CardTitle>Recent Rounds</CardTitle>
<CardTitle className="flex items-center gap-2.5">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<BarChart3 className="h-4 w-4 text-violet-500" />
</div>
Recent Rounds
</CardTitle>
<CardDescription>Overview of the latest voting rounds</CardDescription>
</CardHeader>
<CardContent>
@@ -470,6 +515,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
</div>
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
)

View File

@@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import { Bot, Loader2, Zap, AlertCircle, RefreshCw, Brain } from 'lucide-react'
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -264,7 +264,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center gap-2">
{model.isReasoning && (
<Brain className="h-3 w-3 text-purple-500" />
<SlidersHorizontal className="h-3 w-3 text-purple-500" />
)}
<span>{model.name}</span>
</div>
@@ -278,7 +278,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
<FormDescription>
{form.watch('ai_model')?.startsWith('o') ? (
<span className="flex items-center gap-1 text-purple-600">
<Brain className="h-3 w-3" />
<SlidersHorizontal className="h-3 w-3" />
Reasoning model - optimized for complex analysis tasks
</span>
) : (
@@ -323,7 +323,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
</>
) : (
<>
<Bot className="mr-2 h-4 w-4" />
<Cog className="mr-2 h-4 w-4" />
Save AI Settings
</>
)}

View File

@@ -15,7 +15,7 @@ import {
Zap,
TrendingUp,
Activity,
Brain,
SlidersHorizontal,
Filter,
Users,
Award,
@@ -26,7 +26,7 @@ const ACTION_ICONS: Record<string, typeof Zap> = {
ASSIGNMENT: Users,
FILTERING: Filter,
AWARD_ELIGIBILITY: Award,
MENTOR_MATCHING: Brain,
MENTOR_MATCHING: SlidersHorizontal,
}
const ACTION_LABELS: Record<string, string> = {
@@ -235,7 +235,7 @@ export function AIUsageCard() {
variant="outline"
className="flex items-center gap-2"
>
<Brain className="h-3 w-3" />
<SlidersHorizontal className="h-3 w-3" />
<span>{model}</span>
<span className="text-muted-foreground">
{(data as { costFormatted?: string }).costFormatted}

View File

@@ -11,7 +11,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import {
Bot,
Cog,
Palette,
Mail,
HardDrive,
@@ -29,6 +29,7 @@ import {
} from 'lucide-react'
import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { AnimatedCard } from '@/components/shared/animated-container'
import { AISettingsForm } from './ai-settings-form'
import { AIUsageCard } from './ai-usage-card'
import { BrandingSettingsForm } from './branding-settings-form'
@@ -195,7 +196,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</TabsTrigger>
{isSuperAdmin && (
<TabsTrigger value="ai" className="gap-2 shrink-0">
<Bot className="h-4 w-4" />
<Cog className="h-4 w-4" />
AI
</TabsTrigger>
)}
@@ -275,7 +276,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
{isSuperAdmin && (
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
<Bot className="h-4 w-4" />
<Cog className="h-4 w-4" />
AI
</TabsTrigger>
)}
@@ -308,6 +309,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
{isSuperAdmin && (
<TabsContent value="ai" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>AI Configuration</CardTitle>
@@ -319,11 +321,13 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<AISettingsForm settings={aiSettings} />
</CardContent>
</Card>
</AnimatedCard>
<AIUsageCard />
</TabsContent>
)}
<TabsContent value="tags">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -353,9 +357,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</Button>
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
<TabsContent value="branding">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Platform Branding</CardTitle>
@@ -367,10 +373,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<BrandingSettingsForm settings={brandingSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="email">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Email Configuration</CardTitle>
@@ -382,10 +390,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<EmailSettingsForm settings={emailSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
<TabsContent value="notifications">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Notification Email Settings</CardTitle>
@@ -397,10 +407,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<NotificationSettingsForm />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
{isSuperAdmin && (
<TabsContent value="storage">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>File Storage</CardTitle>
@@ -412,11 +424,13 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<StorageSettingsForm settings={storageSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
{isSuperAdmin && (
<TabsContent value="security">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Security Settings</CardTitle>
@@ -428,10 +442,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<SecuritySettingsForm settings={securitySettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
)}
<TabsContent value="defaults">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Default Settings</CardTitle>
@@ -443,9 +459,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<DefaultsSettingsForm settings={defaultsSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
<TabsContent value="digest" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Digest Configuration</CardTitle>
@@ -457,9 +475,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<DigestSettingsSection settings={digestSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
<TabsContent value="analytics" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Analytics & Reports</CardTitle>
@@ -471,9 +491,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<AnalyticsSettingsSection settings={analyticsSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
<TabsContent value="audit" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Audit & Security</CardTitle>
@@ -485,9 +507,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<AuditSettingsSection settings={auditSecuritySettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
<TabsContent value="localization" className="space-y-6">
<AnimatedCard>
<Card>
<CardHeader>
<CardTitle>Localization</CardTitle>
@@ -499,6 +523,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
<LocalizationSettingsSection settings={localizationSettings} />
</CardContent>
</Card>
</AnimatedCard>
</TabsContent>
</div>{/* end content area */}
</div>{/* end lg:flex */}
@@ -506,7 +531,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
{/* Quick Links to sub-pages */}
<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>
<CardTitle className="text-base flex items-center gap-2">
<LayoutTemplate className="h-4 w-4" />
@@ -528,7 +553,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
</Card>
{isSuperAdmin && (
<Card>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Webhook className="h-4 w-4" />

View File

@@ -28,7 +28,9 @@ export function EmptyState({
className
)}
>
<Icon className="h-12 w-12 text-muted-foreground/50" />
<div className="rounded-2xl bg-muted/60 p-4">
<Icon className="h-8 w-8 text-muted-foreground/70" />
</div>
<h3 className="mt-4 font-medium">{title}</h3>
{description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground">

View File

@@ -6,8 +6,10 @@ import { cn } from '@/lib/utils'
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
gradient?: boolean
}
>(({ className, value, gradient, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
@@ -17,7 +19,12 @@ const Progress = React.forwardRef<
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
className={cn(
'h-full w-full flex-1 transition-all',
gradient
? 'bg-gradient-to-r from-brand-teal to-brand-blue'
: 'bg-primary'
)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@@ -1,18 +1,41 @@
import { z } from 'zod'
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
// Shared input schema: either roundId or programId (for entire edition)
const editionOrRoundInput = z.object({
roundId: z.string().optional(),
programId: z.string().optional(),
}).refine(data => data.roundId || data.programId, {
message: 'Either roundId or programId is required',
})
// Build Prisma where-clauses from the shared input
function projectWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { roundId: input.roundId }
return { programId: input.programId! }
}
function assignmentWhere(input: { roundId?: string; programId?: string }) {
if (input.roundId) return { roundId: input.roundId }
return { round: { programId: input.programId! } }
}
function evalWhere(input: { roundId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
const base = input.roundId
? { assignment: { roundId: input.roundId } }
: { assignment: { round: { programId: input.programId! } } }
return { ...base, ...extra }
}
export const analyticsRouter = router({
/**
* Get score distribution for a round (histogram data)
*/
getScoreDistribution: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
select: {
criterionScoresJson: true,
},
@@ -51,13 +74,10 @@ export const analyticsRouter = router({
* Get evaluation completion over time (timeline data)
*/
getEvaluationTimeline: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
select: {
submittedAt: true,
},
@@ -97,10 +117,10 @@ export const analyticsRouter = router({
* Get juror workload distribution
*/
getJurorWorkload: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId: input.roundId },
where: assignmentWhere(input),
include: {
user: { select: { name: true, email: true } },
evaluation: {
@@ -146,10 +166,10 @@ export const analyticsRouter = router({
* Get project rankings with average scores
*/
getProjectRankings: observerProcedure
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
.input(editionOrRoundInput.and(z.object({ limit: z.number().optional() })))
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: projectWhere(input),
select: {
id: true,
title: true,
@@ -214,11 +234,11 @@ export const analyticsRouter = router({
* Get status breakdown (pie chart data)
*/
getStatusBreakdown: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
where: projectWhere(input),
_count: true,
})
@@ -232,7 +252,7 @@ export const analyticsRouter = router({
* Get overview stats for dashboard
*/
getOverviewStats: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const [
projectCount,
@@ -241,21 +261,18 @@ export const analyticsRouter = router({
jurorCount,
statusCounts,
] = await Promise.all([
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
ctx.prisma.project.count({ where: projectWhere(input) }),
ctx.prisma.assignment.count({ where: assignmentWhere(input) }),
ctx.prisma.evaluation.count({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
}),
ctx.prisma.assignment.groupBy({
by: ['userId'],
where: { roundId: input.roundId },
where: assignmentWhere(input),
}),
ctx.prisma.project.groupBy({
by: ['status'],
where: { roundId: input.roundId },
where: projectWhere(input),
_count: true,
}),
])
@@ -282,33 +299,44 @@ export const analyticsRouter = router({
* Get criteria-level score distribution
*/
getCriteriaScores: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
// Get active evaluation form for this round
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
where: { roundId: input.roundId, isActive: true },
// Get active evaluation forms — either for a specific round or all rounds in the edition
const formWhere = input.roundId
? { roundId: input.roundId, isActive: true }
: { round: { programId: input.programId! }, isActive: true }
const evaluationForms = await ctx.prisma.evaluationForm.findMany({
where: formWhere,
})
if (!evaluationForm?.criteriaJson) {
if (!evaluationForms.length) {
return []
}
// Parse criteria from JSON
const criteria = evaluationForm.criteriaJson as Array<{
id: string
label: string
}>
// Merge criteria from all forms (deduplicate by label for edition-wide)
const criteriaMap = new Map<string, { id: string; label: string }>()
evaluationForms.forEach((form) => {
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
if (criteria) {
criteria.forEach((c) => {
// Use label as dedup key for edition-wide, id for single round
const key = input.roundId ? c.id : c.label
if (!criteriaMap.has(key)) {
criteriaMap.set(key, c)
}
})
}
})
if (!criteria || criteria.length === 0) {
const criteria = Array.from(criteriaMap.values())
if (criteria.length === 0) {
return []
}
// Get all evaluations
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
select: { criterionScoresJson: true },
})
@@ -441,13 +469,10 @@ export const analyticsRouter = router({
* Get juror consistency metrics for a round
*/
getJurorConsistency: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const evaluations = await ctx.prisma.evaluation.findMany({
where: {
assignment: { roundId: input.roundId },
status: 'SUBMITTED',
},
where: evalWhere(input, { status: 'SUBMITTED' }),
include: {
assignment: {
include: {
@@ -513,10 +538,10 @@ export const analyticsRouter = router({
* Get diversity metrics for projects in a round
*/
getDiversityMetrics: observerProcedure
.input(z.object({ roundId: z.string() }))
.input(editionOrRoundInput)
.query(async ({ ctx, input }) => {
const projects = await ctx.prisma.project.findMany({
where: { roundId: input.roundId },
where: projectWhere(input),
select: {
country: true,
competitionCategory: true,

View File

@@ -1,12 +1,19 @@
import crypto from 'crypto'
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification'
// Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
function generateInviteToken(): string {
return crypto.randomBytes(32).toString('hex')
}
export const applicantRouter = router({
/**
@@ -775,6 +782,8 @@ export const applicantRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const normalizedEmail = input.email.trim().toLowerCase()
// Verify user is team lead
const project = await ctx.prisma.project.findFirst({
where: {
@@ -804,7 +813,7 @@ export const applicantRouter = router({
const existingMember = await ctx.prisma.teamMember.findFirst({
where: {
projectId: input.projectId,
user: { email: input.email },
user: { email: normalizedEmail },
},
})
@@ -817,13 +826,13 @@ export const applicantRouter = router({
// Find or create user
let user = await ctx.prisma.user.findUnique({
where: { email: input.email },
where: { email: normalizedEmail },
})
if (!user) {
user = await ctx.prisma.user.create({
data: {
email: input.email,
email: normalizedEmail,
name: input.name,
role: 'APPLICANT',
status: 'NONE',
@@ -831,6 +840,77 @@ export const applicantRouter = router({
})
}
if (user.status === 'SUSPENDED') {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'This user account is suspended and cannot be invited',
})
}
const teamLeadName = ctx.user.name?.trim() || 'A team lead'
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const requiresAccountSetup = user.status !== 'ACTIVE'
try {
if (requiresAccountSetup) {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
status: 'INVITED',
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + TEAM_INVITE_TOKEN_EXPIRY_MS),
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendTeamMemberInviteEmail(
user.email,
user.name || input.name,
project.title,
teamLeadName,
inviteUrl
)
} else {
await sendStyledNotificationEmail(
user.email,
user.name || input.name,
'TEAM_INVITATION',
{
title: 'You were added to a project team',
message: `${teamLeadName} added you to the project "${project.title}".`,
linkUrl: `${baseUrl}/applicant/team`,
linkLabel: 'Open Team',
metadata: {
projectId: project.id,
projectName: project.title,
},
},
`You've been added to "${project.title}"`
)
}
} catch (error) {
try {
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'TEAM_INVITATION',
status: 'FAILED',
errorMsg: error instanceof Error ? error.message : 'Unknown error',
},
})
} catch {
// Never fail on notification logging
}
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to send invitation email. Please try again.',
})
}
// Create team membership
const teamMember = await ctx.prisma.teamMember.create({
data: {
@@ -846,9 +926,43 @@ export const applicantRouter = router({
},
})
// TODO: Send invitation email to the new team member
try {
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'TEAM_INVITATION',
status: 'SENT',
},
})
} catch {
// Never fail on notification logging
}
return teamMember
try {
await createNotification({
userId: user.id,
type: 'TEAM_INVITATION',
title: 'Team Invitation',
message: `${teamLeadName} added you to "${project.title}"`,
linkUrl: '/applicant/team',
linkLabel: 'View Team',
priority: 'normal',
metadata: {
projectId: project.id,
projectName: project.title,
},
})
} catch {
// Never fail invitation flow on in-app notification issues
}
return {
teamMember,
inviteEmailSent: true,
requiresAccountSetup,
}
}),
/**

View File

@@ -87,18 +87,44 @@ export const roundRouter = router({
},
})
// Get evaluation stats
const evaluationStats = await ctx.prisma.evaluation.groupBy({
by: ['status'],
where: {
assignment: { roundId: input.id },
// Get evaluation stats + progress in parallel (avoids duplicate groupBy in getProgress)
const [evaluationStats, totalAssignments, completedAssignments] =
await Promise.all([
ctx.prisma.evaluation.groupBy({
by: ['status'],
where: {
assignment: { roundId: input.id },
},
_count: true,
}),
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
ctx.prisma.assignment.count({
where: { roundId: input.id, isCompleted: true },
}),
])
const evaluationsByStatus = evaluationStats.reduce(
(acc, curr) => {
acc[curr.status] = curr._count
return acc
},
_count: true,
})
{} as Record<string, number>
)
return {
...round,
evaluationStats,
// Inline progress data (eliminates need for separate getProgress call)
progress: {
totalProjects: round._count.projects,
totalAssignments,
completedAssignments,
completionPercentage:
totalAssignments > 0
? Math.round((completedAssignments / totalAssignments) * 100)
: 0,
evaluationsByStatus,
},
}
}),

View File

@@ -525,32 +525,31 @@ export const specialAwardRouter = router({
})
}
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
})
// Get eligible projects
const eligibleProjects = await ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
description: true,
competitionCategory: true,
country: true,
tags: true,
// Fetch award, eligible projects, and votes in parallel
const [award, eligibleProjects, myVotes] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
}),
ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true },
include: {
project: {
select: {
id: true,
title: true,
teamName: true,
description: true,
competitionCategory: true,
country: true,
tags: true,
},
},
},
},
})
// Get user's existing votes
const myVotes = await ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: ctx.user.id },
})
}),
ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: ctx.user.id },
}),
])
return {
award,
@@ -646,25 +645,25 @@ export const specialAwardRouter = router({
getVoteResults: adminProcedure
.input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
})
const votes = await ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId },
include: {
project: {
select: { id: true, title: true, teamName: true },
const [award, votes, jurorCount] = await Promise.all([
ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
}),
ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId },
include: {
project: {
select: { id: true, title: true, teamName: true },
},
user: {
select: { id: true, name: true, email: true },
},
},
user: {
select: { id: true, name: true, email: true },
},
},
})
const jurorCount = await ctx.prisma.awardJuror.count({
where: { awardId: input.awardId },
})
}),
ctx.prisma.awardJuror.count({
where: { awardId: input.awardId },
}),
])
const votedJurorCount = new Set(votes.map((v) => v.userId)).size

View File

@@ -485,6 +485,7 @@ export const userRouter = router({
.optional(),
})
),
sendInvitation: z.boolean().default(true),
})
)
.mutation(async ({ ctx, input }) => {
@@ -544,7 +545,7 @@ export const userRouter = router({
name: u.name,
role: u.role,
expertiseTags: u.expertiseTags,
status: 'INVITED',
status: input.sendInvitation ? 'INVITED' : 'NONE',
})),
})
@@ -559,8 +560,7 @@ export const userRouter = router({
userAgent: ctx.userAgent,
})
// Auto-send invitation emails to newly created users
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
// Fetch newly created users for assignments and optional invitation emails
const createdUsers = await ctx.prisma.user.findMany({
where: { email: { in: newUsers.map((u) => u.email.toLowerCase()) } },
select: { id: true, email: true, name: true, role: true },
@@ -603,49 +603,54 @@ export const userRouter = router({
})
}
// Send invitation emails if requested
let emailsSent = 0
const emailErrors: string[] = []
for (const user of createdUsers) {
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
if (input.sendInvitation) {
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
for (const user of createdUsers) {
try {
const token = generateInviteToken()
await ctx.prisma.user.update({
where: { id: user.id },
data: {
inviteToken: token,
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
},
})
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
emailsSent++
} catch (e) {
emailErrors.push(user.email)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'FAILED',
errorMsg: e instanceof Error ? e.message : 'Unknown error',
},
})
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'SENT',
},
})
emailsSent++
} catch (e) {
emailErrors.push(user.email)
await ctx.prisma.notificationLog.create({
data: {
userId: user.id,
channel: 'EMAIL',
provider: 'SMTP',
type: 'JURY_INVITATION',
status: 'FAILED',
errorMsg: e instanceof Error ? e.message : 'Unknown error',
},
})
}
}
}
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated }
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, invitationSent: input.sendInvitation }
}),
/**