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
|
||||
2. Image is pushed to the Gitea container registry
|
||||
3. On the server, you pull the latest image and restart
|
||||
3. On the server, `docker compose up -d` refreshes the image and restarts the app
|
||||
|
||||
### Gitea Setup
|
||||
|
||||
|
|
@ -165,6 +165,15 @@ cd /opt/mopc
|
|||
|
||||
This pulls the latest image from the registry, restarts only the app container (PostgreSQL stays running), runs migrations via the entrypoint, and waits for the health check.
|
||||
|
||||
Manual equivalent:
|
||||
|
||||
```bash
|
||||
cd /opt/mopc/docker
|
||||
docker compose up -d --pull always --force-recreate app
|
||||
```
|
||||
|
||||
`prisma migrate deploy` runs automatically in the container entrypoint before the app starts.
|
||||
|
||||
## Manual Operations
|
||||
|
||||
### View logs
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@
|
|||
# MinIO and Poste.io are external services connected via environment variables.
|
||||
#
|
||||
# The app image is built by Gitea CI and pushed to the container registry.
|
||||
# To pull the latest image: docker compose pull app
|
||||
# To deploy: docker compose up -d
|
||||
# `pull_policy: always` ensures `docker compose up -d` checks for newer app images.
|
||||
# The app entrypoint runs `prisma migrate deploy` before starting Next.js.
|
||||
|
||||
services:
|
||||
app:
|
||||
image: ${REGISTRY_URL}/mopc-app:latest
|
||||
pull_policy: always
|
||||
container_name: mopc-app
|
||||
restart: unless-stopped
|
||||
dns:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
set -eu
|
||||
|
||||
echo "==> Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
MAX_MIGRATION_RETRIES="${MIGRATION_MAX_RETRIES:-30}"
|
||||
MIGRATION_RETRY_DELAY_SECONDS="${MIGRATION_RETRY_DELAY_SECONDS:-2}"
|
||||
ATTEMPT=1
|
||||
|
||||
echo "==> Running database migrations (with retry)..."
|
||||
until npx prisma migrate deploy; do
|
||||
if [ "$ATTEMPT" -ge "$MAX_MIGRATION_RETRIES" ]; then
|
||||
echo "ERROR: Migration failed after ${MAX_MIGRATION_RETRIES} attempts."
|
||||
exit 1
|
||||
fi
|
||||
echo "Migration attempt ${ATTEMPT} failed. Retrying in ${MIGRATION_RETRY_DELAY_SECONDS}s..."
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
sleep "$MIGRATION_RETRY_DELAY_SECONDS"
|
||||
done
|
||||
|
||||
echo "==> Generating Prisma client..."
|
||||
npx prisma generate
|
||||
|
|
|
|||
|
|
@ -86,7 +86,13 @@
|
|||
"mentoring": "Mentoring",
|
||||
"liveVoting": "Live Voting",
|
||||
"applications": "Applications",
|
||||
"messages": "Messages"
|
||||
"messages": "Messages",
|
||||
"team": "Team",
|
||||
"documents": "Documents",
|
||||
"awards": "Awards",
|
||||
"compare": "Compare",
|
||||
"learningHub": "Learning Hub",
|
||||
"reports": "Reports"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
|
|
|||
|
|
@ -86,7 +86,13 @@
|
|||
"mentoring": "Mentorat",
|
||||
"liveVoting": "Vote en direct",
|
||||
"applications": "Candidatures",
|
||||
"messages": "Messages"
|
||||
"messages": "Messages",
|
||||
"team": "\u00c9quipe",
|
||||
"documents": "Documents",
|
||||
"awards": "Prix",
|
||||
"compare": "Comparer",
|
||||
"learningHub": "Centre de ressources",
|
||||
"reports": "Rapports"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
|
||||
|
|
@ -1449,6 +1449,7 @@ model AwardVote {
|
|||
@@index([awardId])
|
||||
@@index([userId])
|
||||
@@index([projectId])
|
||||
@@index([awardId, userId])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -58,12 +58,9 @@ sudo mkdir -p /data/mopc/postgres
|
|||
sudo chown -R 1000:1000 /data/mopc
|
||||
|
||||
# 6. Pull and start
|
||||
echo "==> Pulling latest images..."
|
||||
echo "==> Pulling latest images and starting services..."
|
||||
cd "$DOCKER_DIR"
|
||||
docker compose pull app
|
||||
|
||||
echo "==> Starting services..."
|
||||
docker compose up -d
|
||||
docker compose up -d --pull always
|
||||
|
||||
# 7. Wait for health check
|
||||
echo "==> Waiting for application to start..."
|
||||
|
|
|
|||
|
|
@ -17,16 +17,12 @@ echo " MOPC Platform - Update"
|
|||
echo "============================================"
|
||||
echo ""
|
||||
|
||||
# 1. Pull latest image from registry
|
||||
echo "==> Pulling latest image..."
|
||||
# 1. Pull and recreate app only (postgres stays running)
|
||||
echo "==> Pulling latest image and recreating app..."
|
||||
cd "$DOCKER_DIR"
|
||||
docker compose pull app
|
||||
docker compose up -d --pull always --force-recreate app
|
||||
|
||||
# 2. Restart app only (postgres stays running)
|
||||
echo "==> Restarting app..."
|
||||
docker compose up -d app
|
||||
|
||||
# 3. Wait for health check
|
||||
# 2. Wait for health check
|
||||
echo "==> Waiting for application to start..."
|
||||
MAX_WAIT=120
|
||||
WAITED=0
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
|
|
@ -66,14 +67,13 @@ import {
|
|||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
Users,
|
||||
CheckCircle2,
|
||||
Brain,
|
||||
ListChecks,
|
||||
BarChart3,
|
||||
Loader2,
|
||||
Crown,
|
||||
|
|
@ -151,19 +151,29 @@ export default function AwardDetailPage({
|
|||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
|
||||
const [activeTab, setActiveTab] = useState('eligibility')
|
||||
|
||||
// Core queries
|
||||
// Pagination for eligibility list
|
||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||
const eligibilityPerPage = 25
|
||||
|
||||
// Core queries — lazy-load tab-specific data based on activeTab
|
||||
const { data: award, isLoading, refetch } =
|
||||
trpc.specialAward.get.useQuery({ id: awardId })
|
||||
const { data: eligibilityData, refetch: refetchEligibility } =
|
||||
trpc.specialAward.listEligible.useQuery({
|
||||
awardId,
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
page: eligibilityPage,
|
||||
perPage: eligibilityPerPage,
|
||||
}, {
|
||||
enabled: activeTab === 'eligibility',
|
||||
})
|
||||
const { data: jurors, refetch: refetchJurors } =
|
||||
trpc.specialAward.listJurors.useQuery({ awardId })
|
||||
trpc.specialAward.listJurors.useQuery({ awardId }, {
|
||||
enabled: activeTab === 'jurors',
|
||||
})
|
||||
const { data: voteResults } =
|
||||
trpc.specialAward.getVoteResults.useQuery({ awardId })
|
||||
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
|
||||
enabled: activeTab === 'results',
|
||||
})
|
||||
|
||||
// Deferred queries - only load when needed
|
||||
const { data: allUsers } = trpc.user.list.useQuery(
|
||||
|
|
@ -539,8 +549,9 @@ export default function AwardDetailPage({
|
|||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<AnimatedCard index={0}>
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-emerald-500">
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -553,7 +564,7 @@ export default function AwardDetailPage({
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-blue-500">
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -561,12 +572,12 @@ export default function AwardDetailPage({
|
|||
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
|
||||
<Brain className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-violet-500">
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -579,7 +590,7 @@ export default function AwardDetailPage({
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="pt-4 pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -593,8 +604,10 @@ export default function AwardDetailPage({
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Tabs */}
|
||||
<AnimatedCard index={1}>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="eligibility">
|
||||
|
|
@ -637,7 +650,7 @@ export default function AwardDetailPage({
|
|||
{runEligibility.isPending || isPollingJob ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Brain className="mr-2 h-4 w-4" />
|
||||
<ListChecks className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isPollingJob ? 'Processing...' : 'Run AI Eligibility'}
|
||||
</Button>
|
||||
|
|
@ -779,6 +792,7 @@ export default function AwardDetailPage({
|
|||
? ((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100
|
||||
: 0
|
||||
}
|
||||
gradient
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -841,15 +855,22 @@ export default function AwardDetailPage({
|
|||
})
|
||||
}} asChild>
|
||||
<>
|
||||
<TableRow className={`${!e.eligible ? 'opacity-50' : ''} ${hasReasoning ? 'cursor-pointer' : ''}`}>
|
||||
<TableRow
|
||||
className={`${!e.eligible ? 'opacity-50' : ''} ${hasReasoning ? 'cursor-pointer hover:bg-muted/50' : ''}`}
|
||||
onClick={() => {
|
||||
if (!hasReasoning) return
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(e.id)) next.delete(e.id)
|
||||
else next.add(e.id)
|
||||
return next
|
||||
})
|
||||
}}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasReasoning && (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button className="flex-shrink-0 p-0.5 rounded hover:bg-muted transition-colors">
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 flex-shrink-0 ${isExpanded ? 'rotate-180' : ''}`} />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{e.project.title}</p>
|
||||
|
|
@ -892,7 +913,7 @@ export default function AwardDetailPage({
|
|||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<TableCell onClick={(ev) => ev.stopPropagation()}>
|
||||
<Switch
|
||||
checked={e.eligible}
|
||||
onCheckedChange={(checked) =>
|
||||
|
|
@ -900,7 +921,7 @@ export default function AwardDetailPage({
|
|||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="text-right" onClick={(ev) => ev.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -917,7 +938,7 @@ export default function AwardDetailPage({
|
|||
<td colSpan={award.useAiEligibility ? 7 : 6} className="p-0">
|
||||
<div className="border-t bg-muted/30 px-6 py-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<Brain className="h-4 w-4 text-brand-teal mt-0.5 flex-shrink-0" />
|
||||
<ListChecks className="h-4 w-4 text-brand-teal mt-0.5 flex-shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Reasoning</p>
|
||||
<p className="text-sm leading-relaxed">{aiReasoning?.reasoning}</p>
|
||||
|
|
@ -934,12 +955,23 @@ export default function AwardDetailPage({
|
|||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{eligibilityData.totalPages > 1 && (
|
||||
<div className="p-4 border-t">
|
||||
<Pagination
|
||||
page={eligibilityData.page}
|
||||
totalPages={eligibilityData.totalPages}
|
||||
total={eligibilityData.total}
|
||||
perPage={eligibilityPerPage}
|
||||
onPageChange={setEligibilityPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
|
||||
<Brain className="h-8 w-8 text-muted-foreground/60" />
|
||||
<ListChecks className="h-8 w-8 text-muted-foreground/60" />
|
||||
</div>
|
||||
<p className="text-lg font-medium">No eligibility data yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||
|
|
@ -950,7 +982,7 @@ export default function AwardDetailPage({
|
|||
<div className="flex gap-2 mt-4">
|
||||
<Button onClick={handleRunEligibility} disabled={runEligibility.isPending || isPollingJob} size="sm">
|
||||
{award.useAiEligibility ? (
|
||||
<><Brain className="mr-2 h-4 w-4" />Run AI Eligibility</>
|
||||
<><ListChecks className="mr-2 h-4 w-4" />Run AI Eligibility</>
|
||||
) : (
|
||||
<><CheckCircle2 className="mr-2 h-4 w-4" />Load Projects</>
|
||||
)}
|
||||
|
|
@ -1185,6 +1217,7 @@ export default function AwardDetailPage({
|
|||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DRAFT: 'secondary',
|
||||
|
|
@ -156,9 +157,10 @@ export default function AwardsListPage() {
|
|||
{/* Awards Grid */}
|
||||
{filteredAwards.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredAwards.map((award) => (
|
||||
<Link key={award.id} href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
{filteredAwards.map((award, index) => (
|
||||
<AnimatedCard key={award.id} index={index}>
|
||||
<Link href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
|
|
@ -202,6 +204,7 @@ export default function AwardsListPage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
) : awards && awards.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -52,48 +52,228 @@ type DashboardContentProps = {
|
|||
sessionName: string
|
||||
}
|
||||
|
||||
function formatEntity(entityType: string | null): string {
|
||||
if (!entityType) return 'record'
|
||||
// Insert space before uppercase letters (PascalCase → words), then lowercase
|
||||
return entityType
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
}
|
||||
|
||||
function formatAction(action: string, entityType: string | null): string {
|
||||
const entity = entityType?.toLowerCase() || 'record'
|
||||
const entity = formatEntity(entityType)
|
||||
const actionMap: Record<string, string> = {
|
||||
// Generic CRUD
|
||||
CREATE: `created a ${entity}`,
|
||||
UPDATE: `updated a ${entity}`,
|
||||
DELETE: `deleted a ${entity}`,
|
||||
LOGIN: 'logged in',
|
||||
EXPORT: `exported ${entity} data`,
|
||||
SUBMIT: `submitted an ${entity}`,
|
||||
ASSIGN: `assigned a ${entity}`,
|
||||
INVITE: `invited a user`,
|
||||
STATUS_CHANGE: `changed ${entity} status`,
|
||||
BULK_UPDATE: `bulk updated ${entity}s`,
|
||||
IMPORT: `imported ${entity}s`,
|
||||
EXPORT: `exported ${entity} data`,
|
||||
REORDER: `reordered ${entity}s`,
|
||||
|
||||
// Auth
|
||||
LOGIN: 'logged in',
|
||||
LOGIN_SUCCESS: 'logged in',
|
||||
LOGIN_FAILED: 'failed to log in',
|
||||
PASSWORD_SET: 'set their password',
|
||||
PASSWORD_CHANGED: 'changed their password',
|
||||
REQUEST_PASSWORD_RESET: 'requested a password reset',
|
||||
COMPLETE_ONBOARDING: 'completed onboarding',
|
||||
DELETE_OWN_ACCOUNT: 'deleted their account',
|
||||
|
||||
// Evaluations
|
||||
EVALUATION_SUBMITTED: 'submitted an evaluation',
|
||||
COI_DECLARED: 'declared a conflict of interest',
|
||||
COI_REVIEWED: 'reviewed a COI declaration',
|
||||
REMINDERS_TRIGGERED: 'triggered evaluation reminders',
|
||||
DISCUSSION_COMMENT_ADDED: 'added a discussion comment',
|
||||
DISCUSSION_CLOSED: 'closed a discussion',
|
||||
|
||||
// Assignments
|
||||
ASSIGN: `assigned a ${entity}`,
|
||||
BULK_CREATE: `bulk created ${entity}s`,
|
||||
BULK_ASSIGN: 'bulk assigned users',
|
||||
BULK_DELETE: `bulk deleted ${entity}s`,
|
||||
BULK_UPDATE: `bulk updated ${entity}s`,
|
||||
BULK_UPDATE_STATUS: 'bulk updated statuses',
|
||||
APPLY_SUGGESTIONS: 'applied assignment suggestions',
|
||||
ASSIGN_PROJECTS_TO_ROUND: 'assigned projects to round',
|
||||
REMOVE_PROJECTS_FROM_ROUND: 'removed projects from round',
|
||||
ADVANCE_PROJECTS: 'advanced projects to next round',
|
||||
BULK_ASSIGN_TO_ROUND: 'bulk assigned to round',
|
||||
REORDER_ROUNDS: 'reordered rounds',
|
||||
|
||||
// Status
|
||||
STATUS_CHANGE: `changed ${entity} status`,
|
||||
UPDATE_STATUS: `updated ${entity} status`,
|
||||
ROLE_CHANGED: 'changed a user role',
|
||||
|
||||
// Invitations
|
||||
INVITE: 'invited a user',
|
||||
SEND_INVITATION: 'sent an invitation',
|
||||
BULK_SEND_INVITATIONS: 'sent bulk invitations',
|
||||
|
||||
// Files
|
||||
UPLOAD_FILE: 'uploaded a file',
|
||||
DELETE_FILE: 'deleted a file',
|
||||
REPLACE_FILE: 'replaced a file',
|
||||
FILE_DOWNLOADED: 'downloaded a file',
|
||||
|
||||
// Filtering
|
||||
EXECUTE_FILTERING: 'ran project filtering',
|
||||
FINALIZE_FILTERING: 'finalized filtering results',
|
||||
OVERRIDE: `overrode a ${entity} result`,
|
||||
BULK_OVERRIDE: 'bulk overrode filtering results',
|
||||
REINSTATE: 'reinstated a project',
|
||||
BULK_REINSTATE: 'bulk reinstated projects',
|
||||
|
||||
// AI
|
||||
AI_TAG: 'ran AI tagging',
|
||||
START_AI_TAG_JOB: 'started AI tagging job',
|
||||
EVALUATION_SUMMARY: 'generated an AI summary',
|
||||
AWARD_ELIGIBILITY: 'ran award eligibility check',
|
||||
PROJECT_TAGGING: 'ran project tagging',
|
||||
FILTERING: 'ran AI filtering',
|
||||
MENTOR_MATCHING: 'ran mentor matching',
|
||||
|
||||
// Tags
|
||||
ADD_TAG: 'added a tag',
|
||||
REMOVE_TAG: 'removed a tag',
|
||||
BULK_CREATE_TAGS: 'bulk created tags',
|
||||
|
||||
// Mentor
|
||||
MENTOR_ASSIGN: 'assigned a mentor',
|
||||
MENTOR_UNASSIGN: 'unassigned a mentor',
|
||||
MENTOR_AUTO_ASSIGN: 'auto-assigned mentors',
|
||||
MENTOR_BULK_ASSIGN: 'bulk assigned mentors',
|
||||
CREATE_MENTOR_NOTE: 'created a mentor note',
|
||||
COMPLETE_MILESTONE: 'completed a milestone',
|
||||
|
||||
// Messages & Webhooks
|
||||
SEND_MESSAGE: 'sent a message',
|
||||
CREATE_MESSAGE_TEMPLATE: 'created a message template',
|
||||
UPDATE_MESSAGE_TEMPLATE: 'updated a message template',
|
||||
DELETE_MESSAGE_TEMPLATE: 'deleted a message template',
|
||||
CREATE_WEBHOOK: 'created a webhook',
|
||||
UPDATE_WEBHOOK: 'updated a webhook',
|
||||
DELETE_WEBHOOK: 'deleted a webhook',
|
||||
TEST_WEBHOOK: 'tested a webhook',
|
||||
REGENERATE_WEBHOOK_SECRET: 'regenerated a webhook secret',
|
||||
|
||||
// Settings
|
||||
UPDATE_SETTING: 'updated a setting',
|
||||
UPDATE_SETTINGS_BATCH: 'updated settings',
|
||||
UPDATE_NOTIFICATION_PREFERENCES: 'updated notification preferences',
|
||||
UPDATE_DIGEST_SETTINGS: 'updated digest settings',
|
||||
UPDATE_ANALYTICS_SETTINGS: 'updated analytics settings',
|
||||
UPDATE_AUDIT_SETTINGS: 'updated audit settings',
|
||||
UPDATE_LOCALIZATION_SETTINGS: 'updated localization settings',
|
||||
UPDATE_RETENTION_CONFIG: 'updated retention config',
|
||||
|
||||
// Live Voting
|
||||
START_VOTING: 'started live voting',
|
||||
END_SESSION: 'ended a live voting session',
|
||||
UPDATE_SESSION_CONFIG: 'updated session config',
|
||||
|
||||
// Round Templates
|
||||
CREATE_ROUND_TEMPLATE: 'created a round template',
|
||||
CREATE_ROUND_TEMPLATE_FROM_ROUND: 'saved round as template',
|
||||
UPDATE_ROUND_TEMPLATE: 'updated a round template',
|
||||
DELETE_ROUND_TEMPLATE: 'deleted a round template',
|
||||
UPDATE_EVALUATION_FORM: 'updated the evaluation form',
|
||||
|
||||
// Grace Period
|
||||
GRANT_GRACE_PERIOD: 'granted a grace period',
|
||||
UPDATE_GRACE_PERIOD: 'updated a grace period',
|
||||
REVOKE_GRACE_PERIOD: 'revoked a grace period',
|
||||
BULK_GRANT_GRACE_PERIOD: 'bulk granted grace periods',
|
||||
|
||||
// Awards
|
||||
SET_AWARD_WINNER: 'set an award winner',
|
||||
|
||||
// Reports & Applications
|
||||
REPORT_GENERATED: 'generated a report',
|
||||
DRAFT_SUBMITTED: 'submitted a draft application',
|
||||
SUBMIT: `submitted a ${entity}`,
|
||||
}
|
||||
return actionMap[action] || `${action.toLowerCase()} ${entity}`
|
||||
if (actionMap[action]) return actionMap[action]
|
||||
|
||||
// Fallback: convert ACTION_NAME to readable text
|
||||
return action.toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
function getActionIcon(action: string) {
|
||||
switch (action) {
|
||||
case 'CREATE': return <Plus className="h-3.5 w-3.5" />
|
||||
case 'UPDATE': return <FileEdit className="h-3.5 w-3.5" />
|
||||
case 'DELETE': return <Trash2 className="h-3.5 w-3.5" />
|
||||
case 'LOGIN': return <LogIn className="h-3.5 w-3.5" />
|
||||
case 'EXPORT': return <ArrowRight className="h-3.5 w-3.5" />
|
||||
case 'SUBMIT': return <Send className="h-3.5 w-3.5" />
|
||||
case 'ASSIGN': return <Users className="h-3.5 w-3.5" />
|
||||
case 'INVITE': return <UserPlus className="h-3.5 w-3.5" />
|
||||
default: return <Eye className="h-3.5 w-3.5" />
|
||||
case 'CREATE':
|
||||
case 'BULK_CREATE':
|
||||
return <Plus className="h-3.5 w-3.5" />
|
||||
case 'UPDATE':
|
||||
case 'UPDATE_STATUS':
|
||||
case 'BULK_UPDATE':
|
||||
case 'BULK_UPDATE_STATUS':
|
||||
case 'STATUS_CHANGE':
|
||||
case 'ROLE_CHANGED':
|
||||
return <FileEdit className="h-3.5 w-3.5" />
|
||||
case 'DELETE':
|
||||
case 'BULK_DELETE':
|
||||
return <Trash2 className="h-3.5 w-3.5" />
|
||||
case 'LOGIN':
|
||||
case 'LOGIN_SUCCESS':
|
||||
case 'LOGIN_FAILED':
|
||||
case 'PASSWORD_SET':
|
||||
case 'PASSWORD_CHANGED':
|
||||
case 'COMPLETE_ONBOARDING':
|
||||
return <LogIn className="h-3.5 w-3.5" />
|
||||
case 'EXPORT':
|
||||
case 'REPORT_GENERATED':
|
||||
return <ArrowRight className="h-3.5 w-3.5" />
|
||||
case 'SUBMIT':
|
||||
case 'EVALUATION_SUBMITTED':
|
||||
case 'DRAFT_SUBMITTED':
|
||||
return <Send className="h-3.5 w-3.5" />
|
||||
case 'ASSIGN':
|
||||
case 'BULK_ASSIGN':
|
||||
case 'APPLY_SUGGESTIONS':
|
||||
case 'ASSIGN_PROJECTS_TO_ROUND':
|
||||
case 'MENTOR_ASSIGN':
|
||||
case 'MENTOR_BULK_ASSIGN':
|
||||
return <Users className="h-3.5 w-3.5" />
|
||||
case 'INVITE':
|
||||
case 'SEND_INVITATION':
|
||||
case 'BULK_SEND_INVITATIONS':
|
||||
return <UserPlus className="h-3.5 w-3.5" />
|
||||
case 'IMPORT':
|
||||
return <Upload className="h-3.5 w-3.5" />
|
||||
default:
|
||||
return <Eye className="h-3.5 w-3.5" />
|
||||
}
|
||||
}
|
||||
|
||||
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
|
||||
const { data, isLoading } = trpc.dashboard.getStats.useQuery(
|
||||
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
|
||||
{ editionId },
|
||||
{ enabled: !!editionId }
|
||||
{ enabled: !!editionId, retry: 1 }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertTriangle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Failed to load dashboard</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{error.message || 'An unexpected error occurred. Please try refreshing the page.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -204,69 +384,85 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Rounds</CardTitle>
|
||||
<CircleDot className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalRoundCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalRoundCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<CircleDot className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{projectCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{newProjectsThisWeek > 0
|
||||
? `${newProjectsThisWeek} new this week`
|
||||
: 'In this edition'}
|
||||
</p>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{projectCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{newProjectsThisWeek > 0
|
||||
? `${newProjectsThisWeek} new this week`
|
||||
: 'In this edition'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalJurors}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
|
||||
</p>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalJurors}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{submittedCount}
|
||||
{totalAssignments > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{' '}/ {totalAssignments}
|
||||
</span>
|
||||
)}
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{submittedCount}
|
||||
{totalAssignments > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{' '}/ {totalAssignments}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Progress value={completionRate} className="h-2" />
|
||||
<Progress value={completionRate} className="h-2" gradient />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{completionRate.toFixed(0)}% completion rate
|
||||
</p>
|
||||
|
|
@ -277,25 +473,34 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/rounds/new">
|
||||
<Plus className="mr-1.5 h-3.5 w-3.5" />
|
||||
New Round
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/projects/new">
|
||||
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
||||
Import Projects
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/members">
|
||||
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
|
||||
Invite Jury
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Link href="/admin/rounds/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<div className="rounded-xl bg-blue-50 p-2.5 transition-colors group-hover:bg-blue-100">
|
||||
<Plus className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">New Round</p>
|
||||
<p className="text-xs text-muted-foreground">Create a voting round</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/admin/projects/new" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-emerald-500/30 hover:bg-emerald-500/5">
|
||||
<div className="rounded-xl bg-emerald-50 p-2.5 transition-colors group-hover:bg-emerald-100">
|
||||
<Upload className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Import Projects</p>
|
||||
<p className="text-xs text-muted-foreground">Upload a CSV file</p>
|
||||
</div>
|
||||
</Link>
|
||||
<Link href="/admin/members" className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-violet-500/30 hover:bg-violet-500/5">
|
||||
<div className="rounded-xl bg-violet-50 p-2.5 transition-colors group-hover:bg-violet-100">
|
||||
<UserPlus className="h-4 w-4 text-violet-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Invite Jury</p>
|
||||
<p className="text-xs text-muted-foreground">Add jury members</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Two-Column Content */}
|
||||
|
|
@ -303,11 +508,17 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
{/* Left Column */}
|
||||
<div className="space-y-6 lg:col-span-7">
|
||||
{/* Rounds Card (enhanced) */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Rounds</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<CircleDot className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Rounds
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Voting rounds in {edition.name}
|
||||
</CardDescription>
|
||||
|
|
@ -363,7 +574,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
</div>
|
||||
</div>
|
||||
{round.totalEvals > 0 && (
|
||||
<Progress value={round.evalPercent} className="mt-3 h-1.5" />
|
||||
<Progress value={round.evalPercent} className="mt-3 h-1.5" gradient />
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
|
@ -372,13 +583,20 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Latest Projects Card */}
|
||||
<AnimatedCard index={5}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Latest Projects</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<ClipboardList className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
Latest Projects
|
||||
</CardTitle>
|
||||
<CardDescription>Recently submitted projects</CardDescription>
|
||||
</div>
|
||||
<Link
|
||||
|
|
@ -453,15 +671,19 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Right Column */}
|
||||
<div className="space-y-6 lg:col-span-5">
|
||||
{/* Pending Actions Card */}
|
||||
<AnimatedCard index={6}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
Pending Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -503,12 +725,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Evaluation Progress Card */}
|
||||
<AnimatedCard index={7}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<TrendingUp className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
Evaluation Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -532,7 +758,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
{round.evalPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={round.evalPercent} className="h-2" />
|
||||
<Progress value={round.evalPercent} className="h-2" gradient />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{round.submittedEvals} of {round.totalEvals} evaluations submitted
|
||||
</p>
|
||||
|
|
@ -542,12 +768,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Category Breakdown Card */}
|
||||
<AnimatedCard index={8}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Layers className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Project Categories
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -607,12 +837,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Recent Activity Card */}
|
||||
<AnimatedCard index={9}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<Activity className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Recent Activity
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -646,12 +880,16 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Upcoming Deadlines Card */}
|
||||
<AnimatedCard index={10}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||
<Calendar className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
Upcoming Deadlines
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -688,6 +926,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
|
|
@ -65,6 +66,8 @@ import {
|
|||
ChevronDown,
|
||||
Check,
|
||||
Tags,
|
||||
Mail,
|
||||
MailX,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -257,10 +260,12 @@ export default function MemberInvitePage() {
|
|||
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
|
||||
const [sendProgress, setSendProgress] = useState(0)
|
||||
const [sendInvitation, setSendInvitation] = useState(true)
|
||||
const [result, setResult] = useState<{
|
||||
created: number
|
||||
skipped: number
|
||||
assignmentsCreated?: number
|
||||
invitationSent?: boolean
|
||||
} | null>(null)
|
||||
|
||||
// Pre-assignment state
|
||||
|
|
@ -505,6 +510,7 @@ export default function MemberInvitePage() {
|
|||
expertiseTags: u.expertiseTags,
|
||||
assignments: u.assignments,
|
||||
})),
|
||||
sendInvitation,
|
||||
})
|
||||
setSendProgress(100)
|
||||
setResult(result)
|
||||
|
|
@ -520,6 +526,7 @@ export default function MemberInvitePage() {
|
|||
setParsedUsers([])
|
||||
setResult(null)
|
||||
setSendProgress(0)
|
||||
setSendInvitation(true)
|
||||
}
|
||||
|
||||
const hasManualData = rows.some((r) => r.email.trim() || r.name.trim())
|
||||
|
|
@ -793,6 +800,32 @@ export default function MemberInvitePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Invitation toggle */}
|
||||
<div className="rounded-lg border border-dashed p-4 bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
{sendInvitation ? (
|
||||
<Mail className="h-5 w-5 text-primary shrink-0" />
|
||||
) : (
|
||||
<MailX className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Label htmlFor="send-invitation" className="text-sm font-medium cursor-pointer">
|
||||
Send platform invitation immediately
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sendInvitation
|
||||
? 'Members will receive an email invitation to create their account'
|
||||
: 'Members will be created without notification — you can send invitations later from the Members page'}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="send-invitation"
|
||||
checked={sendInvitation}
|
||||
onCheckedChange={setSendInvitation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" asChild>
|
||||
|
|
@ -844,6 +877,18 @@ export default function MemberInvitePage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{!sendInvitation && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-blue-500/10 p-4 text-blue-700 dark:text-blue-400">
|
||||
<MailX className="h-5 w-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">No invitations will be sent</p>
|
||||
<p className="text-sm opacity-80">
|
||||
Members will be created with “Not Invited” status. You can send invitations later from the Members page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary.invalid > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700">
|
||||
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
|
||||
|
|
@ -924,10 +969,12 @@ export default function MemberInvitePage() {
|
|||
>
|
||||
{bulkCreate.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : sendInvitation ? (
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create & Invite {summary.valid} Member
|
||||
{sendInvitation ? 'Create & Invite' : 'Create'} {summary.valid} Member
|
||||
{summary.valid !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -948,7 +995,7 @@ export default function MemberInvitePage() {
|
|||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<p className="mt-4 font-medium">
|
||||
Creating members and sending invitations...
|
||||
{sendInvitation ? 'Creating members and sending invitations...' : 'Creating members...'}
|
||||
</p>
|
||||
<Progress value={sendProgress} className="mt-4 w-48" />
|
||||
</CardContent>
|
||||
|
|
@ -963,23 +1010,28 @@ export default function MemberInvitePage() {
|
|||
<CheckCircle2 className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<p className="mt-4 text-xl font-semibold">
|
||||
Invitations Sent!
|
||||
{result?.invitationSent ? 'Members Created & Invited!' : 'Members Created!'}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-center max-w-sm mt-2">
|
||||
{result?.created} member{result?.created !== 1 ? 's' : ''}{' '}
|
||||
created and invited.
|
||||
{result?.invitationSent ? 'created and invited' : 'created'}.
|
||||
{result?.skipped
|
||||
? ` ${result.skipped} skipped (already exist).`
|
||||
: ''}
|
||||
{result?.assignmentsCreated && result.assignmentsCreated > 0
|
||||
? ` ${result.assignmentsCreated} project assignment${result.assignmentsCreated !== 1 ? 's' : ''} pre-assigned.`
|
||||
: ''}
|
||||
{!result?.invitationSent && (
|
||||
<span className="block mt-1">
|
||||
You can send invitations from the Members page when ready.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-6 flex gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/admin/members">View Members</Link>
|
||||
</Button>
|
||||
<Button onClick={resetForm}>Invite More</Button>
|
||||
<Button onClick={resetForm}>Add More</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import {
|
|||
FolderKanban,
|
||||
Eye,
|
||||
Pencil,
|
||||
Wand2,
|
||||
Copy,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ async function ProgramsContent() {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Apply Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
|
@ -204,7 +204,7 @@ async function ProgramsContent() {
|
|||
</Button>
|
||||
<Button variant="outline" size="sm" className="flex-1" asChild>
|
||||
<Link href={`/admin/programs/${program.id}/apply-settings` as Route}>
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Apply
|
||||
</Link>
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -19,10 +19,10 @@ import { Progress } from '@/components/ui/progress'
|
|||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Users,
|
||||
User,
|
||||
Check,
|
||||
Wand2,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
import { getInitials } from '@/lib/utils'
|
||||
|
||||
|
|
@ -199,7 +199,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
AI-Suggested Mentors
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -225,7 +225,7 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
|||
{autoAssignMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="mr-2 h-4 w-4" />
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Auto-Assign Best Match
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { FileUpload } from '@/components/shared/file-upload'
|
|||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Edit,
|
||||
|
|
@ -184,13 +185,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
|
||||
{/* Stats Grid */}
|
||||
{stats && (
|
||||
<AnimatedCard index={0}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Score
|
||||
</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
|
|
@ -202,12 +206,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Recommendations
|
||||
</CardTitle>
|
||||
<ThumbsUp className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<ThumbsUp className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
|
|
@ -219,12 +225,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Project Info */}
|
||||
<AnimatedCard index={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project Information</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
Project Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Category & Ocean Issue badges */}
|
||||
|
|
@ -393,14 +406,18 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Team Members Section */}
|
||||
{project.teamMembers && project.teamMembers.length > 0 && (
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Team Members ({project.teamMembers.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
|
|
@ -437,15 +454,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Mentor Assignment Section */}
|
||||
{project.wantsMentorship && (
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Heart className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||
<Heart className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
Mentor Assignment
|
||||
</CardTitle>
|
||||
{!project.mentorAssignment && (
|
||||
|
|
@ -487,12 +508,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Files Section */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Files</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
Files
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Project documents and materials
|
||||
</CardDescription>
|
||||
|
|
@ -535,14 +563,21 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Assignments Section */}
|
||||
{assignments && assignments.length > 0 && (
|
||||
<AnimatedCard index={5}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">Jury Assignments</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Jury Assignments
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED')
|
||||
.length}{' '}
|
||||
|
|
@ -649,6 +684,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* AI Evaluation Summary */}
|
||||
|
|
|
|||
|
|
@ -60,7 +60,6 @@ import {
|
|||
Search,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Tags,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
|
|
@ -98,6 +97,7 @@ import {
|
|||
ProjectFiltersBar,
|
||||
type ProjectFilters,
|
||||
} from './project-filters'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const statusColors: Record<
|
||||
string,
|
||||
|
|
@ -584,7 +584,7 @@ export default function ProjectsPage() {
|
|||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<Tags className="mr-2 h-4 w-4" />
|
||||
AI Tags
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
|
|
@ -983,7 +983,7 @@ export default function ProjectsPage() {
|
|||
/>
|
||||
</div>
|
||||
<Link href={`/admin/projects/${project.id}`} className="block">
|
||||
<Card className="transition-colors hover:bg-muted/50">
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3 pl-8">
|
||||
<ProjectLogo project={project} size="md" fallback="initials" />
|
||||
|
|
@ -1051,7 +1051,7 @@ export default function ProjectsPage() {
|
|||
/>
|
||||
</div>
|
||||
<Link href={`/admin/projects/${project.id}`} className="block">
|
||||
<Card className={`transition-colors hover:bg-muted/50 h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
|
||||
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3 pl-7">
|
||||
<ProjectLogo project={project} size="lg" fallback="initials" />
|
||||
|
|
@ -1483,7 +1483,7 @@ export default function ProjectsPage() {
|
|||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-amber-400 to-orange-500">
|
||||
<Sparkles className="h-5 w-5 text-white" />
|
||||
<Tags className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span>AI Tag Generator</span>
|
||||
|
|
@ -1723,7 +1723,7 @@ export default function ProjectsPage() {
|
|||
{taggingInProgress ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<Tags className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{taggingInProgress ? 'Processing...' : 'Generate Tags'}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import {
|
|||
DiversityMetricsChart,
|
||||
} from '@/components/charts'
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
function ReportsOverview() {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
|
@ -96,62 +97,91 @@ function ReportsOverview() {
|
|||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Programs</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalPrograms}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeRounds} active round{activeRounds !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Programs</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeRounds} active round{activeRounds !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalProjects}</div>
|
||||
<p className="text-xs text-muted-foreground">Across all programs</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all programs</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{jurorCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Active jurors</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
|
||||
<p className="text-2xl font-bold mt-1">{jurorCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Active jurors</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{submittedEvaluations}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalEvaluations > 0
|
||||
? `${completionRate}% completion rate`
|
||||
: 'No assignments yet'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{totalEvaluations > 0
|
||||
? `${completionRate}% completion rate`
|
||||
: 'No assignments yet'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<BarChart3 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Score Distribution (if any evaluations exist) */}
|
||||
{dashStats?.scoreDistribution && dashStats.scoreDistribution.some(b => b.count > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Distribution</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
Score Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>Overall score distribution across all evaluations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -162,7 +192,7 @@ function ReportsOverview() {
|
|||
<div key={bucket.label} className="flex items-center gap-3">
|
||||
<span className="w-10 text-sm font-medium text-right">{bucket.label}</span>
|
||||
<div className="flex-1">
|
||||
<Progress value={(bucket.count / maxCount) * 100} className="h-6" />
|
||||
<Progress value={(bucket.count / maxCount) * 100} className="h-6" gradient />
|
||||
</div>
|
||||
<span className="w-8 text-sm text-muted-foreground text-right">{bucket.count}</span>
|
||||
</div>
|
||||
|
|
@ -176,7 +206,12 @@ function ReportsOverview() {
|
|||
{/* Rounds Table */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Round Reports</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileSpreadsheet className="h-4 w-4 text-emerald-600" />
|
||||
</div>
|
||||
Round Reports
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
View progress and export data for each round
|
||||
</CardDescription>
|
||||
|
|
@ -263,60 +298,73 @@ function ReportsOverview() {
|
|||
)
|
||||
}
|
||||
|
||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { roundId: value }
|
||||
}
|
||||
|
||||
function RoundAnalytics() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
// Flatten rounds from all programs with program name
|
||||
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programName: `${p.year} Edition` }))) || []
|
||||
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: `${p.year} Edition` }))) || []
|
||||
|
||||
// Set default selected round
|
||||
if (rounds.length && !selectedRoundId) {
|
||||
setSelectedRoundId(rounds[0].id)
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.analytics.getEvaluationTimeline.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: jurorWorkload, isLoading: workloadLoading } =
|
||||
trpc.analytics.getJurorWorkload.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: projectRankings, isLoading: rankingsLoading } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
{ roundId: selectedRoundId!, limit: 15 },
|
||||
{ enabled: !!selectedRoundId }
|
||||
{ ...queryInput, limit: 15 },
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||
trpc.analytics.getCriteriaScores.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
const geoInput = queryInput.programId
|
||||
? { programId: queryInput.programId }
|
||||
: { programId: selectedRound?.programId || '', roundId: queryInput.roundId }
|
||||
const { data: geoData, isLoading: geoLoading } =
|
||||
trpc.analytics.getGeographicDistribution.useQuery(
|
||||
{ programId: selectedRound?.programId || '', roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId && !!selectedRound?.programId }
|
||||
geoInput,
|
||||
{ enabled: hasSelection && !!(geoInput.programId || geoInput.roundId) }
|
||||
)
|
||||
|
||||
if (roundsLoading) {
|
||||
|
|
@ -350,11 +398,16 @@ function RoundAnalytics() {
|
|||
{/* Round Selector */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
|
|
@ -364,7 +417,7 @@ function RoundAnalytics() {
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedRoundId && (
|
||||
{hasSelection && (
|
||||
<div className="space-y-6">
|
||||
{/* Row 1: Score Distribution & Status Breakdown */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
|
|
@ -537,22 +590,25 @@ function CrossRoundTab() {
|
|||
}
|
||||
|
||||
function JurorConsistencyTab() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
if (rounds.length && !selectedRoundId) {
|
||||
setSelectedRoundId(rounds[0].id)
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading: consistencyLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (programsLoading) {
|
||||
|
|
@ -563,11 +619,16 @@ function JurorConsistencyTab() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
|
|
@ -601,22 +662,25 @@ function JurorConsistencyTab() {
|
|||
}
|
||||
|
||||
function DiversityTab() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programName: `${p.year} Edition` }))
|
||||
p.rounds.map(r => ({ id: r.id, name: r.name, programId: p.id, programName: `${p.year} Edition` }))
|
||||
) || []
|
||||
|
||||
if (rounds.length && !selectedRoundId) {
|
||||
setSelectedRoundId(rounds[0].id)
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading: diversityLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (programsLoading) {
|
||||
|
|
@ -627,11 +691,16 @@ function DiversityTab() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Select Round:</label>
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
|
|
|
|||
|
|
@ -64,14 +64,14 @@ import {
|
|||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Shuffle,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
UserPlus,
|
||||
Cpu,
|
||||
Brain,
|
||||
Calculator,
|
||||
Workflow,
|
||||
Search,
|
||||
ChevronsUpDown,
|
||||
Check,
|
||||
|
|
@ -829,7 +829,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-amber-500" />
|
||||
<Shuffle className="h-5 w-5 text-amber-500" />
|
||||
Smart Assignment Suggestions
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -844,7 +844,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="algorithm" className="gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
<Calculator className="h-4 w-4" />
|
||||
Algorithm
|
||||
{algorithmicSuggestions && algorithmicSuggestions.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
|
|
@ -853,7 +853,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
|||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="ai" className="gap-2" disabled={!isAIAvailable && !hasStoredAISuggestions}>
|
||||
<Brain className="h-4 w-4" />
|
||||
<Workflow className="h-4 w-4" />
|
||||
AI Powered
|
||||
{aiSuggestions.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 text-xs">
|
||||
|
|
@ -983,7 +983,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
|||
/>
|
||||
) : !hasStoredAISuggestions ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Brain className="h-12 w-12 text-muted-foreground/50" />
|
||||
<Workflow className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No AI analysis yet</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Click "Start Analysis" to generate AI-powered suggestions
|
||||
|
|
@ -995,7 +995,7 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
|||
{startAIJob.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Brain className="mr-2 h-4 w-4" />
|
||||
<Workflow className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Start AI Analysis
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import {
|
|||
GripVertical,
|
||||
Loader2,
|
||||
FileCheck,
|
||||
Brain,
|
||||
SlidersHorizontal,
|
||||
Filter,
|
||||
} from 'lucide-react'
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ const RULE_TYPE_LABELS: Record<RuleType, string> = {
|
|||
const RULE_TYPE_ICONS: Record<RuleType, React.ReactNode> = {
|
||||
FIELD_BASED: <Filter className="h-4 w-4" />,
|
||||
DOCUMENT_CHECK: <FileCheck className="h-4 w-4" />,
|
||||
AI_SCREENING: <Brain className="h-4 w-4" />,
|
||||
AI_SCREENING: <SlidersHorizontal className="h-4 w-4" />,
|
||||
}
|
||||
|
||||
const FIELD_OPTIONS = [
|
||||
|
|
|
|||
|
|
@ -81,13 +81,17 @@ import {
|
|||
AlertTriangle,
|
||||
ListChecks,
|
||||
ClipboardCheck,
|
||||
Sparkles,
|
||||
FileSearch,
|
||||
LayoutTemplate,
|
||||
ShieldCheck,
|
||||
Download,
|
||||
RotateCcw,
|
||||
Zap,
|
||||
QrCode,
|
||||
ExternalLink,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { AssignProjectsDialog } from '@/components/admin/assign-projects-dialog'
|
||||
import { AdvanceProjectsDialog } from '@/components/admin/advance-projects-dialog'
|
||||
import { RemoveProjectsDialog } from '@/components/admin/remove-projects-dialog'
|
||||
|
|
@ -126,6 +130,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const [advanceOpen, setAdvanceOpen] = useState(false)
|
||||
const [removeOpen, setRemoveOpen] = useState(false)
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||
const [jobPollInterval, setJobPollInterval] = useState(2000)
|
||||
|
||||
// Inline filtering results state
|
||||
const [outcomeFilter, setOutcomeFilter] = useState<string>('')
|
||||
|
|
@ -140,7 +145,8 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const [showExportDialog, setShowExportDialog] = useState(false)
|
||||
|
||||
const { data: round, isLoading, refetch: refetchRound } = trpc.round.get.useQuery({ id: roundId })
|
||||
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
|
||||
// Progress data is now included in round.get response (eliminates duplicate evaluation.groupBy)
|
||||
const progress = round?.progress
|
||||
|
||||
// Check if this is a filtering round - roundType is stored directly on the round
|
||||
const isFilteringRound = round?.roundType === 'FILTERING'
|
||||
|
|
@ -149,7 +155,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const { data: filteringStats, isLoading: isLoadingFilteringStats, refetch: refetchFilteringStats } =
|
||||
trpc.filtering.getResultStats.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound, staleTime: 0 }
|
||||
{ enabled: isFilteringRound, staleTime: 30_000 }
|
||||
)
|
||||
const { data: filteringRules } = trpc.filtering.getRules.useQuery(
|
||||
{ roundId },
|
||||
|
|
@ -162,31 +168,41 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const { data: latestJob, refetch: refetchLatestJob } =
|
||||
trpc.filtering.getLatestJob.useQuery(
|
||||
{ roundId },
|
||||
{ enabled: isFilteringRound, staleTime: 0 }
|
||||
{ enabled: isFilteringRound, staleTime: 30_000 }
|
||||
)
|
||||
|
||||
// Poll for job status when there's an active job
|
||||
// Poll for job status with exponential backoff (2s → 4s → 8s → 15s cap)
|
||||
const { data: jobStatus } = trpc.filtering.getJobStatus.useQuery(
|
||||
{ jobId: activeJobId! },
|
||||
{
|
||||
enabled: !!activeJobId,
|
||||
refetchInterval: activeJobId ? 2000 : false,
|
||||
refetchInterval: activeJobId ? jobPollInterval : false,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 0,
|
||||
}
|
||||
)
|
||||
|
||||
// Increase polling interval over time (exponential backoff)
|
||||
useEffect(() => {
|
||||
if (!activeJobId) {
|
||||
setJobPollInterval(2000)
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setJobPollInterval((prev) => Math.min(prev * 2, 15000))
|
||||
}, jobPollInterval)
|
||||
return () => clearTimeout(timer)
|
||||
}, [activeJobId, jobPollInterval])
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const updateStatus = trpc.round.updateStatus.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
utils.round.list.invalidate()
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
},
|
||||
})
|
||||
const deleteRound = trpc.round.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Round deleted')
|
||||
utils.program.list.invalidate()
|
||||
utils.round.list.invalidate()
|
||||
router.push('/admin/rounds')
|
||||
},
|
||||
|
|
@ -200,7 +216,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const finalizeResults = trpc.filtering.finalizeResults.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
utils.project.list.invalidate()
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -218,7 +233,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
},
|
||||
{
|
||||
enabled: isFilteringRound && (filteringStats?.total ?? 0) > 0,
|
||||
staleTime: 0,
|
||||
staleTime: 30_000,
|
||||
}
|
||||
)
|
||||
const overrideResult = trpc.filtering.overrideResult.useMutation()
|
||||
|
|
@ -286,6 +301,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
const handleStartFiltering = async () => {
|
||||
try {
|
||||
const result = await startJob.mutateAsync({ roundId })
|
||||
setJobPollInterval(2000)
|
||||
setActiveJobId(result.jobId)
|
||||
toast.info('Filtering job started. Progress will update automatically.')
|
||||
} catch (error) {
|
||||
|
|
@ -309,8 +325,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
}
|
||||
refetchFilteringStats()
|
||||
refetchRound()
|
||||
utils.project.list.invalidate()
|
||||
utils.program.list.invalidate({ includeRounds: true })
|
||||
utils.round.get.invalidate({ id: roundId })
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
|
|
@ -340,7 +354,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
setOverrideReason('')
|
||||
refetchResults()
|
||||
refetchFilteringStats()
|
||||
utils.project.list.invalidate()
|
||||
} catch {
|
||||
toast.error('Failed to override result')
|
||||
}
|
||||
|
|
@ -352,7 +365,6 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
toast.success('Project reinstated')
|
||||
refetchResults()
|
||||
refetchFilteringStats()
|
||||
utils.project.list.invalidate()
|
||||
} catch {
|
||||
toast.error('Failed to reinstate project')
|
||||
}
|
||||
|
|
@ -548,11 +560,14 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
<Separator />
|
||||
|
||||
{/* Stats Grid */}
|
||||
<AnimatedCard index={0}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{round._count.projects}</div>
|
||||
|
|
@ -562,10 +577,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Judge Assignments</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{round._count.assignments}</div>
|
||||
|
|
@ -577,10 +594,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{round.requiredReviews}</div>
|
||||
|
|
@ -588,10 +607,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completion</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
|
|
@ -603,12 +624,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Progress */}
|
||||
{progress && progress.totalAssignments > 0 && (
|
||||
<AnimatedCard index={1}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
Evaluation Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
|
|
@ -616,7 +644,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
<span>Overall Completion</span>
|
||||
<span>{progress.completionPercentage}%</span>
|
||||
</div>
|
||||
<Progress value={progress.completionPercentage} />
|
||||
<Progress value={progress.completionPercentage} gradient />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-4">
|
||||
|
|
@ -631,12 +659,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Voting Window */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Voting Window</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<Clock className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Voting Window
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
|
|
@ -723,15 +758,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Filtering Section (for FILTERING rounds) */}
|
||||
{isFilteringRound && (
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Filter className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||
<Filter className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
Project Filtering
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -782,7 +821,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
{progressPercent}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPercent} className="h-2" />
|
||||
<Progress value={progressPercent} className="h-2" gradient />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1226,12 +1265,19 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||
<Zap className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Project Management */}
|
||||
|
|
@ -1275,6 +1321,12 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
Jury Assignments
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href={`/admin/rounds/${round.id}/live-voting`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Live Voting
|
||||
</Link>
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
@ -1287,7 +1339,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
{bulkSummaries.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<FileSearch className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{bulkSummaries.isPending ? 'Generating...' : 'Generate AI Summaries'}
|
||||
</Button>
|
||||
|
|
@ -1319,6 +1371,7 @@ function RoundDetailContent({ roundId }: { roundId: string }) {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Dialogs */}
|
||||
<AssignProjectsDialog
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ import {
|
|||
} from 'lucide-react'
|
||||
import { format, isPast, isFuture } from 'date-fns'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type RoundData = {
|
||||
id: string
|
||||
|
|
@ -108,8 +109,10 @@ function RoundsContent() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{programs.map((program) => (
|
||||
<ProgramRounds key={program.id} program={program} />
|
||||
{programs.map((program, index) => (
|
||||
<AnimatedCard key={program.id} index={index}>
|
||||
<ProgramRounds program={program} />
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -485,7 +488,7 @@ function SortableRoundRow({
|
|||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card transition-all',
|
||||
'rounded-lg border bg-card transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
|
||||
isDragging && 'shadow-lg ring-2 ring-primary/20 z-50 opacity-90',
|
||||
isReordering && !isDragging && 'opacity-50'
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { StatusTracker } from '@/components/shared/status-tracker'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
FileText,
|
||||
Calendar,
|
||||
|
|
@ -79,16 +80,20 @@ export default function ApplicantDashboardPage() {
|
|||
Your applicant dashboard
|
||||
</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">No Project Yet</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
You haven't submitted a project yet. Check for open application rounds
|
||||
on the MOPC website.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={0}>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-2xl bg-muted/60 p-4 mb-4">
|
||||
<FileText className="h-8 w-8 text-muted-foreground/70" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No Project Yet</h2>
|
||||
<p className="text-muted-foreground text-center max-w-md">
|
||||
You haven't submitted a project yet. Check for open application rounds
|
||||
on the MOPC website.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -132,6 +137,7 @@ export default function ApplicantDashboardPage() {
|
|||
{/* Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Project details */}
|
||||
<AnimatedCard index={0}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Project Details</CardTitle>
|
||||
|
|
@ -203,65 +209,57 @@ export default function ApplicantDashboardPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Card className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<Link href={"/applicant/documents" as Route} className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Documents</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={1}>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
|
||||
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
|
||||
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Documents</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{openRounds.length > 0 ? `${openRounds.length} round(s) open` : 'View uploads'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
<Card className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<Link href={"/applicant/team" as Route} className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-900/30">
|
||||
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Team</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.teamMembers.length} member(s)
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Link href={"/applicant/team" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-purple-500/30 hover:bg-purple-500/5">
|
||||
<div className="rounded-xl bg-purple-500/10 p-2.5 transition-colors group-hover:bg-purple-500/20">
|
||||
<Users className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Team</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.teamMembers.length} member(s)
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
|
||||
<Card className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<Link href={"/applicant/mentor" as Route} className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
|
||||
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
|
||||
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Mentor</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Status timeline */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Timeline</CardTitle>
|
||||
|
|
@ -273,8 +271,10 @@ export default function ApplicantDashboardPage() {
|
|||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Team overview */}
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -324,8 +324,10 @@ export default function ApplicantDashboardPage() {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Key dates */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key Dates</CardTitle>
|
||||
|
|
@ -353,6 +355,7 @@ export default function ApplicantDashboardPage() {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -99,8 +99,12 @@ export default function ApplicantTeamPage() {
|
|||
)
|
||||
|
||||
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member invited!')
|
||||
onSuccess: (result) => {
|
||||
if (result.requiresAccountSetup) {
|
||||
toast.success('Invitation email sent to team member')
|
||||
} else {
|
||||
toast.success('Team member added and notified by email')
|
||||
}
|
||||
setIsInviteOpen(false)
|
||||
refetch()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '@/components/ui/card'
|
||||
import { Loader2, CheckCircle2, AlertCircle, XCircle, Clock } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type InviteState = 'loading' | 'valid' | 'accepting' | 'error'
|
||||
|
||||
|
|
@ -134,12 +135,15 @@ function AcceptInviteContent() {
|
|||
// Loading state
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Verifying your invitation...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -147,9 +151,11 @@ function AcceptInviteContent() {
|
|||
if (state === 'error') {
|
||||
const errorContent = getErrorContent()
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100">
|
||||
{errorContent.icon}
|
||||
</div>
|
||||
<CardTitle className="text-xl">{errorContent.title}</CardTitle>
|
||||
|
|
@ -167,15 +173,18 @@ function AcceptInviteContent() {
|
|||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Valid invitation - show welcome
|
||||
const user = data?.user
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">
|
||||
|
|
@ -213,18 +222,22 @@ function AcceptInviteContent() {
|
|||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Loading fallback for Suspense
|
||||
function LoadingCard() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="mt-4 text-sm text-muted-foreground">Loading...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
Configuration: 'There is a problem with the server configuration.',
|
||||
|
|
@ -20,12 +21,14 @@ export default function AuthErrorPage() {
|
|||
const message = errorMessages[error] || errorMessages.Default
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4">
|
||||
<Logo variant="small" />
|
||||
</div>
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-full bg-destructive/10">
|
||||
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-2xl bg-destructive/10">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Authentication Error</CardTitle>
|
||||
|
|
@ -42,5 +45,6 @@ export default function AuthErrorPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { Mail, Loader2, CheckCircle2, AlertCircle, Lock, KeyRound } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type LoginMode = 'password' | 'magic-link'
|
||||
|
||||
|
|
@ -102,9 +103,11 @@ export default function LoginPage() {
|
|||
// Success state after sending magic link
|
||||
if (isSent) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 animate-in zoom-in-50 duration-300">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-50 animate-in zoom-in-50 duration-300">
|
||||
<Mail className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
|
|
@ -137,11 +140,14 @@ export default function LoginPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl">Welcome back</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -299,5 +305,6 @@ export default function LoginPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
Globe,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
type Step = 'name' | 'photo' | 'country' | 'bio' | 'phone' | 'tags' | 'preferences' | 'complete'
|
||||
|
||||
|
|
@ -181,19 +182,24 @@ export default function OnboardingPage() {
|
|||
if (sessionStatus === 'loading' || userLoading || !initialized) {
|
||||
return (
|
||||
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Card className="w-full max-w-lg shadow-2xl">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-lg shadow-2xl overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mb-4" />
|
||||
<p className="text-muted-foreground">Loading your profile...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
|
||||
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto shadow-2xl">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-hidden shadow-2xl">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
{/* Progress indicator */}
|
||||
<div className="px-6 pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -570,7 +576,7 @@ export default function OnboardingPage() {
|
|||
{/* Step 7: Complete */}
|
||||
{step === 'complete' && (
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<div className="rounded-full bg-green-100 p-4 mb-4 animate-in zoom-in-50 duration-500">
|
||||
<div className="rounded-2xl bg-emerald-50 p-4 mb-4 animate-in zoom-in-50 duration-500">
|
||||
<CheckCircle className="h-12 w-12 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2 animate-in fade-in slide-in-from-bottom-2 duration-500 delay-200">
|
||||
|
|
@ -584,6 +590,7 @@ export default function OnboardingPage() {
|
|||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Progress } from '@/components/ui/progress'
|
|||
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function SetPasswordPage() {
|
||||
const [password, setPassword] = useState('')
|
||||
|
|
@ -116,20 +117,25 @@ export default function SetPasswordPage() {
|
|||
// Loading state while checking session
|
||||
if (session === undefined) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Password Set Successfully</CardTitle>
|
||||
|
|
@ -144,13 +150,16 @@ export default function SetPasswordPage() {
|
|||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-sm border">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-white shadow-sm border">
|
||||
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Set Your Password</CardTitle>
|
||||
|
|
@ -294,5 +303,6 @@ export default function SetPasswordPage() {
|
|||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Mail } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-brand-teal/10">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-teal/10">
|
||||
<Mail className="h-6 w-6 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
|
|
@ -23,5 +26,6 @@ export default function VerifyEmailPage() {
|
|||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ import Link from 'next/link'
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2 } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
export default function VerifyPage() {
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<AnimatedCard>
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-50">
|
||||
<CheckCircle2 className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Check your email</CardTitle>
|
||||
|
|
@ -24,5 +27,6 @@ export default function VerifyPage() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ async function AssignmentsContent({
|
|||
</div>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Progress value={overallProgress} className="h-2 w-32" />
|
||||
<Progress value={overallProgress} className="h-2 w-32" gradient />
|
||||
<p className="text-xs text-muted-foreground mt-1">{overallProgress}% complete</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -210,7 +210,7 @@ async function AssignmentsContent({
|
|||
new Date(assignment.round.votingEndAt) >= now
|
||||
|
||||
return (
|
||||
<TableRow key={assignment.id}>
|
||||
<TableRow key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm">
|
||||
<TableCell>
|
||||
<Link
|
||||
href={`/jury/projects/${assignment.project.id}`}
|
||||
|
|
@ -328,7 +328,7 @@ async function AssignmentsContent({
|
|||
new Date(assignment.round.votingEndAt) >= now
|
||||
|
||||
return (
|
||||
<Card key={assignment.id}>
|
||||
<Card key={assignment.id} className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<Link
|
||||
|
|
|
|||
|
|
@ -743,16 +743,13 @@ export default async function JuryDashboardPage() {
|
|||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="relative">
|
||||
<div className="absolute -top-6 -left-6 -right-6 h-32 bg-gradient-to-b from-brand-blue/[0.03] to-transparent dark:from-brand-blue/[0.06] pointer-events-none rounded-xl" />
|
||||
<div className="relative">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
{getGreeting()}, {session?.user?.name || 'Juror'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
Here's an overview of your evaluation progress
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
{getGreeting()}, {session?.user?.name || 'Juror'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
Here's an overview of your evaluation progress
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
Star,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { CollapsibleFilesSection } from '@/components/jury/collapsible-files-section'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface PageProps {
|
||||
|
|
@ -83,6 +84,7 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
|
|||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
_count: { select: { files: true } },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -223,6 +225,13 @@ async function EvaluationContent({ projectId }: { projectId: string }) {
|
|||
|
||||
<Separator />
|
||||
|
||||
{/* Project Documents */}
|
||||
<CollapsibleFilesSection
|
||||
projectId={project.id}
|
||||
roundId={round.id}
|
||||
fileCount={project._count?.files ?? 0}
|
||||
/>
|
||||
|
||||
{/* Criteria scores */}
|
||||
{criteria.length > 0 && (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -240,7 +240,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
|
|||
{/* Description */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project Description</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
Project Description
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.description ? (
|
||||
|
|
@ -266,7 +271,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
|
|||
{/* Round Info */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Round Details</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<Calendar className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Round Details
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -310,7 +320,12 @@ async function ProjectContent({ projectId }: { projectId: string }) {
|
|||
{evaluation && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Your Evaluation</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5">
|
||||
<CheckCircle2 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
Your Evaluation
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import {
|
|||
Search,
|
||||
} from 'lucide-react'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
// Status badge colors
|
||||
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
|
|
@ -117,63 +118,72 @@ export default function MentorDashboard() {
|
|||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Assigned Projects
|
||||
</CardTitle>
|
||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{projects.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Projects you are mentoring
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Assigned Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{projects.length}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Projects you are mentoring</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<Briefcase className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Completed
|
||||
</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{completedCount}</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{projects.length > 0 && (
|
||||
<Progress
|
||||
value={(completedCount / projects.length) * 100}
|
||||
className="h-1.5 flex-1"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Completed</p>
|
||||
<p className="text-2xl font-bold mt-1">{completedCount}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{projects.length > 0 && (
|
||||
<Progress
|
||||
value={(completedCount / projects.length) * 100}
|
||||
className="h-1.5 flex-1"
|
||||
gradient
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{projects.length > 0 ? Math.round((completedCount / projects.length) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Total Team Members
|
||||
</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{projects.reduce(
|
||||
(acc, a) => acc + (a.project.teamMembers?.length || 0),
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Across all assigned projects
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Team Members</p>
|
||||
<p className="text-2xl font-bold mt-1">
|
||||
{projects.reduce(
|
||||
(acc, a) => acc + (a.project.teamMembers?.length || 0),
|
||||
0
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all assigned projects</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
|
|
@ -219,8 +229,8 @@ export default function MentorDashboard() {
|
|||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
<Users className="h-6 w-6 text-muted-foreground" />
|
||||
<div className="rounded-2xl bg-brand-teal/10 p-4">
|
||||
<Users className="h-8 w-8 text-brand-teal" />
|
||||
</div>
|
||||
<p className="mt-4 font-medium">No assigned projects yet</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
|
|
@ -248,7 +258,7 @@ export default function MentorDashboard() {
|
|||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{filteredProjects.map((assignment) => {
|
||||
{filteredProjects.map((assignment, index) => {
|
||||
const project = assignment.project
|
||||
const teamLead = project.teamMembers?.find(
|
||||
(m) => m.role === 'LEAD'
|
||||
|
|
@ -256,7 +266,8 @@ export default function MentorDashboard() {
|
|||
const badge = completionBadge[assignment.completionStatus] || completionBadge.in_progress
|
||||
|
||||
return (
|
||||
<Card key={assignment.id}>
|
||||
<AnimatedCard key={assignment.id} index={index}>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -376,6 +387,7 @@ export default function MentorDashboard() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { MentorChat } from '@/components/shared/mentor-chat'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
|
|
@ -194,21 +195,31 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
|
||||
{/* Milestones Section */}
|
||||
{programId && mentorAssignmentId && (
|
||||
<AnimatedCard index={0}>
|
||||
<MilestonesSection
|
||||
programId={programId}
|
||||
mentorAssignmentId={mentorAssignmentId}
|
||||
/>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Private Notes Section */}
|
||||
{mentorAssignmentId && (
|
||||
<NotesSection mentorAssignmentId={mentorAssignmentId} />
|
||||
<AnimatedCard index={1}>
|
||||
<NotesSection mentorAssignmentId={mentorAssignmentId} />
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Project Info */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project Information</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
Project Information
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Category & Ocean Issue badges */}
|
||||
|
|
@ -299,12 +310,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Team Members Section */}
|
||||
<AnimatedCard index={3}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Team Members ({project.teamMembers?.length || 0})
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -392,12 +407,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Files Section */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-rose-500/10 p-1.5">
|
||||
<FileText className="h-4 w-4 text-rose-500" />
|
||||
</div>
|
||||
Project Files
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -426,12 +445,16 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Messaging Section */}
|
||||
<AnimatedCard index={5}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-blue-500/10 p-1.5">
|
||||
<MessageSquare className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
Messages
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -450,6 +473,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
|||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -529,8 +553,10 @@ function MilestonesSection({
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||
<Target className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
Milestones
|
||||
</CardTitle>
|
||||
<Badge variant="secondary">
|
||||
|
|
@ -552,7 +578,7 @@ function MilestonesSection({
|
|||
return (
|
||||
<div
|
||||
key={milestone.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-colors ${
|
||||
className={`flex items-start gap-3 p-3 rounded-lg border transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm ${
|
||||
isCompleted ? 'bg-green-50/50 border-green-200 dark:bg-green-950/20 dark:border-green-900' : ''
|
||||
}`}
|
||||
>
|
||||
|
|
@ -676,8 +702,10 @@ function NotesSection({ mentorAssignmentId }: { mentorAssignmentId: string }) {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<StickyNote className="h-5 w-5" />
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||
<StickyNote className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
Private Notes
|
||||
</CardTitle>
|
||||
{!isAdding && !editingId && (
|
||||
|
|
|
|||
|
|
@ -52,8 +52,16 @@ import {
|
|||
DiversityMetricsChart,
|
||||
} from '@/components/charts'
|
||||
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
||||
// Parse selection value: "all:programId" for edition-wide, or roundId
|
||||
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
|
||||
if (!value) return {}
|
||||
if (value.startsWith('all:')) return { programId: value.slice(4) }
|
||||
return { roundId: value }
|
||||
}
|
||||
|
||||
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
|
|
@ -63,10 +71,13 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
|||
}))
|
||||
) || []
|
||||
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: overviewStats, isLoading: statsLoading } =
|
||||
trpc.analytics.getOverviewStats.useQuery(
|
||||
{ roundId: selectedRoundId! },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -97,55 +108,79 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
|||
<div className="space-y-6">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Rounds</CardTitle>
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{rounds.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activeRounds} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{rounds.length}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{activeRounds} active
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<BarChart3 className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalProjects}</div>
|
||||
<p className="text-xs text-muted-foreground">Across all rounds</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Across all rounds</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{activeRounds}</div>
|
||||
<p className="text-xs text-muted-foreground">Currently active</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Rounds</p>
|
||||
<p className="text-2xl font-bold mt-1">{activeRounds}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Programs</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{totalPrograms}</div>
|
||||
<p className="text-xs text-muted-foreground">Total programs</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Programs</p>
|
||||
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Total programs</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
|
||||
{/* Round-specific overview stats */}
|
||||
{selectedRoundId && (
|
||||
{/* Round/edition-specific overview stats */}
|
||||
{hasSelection && (
|
||||
<>
|
||||
{statsLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
|
@ -163,7 +198,7 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
|||
</div>
|
||||
) : overviewStats ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Selected Round Details</h3>
|
||||
<h3 className="text-lg font-semibold">{queryInput.programId ? 'Edition Overview' : 'Selected Round Details'}</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
|
|
@ -207,7 +242,7 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
|
||||
<Progress value={overviewStats.completionRate} className="mt-2 h-2" />
|
||||
<Progress value={overviewStats.completionRate} className="mt-2 h-2" gradient />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -304,41 +339,44 @@ function OverviewTab({ selectedRoundId }: { selectedRoundId: string | null }) {
|
|||
)
|
||||
}
|
||||
|
||||
function AnalyticsTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: scoreDistribution, isLoading: scoreLoading } =
|
||||
trpc.analytics.getScoreDistribution.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: timeline, isLoading: timelineLoading } =
|
||||
trpc.analytics.getEvaluationTimeline.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: statusBreakdown, isLoading: statusLoading } =
|
||||
trpc.analytics.getStatusBreakdown.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: jurorWorkload, isLoading: workloadLoading } =
|
||||
trpc.analytics.getJurorWorkload.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: projectRankings, isLoading: rankingsLoading } =
|
||||
trpc.analytics.getProjectRankings.useQuery(
|
||||
{ roundId: selectedRoundId, limit: 15 },
|
||||
{ enabled: !!selectedRoundId }
|
||||
{ ...queryInput, limit: 15 },
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
const { data: criteriaScores, isLoading: criteriaLoading } =
|
||||
trpc.analytics.getCriteriaScores.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
return (
|
||||
|
|
@ -483,11 +521,14 @@ function CrossRoundTab() {
|
|||
)
|
||||
}
|
||||
|
||||
function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: consistency, isLoading } =
|
||||
trpc.analytics.getJurorConsistency.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
||||
|
|
@ -508,11 +549,14 @@ function JurorConsistencyTab({ selectedRoundId }: { selectedRoundId: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) {
|
||||
function DiversityTab({ selectedValue }: { selectedValue: string }) {
|
||||
const queryInput = parseSelection(selectedValue)
|
||||
const hasSelection = !!queryInput.roundId || !!queryInput.programId
|
||||
|
||||
const { data: diversity, isLoading } =
|
||||
trpc.analytics.getDiversityMetrics.useQuery(
|
||||
{ roundId: selectedRoundId },
|
||||
{ enabled: !!selectedRoundId }
|
||||
queryInput,
|
||||
{ enabled: hasSelection }
|
||||
)
|
||||
|
||||
if (isLoading) return <Skeleton className="h-[400px]" />
|
||||
|
|
@ -533,22 +577,26 @@ function DiversityTab({ selectedRoundId }: { selectedRoundId: string }) {
|
|||
}
|
||||
|
||||
export default function ObserverReportsPage() {
|
||||
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null)
|
||||
|
||||
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
p.rounds.map(r => ({
|
||||
...r,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
// Set default selected round
|
||||
if (rounds.length && !selectedRoundId) {
|
||||
setSelectedRoundId(rounds[0].id)
|
||||
if (rounds.length && !selectedValue) {
|
||||
setSelectedValue(rounds[0].id)
|
||||
}
|
||||
|
||||
const hasSelection = !!selectedValue
|
||||
const selectedRound = rounds.find((r) => r.id === selectedValue)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
|
|
@ -565,11 +613,16 @@ export default function ObserverReportsPage() {
|
|||
{roundsLoading ? (
|
||||
<Skeleton className="h-10 w-full sm:w-[300px]" />
|
||||
) : rounds.length > 0 ? (
|
||||
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
|
||||
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
|
||||
<SelectTrigger className="w-full sm:w-[300px]">
|
||||
<SelectValue placeholder="Select a round" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{programs?.map((p) => (
|
||||
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
|
||||
{p.year} Edition — All Rounds
|
||||
</SelectItem>
|
||||
))}
|
||||
{rounds.map((round) => (
|
||||
<SelectItem key={round.id} value={round.id}>
|
||||
{round.programName} - {round.name}
|
||||
|
|
@ -590,7 +643,7 @@ export default function ObserverReportsPage() {
|
|||
<FileSpreadsheet className="h-4 w-4" />
|
||||
Overview
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="analytics" className="gap-2" disabled={!selectedRoundId}>
|
||||
<TabsTrigger value="analytics" className="gap-2" disabled={!hasSelection}>
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
|
|
@ -598,38 +651,38 @@ export default function ObserverReportsPage() {
|
|||
<GitCompare className="h-4 w-4" />
|
||||
Cross-Round
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="consistency" className="gap-2" disabled={!selectedRoundId}>
|
||||
<TabsTrigger value="consistency" className="gap-2" disabled={!hasSelection}>
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Juror Consistency
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="diversity" className="gap-2" disabled={!selectedRoundId}>
|
||||
<TabsTrigger value="diversity" className="gap-2" disabled={!hasSelection}>
|
||||
<Globe className="h-4 w-4" />
|
||||
Diversity
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{selectedRoundId && (
|
||||
{selectedValue && !selectedValue.startsWith('all:') && (
|
||||
<ExportPdfButton
|
||||
roundId={selectedRoundId}
|
||||
roundName={rounds.find((r) => r.id === selectedRoundId)?.name}
|
||||
programName={rounds.find((r) => r.id === selectedRoundId)?.programName}
|
||||
roundId={selectedValue}
|
||||
roundName={selectedRound?.name}
|
||||
programName={selectedRound?.programName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab selectedRoundId={selectedRoundId} />
|
||||
<OverviewTab selectedValue={selectedValue} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics">
|
||||
{selectedRoundId ? (
|
||||
<AnalyticsTab selectedRoundId={selectedRoundId} />
|
||||
{hasSelection ? (
|
||||
<AnalyticsTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round from the dropdown above to view analytics
|
||||
Choose a round or edition from the dropdown above to view analytics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -641,15 +694,15 @@ export default function ObserverReportsPage() {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="consistency">
|
||||
{selectedRoundId ? (
|
||||
<JurorConsistencyTab selectedRoundId={selectedRoundId} />
|
||||
{hasSelection ? (
|
||||
<JurorConsistencyTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<UserCheck className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round above to view juror consistency metrics
|
||||
Choose a round or edition above to view juror consistency metrics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -657,15 +710,15 @@ export default function ObserverReportsPage() {
|
|||
</TabsContent>
|
||||
|
||||
<TabsContent value="diversity">
|
||||
{selectedRoundId ? (
|
||||
<DiversityTab selectedRoundId={selectedRoundId} />
|
||||
{hasSelection ? (
|
||||
<DiversityTab selectedValue={selectedValue!} />
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Globe className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">Select a round</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose a round above to view diversity metrics
|
||||
Choose a round or edition above to view diversity metrics
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -98,8 +98,12 @@ export default function TeamManagementPage() {
|
|||
)
|
||||
|
||||
const inviteMutation = trpc.applicant.inviteTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member invited!')
|
||||
onSuccess: (result) => {
|
||||
if (result.requiresAccountSetup) {
|
||||
toast.success('Invitation email sent to team member')
|
||||
} else {
|
||||
toast.success('Team member added and notified by email')
|
||||
}
|
||||
setIsInviteOpen(false)
|
||||
refetch()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Search, Loader2, Plus, Package } from 'lucide-react'
|
||||
import { Search, Loader2, Plus, Package, CheckCircle2 } from 'lucide-react'
|
||||
import { getCountryName } from '@/lib/countries'
|
||||
|
||||
interface AssignProjectsDialogProps {
|
||||
|
|
@ -65,7 +65,6 @@ export function AssignProjectsDialog({
|
|||
const { data, isLoading } = trpc.project.list.useQuery(
|
||||
{
|
||||
programId,
|
||||
notInRoundId: roundId,
|
||||
search: debouncedSearch || undefined,
|
||||
page: 1,
|
||||
perPage: 5000,
|
||||
|
|
@ -87,23 +86,28 @@ export function AssignProjectsDialog({
|
|||
})
|
||||
|
||||
const projects = data?.projects ?? []
|
||||
const alreadyInRound = new Set(
|
||||
projects.filter((p) => p.round?.id === roundId).map((p) => p.id)
|
||||
)
|
||||
const assignableProjects = projects.filter((p) => !alreadyInRound.has(p.id))
|
||||
|
||||
const toggleProject = useCallback((id: string) => {
|
||||
if (alreadyInRound.has(id)) return
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
}, [alreadyInRound])
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
if (selectedIds.size === projects.length) {
|
||||
if (selectedIds.size === assignableProjects.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(projects.map((p) => p.id)))
|
||||
setSelectedIds(new Set(assignableProjects.map((p) => p.id)))
|
||||
}
|
||||
}, [selectedIds.size, projects])
|
||||
}, [selectedIds.size, assignableProjects])
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedIds.size === 0) return
|
||||
|
|
@ -144,9 +148,9 @@ export function AssignProjectsDialog({
|
|||
) : projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Package className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No available projects</p>
|
||||
<p className="mt-2 font-medium">No projects found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All program projects are already in this round.
|
||||
{debouncedSearch ? 'No projects match your search.' : 'This program has no projects yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -154,11 +158,15 @@ export function AssignProjectsDialog({
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.size === projects.length && projects.length > 0}
|
||||
checked={assignableProjects.length > 0 && selectedIds.size === assignableProjects.length}
|
||||
disabled={assignableProjects.length === 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selectedIds.size} of {projects.length} selected
|
||||
{selectedIds.size} of {assignableProjects.length} assignable selected
|
||||
{alreadyInRound.size > 0 && (
|
||||
<span className="ml-1">({alreadyInRound.size} already in round)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -174,34 +182,54 @@ export function AssignProjectsDialog({
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{projects.map((project) => (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
|
||||
onClick={() => toggleProject(project.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => toggleProject(project.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{project.title}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{project.teamName || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.country ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getCountryName(project.country)}
|
||||
</Badge>
|
||||
) : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{projects.map((project) => {
|
||||
const isInRound = alreadyInRound.has(project.id)
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
className={
|
||||
isInRound
|
||||
? 'opacity-60'
|
||||
: selectedIds.has(project.id)
|
||||
? 'bg-muted/50'
|
||||
: 'cursor-pointer'
|
||||
}
|
||||
onClick={() => toggleProject(project.id)}
|
||||
>
|
||||
<TableCell>
|
||||
{isInRound ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedIds.has(project.id)}
|
||||
onCheckedChange={() => toggleProject(project.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{project.title}
|
||||
{isInRound && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
In round
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{project.teamName || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.country ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getCountryName(project.country)}
|
||||
</Badge>
|
||||
) : '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Sparkles,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
|
|
@ -119,7 +119,7 @@ export function EvaluationSummaryCard({
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<FileText className="h-5 w-5" />
|
||||
AI Evaluation Summary
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -128,7 +128,7 @@ export function EvaluationSummaryCard({
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Sparkles className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<FileText className="h-10 w-10 text-muted-foreground/50 mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
No summary generated yet. Click below to analyze submitted evaluations.
|
||||
</p>
|
||||
|
|
@ -136,7 +136,7 @@ export function EvaluationSummaryCard({
|
|||
{isGenerating ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isGenerating ? 'Generating...' : 'Generate Summary'}
|
||||
</Button>
|
||||
|
|
@ -155,7 +155,7 @@ export function EvaluationSummaryCard({
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
<FileText className="h-5 w-5" />
|
||||
AI Evaluation Summary
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 mt-1">
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { Plus, Users, Search } from 'lucide-react'
|
||||
import { Plus, Users, Search, Mail, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatRelativeTime } from '@/lib/utils'
|
||||
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
|
||||
|
||||
|
|
@ -60,6 +61,39 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
|
|||
SUPER_ADMIN: 'destructive' as 'default',
|
||||
}
|
||||
|
||||
function InlineSendInvite({ userId, userEmail }: { userId: string; userEmail: string }) {
|
||||
const utils = trpc.useUtils()
|
||||
const sendInvitation = trpc.user.sendInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success(`Invitation sent to ${userEmail}`)
|
||||
utils.user.list.invalidate()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || 'Failed to send invitation')
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs gap-1 px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
sendInvitation.mutate({ userId })
|
||||
}}
|
||||
disabled={sendInvitation.isPending}
|
||||
>
|
||||
{sendInvitation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Mail className="h-3 w-3" />
|
||||
)}
|
||||
Send Invite
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function MembersContent() {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
|
|
@ -124,7 +158,7 @@ export function MembersContent() {
|
|||
<Button asChild>
|
||||
<Link href="/admin/members/invite">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Invite Member
|
||||
Add Member
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -223,9 +257,14 @@ export function MembersContent() {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
{statusLabels[user.status] || user.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
{statusLabels[user.status] || user.status}
|
||||
</Badge>
|
||||
{user.status === 'NONE' && (
|
||||
<InlineSendInvite userId={user.id} userEmail={user.email} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.lastLoginAt ? (
|
||||
|
|
@ -272,9 +311,14 @@ export function MembersContent() {
|
|||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
{statusLabels[user.status] || user.status}
|
||||
</Badge>
|
||||
<div className="flex flex-col items-end gap-1.5">
|
||||
<Badge variant={statusColors[user.status] || 'secondary'}>
|
||||
{statusLabels[user.status] || user.status}
|
||||
</Badge>
|
||||
{user.status === 'NONE' && (
|
||||
<InlineSendInvite userId={user.id} userEmail={user.email} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||
import type { Route } from 'next'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
|
|
@ -33,7 +34,7 @@ import {
|
|||
Trophy,
|
||||
User,
|
||||
MessageSquare,
|
||||
Wand2,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react'
|
||||
import { getInitials } from '@/lib/utils'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
|
|
@ -41,6 +42,7 @@ import { EditionSelector } from '@/components/shared/edition-selector'
|
|||
import { useEdition } from '@/contexts/edition-context'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||
import { LanguageSwitcher } from '@/components/shared/language-switcher'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
|
||||
|
|
@ -120,7 +122,7 @@ const adminNavigation: NavItem[] = [
|
|||
{
|
||||
name: 'Apply Page',
|
||||
href: '/admin/programs',
|
||||
icon: Wand2,
|
||||
icon: LayoutTemplate,
|
||||
activeMatch: 'apply-settings',
|
||||
},
|
||||
{
|
||||
|
|
@ -145,6 +147,7 @@ const roleLabels: Record<string, string> = {
|
|||
|
||||
export function AdminSidebar({ user }: AdminSidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const tAuth = useTranslations('auth')
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
|
@ -170,6 +173,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||
<div className="fixed top-0 left-0 right-0 z-40 flex h-16 items-center justify-between border-b bg-card px-4 lg:hidden">
|
||||
<Logo showText textSuffix="Admin" />
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
<NotificationBell />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -204,7 +208,8 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||
{/* Logo + Notification */}
|
||||
<div className="flex h-16 items-center justify-between border-b px-6">
|
||||
<Logo showText textSuffix="Admin" />
|
||||
<div className="hidden lg:block">
|
||||
<div className="hidden lg:flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<NotificationBell />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -344,7 +349,7 @@ export function AdminSidebar({ user }: AdminSidebarProps) {
|
|||
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-2 text-destructive focus:bg-destructive/10 focus:text-destructive"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Sign out</span>
|
||||
<span>{tAuth('signOut')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -2,35 +2,37 @@
|
|||
|
||||
import { Home, Users, FileText, MessageSquare } from 'lucide-react'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/applicant',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: 'Team',
|
||||
href: '/applicant/team',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: 'Documents',
|
||||
href: '/applicant/documents',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
name: 'Mentor',
|
||||
href: '/applicant/mentor',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
]
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface ApplicantNavProps {
|
||||
user: RoleNavUser
|
||||
}
|
||||
|
||||
export function ApplicantNav({ user }: ApplicantNavProps) {
|
||||
const t = useTranslations('nav')
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: t('dashboard'),
|
||||
href: '/applicant',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: t('team'),
|
||||
href: '/applicant/team',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: t('documents'),
|
||||
href: '/applicant/documents',
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
name: t('mentoring'),
|
||||
href: '/applicant/mentor',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<RoleNav
|
||||
navigation={navigation}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,10 @@
|
|||
'use client'
|
||||
|
||||
import { BookOpen, ClipboardList, GitCompare, Home } from 'lucide-react'
|
||||
import { BookOpen, ClipboardList, GitCompare, Home, Trophy } from 'lucide-react'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/jury',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: 'My Assignments',
|
||||
href: '/jury/assignments',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
name: 'Compare',
|
||||
href: '/jury/compare',
|
||||
icon: GitCompare,
|
||||
},
|
||||
{
|
||||
name: 'Learning Hub',
|
||||
href: '/jury/learning',
|
||||
icon: BookOpen,
|
||||
},
|
||||
]
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface JuryNavProps {
|
||||
user: RoleNavUser
|
||||
|
|
@ -65,6 +43,35 @@ function RemainingBadge() {
|
|||
}
|
||||
|
||||
export function JuryNav({ user }: JuryNavProps) {
|
||||
const t = useTranslations('nav')
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: t('dashboard'),
|
||||
href: '/jury',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: t('assignments'),
|
||||
href: '/jury/assignments',
|
||||
icon: ClipboardList,
|
||||
},
|
||||
{
|
||||
name: t('awards'),
|
||||
href: '/jury/awards',
|
||||
icon: Trophy,
|
||||
},
|
||||
{
|
||||
name: t('compare'),
|
||||
href: '/jury/compare',
|
||||
icon: GitCompare,
|
||||
},
|
||||
{
|
||||
name: t('learningHub'),
|
||||
href: '/jury/learning',
|
||||
icon: BookOpen,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<RoleNav
|
||||
navigation={navigation}
|
||||
|
|
|
|||
|
|
@ -2,30 +2,32 @@
|
|||
|
||||
import { BookOpen, Home, Users } from 'lucide-react'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/mentor',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: 'My Mentees',
|
||||
href: '/mentor/projects',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: 'Resources',
|
||||
href: '/mentor/resources',
|
||||
icon: BookOpen,
|
||||
},
|
||||
]
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface MentorNavProps {
|
||||
user: RoleNavUser
|
||||
}
|
||||
|
||||
export function MentorNav({ user }: MentorNavProps) {
|
||||
const t = useTranslations('nav')
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: t('dashboard'),
|
||||
href: '/mentor',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: t('myProjects'),
|
||||
href: '/mentor/projects',
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
name: t('learningHub'),
|
||||
href: '/mentor/resources',
|
||||
icon: BookOpen,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<RoleNav
|
||||
navigation={navigation}
|
||||
|
|
|
|||
|
|
@ -2,25 +2,27 @@
|
|||
|
||||
import { BarChart3, Home } from 'lucide-react'
|
||||
import { RoleNav, type NavItem, type RoleNavUser } from '@/components/layouts/role-nav'
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
href: '/observer',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: 'Reports',
|
||||
href: '/observer/reports',
|
||||
icon: BarChart3,
|
||||
},
|
||||
]
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface ObserverNavProps {
|
||||
user: RoleNavUser
|
||||
}
|
||||
|
||||
export function ObserverNav({ user }: ObserverNavProps) {
|
||||
const t = useTranslations('nav')
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
name: t('dashboard'),
|
||||
href: '/observer',
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
name: t('reports'),
|
||||
href: '/observer/reports',
|
||||
icon: BarChart3,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<RoleNav
|
||||
navigation={navigation}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
|
|||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { signOut, useSession } from 'next-auth/react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
|
|
@ -21,6 +22,7 @@ import { LogOut, Menu, Moon, Settings, Sun, User, X } from 'lucide-react'
|
|||
import { useTheme } from 'next-themes'
|
||||
import { Logo } from '@/components/shared/logo'
|
||||
import { NotificationBell } from '@/components/shared/notification-bell'
|
||||
import { LanguageSwitcher } from '@/components/shared/language-switcher'
|
||||
|
||||
export type NavItem = {
|
||||
name: string
|
||||
|
|
@ -49,6 +51,8 @@ function isNavItemActive(pathname: string, href: string, basePath: string): bool
|
|||
|
||||
export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: RoleNavProps) {
|
||||
const pathname = usePathname()
|
||||
const tCommon = useTranslations('common')
|
||||
const tAuth = useTranslations('auth')
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const { status: sessionStatus } = useSession()
|
||||
const isAuthenticated = sessionStatus === 'authenticated'
|
||||
|
|
@ -107,6 +111,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
|
|||
)}
|
||||
</Button>
|
||||
)}
|
||||
<LanguageSwitcher />
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -130,7 +135,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
|
|||
<DropdownMenuItem asChild>
|
||||
<Link href={"/settings/profile" as Route} className="flex cursor-pointer items-center">
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Profile Settings
|
||||
{tCommon('settings')}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
@ -139,7 +144,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
|
|||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
{tAuth('signOut')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
@ -191,7 +196,7 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge }: R
|
|||
onClick={() => signOut({ callbackUrl: '/login' })}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Sign out
|
||||
{tAuth('signOut')}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import {
|
|||
ChevronRight,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { useDebouncedCallback } from 'use-debounce'
|
||||
|
||||
const PER_PAGE_OPTIONS = [10, 20, 50]
|
||||
|
|
@ -121,9 +122,9 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||
</div>
|
||||
|
||||
{/* Observer Notice */}
|
||||
<div className="rounded-lg border-2 border-blue-300 bg-blue-50 px-4 py-3">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50/50 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-100">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-blue-100 p-2.5">
|
||||
<Eye className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -175,65 +176,95 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||
</div>
|
||||
) : stats ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Programs</CardTitle>
|
||||
<FolderKanban className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.programCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={0}>
|
||||
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Programs</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.programCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{stats.activeRoundCount} active round{stats.activeRoundCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-blue-50 p-3">
|
||||
<FolderKanban className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.projectCount}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={1}>
|
||||
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Projects</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.projectCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{selectedRoundId !== 'all' ? 'In selected round' : 'Across all rounds'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-emerald-50 p-3">
|
||||
<ClipboardList className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.jurorCount}</div>
|
||||
<p className="text-xs text-muted-foreground">Active members</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={2}>
|
||||
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Jury Members</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.jurorCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Active members</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-violet-50 p-3">
|
||||
<Users className="h-5 w-5 text-violet-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
<Card className="transition-all hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
|
||||
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.submittedEvaluations}</div>
|
||||
<div className="mt-2">
|
||||
<Progress value={stats.completionRate} className="h-2" />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{stats.completionRate}% completion rate
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AnimatedCard index={3}>
|
||||
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
|
||||
<p className="text-2xl font-bold mt-1">{stats.submittedEvaluations}</p>
|
||||
<div className="mt-2">
|
||||
<Progress value={stats.completionRate} className="h-2" gradient />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{stats.completionRate}% completion rate
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-brand-teal/10 p-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Projects Table */}
|
||||
<AnimatedCard index={4}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All Projects</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-emerald-500/10 p-1.5">
|
||||
<ClipboardList className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
All Projects
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{projectsData ? `${projectsData.total} project${projectsData.total !== 1 ? 's' : ''} found` : 'Loading projects...'}
|
||||
</CardDescription>
|
||||
|
|
@ -395,12 +426,19 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Score Distribution */}
|
||||
{stats && stats.scoreDistribution.some((b) => b.count > 0) && (
|
||||
<AnimatedCard index={5}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Score Distribution</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-amber-500/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
Score Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>Distribution of global scores across evaluations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -424,13 +462,20 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Recent Rounds */}
|
||||
{recentRounds.length > 0 && (
|
||||
<AnimatedCard index={6}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Rounds</CardTitle>
|
||||
<CardTitle className="flex items-center gap-2.5">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Recent Rounds
|
||||
</CardTitle>
|
||||
<CardDescription>Overview of the latest voting rounds</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -470,6 +515,7 @@ export function ObserverDashboardContent({ userName }: { userName?: string }) {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useForm } from 'react-hook-form'
|
|||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
import { Bot, Loader2, Zap, AlertCircle, RefreshCw, Brain } from 'lucide-react'
|
||||
import { Cog, Loader2, Zap, AlertCircle, RefreshCw, SlidersHorizontal } from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
|
@ -264,7 +264,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.isReasoning && (
|
||||
<Brain className="h-3 w-3 text-purple-500" />
|
||||
<SlidersHorizontal className="h-3 w-3 text-purple-500" />
|
||||
)}
|
||||
<span>{model.name}</span>
|
||||
</div>
|
||||
|
|
@ -278,7 +278,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||
<FormDescription>
|
||||
{form.watch('ai_model')?.startsWith('o') ? (
|
||||
<span className="flex items-center gap-1 text-purple-600">
|
||||
<Brain className="h-3 w-3" />
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
Reasoning model - optimized for complex analysis tasks
|
||||
</span>
|
||||
) : (
|
||||
|
|
@ -323,7 +323,7 @@ export function AISettingsForm({ settings }: AISettingsFormProps) {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="mr-2 h-4 w-4" />
|
||||
<Cog className="mr-2 h-4 w-4" />
|
||||
Save AI Settings
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
Zap,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
Brain,
|
||||
SlidersHorizontal,
|
||||
Filter,
|
||||
Users,
|
||||
Award,
|
||||
|
|
@ -26,7 +26,7 @@ const ACTION_ICONS: Record<string, typeof Zap> = {
|
|||
ASSIGNMENT: Users,
|
||||
FILTERING: Filter,
|
||||
AWARD_ELIGIBILITY: Award,
|
||||
MENTOR_MATCHING: Brain,
|
||||
MENTOR_MATCHING: SlidersHorizontal,
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
|
|
@ -235,7 +235,7 @@ export function AIUsageCard() {
|
|||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Brain className="h-3 w-3" />
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
<span>{model}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{(data as { costFormatted?: string }).costFormatted}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Bot,
|
||||
Cog,
|
||||
Palette,
|
||||
Mail,
|
||||
HardDrive,
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { AISettingsForm } from './ai-settings-form'
|
||||
import { AIUsageCard } from './ai-usage-card'
|
||||
import { BrandingSettingsForm } from './branding-settings-form'
|
||||
|
|
@ -195,7 +196,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
</TabsTrigger>
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="ai" className="gap-2 shrink-0">
|
||||
<Bot className="h-4 w-4" />
|
||||
<Cog className="h-4 w-4" />
|
||||
AI
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
|
@ -275,7 +276,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<TabsList className="flex flex-col items-stretch h-auto w-full bg-transparent p-0 gap-0.5">
|
||||
{isSuperAdmin && (
|
||||
<TabsTrigger value="ai" className="justify-start gap-2 w-full px-3 py-2 h-auto data-[state=active]:bg-muted">
|
||||
<Bot className="h-4 w-4" />
|
||||
<Cog className="h-4 w-4" />
|
||||
AI
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
|
@ -308,6 +309,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="ai" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI Configuration</CardTitle>
|
||||
|
|
@ -319,11 +321,13 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<AISettingsForm settings={aiSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
<AIUsageCard />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="tags">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
|
@ -353,9 +357,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="branding">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Platform Branding</CardTitle>
|
||||
|
|
@ -367,10 +373,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<BrandingSettingsForm settings={brandingSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="email">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Email Configuration</CardTitle>
|
||||
|
|
@ -382,10 +390,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<EmailSettingsForm settings={emailSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="notifications">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Email Settings</CardTitle>
|
||||
|
|
@ -397,10 +407,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<NotificationSettingsForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="storage">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>File Storage</CardTitle>
|
||||
|
|
@ -412,11 +424,13 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<StorageSettingsForm settings={storageSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{isSuperAdmin && (
|
||||
<TabsContent value="security">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Settings</CardTitle>
|
||||
|
|
@ -428,10 +442,12 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<SecuritySettingsForm settings={securitySettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="defaults">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Default Settings</CardTitle>
|
||||
|
|
@ -443,9 +459,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<DefaultsSettingsForm settings={defaultsSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="digest" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Digest Configuration</CardTitle>
|
||||
|
|
@ -457,9 +475,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<DigestSettingsSection settings={digestSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analytics & Reports</CardTitle>
|
||||
|
|
@ -471,9 +491,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<AnalyticsSettingsSection settings={analyticsSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="audit" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit & Security</CardTitle>
|
||||
|
|
@ -485,9 +507,11 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<AuditSettingsSection settings={auditSecuritySettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="localization" className="space-y-6">
|
||||
<AnimatedCard>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Localization</CardTitle>
|
||||
|
|
@ -499,6 +523,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
<LocalizationSettingsSection settings={localizationSettings} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
</TabsContent>
|
||||
</div>{/* end content area */}
|
||||
</div>{/* end lg:flex */}
|
||||
|
|
@ -506,7 +531,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
|
||||
{/* Quick Links to sub-pages */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
|
|
@ -528,7 +553,7 @@ export function SettingsContent({ initialSettings, isSuperAdmin = true }: Settin
|
|||
</Card>
|
||||
|
||||
{isSuperAdmin && (
|
||||
<Card>
|
||||
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Webhook className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ export function EmptyState({
|
|||
className
|
||||
)}
|
||||
>
|
||||
<Icon className="h-12 w-12 text-muted-foreground/50" />
|
||||
<div className="rounded-2xl bg-muted/60 p-4">
|
||||
<Icon className="h-8 w-8 text-muted-foreground/70" />
|
||||
</div>
|
||||
<h3 className="mt-4 font-medium">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import { cn } from '@/lib/utils'
|
|||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||
gradient?: boolean
|
||||
}
|
||||
>(({ className, value, gradient, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
|
@ -17,7 +19,12 @@ const Progress = React.forwardRef<
|
|||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
className={cn(
|
||||
'h-full w-full flex-1 transition-all',
|
||||
gradient
|
||||
? 'bg-gradient-to-r from-brand-teal to-brand-blue'
|
||||
: 'bg-primary'
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,41 @@
|
|||
import { z } from 'zod'
|
||||
import { router, protectedProcedure, adminProcedure, observerProcedure } from '../trpc'
|
||||
|
||||
// Shared input schema: either roundId or programId (for entire edition)
|
||||
const editionOrRoundInput = z.object({
|
||||
roundId: z.string().optional(),
|
||||
programId: z.string().optional(),
|
||||
}).refine(data => data.roundId || data.programId, {
|
||||
message: 'Either roundId or programId is required',
|
||||
})
|
||||
|
||||
// Build Prisma where-clauses from the shared input
|
||||
function projectWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { roundId: input.roundId }
|
||||
return { programId: input.programId! }
|
||||
}
|
||||
|
||||
function assignmentWhere(input: { roundId?: string; programId?: string }) {
|
||||
if (input.roundId) return { roundId: input.roundId }
|
||||
return { round: { programId: input.programId! } }
|
||||
}
|
||||
|
||||
function evalWhere(input: { roundId?: string; programId?: string }, extra: Record<string, unknown> = {}) {
|
||||
const base = input.roundId
|
||||
? { assignment: { roundId: input.roundId } }
|
||||
: { assignment: { round: { programId: input.programId! } } }
|
||||
return { ...base, ...extra }
|
||||
}
|
||||
|
||||
export const analyticsRouter = router({
|
||||
/**
|
||||
* Get score distribution for a round (histogram data)
|
||||
*/
|
||||
getScoreDistribution: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
select: {
|
||||
criterionScoresJson: true,
|
||||
},
|
||||
|
|
@ -51,13 +74,10 @@ export const analyticsRouter = router({
|
|||
* Get evaluation completion over time (timeline data)
|
||||
*/
|
||||
getEvaluationTimeline: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
select: {
|
||||
submittedAt: true,
|
||||
},
|
||||
|
|
@ -97,10 +117,10 @@ export const analyticsRouter = router({
|
|||
* Get juror workload distribution
|
||||
*/
|
||||
getJurorWorkload: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const assignments = await ctx.prisma.assignment.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
where: assignmentWhere(input),
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
evaluation: {
|
||||
|
|
@ -146,10 +166,10 @@ export const analyticsRouter = router({
|
|||
* Get project rankings with average scores
|
||||
*/
|
||||
getProjectRankings: observerProcedure
|
||||
.input(z.object({ roundId: z.string(), limit: z.number().optional() }))
|
||||
.input(editionOrRoundInput.and(z.object({ limit: z.number().optional() })))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
where: projectWhere(input),
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
|
|
@ -214,11 +234,11 @@ export const analyticsRouter = router({
|
|||
* Get status breakdown (pie chart data)
|
||||
*/
|
||||
getStatusBreakdown: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
where: { roundId: input.roundId },
|
||||
where: projectWhere(input),
|
||||
_count: true,
|
||||
})
|
||||
|
||||
|
|
@ -232,7 +252,7 @@ export const analyticsRouter = router({
|
|||
* Get overview stats for dashboard
|
||||
*/
|
||||
getOverviewStats: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [
|
||||
projectCount,
|
||||
|
|
@ -241,21 +261,18 @@ export const analyticsRouter = router({
|
|||
jurorCount,
|
||||
statusCounts,
|
||||
] = await Promise.all([
|
||||
ctx.prisma.project.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.roundId } }),
|
||||
ctx.prisma.project.count({ where: projectWhere(input) }),
|
||||
ctx.prisma.assignment.count({ where: assignmentWhere(input) }),
|
||||
ctx.prisma.evaluation.count({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
}),
|
||||
ctx.prisma.assignment.groupBy({
|
||||
by: ['userId'],
|
||||
where: { roundId: input.roundId },
|
||||
where: assignmentWhere(input),
|
||||
}),
|
||||
ctx.prisma.project.groupBy({
|
||||
by: ['status'],
|
||||
where: { roundId: input.roundId },
|
||||
where: projectWhere(input),
|
||||
_count: true,
|
||||
}),
|
||||
])
|
||||
|
|
@ -282,33 +299,44 @@ export const analyticsRouter = router({
|
|||
* Get criteria-level score distribution
|
||||
*/
|
||||
getCriteriaScores: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Get active evaluation form for this round
|
||||
const evaluationForm = await ctx.prisma.evaluationForm.findFirst({
|
||||
where: { roundId: input.roundId, isActive: true },
|
||||
// Get active evaluation forms — either for a specific round or all rounds in the edition
|
||||
const formWhere = input.roundId
|
||||
? { roundId: input.roundId, isActive: true }
|
||||
: { round: { programId: input.programId! }, isActive: true }
|
||||
|
||||
const evaluationForms = await ctx.prisma.evaluationForm.findMany({
|
||||
where: formWhere,
|
||||
})
|
||||
|
||||
if (!evaluationForm?.criteriaJson) {
|
||||
if (!evaluationForms.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Parse criteria from JSON
|
||||
const criteria = evaluationForm.criteriaJson as Array<{
|
||||
id: string
|
||||
label: string
|
||||
}>
|
||||
// Merge criteria from all forms (deduplicate by label for edition-wide)
|
||||
const criteriaMap = new Map<string, { id: string; label: string }>()
|
||||
evaluationForms.forEach((form) => {
|
||||
const criteria = form.criteriaJson as Array<{ id: string; label: string }> | null
|
||||
if (criteria) {
|
||||
criteria.forEach((c) => {
|
||||
// Use label as dedup key for edition-wide, id for single round
|
||||
const key = input.roundId ? c.id : c.label
|
||||
if (!criteriaMap.has(key)) {
|
||||
criteriaMap.set(key, c)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (!criteria || criteria.length === 0) {
|
||||
const criteria = Array.from(criteriaMap.values())
|
||||
if (criteria.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get all evaluations
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
select: { criterionScoresJson: true },
|
||||
})
|
||||
|
||||
|
|
@ -441,13 +469,10 @@ export const analyticsRouter = router({
|
|||
* Get juror consistency metrics for a round
|
||||
*/
|
||||
getJurorConsistency: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const evaluations = await ctx.prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: input.roundId },
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
where: evalWhere(input, { status: 'SUBMITTED' }),
|
||||
include: {
|
||||
assignment: {
|
||||
include: {
|
||||
|
|
@ -513,10 +538,10 @@ export const analyticsRouter = router({
|
|||
* Get diversity metrics for projects in a round
|
||||
*/
|
||||
getDiversityMetrics: observerProcedure
|
||||
.input(z.object({ roundId: z.string() }))
|
||||
.input(editionOrRoundInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const projects = await ctx.prisma.project.findMany({
|
||||
where: { roundId: input.roundId },
|
||||
where: projectWhere(input),
|
||||
select: {
|
||||
country: true,
|
||||
competitionCategory: true,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
import crypto from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { TRPCError } from '@trpc/server'
|
||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
||||
import { getPresignedUrl } from '@/lib/minio'
|
||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||
import { logAudit } from '@/server/utils/audit'
|
||||
import { createNotification } from '../services/in-app-notification'
|
||||
|
||||
// Bucket for applicant submissions
|
||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
||||
const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||
|
||||
function generateInviteToken(): string {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
export const applicantRouter = router({
|
||||
/**
|
||||
|
|
@ -775,6 +782,8 @@ export const applicantRouter = router({
|
|||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const normalizedEmail = input.email.trim().toLowerCase()
|
||||
|
||||
// Verify user is team lead
|
||||
const project = await ctx.prisma.project.findFirst({
|
||||
where: {
|
||||
|
|
@ -804,7 +813,7 @@ export const applicantRouter = router({
|
|||
const existingMember = await ctx.prisma.teamMember.findFirst({
|
||||
where: {
|
||||
projectId: input.projectId,
|
||||
user: { email: input.email },
|
||||
user: { email: normalizedEmail },
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -817,13 +826,13 @@ export const applicantRouter = router({
|
|||
|
||||
// Find or create user
|
||||
let user = await ctx.prisma.user.findUnique({
|
||||
where: { email: input.email },
|
||||
where: { email: normalizedEmail },
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
user = await ctx.prisma.user.create({
|
||||
data: {
|
||||
email: input.email,
|
||||
email: normalizedEmail,
|
||||
name: input.name,
|
||||
role: 'APPLICANT',
|
||||
status: 'NONE',
|
||||
|
|
@ -831,6 +840,77 @@ export const applicantRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
if (user.status === 'SUSPENDED') {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'This user account is suspended and cannot be invited',
|
||||
})
|
||||
}
|
||||
|
||||
const teamLeadName = ctx.user.name?.trim() || 'A team lead'
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||
const requiresAccountSetup = user.status !== 'ACTIVE'
|
||||
|
||||
try {
|
||||
if (requiresAccountSetup) {
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
status: 'INVITED',
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + TEAM_INVITE_TOKEN_EXPIRY_MS),
|
||||
},
|
||||
})
|
||||
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||
await sendTeamMemberInviteEmail(
|
||||
user.email,
|
||||
user.name || input.name,
|
||||
project.title,
|
||||
teamLeadName,
|
||||
inviteUrl
|
||||
)
|
||||
} else {
|
||||
await sendStyledNotificationEmail(
|
||||
user.email,
|
||||
user.name || input.name,
|
||||
'TEAM_INVITATION',
|
||||
{
|
||||
title: 'You were added to a project team',
|
||||
message: `${teamLeadName} added you to the project "${project.title}".`,
|
||||
linkUrl: `${baseUrl}/applicant/team`,
|
||||
linkLabel: 'Open Team',
|
||||
metadata: {
|
||||
projectId: project.id,
|
||||
projectName: project.title,
|
||||
},
|
||||
},
|
||||
`You've been added to "${project.title}"`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
channel: 'EMAIL',
|
||||
provider: 'SMTP',
|
||||
type: 'TEAM_INVITATION',
|
||||
status: 'FAILED',
|
||||
errorMsg: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Never fail on notification logging
|
||||
}
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to send invitation email. Please try again.',
|
||||
})
|
||||
}
|
||||
|
||||
// Create team membership
|
||||
const teamMember = await ctx.prisma.teamMember.create({
|
||||
data: {
|
||||
|
|
@ -846,9 +926,43 @@ export const applicantRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// TODO: Send invitation email to the new team member
|
||||
try {
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
channel: 'EMAIL',
|
||||
provider: 'SMTP',
|
||||
type: 'TEAM_INVITATION',
|
||||
status: 'SENT',
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Never fail on notification logging
|
||||
}
|
||||
|
||||
return teamMember
|
||||
try {
|
||||
await createNotification({
|
||||
userId: user.id,
|
||||
type: 'TEAM_INVITATION',
|
||||
title: 'Team Invitation',
|
||||
message: `${teamLeadName} added you to "${project.title}"`,
|
||||
linkUrl: '/applicant/team',
|
||||
linkLabel: 'View Team',
|
||||
priority: 'normal',
|
||||
metadata: {
|
||||
projectId: project.id,
|
||||
projectName: project.title,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Never fail invitation flow on in-app notification issues
|
||||
}
|
||||
|
||||
return {
|
||||
teamMember,
|
||||
inviteEmailSent: true,
|
||||
requiresAccountSetup,
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -87,18 +87,44 @@ export const roundRouter = router({
|
|||
},
|
||||
})
|
||||
|
||||
// Get evaluation stats
|
||||
const evaluationStats = await ctx.prisma.evaluation.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
assignment: { roundId: input.id },
|
||||
// Get evaluation stats + progress in parallel (avoids duplicate groupBy in getProgress)
|
||||
const [evaluationStats, totalAssignments, completedAssignments] =
|
||||
await Promise.all([
|
||||
ctx.prisma.evaluation.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
assignment: { roundId: input.id },
|
||||
},
|
||||
_count: true,
|
||||
}),
|
||||
ctx.prisma.assignment.count({ where: { roundId: input.id } }),
|
||||
ctx.prisma.assignment.count({
|
||||
where: { roundId: input.id, isCompleted: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const evaluationsByStatus = evaluationStats.reduce(
|
||||
(acc, curr) => {
|
||||
acc[curr.status] = curr._count
|
||||
return acc
|
||||
},
|
||||
_count: true,
|
||||
})
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
return {
|
||||
...round,
|
||||
evaluationStats,
|
||||
// Inline progress data (eliminates need for separate getProgress call)
|
||||
progress: {
|
||||
totalProjects: round._count.projects,
|
||||
totalAssignments,
|
||||
completedAssignments,
|
||||
completionPercentage:
|
||||
totalAssignments > 0
|
||||
? Math.round((completedAssignments / totalAssignments) * 100)
|
||||
: 0,
|
||||
evaluationsByStatus,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
|
|
|
|||
|
|
@ -525,32 +525,31 @@ export const specialAwardRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
})
|
||||
|
||||
// Get eligible projects
|
||||
const eligibleProjects = await ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId: input.awardId, eligible: true },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
tags: true,
|
||||
// Fetch award, eligible projects, and votes in parallel
|
||||
const [award, eligibleProjects, myVotes] = await Promise.all([
|
||||
ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
}),
|
||||
ctx.prisma.awardEligibility.findMany({
|
||||
where: { awardId: input.awardId, eligible: true },
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamName: true,
|
||||
description: true,
|
||||
competitionCategory: true,
|
||||
country: true,
|
||||
tags: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Get user's existing votes
|
||||
const myVotes = await ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||
})
|
||||
}),
|
||||
ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId, userId: ctx.user.id },
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
award,
|
||||
|
|
@ -646,25 +645,25 @@ export const specialAwardRouter = router({
|
|||
getVoteResults: adminProcedure
|
||||
.input(z.object({ awardId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const award = await ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
})
|
||||
|
||||
const votes = await ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, teamName: true },
|
||||
const [award, votes, jurorCount] = await Promise.all([
|
||||
ctx.prisma.specialAward.findUniqueOrThrow({
|
||||
where: { id: input.awardId },
|
||||
}),
|
||||
ctx.prisma.awardVote.findMany({
|
||||
where: { awardId: input.awardId },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, title: true, teamName: true },
|
||||
},
|
||||
user: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const jurorCount = await ctx.prisma.awardJuror.count({
|
||||
where: { awardId: input.awardId },
|
||||
})
|
||||
}),
|
||||
ctx.prisma.awardJuror.count({
|
||||
where: { awardId: input.awardId },
|
||||
}),
|
||||
])
|
||||
|
||||
const votedJurorCount = new Set(votes.map((v) => v.userId)).size
|
||||
|
||||
|
|
|
|||
|
|
@ -485,6 +485,7 @@ export const userRouter = router({
|
|||
.optional(),
|
||||
})
|
||||
),
|
||||
sendInvitation: z.boolean().default(true),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
|
@ -544,7 +545,7 @@ export const userRouter = router({
|
|||
name: u.name,
|
||||
role: u.role,
|
||||
expertiseTags: u.expertiseTags,
|
||||
status: 'INVITED',
|
||||
status: input.sendInvitation ? 'INVITED' : 'NONE',
|
||||
})),
|
||||
})
|
||||
|
||||
|
|
@ -559,8 +560,7 @@ export const userRouter = router({
|
|||
userAgent: ctx.userAgent,
|
||||
})
|
||||
|
||||
// Auto-send invitation emails to newly created users
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||
// Fetch newly created users for assignments and optional invitation emails
|
||||
const createdUsers = await ctx.prisma.user.findMany({
|
||||
where: { email: { in: newUsers.map((u) => u.email.toLowerCase()) } },
|
||||
select: { id: true, email: true, name: true, role: true },
|
||||
|
|
@ -603,49 +603,54 @@ export const userRouter = router({
|
|||
})
|
||||
}
|
||||
|
||||
// Send invitation emails if requested
|
||||
let emailsSent = 0
|
||||
const emailErrors: string[] = []
|
||||
|
||||
for (const user of createdUsers) {
|
||||
try {
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
},
|
||||
})
|
||||
if (input.sendInvitation) {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
||||
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
|
||||
for (const user of createdUsers) {
|
||||
try {
|
||||
const token = generateInviteToken()
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
inviteToken: token,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
||||
},
|
||||
})
|
||||
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
channel: 'EMAIL',
|
||||
provider: 'SMTP',
|
||||
type: 'JURY_INVITATION',
|
||||
status: 'SENT',
|
||||
},
|
||||
})
|
||||
emailsSent++
|
||||
} catch (e) {
|
||||
emailErrors.push(user.email)
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
channel: 'EMAIL',
|
||||
provider: 'SMTP',
|
||||
type: 'JURY_INVITATION',
|
||||
status: 'FAILED',
|
||||
errorMsg: e instanceof Error ? e.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
|
||||
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
channel: 'EMAIL',
|
||||
provider: 'SMTP',
|
||||
type: 'JURY_INVITATION',
|
||||
status: 'SENT',
|
||||
},
|
||||
})
|
||||
emailsSent++
|
||||
} catch (e) {
|
||||
emailErrors.push(user.email)
|
||||
await ctx.prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
channel: 'EMAIL',
|
||||
provider: 'SMTP',
|
||||
type: 'JURY_INVITATION',
|
||||
status: 'FAILED',
|
||||
errorMsg: e instanceof Error ? e.message : 'Unknown error',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated }
|
||||
return { created: created.count, skipped, emailsSent, emailErrors, assignmentsCreated, invitationSent: input.sendInvitation }
|
||||
}),
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue