Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening
Build and Push Docker Image / build (push) Successful in 11m14s Details

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:
Matt 2026-02-11 13:20:52 +01:00
parent 98f4a957cc
commit ce4069bf92
59 changed files with 1949 additions and 913 deletions

View File

@ -12,7 +12,7 @@ The app is built automatically by a Gitea runner on every push to `main`:
1. Gitea Actions workflow builds the Docker image on Ubuntu
2. Image is pushed to the Gitea container registry
3. On the server, you pull the latest image and restart
3. On the server, `docker compose up -d` refreshes the image and restarts the app
### Gitea Setup
@ -165,6 +165,15 @@ cd /opt/mopc
This pulls the latest image from the registry, restarts only the app container (PostgreSQL stays running), runs migrations via the entrypoint, and waits for the health check.
Manual equivalent:
```bash
cd /opt/mopc/docker
docker compose up -d --pull always --force-recreate app
```
`prisma migrate deploy` runs automatically in the container entrypoint before the app starts.
## Manual Operations
### View logs

View File

@ -5,12 +5,13 @@
# MinIO and Poste.io are external services connected via environment variables.
#
# The app image is built by Gitea CI and pushed to the container registry.
# To pull the latest image: docker compose pull app
# To deploy: docker compose up -d
# `pull_policy: always` ensures `docker compose up -d` checks for newer app images.
# The app entrypoint runs `prisma migrate deploy` before starting Next.js.
services:
app:
image: ${REGISTRY_URL}/mopc-app:latest
pull_policy: always
container_name: mopc-app
restart: unless-stopped
dns:

View File

@ -1,8 +1,20 @@
#!/bin/sh
set -e
set -eu
echo "==> Running database migrations..."
npx prisma migrate deploy
MAX_MIGRATION_RETRIES="${MIGRATION_MAX_RETRIES:-30}"
MIGRATION_RETRY_DELAY_SECONDS="${MIGRATION_RETRY_DELAY_SECONDS:-2}"
ATTEMPT=1
echo "==> Running database migrations (with retry)..."
until npx prisma migrate deploy; do
if [ "$ATTEMPT" -ge "$MAX_MIGRATION_RETRIES" ]; then
echo "ERROR: Migration failed after ${MAX_MIGRATION_RETRIES} attempts."
exit 1
fi
echo "Migration attempt ${ATTEMPT} failed. Retrying in ${MIGRATION_RETRY_DELAY_SECONDS}s..."
ATTEMPT=$((ATTEMPT + 1))
sleep "$MIGRATION_RETRY_DELAY_SECONDS"
done
echo "==> Generating Prisma client..."
npx prisma generate

View File

@ -86,7 +86,13 @@
"mentoring": "Mentoring",
"liveVoting": "Live Voting",
"applications": "Applications",
"messages": "Messages"
"messages": "Messages",
"team": "Team",
"documents": "Documents",
"awards": "Awards",
"compare": "Compare",
"learningHub": "Learning Hub",
"reports": "Reports"
},
"dashboard": {
"title": "Dashboard",

View File

@ -86,7 +86,13 @@
"mentoring": "Mentorat",
"liveVoting": "Vote en direct",
"applications": "Candidatures",
"messages": "Messages"
"messages": "Messages",
"team": "\u00c9quipe",
"documents": "Documents",
"awards": "Prix",
"compare": "Comparer",
"learningHub": "Centre de ressources",
"reports": "Rapports"
},
"dashboard": {
"title": "Tableau de bord",

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");

View File

@ -1449,6 +1449,7 @@ model AwardVote {
@@index([awardId])
@@index([userId])
@@index([projectId])
@@index([awardId, userId])
}
// =============================================================================

View File

@ -58,12 +58,9 @@ sudo mkdir -p /data/mopc/postgres
sudo chown -R 1000:1000 /data/mopc
# 6. Pull and start
echo "==> Pulling latest images..."
echo "==> Pulling latest images and starting services..."
cd "$DOCKER_DIR"
docker compose pull app
echo "==> Starting services..."
docker compose up -d
docker compose up -d --pull always
# 7. Wait for health check
echo "==> Waiting for application to start..."

View File

@ -17,16 +17,12 @@ echo " MOPC Platform - Update"
echo "============================================"
echo ""
# 1. Pull latest image from registry
echo "==> Pulling latest image..."
# 1. Pull and recreate app only (postgres stays running)
echo "==> Pulling latest image and recreating app..."
cd "$DOCKER_DIR"
docker compose pull app
docker compose up -d --pull always --force-recreate app
# 2. Restart app only (postgres stays running)
echo "==> Restarting app..."
docker compose up -d app
# 3. Wait for health check
# 2. Wait for health check
echo "==> Waiting for application to start..."
MAX_WAIT=120
WAITED=0

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 }
}),
/**