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 1. Gitea Actions workflow builds the Docker image on Ubuntu
2. Image is pushed to the Gitea container registry 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 ### 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. 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 ## Manual Operations
### View logs ### View logs

View File

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

View File

@ -1,8 +1,20 @@
#!/bin/sh #!/bin/sh
set -e set -eu
echo "==> Running database migrations..." MAX_MIGRATION_RETRIES="${MIGRATION_MAX_RETRIES:-30}"
npx prisma migrate deploy 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..." echo "==> Generating Prisma client..."
npx prisma generate npx prisma generate

View File

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

View File

@ -86,7 +86,13 @@
"mentoring": "Mentorat", "mentoring": "Mentorat",
"liveVoting": "Vote en direct", "liveVoting": "Vote en direct",
"applications": "Candidatures", "applications": "Candidatures",
"messages": "Messages" "messages": "Messages",
"team": "\u00c9quipe",
"documents": "Documents",
"awards": "Prix",
"compare": "Comparer",
"learningHub": "Centre de ressources",
"reports": "Rapports"
}, },
"dashboard": { "dashboard": {
"title": "Tableau de bord", "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([awardId])
@@index([userId]) @@index([userId])
@@index([projectId]) @@index([projectId])
@@index([awardId, userId])
} }
// ============================================================================= // =============================================================================

View File

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

View File

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

View File

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

View File

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

View File

@ -52,48 +52,228 @@ type DashboardContentProps = {
sessionName: string 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 { function formatAction(action: string, entityType: string | null): string {
const entity = entityType?.toLowerCase() || 'record' const entity = formatEntity(entityType)
const actionMap: Record<string, string> = { const actionMap: Record<string, string> = {
// Generic CRUD
CREATE: `created a ${entity}`, CREATE: `created a ${entity}`,
UPDATE: `updated a ${entity}`, UPDATE: `updated a ${entity}`,
DELETE: `deleted 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`, 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) { function getActionIcon(action: string) {
switch (action) { switch (action) {
case 'CREATE': return <Plus className="h-3.5 w-3.5" /> case 'CREATE':
case 'UPDATE': return <FileEdit className="h-3.5 w-3.5" /> case 'BULK_CREATE':
case 'DELETE': return <Trash2 className="h-3.5 w-3.5" /> return <Plus className="h-3.5 w-3.5" />
case 'LOGIN': return <LogIn className="h-3.5 w-3.5" /> case 'UPDATE':
case 'EXPORT': return <ArrowRight className="h-3.5 w-3.5" /> case 'UPDATE_STATUS':
case 'SUBMIT': return <Send className="h-3.5 w-3.5" /> case 'BULK_UPDATE':
case 'ASSIGN': return <Users className="h-3.5 w-3.5" /> case 'BULK_UPDATE_STATUS':
case 'INVITE': return <UserPlus className="h-3.5 w-3.5" /> case 'STATUS_CHANGE':
default: return <Eye className="h-3.5 w-3.5" /> 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) { export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading } = trpc.dashboard.getStats.useQuery( const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
{ editionId }, { editionId },
{ enabled: !!editionId } { enabled: !!editionId, retry: 1 }
) )
if (isLoading) { if (isLoading) {
return <DashboardSkeleton /> 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) { if (!data) {
return ( return (
<Card> <Card>
@ -204,69 +384,85 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}> <AnimatedCard index={0}>
<Card className="transition-all hover:shadow-md"> <Card className="border-l-4 border-l-blue-500 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"> <CardContent className="p-5">
<CardTitle className="text-sm font-medium">Rounds</CardTitle> <div className="flex items-center justify-between">
<CircleDot className="h-4 w-4 text-muted-foreground" /> <div>
</CardHeader> <p className="text-sm font-medium text-muted-foreground">Rounds</p>
<CardContent> <p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
<div className="text-2xl font-bold">{totalRoundCount}</div> <p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-muted-foreground"> {activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''} </p>
</p> </div>
<div className="rounded-xl bg-blue-50 p-3">
<CircleDot className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
<AnimatedCard index={1}> <AnimatedCard index={1}>
<Card className="transition-all hover:shadow-md"> <Card className="border-l-4 border-l-emerald-500 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"> <CardContent className="p-5">
<CardTitle className="text-sm font-medium">Projects</CardTitle> <div className="flex items-center justify-between">
<ClipboardList className="h-4 w-4 text-muted-foreground" /> <div>
</CardHeader> <p className="text-sm font-medium text-muted-foreground">Projects</p>
<CardContent> <p className="text-2xl font-bold mt-1">{projectCount}</p>
<div className="text-2xl font-bold">{projectCount}</div> <p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-muted-foreground"> {newProjectsThisWeek > 0
{newProjectsThisWeek > 0 ? `${newProjectsThisWeek} new this week`
? `${newProjectsThisWeek} new this week` : 'In this edition'}
: 'In this edition'} </p>
</p> </div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
<AnimatedCard index={2}> <AnimatedCard index={2}>
<Card className="transition-all hover:shadow-md"> <Card className="border-l-4 border-l-violet-500 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"> <CardContent className="p-5">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle> <div className="flex items-center justify-between">
<Users className="h-4 w-4 text-muted-foreground" /> <div>
</CardHeader> <p className="text-sm font-medium text-muted-foreground">Jury Members</p>
<CardContent> <p className="text-2xl font-bold mt-1">{totalJurors}</p>
<div className="text-2xl font-bold">{totalJurors}</div> <p className="text-xs text-muted-foreground mt-1">
<p className="text-xs text-muted-foreground"> {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`} </p>
</p> </div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard> </AnimatedCard>
<AnimatedCard index={3}> <AnimatedCard index={3}>
<Card className="transition-all hover:shadow-md"> <Card className="border-l-4 border-l-brand-teal 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"> <CardContent className="p-5">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle> <div className="flex items-center justify-between">
<CheckCircle2 className="h-4 w-4 text-muted-foreground" /> <div>
</CardHeader> <p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<CardContent> <p className="text-2xl font-bold mt-1">
<div className="text-2xl font-bold"> {submittedCount}
{submittedCount} {totalAssignments > 0 && (
{totalAssignments > 0 && ( <span className="text-sm font-normal text-muted-foreground">
<span className="text-sm font-normal text-muted-foreground"> {' '}/ {totalAssignments}
{' '}/ {totalAssignments} </span>
</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>
<div className="mt-2"> <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"> <p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate {completionRate.toFixed(0)}% completion rate
</p> </p>
@ -277,25 +473,34 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}
<div className="flex flex-wrap gap-2"> <div className="grid gap-3 sm:grid-cols-3">
<Button variant="outline" size="sm" asChild> <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">
<Link href="/admin/rounds/new"> <div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
<Plus className="mr-1.5 h-3.5 w-3.5" /> <Plus className="h-4 w-4 text-blue-600" />
New Round </div>
</Link> <div>
</Button> <p className="text-sm font-medium">New Round</p>
<Button variant="outline" size="sm" asChild> <p className="text-xs text-muted-foreground">Create a voting round</p>
<Link href="/admin/projects/new"> </div>
<Upload className="mr-1.5 h-3.5 w-3.5" /> </Link>
Import Projects <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">
</Link> <div className="rounded-xl bg-emerald-50 p-2.5 transition-colors group-hover:bg-emerald-100">
</Button> <Upload className="h-4 w-4 text-emerald-600" />
<Button variant="outline" size="sm" asChild> </div>
<Link href="/admin/members"> <div>
<UserPlus className="mr-1.5 h-3.5 w-3.5" /> <p className="text-sm font-medium">Import Projects</p>
Invite Jury <p className="text-xs text-muted-foreground">Upload a CSV file</p>
</Link> </div>
</Button> </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> </div>
{/* Two-Column Content */} {/* Two-Column Content */}
@ -303,11 +508,17 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{/* Left Column */} {/* Left Column */}
<div className="space-y-6 lg:col-span-7"> <div className="space-y-6 lg:col-span-7">
{/* Rounds Card (enhanced) */} {/* Rounds Card (enhanced) */}
<AnimatedCard index={4}>
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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> <CardDescription>
Voting rounds in {edition.name} Voting rounds in {edition.name}
</CardDescription> </CardDescription>
@ -363,7 +574,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div> </div>
</div> </div>
{round.totalEvals > 0 && ( {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> </div>
</Link> </Link>
@ -372,13 +583,20 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)} )}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
{/* Latest Projects Card */} {/* Latest Projects Card */}
<AnimatedCard index={5}>
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <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> <CardDescription>Recently submitted projects</CardDescription>
</div> </div>
<Link <Link
@ -453,15 +671,19 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)} )}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
</div> </div>
{/* Right Column */} {/* Right Column */}
<div className="space-y-6 lg:col-span-5"> <div className="space-y-6 lg:col-span-5">
{/* Pending Actions Card */} {/* Pending Actions Card */}
<AnimatedCard index={6}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2.5">
<AlertTriangle className="h-4 w-4" /> <div className="rounded-lg bg-amber-500/10 p-1.5">
<AlertTriangle className="h-4 w-4 text-amber-500" />
</div>
Pending Actions Pending Actions
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -503,12 +725,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
{/* Evaluation Progress Card */} {/* Evaluation Progress Card */}
<AnimatedCard index={7}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2.5">
<TrendingUp className="h-4 w-4" /> <div className="rounded-lg bg-brand-teal/10 p-1.5">
<TrendingUp className="h-4 w-4 text-brand-teal" />
</div>
Evaluation Progress Evaluation Progress
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -532,7 +758,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{round.evalPercent}% {round.evalPercent}%
</span> </span>
</div> </div>
<Progress value={round.evalPercent} className="h-2" /> <Progress value={round.evalPercent} className="h-2" gradient />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{round.submittedEvals} of {round.totalEvals} evaluations submitted {round.submittedEvals} of {round.totalEvals} evaluations submitted
</p> </p>
@ -542,12 +768,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)} )}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
{/* Category Breakdown Card */} {/* Category Breakdown Card */}
<AnimatedCard index={8}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2.5">
<Layers className="h-4 w-4" /> <div className="rounded-lg bg-violet-500/10 p-1.5">
<Layers className="h-4 w-4 text-violet-500" />
</div>
Project Categories Project Categories
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -607,12 +837,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)} )}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
{/* Recent Activity Card */} {/* Recent Activity Card */}
<AnimatedCard index={9}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2.5">
<Activity className="h-4 w-4" /> <div className="rounded-lg bg-blue-500/10 p-1.5">
<Activity className="h-4 w-4 text-blue-500" />
</div>
Recent Activity Recent Activity
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -646,12 +880,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)} )}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
{/* Upcoming Deadlines Card */} {/* Upcoming Deadlines Card */}
<AnimatedCard index={10}>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2.5">
<Calendar className="h-4 w-4" /> <div className="rounded-lg bg-rose-500/10 p-1.5">
<Calendar className="h-4 w-4 text-rose-500" />
</div>
Upcoming Deadlines Upcoming Deadlines
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -688,6 +926,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)} )}
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
</div> </div>
</div> </div>

View File

@ -50,6 +50,7 @@ import {
CommandItem, CommandItem,
CommandList, CommandList,
} from '@/components/ui/command' } from '@/components/ui/command'
import { Switch } from '@/components/ui/switch'
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
@ -65,6 +66,8 @@ import {
ChevronDown, ChevronDown,
Check, Check,
Tags, Tags,
Mail,
MailX,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -257,10 +260,12 @@ export default function MemberInvitePage() {
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()]) const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([]) const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
const [sendProgress, setSendProgress] = useState(0) const [sendProgress, setSendProgress] = useState(0)
const [sendInvitation, setSendInvitation] = useState(true)
const [result, setResult] = useState<{ const [result, setResult] = useState<{
created: number created: number
skipped: number skipped: number
assignmentsCreated?: number assignmentsCreated?: number
invitationSent?: boolean
} | null>(null) } | null>(null)
// Pre-assignment state // Pre-assignment state
@ -505,6 +510,7 @@ export default function MemberInvitePage() {
expertiseTags: u.expertiseTags, expertiseTags: u.expertiseTags,
assignments: u.assignments, assignments: u.assignments,
})), })),
sendInvitation,
}) })
setSendProgress(100) setSendProgress(100)
setResult(result) setResult(result)
@ -520,6 +526,7 @@ export default function MemberInvitePage() {
setParsedUsers([]) setParsedUsers([])
setResult(null) setResult(null)
setSendProgress(0) setSendProgress(0)
setSendInvitation(true)
} }
const hasManualData = rows.some((r) => r.email.trim() || r.name.trim()) const hasManualData = rows.some((r) => r.email.trim() || r.name.trim())
@ -793,6 +800,32 @@ export default function MemberInvitePage() {
</div> </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 */} {/* Actions */}
<div className="flex justify-between pt-4"> <div className="flex justify-between pt-4">
<Button variant="outline" asChild> <Button variant="outline" asChild>
@ -844,6 +877,18 @@ export default function MemberInvitePage() {
</div> </div>
</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 && ( {summary.invalid > 0 && (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700"> <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" /> <AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
@ -924,10 +969,12 @@ export default function MemberInvitePage() {
> >
{bulkCreate.isPending ? ( {bulkCreate.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <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" /> <Users className="mr-2 h-4 w-4" />
)} )}
Create & Invite {summary.valid} Member {sendInvitation ? 'Create & Invite' : 'Create'} {summary.valid} Member
{summary.valid !== 1 ? 's' : ''} {summary.valid !== 1 ? 's' : ''}
</Button> </Button>
</div> </div>
@ -948,7 +995,7 @@ export default function MemberInvitePage() {
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" /> <Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 font-medium"> <p className="mt-4 font-medium">
Creating members and sending invitations... {sendInvitation ? 'Creating members and sending invitations...' : 'Creating members...'}
</p> </p>
<Progress value={sendProgress} className="mt-4 w-48" /> <Progress value={sendProgress} className="mt-4 w-48" />
</CardContent> </CardContent>
@ -963,23 +1010,28 @@ export default function MemberInvitePage() {
<CheckCircle2 className="h-8 w-8 text-green-600" /> <CheckCircle2 className="h-8 w-8 text-green-600" />
</div> </div>
<p className="mt-4 text-xl font-semibold"> <p className="mt-4 text-xl font-semibold">
Invitations Sent! {result?.invitationSent ? 'Members Created & Invited!' : 'Members Created!'}
</p> </p>
<p className="text-muted-foreground text-center max-w-sm mt-2"> <p className="text-muted-foreground text-center max-w-sm mt-2">
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '} {result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
created and invited. {result?.invitationSent ? 'created and invited' : 'created'}.
{result?.skipped {result?.skipped
? ` ${result.skipped} skipped (already exist).` ? ` ${result.skipped} skipped (already exist).`
: ''} : ''}
{result?.assignmentsCreated && result.assignmentsCreated > 0 {result?.assignmentsCreated && result.assignmentsCreated > 0
? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.` ? ` ${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> </p>
<div className="mt-6 flex gap-3"> <div className="mt-6 flex gap-3">
<Button variant="outline" asChild> <Button variant="outline" asChild>
<Link href="/admin/members">View Members</Link> <Link href="/admin/members">View Members</Link>
</Button> </Button>
<Button onClick={resetForm}>Invite More</Button> <Button onClick={resetForm}>Add More</Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

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

View File

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

View File

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

View File

@ -60,7 +60,6 @@ import {
Search, Search,
Trash2, Trash2,
Loader2, Loader2,
Sparkles,
Tags, Tags,
Clock, Clock,
CheckCircle2, CheckCircle2,
@ -98,6 +97,7 @@ import {
ProjectFiltersBar, ProjectFiltersBar,
type ProjectFilters, type ProjectFilters,
} from './project-filters' } from './project-filters'
import { AnimatedCard } from '@/components/shared/animated-container'
const statusColors: Record< const statusColors: Record<
string, string,
@ -584,7 +584,7 @@ export default function ProjectsPage() {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}> <Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" /> <Tags className="mr-2 h-4 w-4" />
AI Tags AI Tags
</Button> </Button>
<Button variant="outline" asChild> <Button variant="outline" asChild>
@ -983,7 +983,7 @@ export default function ProjectsPage() {
/> />
</div> </div>
<Link href={`/admin/projects/${project.id}`} className="block"> <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"> <CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-8"> <div className="flex items-start gap-3 pl-8">
<ProjectLogo project={project} size="md" fallback="initials" /> <ProjectLogo project={project} size="md" fallback="initials" />
@ -1051,7 +1051,7 @@ export default function ProjectsPage() {
/> />
</div> </div>
<Link href={`/admin/projects/${project.id}`} className="block"> <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"> <CardHeader className="pb-3">
<div className="flex items-start gap-3 pl-7"> <div className="flex items-start gap-3 pl-7">
<ProjectLogo project={project} size="lg" fallback="initials" /> <ProjectLogo project={project} size="lg" fallback="initials" />
@ -1483,7 +1483,7 @@ export default function ProjectsPage() {
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <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"> <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>
<div> <div>
<span>AI Tag Generator</span> <span>AI Tag Generator</span>
@ -1723,7 +1723,7 @@ export default function ProjectsPage() {
{taggingInProgress ? ( {taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <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'} {taggingInProgress ? 'Processing...' : 'Generate Tags'}
</Button> </Button>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Logo } from '@/components/shared/logo' import { Logo } from '@/components/shared/logo'
import { AlertCircle } from 'lucide-react' import { AlertCircle } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
const errorMessages: Record<string, string> = { const errorMessages: Record<string, string> = {
Configuration: 'There is a problem with the server configuration.', Configuration: 'There is a problem with the server configuration.',
@ -20,12 +21,14 @@ export default function AuthErrorPage() {
const message = errorMessages[error] || errorMessages.Default const message = errorMessages[error] || errorMessages.Default
return ( 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"> <CardHeader className="text-center">
<div className="mx-auto mb-4"> <div className="mx-auto mb-4">
<Logo variant="small" /> <Logo variant="small" />
</div> </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" /> <AlertCircle className="h-6 w-6 text-destructive" />
</div> </div>
<CardTitle className="text-xl">Authentication Error</CardTitle> <CardTitle className="text-xl">Authentication Error</CardTitle>
@ -42,5 +45,6 @@ export default function AuthErrorPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
} }

View File

@ -14,6 +14,7 @@ import {
CardTitle, CardTitle,
} from '@/components/ui/card' } from '@/components/ui/card'
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react' import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
type LoginMode = 'password' | 'magic-link' type LoginMode = 'password' | 'magic-link'
@ -102,9 +103,11 @@ export default function LoginPage() {
// Success state after sending magic link // Success state after sending magic link
if (isSent) { if (isSent) {
return ( 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"> <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" /> <Mail className="h-8 w-8 text-green-600" />
</div> </div>
<CardTitle className="text-xl">Check your email</CardTitle> <CardTitle className="text-xl">Check your email</CardTitle>
@ -137,11 +140,14 @@ export default function LoginPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
} }
return ( 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"> <CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome back</CardTitle> <CardTitle className="text-2xl">Welcome back</CardTitle>
<CardDescription> <CardDescription>
@ -299,5 +305,6 @@ export default function LoginPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
} }

View File

@ -41,6 +41,7 @@ import {
Globe, Globe,
FileText, FileText,
} from 'lucide-react' } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete' type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete'
@ -181,19 +182,24 @@ export default function OnboardingPage() {
if (sessionStatus === 'loading' || userLoading || !initialized) { if (sessionStatus === 'loading' || userLoading || !initialized) {
return ( 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]"> <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"> <CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" /> <Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
<p className="text-muted-foreground">Loading your profile...</p> <p className="text-muted-foreground">Loading your profile...</p>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
</div> </div>
) )
} }
return ( 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]"> <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 */} {/* Progress indicator */}
<div className="px-6 pt-6"> <div className="px-6 pt-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -570,7 +576,7 @@ export default function OnboardingPage() {
{/* Step 7: Complete */} {/* Step 7: Complete */}
{step === 'complete' && ( {step === 'complete' && (
<CardContent className="flex flex-col items-center justify-center py-12"> <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" /> <CheckCircle className="h-12 w-12 text-green-600" />
</div> </div>
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200"> <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> </CardContent>
)} )}
</Card> </Card>
</AnimatedCard>
</div> </div>
) )
} }

View File

@ -17,6 +17,7 @@ import { Progress } from '@/components/ui/progress'
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react' import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { AnimatedCard } from '@/components/shared/animated-container'
export default function SetPasswordPage() { export default function SetPasswordPage() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@ -116,20 +117,25 @@ export default function SetPasswordPage() {
// Loading state while checking session // Loading state while checking session
if (session === undefined) { if (session === undefined) {
return ( 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"> <CardContent className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
} }
// Success state // Success state
if (isSuccess) { if (isSuccess) {
return ( 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"> <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" /> <CheckCircle2 className="h-6 w-6 text-green-600" />
</div> </div>
<CardTitle className="text-xl">Password Set Successfully</CardTitle> <CardTitle className="text-xl">Password Set Successfully</CardTitle>
@ -144,13 +150,16 @@ export default function SetPasswordPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
} }
return ( 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"> <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" /> <Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
</div> </div>
<CardTitle className="text-xl">Set Your Password</CardTitle> <CardTitle className="text-xl">Set Your Password</CardTitle>
@ -294,5 +303,6 @@ export default function SetPasswordPage() {
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
} }

View File

@ -1,11 +1,14 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Mail } from 'lucide-react' import { Mail } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
export default function VerifyEmailPage() { export default function VerifyEmailPage() {
return ( 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"> <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" /> <Mail className="h-6 w-6 text-brand-teal" />
</div> </div>
<CardTitle className="text-xl">Check your email</CardTitle> <CardTitle className="text-xl">Check your email</CardTitle>
@ -23,5 +26,6 @@ export default function VerifyEmailPage() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
} }

View File

@ -2,12 +2,15 @@ import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2 } from 'lucide-react'
import { AnimatedCard } from '@/components/shared/animated-container'
export default function VerifyPage() { export default function VerifyPage() {
return ( 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"> <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" /> <CheckCircle2 className="h-6 w-6 text-green-600" />
</div> </div>
<CardTitle className="text-xl">Check your email</CardTitle> <CardTitle className="text-xl">Check your email</CardTitle>
@ -24,5 +27,6 @@ export default function VerifyPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</AnimatedCard>
) )
} }

View File

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

View File

@ -743,16 +743,13 @@ export default async function JuryDashboardPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="relative"> <div>
<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" /> <h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
<div className="relative"> {getGreeting()}, {session?.user?.name || 'Juror'}
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground"> </h1>
{getGreeting()}, {session?.user?.name || 'Juror'} <p className="text-muted-foreground mt-0.5">
</h1> Here&apos;s an overview of your evaluation progress
<p className="text-muted-foreground mt-0.5"> </p>
Here&apos;s an overview of your evaluation progress
</p>
</div>
</div> </div>
{/* Content */} {/* Content */}

View File

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

View File

@ -240,7 +240,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
{/* Description */} {/* Description */}
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent> <CardContent>
{project.description ? ( {project.description ? (
@ -266,7 +271,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
{/* Round Info */} {/* Round Info */}
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -310,7 +320,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
{evaluation && ( {evaluation && (
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { import {
Sparkles, FileText,
RefreshCw, RefreshCw,
Loader2, Loader2,
CheckCircle2, CheckCircle2,
@ -119,7 +119,7 @@ export function EvaluationSummaryCard({
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5" /> <FileText className="h-5 w-5" />
AI Evaluation Summary AI Evaluation Summary
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@ -128,7 +128,7 @@ export function EvaluationSummaryCard({
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col items-center justify-center py-6 text-center"> <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"> <p className="text-sm text-muted-foreground mb-4">
No summary generated yet. Click below to analyze submitted evaluations. No summary generated yet. Click below to analyze submitted evaluations.
</p> </p>
@ -136,7 +136,7 @@ export function EvaluationSummaryCard({
{isGenerating ? ( {isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <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'} {isGenerating ? 'Generating...' : 'Generate Summary'}
</Button> </Button>
@ -155,7 +155,7 @@ export function EvaluationSummaryCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle className="text-lg flex items-center gap-2"> <CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5" /> <FileText className="h-5 w-5" />
AI Evaluation Summary AI Evaluation Summary
</CardTitle> </CardTitle>
<CardDescription className="flex items-center gap-2 mt-1"> <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 { UserAvatar } from '@/components/shared/user-avatar'
import { UserActions, UserMobileActions } from '@/components/admin/user-actions' import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
import { Pagination } from '@/components/shared/pagination' 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' import { formatRelativeTime } from '@/lib/utils'
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' 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', 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() { export function MembersContent() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
@ -124,7 +158,7 @@ export function MembersContent() {
<Button asChild> <Button asChild>
<Link href="/admin/members/invite"> <Link href="/admin/members/invite">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Invite Member Add Member
</Link> </Link>
</Button> </Button>
</div> </div>
@ -223,9 +257,14 @@ export function MembersContent() {
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={statusColors[user.status] || 'secondary'}> <div className="flex items-center gap-2">
{statusLabels[user.status] || user.status} <Badge variant={statusColors[user.status] || 'secondary'}>
</Badge> {statusLabels[user.status] || user.status}
</Badge>
{user.status === 'NONE' && (
<InlineSendInvite userId={user.id} userEmail={user.email} />
)}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
{user.lastLoginAt ? ( {user.lastLoginAt ? (
@ -272,9 +311,14 @@ export function MembersContent() {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<Badge variant={statusColors[user.status] || 'secondary'}> <div className="flex flex-col items-end gap-1.5">
{statusLabels[user.status] || user.status} <Badge variant={statusColors[user.status] || 'secondary'}>
</Badge> {statusLabels[user.status] || user.status}
</Badge>
{user.status === 'NONE' && (
<InlineSendInvite userId={user.id} userEmail={user.email} />
)}
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">

View File

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

View File

@ -2,35 +2,37 @@
import { Home, Users, FileText, MessageSquare } from 'lucide-react' import { Home, Users, FileText, MessageSquare } from 'lucide-react'
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav' import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { useTranslations } from 'next-intl'
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,
},
]
interface ApplicantNavProps { interface ApplicantNavProps {
user: RoleNavUser user: RoleNavUser
} }
export function ApplicantNav({ user }: ApplicantNavProps) { 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 ( return (
<RoleNav <RoleNav
navigation={navigation} navigation={navigation}

View File

@ -1,32 +1,10 @@
'use client' '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 { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
import { trpc } from '@/lib/trpc/client' import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useTranslations } from 'next-intl'
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,
},
]
interface JuryNavProps { interface JuryNavProps {
user: RoleNavUser user: RoleNavUser
@ -65,6 +43,35 @@ function RemainingBadge() {
} }
export function JuryNav({ user }: JuryNavProps) { 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 ( return (
<RoleNav <RoleNav
navigation={navigation} navigation={navigation}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,7 +28,9 @@ export function EmptyState({
className 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> <h3 className="mt-4 font-medium">{title}</h3>
{description && ( {description && (
<p className="mt-1 max-w-sm text-sm text-muted-foreground"> <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< const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>, React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
>(({ className, value, ...props }, ref) => ( gradient?: boolean
}
>(({ className, value, gradient, ...props }, ref) => (
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
@ -17,7 +19,12 @@ const Progress = React.forwardRef<
{...props} {...props}
> >
<ProgressPrimitive.Indicator <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)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>

View File

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

View File

@ -1,12 +1,19 @@
import crypto from 'crypto'
import { z } from 'zod' import { z } from 'zod'
import { TRPCError } from '@trpc/server' import { TRPCError } from '@trpc/server'
import { router, publicProcedure, protectedProcedure } from '../trpc' import { router, publicProcedure, protectedProcedure } from '../trpc'
import { getPresignedUrl } from '@/lib/minio' import { getPresignedUrl } from '@/lib/minio'
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
import { logAudit } from '@/server/utils/audit' import { logAudit } from '@/server/utils/audit'
import { createNotification } from '../services/in-app-notification' import { createNotification } from '../services/in-app-notification'
// Bucket for applicant submissions // Bucket for applicant submissions
export const SUBMISSIONS_BUCKET = 'mopc-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({ export const applicantRouter = router({
/** /**
@ -775,6 +782,8 @@ export const applicantRouter = router({
}) })
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const normalizedEmail = input.email.trim().toLowerCase()
// Verify user is team lead // Verify user is team lead
const project = await ctx.prisma.project.findFirst({ const project = await ctx.prisma.project.findFirst({
where: { where: {
@ -804,7 +813,7 @@ export const applicantRouter = router({
const existingMember = await ctx.prisma.teamMember.findFirst({ const existingMember = await ctx.prisma.teamMember.findFirst({
where: { where: {
projectId: input.projectId, projectId: input.projectId,
user: { email: input.email }, user: { email: normalizedEmail },
}, },
}) })
@ -817,13 +826,13 @@ export const applicantRouter = router({
// Find or create user // Find or create user
let user = await ctx.prisma.user.findUnique({ let user = await ctx.prisma.user.findUnique({
where: { email: input.email }, where: { email: normalizedEmail },
}) })
if (!user) { if (!user) {
user = await ctx.prisma.user.create({ user = await ctx.prisma.user.create({
data: { data: {
email: input.email, email: normalizedEmail,
name: input.name, name: input.name,
role: 'APPLICANT', role: 'APPLICANT',
status: 'NONE', 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 // Create team membership
const teamMember = await ctx.prisma.teamMember.create({ const teamMember = await ctx.prisma.teamMember.create({
data: { 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 // Get evaluation stats + progress in parallel (avoids duplicate groupBy in getProgress)
const evaluationStats = await ctx.prisma.evaluation.groupBy({ const [evaluationStats, totalAssignments, completedAssignments] =
by: ['status'], await Promise.all([
where: { ctx.prisma.evaluation.groupBy({
assignment: { roundId: input.id }, 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 { return {
...round, ...round,
evaluationStats, 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({ // Fetch award, eligible projects, and votes in parallel
where: { id: input.awardId }, const [award, eligibleProjects, myVotes] = await Promise.all([
}) ctx.prisma.specialAward.findUniqueOrThrow({
where: { id: input.awardId },
// Get eligible projects }),
const eligibleProjects = await ctx.prisma.awardEligibility.findMany({ ctx.prisma.awardEligibility.findMany({
where: { awardId: input.awardId, eligible: true }, where: { awardId: input.awardId, eligible: true },
include: { include: {
project: { project: {
select: { select: {
id: true, id: true,
title: true, title: true,
teamName: true, teamName: true,
description: true, description: true,
competitionCategory: true, competitionCategory: true,
country: true, country: true,
tags: true, tags: true,
},
}, },
}, },
}, }),
}) ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId, userId: ctx.user.id },
// Get user's existing votes }),
const myVotes = await ctx.prisma.awardVote.findMany({ ])
where: { awardId: input.awardId, userId: ctx.user.id },
})
return { return {
award, award,
@ -646,25 +645,25 @@ export const specialAwardRouter = router({
getVoteResults: adminProcedure getVoteResults: adminProcedure
.input(z.object({ awardId: z.string() })) .input(z.object({ awardId: z.string() }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const award = await ctx.prisma.specialAward.findUniqueOrThrow({ const [award, votes, jurorCount] = await Promise.all([
where: { id: input.awardId }, ctx.prisma.specialAward.findUniqueOrThrow({
}) where: { id: input.awardId },
}),
const votes = await ctx.prisma.awardVote.findMany({ ctx.prisma.awardVote.findMany({
where: { awardId: input.awardId }, where: { awardId: input.awardId },
include: { include: {
project: { project: {
select: { id: true, title: true, teamName: true }, select: { id: true, title: true, teamName: true },
},
user: {
select: { id: true, name: true, email: true },
},
}, },
user: { }),
select: { id: true, name: true, email: true }, ctx.prisma.awardJuror.count({
}, where: { awardId: input.awardId },
}, }),
}) ])
const jurorCount = await ctx.prisma.awardJuror.count({
where: { awardId: input.awardId },
})
const votedJurorCount = new Set(votes.map((v) => v.userId)).size const votedJurorCount = new Set(votes.map((v) => v.userId)).size

View File

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