Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening
Build and Push Docker Image / build (push) Successful in 11m14s
Details
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:
parent
98f4a957cc
commit
ce4069bf92
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
||||||
|
|
@ -1449,6 +1449,7 @@ model AwardVote {
|
||||||
@@index([awardId])
|
@@index([awardId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
|
@@index([awardId, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 “Not Invited” 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 "Start Analysis" to generate AI-powered suggestions
|
Click "Start Analysis" 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>
|
||||||
|
|
|
||||||
|
|
@ -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 = [
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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'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'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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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's an overview of your evaluation progress
|
||||||
<p className="text-muted-foreground mt-0.5">
|
</p>
|
||||||
Here's an overview of your evaluation progress
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue