From e73a676412479f881ce667f7cbd3fad090450013 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 8 Feb 2026 22:05:01 +0100 Subject: [PATCH] Comprehensive platform audit: security, UX, performance, and visual polish Phase 1: Security - status transition validation, Zod tightening, DB indexes, transactions Phase 2: Admin UX - search/filter for awards, learning, partners pages Phase 3: Dashboard - Recent Activity feed, Pending Actions card, quick actions Phase 4: Jury - assignments progress/urgency, autosave indicator, divergence highlighting Phase 5: Portals - observer charts, mentor search, login/onboarding polish Phase 6: Messages preview dialog, CsvExportDialog with column selection Phase 7: Performance - query optimizations, loading skeletons, useDebounce hook Phase 8: Visual - AnimatedCard, hover effects, StatusBadge, empty state CTAs Co-Authored-By: Claude Opus 4.6 --- prisma/schema.prisma | 10 + src/app/(admin)/admin/audit/page.tsx | 54 +- src/app/(admin)/admin/awards/[id]/page.tsx | 669 ++++++++++++++---- src/app/(admin)/admin/awards/page.tsx | 117 ++- src/app/(admin)/admin/learning/page.tsx | 294 +++++--- src/app/(admin)/admin/messages/page.tsx | 120 +++- src/app/(admin)/admin/page.tsx | 362 +++++++--- src/app/(admin)/admin/partners/page.tsx | 311 +++++--- .../programs/[id]/apply-settings/page.tsx | 12 +- src/app/(admin)/admin/projects/new/page.tsx | 313 ++++---- src/app/(admin)/admin/projects/page.tsx | 114 ++- .../(admin)/admin/rounds/[id]/edit/page.tsx | 78 +- .../rounds/[id]/filtering/results/page.tsx | 49 +- src/app/(auth)/login/page.tsx | 43 +- src/app/(auth)/onboarding/page.tsx | 31 +- src/app/(jury)/jury/assignments/page.tsx | 85 ++- src/app/(jury)/jury/compare/page.tsx | 48 +- src/app/(jury)/jury/page.tsx | 4 +- src/app/(mentor)/mentor/page.tsx | 85 ++- src/app/(observer)/observer/page.tsx | 106 ++- src/components/admin/members-content.tsx | 16 +- src/components/forms/evaluation-form.tsx | 31 +- src/components/layouts/admin-sidebar.tsx | 57 +- src/components/shared/animated-container.tsx | 28 + src/components/shared/csv-export-dialog.tsx | 212 ++++++ src/components/shared/file-viewer.tsx | 93 ++- src/components/shared/logo-upload.tsx | 217 ++++-- src/components/shared/status-badge.tsx | 55 ++ src/hooks/use-debounce.ts | 12 + src/server/routers/project.ts | 193 +++-- src/server/routers/round.ts | 17 + src/server/routers/specialAward.ts | 150 +--- src/server/services/award-eligibility-job.ts | 184 +++++ 33 files changed, 3193 insertions(+), 977 deletions(-) create mode 100644 src/components/shared/animated-container.tsx create mode 100644 src/components/shared/csv-export-dialog.tsx create mode 100644 src/components/shared/status-badge.tsx create mode 100644 src/hooks/use-debounce.ts create mode 100644 src/server/services/award-eligibility-job.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c056e51..ce1a0b1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -613,6 +613,7 @@ model ProjectFile { @@unique([bucket, objectKey]) @@index([projectId]) @@index([roundId]) + @@index([projectId, roundId]) @@index([fileType]) } @@ -651,6 +652,7 @@ model Assignment { @@index([projectId]) @@index([roundId]) @@index([isCompleted]) + @@index([projectId, userId]) } model Evaluation { @@ -1339,6 +1341,13 @@ model SpecialAward { sortOrder Int @default(0) + // Eligibility job tracking + eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED + eligibilityJobTotal Int? // total projects to process + eligibilityJobDone Int? // completed so far + eligibilityJobError String? @db.Text // error message if failed + eligibilityJobStarted DateTime? // when job started + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -1379,6 +1388,7 @@ model AwardEligibility { @@index([awardId]) @@index([projectId]) @@index([eligible]) + @@index([awardId, eligible]) } model AwardJuror { diff --git a/src/app/(admin)/admin/audit/page.tsx b/src/app/(admin)/admin/audit/page.tsx index 4c1b5b7..c4d9045 100644 --- a/src/app/(admin)/admin/audit/page.tsx +++ b/src/app/(admin)/admin/audit/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useMemo } from 'react' +import { useState, useMemo, useCallback } from 'react' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { @@ -53,6 +53,7 @@ import { ArrowLeftRight, } from 'lucide-react' import { Switch } from '@/components/ui/switch' +import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { formatDate } from '@/lib/utils' import { cn } from '@/lib/utils' @@ -163,7 +164,7 @@ export default function AuditLogPage() { retry: false, }) - // Export mutation + // Export query const exportLogs = trpc.export.auditLogs.useQuery( { userId: filters.userId || undefined, @@ -176,41 +177,18 @@ export default function AuditLogPage() { }, { enabled: false } ) + const [showExportDialog, setShowExportDialog] = useState(false) // Handle export - const handleExport = async () => { - const result = await exportLogs.refetch() - if (result.data) { - const { data: rows, columns } = result.data - - // Build CSV - const csvContent = [ - columns.join(','), - ...rows.map((row) => - columns - .map((col) => { - const value = row[col as keyof typeof row] - // Escape quotes and wrap in quotes if contains comma - if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) { - return `"${value.replace(/"/g, '""')}"` - } - return value ?? '' - }) - .join(',') - ), - ].join('\n') - - // Download - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv` - link.click() - URL.revokeObjectURL(url) - } + const handleExport = () => { + setShowExportDialog(true) } + const handleRequestExportData = useCallback(async () => { + const result = await exportLogs.refetch() + return result.data ?? undefined + }, [exportLogs]) + // Reset filters const resetFilters = () => { setFilters({ @@ -701,6 +679,16 @@ export default function AuditLogPage() { )} + + {/* CSV Export Dialog with Column Selection */} + ) } diff --git a/src/app/(admin)/admin/awards/[id]/page.tsx b/src/app/(admin)/admin/awards/[id]/page.tsx index d458916..c9cbc56 100644 --- a/src/app/(admin)/admin/awards/[id]/page.tsx +++ b/src/app/(admin)/admin/awards/[id]/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { use, useState } from 'react' +import { use, useEffect, useRef, useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/navigation' import { trpc } from '@/lib/trpc/client' @@ -53,9 +53,21 @@ import { } from '@/components/ui/dialog' 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 { Pagination } from '@/components/shared/pagination' import { toast } from 'sonner' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible' import { ArrowLeft, Trophy, @@ -73,6 +85,9 @@ import { Trash2, Plus, Search, + Vote, + ChevronDown, + AlertCircle, } from 'lucide-react' const STATUS_COLORS: Record = { @@ -83,6 +98,41 @@ const STATUS_COLORS: Record s.key === status) + return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0) +} + +function ConfidenceBadge({ confidence }: { confidence: number }) { + if (confidence > 0.8) { + return ( + + {Math.round(confidence * 100)}% + + ) + } + if (confidence >= 0.5) { + return ( + + {Math.round(confidence * 100)}% + + ) + } + return ( + + {Math.round(confidence * 100)}% + + ) +} + export default function AwardDetailPage({ params, }: { @@ -111,6 +161,53 @@ export default function AwardDetailPage({ { enabled: !!award?.programId } ) + const [isPollingJob, setIsPollingJob] = useState(false) + const pollingIntervalRef = useRef | null>(null) + + // Eligibility job polling + const { data: jobStatus, refetch: refetchJobStatus } = + trpc.specialAward.getEligibilityJobStatus.useQuery( + { awardId }, + { enabled: isPollingJob } + ) + + useEffect(() => { + if (!isPollingJob) return + + pollingIntervalRef.current = setInterval(() => { + refetchJobStatus() + }, 2000) + + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current) + pollingIntervalRef.current = null + } + } + }, [isPollingJob, refetchJobStatus]) + + // React to job status changes + useEffect(() => { + if (!jobStatus || !isPollingJob) return + + if (jobStatus.eligibilityJobStatus === 'COMPLETED') { + setIsPollingJob(false) + toast.success('Eligibility processing completed') + refetchEligibility() + refetch() + } else if (jobStatus.eligibilityJobStatus === 'FAILED') { + setIsPollingJob(false) + toast.error(jobStatus.eligibilityJobError || 'Eligibility processing failed') + } + }, [jobStatus, isPollingJob, refetchEligibility, refetch]) + + // Check on mount if there's an ongoing job + useEffect(() => { + if (award?.eligibilityJobStatus === 'PROCESSING' || award?.eligibilityJobStatus === 'PENDING') { + setIsPollingJob(true) + } + }, [award?.eligibilityJobStatus]) + const updateStatus = trpc.specialAward.updateStatus.useMutation() const runEligibility = trpc.specialAward.runEligibility.useMutation() const setEligibility = trpc.specialAward.setEligibility.useMutation() @@ -123,6 +220,7 @@ export default function AwardDetailPage({ const [includeSubmitted, setIncludeSubmitted] = useState(true) const [addProjectDialogOpen, setAddProjectDialogOpen] = useState(false) const [projectSearchQuery, setProjectSearchQuery] = useState('') + const [expandedRows, setExpandedRows] = useState>(new Set()) const handleStatusChange = async ( status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED' @@ -140,15 +238,12 @@ export default function AwardDetailPage({ const handleRunEligibility = async () => { try { - const result = await runEligibility.mutateAsync({ awardId, includeSubmitted }) - toast.success( - `Eligibility run: ${result.eligible} eligible, ${result.ineligible} ineligible` - ) - refetchEligibility() - refetch() + await runEligibility.mutateAsync({ awardId, includeSubmitted }) + toast.success('Eligibility processing started') + setIsPollingJob(true) } catch (error) { toast.error( - error instanceof Error ? error.message : 'Failed to run eligibility' + error instanceof Error ? error.message : 'Failed to start eligibility' ) } } @@ -369,6 +464,110 @@ export default function AwardDetailPage({

{award.description}

)} + {/* Status Workflow Step Indicator */} +
+
+ {WORKFLOW_STEPS.map((step, i) => { + const currentIdx = getStepIndex(award.status) + const isComplete = i < currentIdx + const isCurrent = i === currentIdx + return ( +
+
+
+ {isComplete ? ( + + ) : ( + i + 1 + )} +
+ + {step.label} + +
+ {i < WORKFLOW_STEPS.length - 1 && ( +
+
+
+ )} +
+ ) + })} +
+
+ + {/* Stats Cards */} +
+ + +
+
+

Eligible

+

{award.eligibleCount}

+
+
+ +
+
+
+
+ + +
+
+

Evaluated

+

{award._count.eligibilities}

+
+
+ +
+
+
+
+ + +
+
+

Jurors

+

{award._count.jurors}

+
+
+ +
+
+
+
+ + +
+
+

Votes

+

{award._count.votes}

+
+
+ +
+
+
+
+
+ {/* Tabs */} @@ -407,27 +606,27 @@ export default function AwardDetailPage({ {award.useAiEligibility ? ( ) : ( )} @@ -527,6 +726,59 @@ export default function AwardDetailPage({
+ {/* Eligibility job progress */} + {isPollingJob && jobStatus && ( + + +
+ +
+
+ + {jobStatus.eligibilityJobStatus === 'PENDING' + ? 'Preparing...' + : `Processing... ${jobStatus.eligibilityJobDone ?? 0} of ${jobStatus.eligibilityJobTotal ?? '?'} projects`} + + {jobStatus.eligibilityJobTotal && jobStatus.eligibilityJobTotal > 0 && ( + + {Math.round( + ((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100 + )}% + + )} +
+ 0 + ? ((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100 + : 0 + } + /> +
+
+
+
+ )} + + {/* Failed job notice */} + {!isPollingJob && award.eligibilityJobStatus === 'FAILED' && ( + + +
+
+ + + Last eligibility run failed: {award.eligibilityJobError || 'Unknown error'} + +
+ +
+
+
+ )} + {!award.useAiEligibility && (

AI eligibility is off for this award. Projects are loaded for manual selection. @@ -542,67 +794,146 @@ export default function AwardDetailPage({ Category Country Method + {award.useAiEligibility && AI Confidence} Eligible Actions - {eligibilityData.eligibilities.map((e) => ( - - -

-

{e.project.title}

-

- {e.project.teamName} -

-
- - - {e.project.competitionCategory ? ( - - {e.project.competitionCategory.replace('_', ' ')} - - ) : ( - '-' - )} - - {e.project.country || '-'} - - - {e.method === 'MANUAL' ? 'Manual' : 'Auto'} - - - - - handleToggleEligibility(e.projectId, checked) - } - /> - - - - - - ))} + {eligibilityData.eligibilities.map((e) => { + const aiReasoning = e.aiReasoningJson as { confidence?: number; reasoning?: string } | null + const hasReasoning = !!aiReasoning?.reasoning + const isExpanded = expandedRows.has(e.id) + + return ( + { + setExpandedRows((prev) => { + const next = new Set(prev) + if (open) next.add(e.id) + else next.delete(e.id) + return next + }) + }} asChild> + <> + + +
+ {hasReasoning && ( + + + + )} +
+

{e.project.title}

+

+ {e.project.teamName} +

+
+
+
+ + {e.project.competitionCategory ? ( + + {e.project.competitionCategory.replace('_', ' ')} + + ) : ( + '-' + )} + + {e.project.country || '-'} + + + {e.method === 'MANUAL' ? 'Manual' : 'Auto'} + + + {award.useAiEligibility && ( + + {aiReasoning?.confidence != null ? ( + + + + + + + AI confidence: {Math.round(aiReasoning.confidence * 100)}% + + + + ) : ( + - + )} + + )} + + + handleToggleEligibility(e.projectId, checked) + } + /> + + + + +
+ {hasReasoning && ( + + + +
+
+ +
+

AI Reasoning

+

{aiReasoning?.reasoning}

+
+
+
+ + +
+ )} + +
+ ) + })} ) : ( - - -

No eligibility data

-

- Run AI eligibility to evaluate projects or manually add projects + +

+ +
+

No eligibility data yet

+

+ {award.useAiEligibility + ? 'Run AI eligibility to automatically evaluate projects against this award\'s criteria, or manually add projects.' + : 'Load all eligible projects into the evaluation list, or manually add specific projects.'}

+
+ + +
)} @@ -680,11 +1011,13 @@ export default function AwardDetailPage({ ) : ( - - -

No jurors assigned

-

- Add members as jurors for this award + +

+ +
+

No jurors assigned

+

+ Add jury members who will vote on eligible projects for this award. Select from existing jury members above.

@@ -693,84 +1026,134 @@ export default function AwardDetailPage({ {/* Results Tab */} - {voteResults && voteResults.results.length > 0 ? ( - <> -
- - {voteResults.votedJurorCount} of {voteResults.jurorCount}{' '} - jurors voted - - - {voteResults.scoringMode.replace('_', ' ')} - -
+ {voteResults && voteResults.results.length > 0 ? (() => { + const maxPoints = Math.max(...voteResults.results.map((r) => r.points), 1) + return ( + <> +
+ + {voteResults.votedJurorCount} of {voteResults.jurorCount}{' '} + jurors voted + + + {voteResults.scoringMode.replace('_', ' ')} + +
- - - - - # - Project - Votes - Points - Actions - - - - {voteResults.results.map((r, i) => ( - - {i + 1} - -
- {r.project.id === voteResults.winnerId && ( - - )} -
-

{r.project.title}

-

- {r.project.teamName} -

-
-
-
- {r.votes} - - {r.points} - - - {r.project.id !== voteResults.winnerId && ( - - )} - + +
+ + + # + Project + Votes + Score + Actions - ))} - -
-
- - ) : ( + + + {voteResults.results.map((r, i) => { + const isWinner = r.project.id === voteResults.winnerId + const barPercent = (r.points / maxPoints) * 100 + return ( + + + + {i + 1} + + + +
+ {isWinner && ( + + )} +
+

{r.project.title}

+

+ {r.project.teamName} +

+
+
+
+ + {r.votes} + + +
+
+
+
+ + {r.points} + +
+ + + {!isWinner && ( + + )} + + + ) + })} + + + + + ) + })() : ( - - -

No votes yet

-

- Votes will appear here once jurors submit their selections + +

+ +
+

No votes yet

+

+ {award._count.jurors === 0 + ? 'Assign jurors to this award first, then open voting to collect their selections.' + : award.status === 'DRAFT' || award.status === 'NOMINATIONS_OPEN' + ? 'Open voting to allow jurors to submit their selections for this award.' + : 'Votes will appear here as jurors submit their selections.'}

+ {award.status === 'NOMINATIONS_OPEN' && ( + + )}
)} diff --git a/src/app/(admin)/admin/awards/page.tsx b/src/app/(admin)/admin/awards/page.tsx index 8fe3bcb..1ac190a 100644 --- a/src/app/(admin)/admin/awards/page.tsx +++ b/src/app/(admin)/admin/awards/page.tsx @@ -1,5 +1,7 @@ 'use client' +import { useState, useMemo } from 'react' +import { useDebounce } from '@/hooks/use-debounce' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -12,7 +14,15 @@ import { } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' -import { Plus, Trophy, Users, CheckCircle2 } from 'lucide-react' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react' const STATUS_COLORS: Record = { DRAFT: 'secondary', @@ -31,16 +41,46 @@ const SCORING_LABELS: Record = { export default function AwardsListPage() { const { data: awards, isLoading } = trpc.specialAward.list.useQuery({}) + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 300) + const [statusFilter, setStatusFilter] = useState('all') + const [scoringFilter, setScoringFilter] = useState('all') + + const filteredAwards = useMemo(() => { + if (!awards) return [] + return awards.filter((award) => { + const matchesSearch = + !debouncedSearch || + award.name.toLowerCase().includes(debouncedSearch.toLowerCase()) || + award.description?.toLowerCase().includes(debouncedSearch.toLowerCase()) + const matchesStatus = statusFilter === 'all' || award.status === statusFilter + const matchesScoring = scoringFilter === 'all' || award.scoringMode === scoringFilter + return matchesSearch && matchesStatus && matchesScoring + }) + }, [awards, debouncedSearch, statusFilter, scoringFilter]) + if (isLoading) { return (
- +
+ + +
+ {/* Toolbar skeleton */} +
+ +
+ + +
+
+ {/* Cards skeleton */}
- {[...Array(3)].map((_, i) => ( - + {[...Array(6)].map((_, i) => ( + ))}
@@ -67,12 +107,58 @@ export default function AwardsListPage() {
+ {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + +
+
+ + {/* Results count */} + {awards && ( +

+ {filteredAwards.length} of {awards.length} awards +

+ )} + {/* Awards Grid */} - {awards && awards.length > 0 ? ( + {filteredAwards.length > 0 ? (
- {awards.map((award) => ( + {filteredAwards.map((award) => ( - +
@@ -118,13 +204,22 @@ export default function AwardsListPage() { ))}
+ ) : awards && awards.length > 0 ? ( + + + +

+ No awards match your filters +

+
+
) : ( - -

No awards yet

-

- Create special awards for outstanding projects + +

No awards yet

+

+ Create special awards with eligibility criteria and jury voting for outstanding projects.

- -
-
+
+
+
+ + +
+ +
+ {/* Toolbar skeleton */} +
+ +
+ + +
+
+ {/* Resource list skeleton */} +
+ {[...Array(5)].map((_, i) => ( + + + +
+ + +
+ +
+
+ ))} +
+
) } - return ( -
- {resources.map((resource) => { - const Icon = resourceTypeIcons[resource.resourceType] - return ( - - -
- -
-
-
-

{resource.title}

- {!resource.isPublished && ( - Draft - )} -
-
- - {resource.cohortLevel} - - {resource.resourceType} - - - {resource._count.accessLogs} views -
-
-
- {resource.externalUrl && ( - - - - )} - - - -
-
-
- ) - })} -
- ) -} - -function LoadingSkeleton() { - return ( -
- {[1, 2, 3].map((i) => ( - - - -
- - -
-
-
- ))} -
- ) -} - -export default function LearningHubPage() { return (
+ {/* Header */}

Learning Hub

@@ -151,9 +120,128 @@ export default function LearningHubPage() {
- }> - - + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + +
+
+ + {/* Results count */} + {resources && ( +

+ {filteredResources.length} of {resources.length} resources +

+ )} + + {/* Resource List */} + {filteredResources.length > 0 ? ( +
+ {filteredResources.map((resource) => { + const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File + return ( + + +
+ +
+
+
+

{resource.title}

+ {!resource.isPublished && ( + Draft + )} +
+
+ + {resource.cohortLevel} + + {resource.resourceType} + - + {resource._count.accessLogs} views +
+
+
+ {resource.externalUrl && ( + + + + )} + + + +
+
+
+ ) + })} +
+ ) : resources && resources.length > 0 ? ( + + + +

+ No resources match your filters +

+
+
+ ) : ( + + + +

No resources yet

+

+ Add learning materials like videos, documents, and links for program participants. +

+ +
+
+ )}
) } diff --git a/src/app/(admin)/admin/messages/page.tsx b/src/app/(admin)/admin/messages/page.tsx index 017b9b3..021e0d7 100644 --- a/src/app/(admin)/admin/messages/page.tsx +++ b/src/app/(admin)/admin/messages/page.tsx @@ -41,6 +41,14 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Send, Mail, @@ -51,6 +59,7 @@ import { AlertCircle, Inbox, CheckCircle2, + Eye, } from 'lucide-react' import { toast } from 'sonner' import { formatDate } from '@/lib/utils' @@ -79,6 +88,7 @@ export default function MessagesPage() { const [deliveryChannels, setDeliveryChannels] = useState(['EMAIL', 'IN_APP']) const [isScheduled, setIsScheduled] = useState(false) const [scheduledAt, setScheduledAt] = useState('') + const [showPreview, setShowPreview] = useState(false) const utils = trpc.useUtils() @@ -152,7 +162,42 @@ export default function MessagesPage() { } } - const handleSend = () => { + const getRecipientDescription = (): string => { + switch (recipientType) { + case 'ALL': + return 'All platform users' + case 'ROLE': { + const roleLabel = selectedRole ? selectedRole.replace(/_/g, ' ') : '' + return roleLabel ? `All ${roleLabel}s` : 'By Role (none selected)' + } + case 'ROUND_JURY': { + if (!roundId) return 'Round Jury (none selected)' + const round = (rounds as Array<{ id: string; name: string; program?: { name: string } }> | undefined)?.find( + (r) => r.id === roundId + ) + return round + ? `Jury of ${round.program ? `${round.program.name} - ` : ''}${round.name}` + : 'Round Jury' + } + case 'PROGRAM_TEAM': { + if (!selectedProgramId) return 'Program Team (none selected)' + const program = (programs as Array<{ id: string; name: string }> | undefined)?.find( + (p) => p.id === selectedProgramId + ) + return program ? `Team of ${program.name}` : 'Program Team' + } + case 'USER': { + if (!selectedUserId) return 'Specific User (none selected)' + const userList = (users as { users: Array<{ id: string; name: string | null; email: string }> } | undefined)?.users + const user = userList?.find((u) => u.id === selectedUserId) + return user ? (user.name || user.email) : 'Specific User' + } + default: + return 'Unknown' + } + } + + const handlePreview = () => { if (!subject.trim()) { toast.error('Subject is required') return @@ -182,6 +227,10 @@ export default function MessagesPage() { return } + setShowPreview(true) + } + + const handleActualSend = () => { sendMutation.mutate({ recipientType, recipientFilter: buildRecipientFilter(), @@ -192,6 +241,7 @@ export default function MessagesPage() { scheduledAt: isScheduled && scheduledAt ? new Date(scheduledAt).toISOString() : undefined, templateId: selectedTemplateId && selectedTemplateId !== '__none__' ? selectedTemplateId : undefined, }) + setShowPreview(false) } return ( @@ -474,13 +524,13 @@ export default function MessagesPage() { {/* Send button */}
-
@@ -581,6 +631,68 @@ export default function MessagesPage() { + + {/* Preview Dialog */} + + + + Preview Message + Review your message before sending + +
+
+

Recipients

+

{getRecipientDescription()}

+
+
+

Subject

+

{subject}

+
+
+

Message

+
+

{body}

+
+
+
+

Delivery Channels

+
+ {deliveryChannels.includes('EMAIL') && ( + + + Email + + )} + {deliveryChannels.includes('IN_APP') && ( + + + In-App + + )} +
+
+ {isScheduled && scheduledAt && ( +
+

Scheduled For

+

{formatDate(new Date(scheduledAt))}

+
+ )} +
+ + + + +
+
) } diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx index d856ec6..245ae18 100644 --- a/src/app/(admin)/admin/page.tsx +++ b/src/app/(admin)/admin/page.tsx @@ -16,6 +16,7 @@ import { import { Badge } from '@/components/ui/badge' import { Progress } from '@/components/ui/progress' import { Skeleton } from '@/components/ui/skeleton' +import { Button } from '@/components/ui/button' import { CircleDot, ClipboardList, @@ -25,13 +26,27 @@ import { TrendingUp, ArrowRight, Layers, + Activity, + AlertTriangle, + ShieldAlert, + Plus, + Upload, + UserPlus, + FileEdit, + LogIn, + Send, + Eye, + Trash2, } from 'lucide-react' import { GeographicSummaryCard } from '@/components/charts' +import { AnimatedCard } from '@/components/shared/animated-container' +import { StatusBadge } from '@/components/shared/status-badge' import { ProjectLogo } from '@/components/shared/project-logo' import { getCountryName } from '@/lib/countries' import { formatDateOnly, formatEnumLabel, + formatRelativeTime, truncate, daysUntil, } from '@/lib/utils' @@ -104,6 +119,10 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { latestProjects, categoryBreakdown, oceanIssueBreakdown, + recentActivity, + pendingCOIs, + draftRounds, + unassignedProjects, ] = await Promise.all([ prisma.round.count({ where: { programId: editionId, status: 'ACTIVE' }, @@ -146,7 +165,13 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { where: { programId: editionId }, orderBy: { createdAt: 'desc' }, take: 5, - include: { + select: { + id: true, + name: true, + status: true, + votingStartAt: true, + votingEndAt: true, + submissionEndDate: true, _count: { select: { projects: true, @@ -188,6 +213,40 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { where: { round: { programId: editionId } }, _count: true, }), + // Recent activity feed (scoped to last 7 days for performance) + prisma.auditLog.findMany({ + where: { + timestamp: { gte: sevenDaysAgo }, + }, + orderBy: { timestamp: 'desc' }, + take: 8, + select: { + id: true, + action: true, + entityType: true, + timestamp: true, + user: { select: { name: true } }, + }, + }), + // Pending COI declarations (hasConflict declared but not yet reviewed) + prisma.conflictOfInterest.count({ + where: { + hasConflict: true, + reviewedAt: null, + assignment: { round: { programId: editionId } }, + }, + }), + // Draft rounds needing activation + prisma.round.count({ + where: { programId: editionId, status: 'DRAFT' }, + }), + // Projects without assignments in active rounds + prisma.project.count({ + where: { + round: { programId: editionId, status: 'ACTIVE' }, + assignments: { none: {} }, + }, + }), ]) const submittedCount = @@ -253,6 +312,40 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1) const maxIssueCount = Math.max(...issues.map((i) => i.count), 1) + // Helper: human-readable action descriptions for audit log + function formatAction(action: string, entityType: string | null): string { + const entity = entityType?.toLowerCase() || 'record' + const actionMap: Record = { + 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`, + } + return actionMap[action] || `${action.toLowerCase()} ${entity}` + } + + // Helper: pick an icon for an audit action + function getActionIcon(action: string) { + switch (action) { + case 'CREATE': return + case 'UPDATE': return + case 'DELETE': return + case 'LOGIN': return + case 'EXPORT': return + case 'SUBMIT': return + case 'ASSIGN': return + case 'INVITE': return + default: return + } + } + return ( <> {/* Header */} @@ -265,69 +358,99 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { {/* Stats Grid */}
- - - Rounds - - - -
{totalRoundCount}
-

- {activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''} -

-
-
- - - - Projects - - - -
{projectCount}
-

- {newProjectsThisWeek > 0 - ? `${newProjectsThisWeek} new this week` - : 'In this edition'} -

-
-
- - - - Jury Members - - - -
{totalJurors}
-

- {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`} -

-
-
- - - - Evaluations - - - -
- {submittedCount} - {totalAssignments > 0 && ( - - {' '}/ {totalAssignments} - - )} -
-
- -

- {completionRate.toFixed(0)}% completion rate + + + + Rounds + + + +

{totalRoundCount}
+

+ {activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}

-
-
-
+ + + + + + + + Projects + + + +
{projectCount}
+

+ {newProjectsThisWeek > 0 + ? `${newProjectsThisWeek} new this week` + : 'In this edition'} +

+
+
+
+ + + + + Jury Members + + + +
{totalJurors}
+

+ {activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`} +

+
+
+
+ + + + + Evaluations + + + +
+ {submittedCount} + {totalAssignments > 0 && ( + + {' '}/ {totalAssignments} + + )} +
+
+ +

+ {completionRate.toFixed(0)}% completion rate +

+
+
+
+
+
+ + {/* Quick Actions */} +
+ + +
{/* Two-Column Content */} @@ -374,22 +497,12 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { href={`/admin/rounds/${round.id}`} className="block" > -
+

{round.name}

- - {round.status} - +

{round._count.projects} projects · {round._count.assignments} assignments @@ -447,7 +560,7 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { href={`/admin/projects/${project.id}`} className="block" > -

+
{truncate(project.title, 45)}

- - {(project.status ?? 'SUBMITTED').replace('_', ' ')} - +

{[ @@ -500,6 +612,53 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { {/* Right Column */}

+ {/* Pending Actions Card */} + + + + + Pending Actions + + + +
+ {pendingCOIs > 0 && ( + +
+ + COI declarations to review +
+ {pendingCOIs} + + )} + {unassignedProjects > 0 && ( + +
+ + Projects without assignments +
+ {unassignedProjects} + + )} + {draftRounds > 0 && ( + +
+ + Draft rounds to activate +
+ {draftRounds} + + )} + {pendingCOIs === 0 && unassignedProjects === 0 && draftRounds === 0 && ( +
+ +

All caught up!

+
+ )} +
+
+
+ {/* Evaluation Progress Card */} @@ -604,6 +763,45 @@ async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) { + {/* Recent Activity Card */} + + + + + Recent Activity + + + + {recentActivity.length === 0 ? ( +
+ +

+ No recent activity +

+
+ ) : ( +
+ {recentActivity.map((log) => ( +
+
+ {getActionIcon(log.action)} +
+
+

+ {log.user?.name || 'System'} + {' '}{formatAction(log.action, log.entityType)} +

+

+ {formatRelativeTime(log.timestamp)} +

+
+
+ ))} +
+ )} +
+
+ {/* Upcoming Deadlines Card */} diff --git a/src/app/(admin)/admin/partners/page.tsx b/src/app/(admin)/admin/partners/page.tsx index 333c088..bacf690 100644 --- a/src/app/(admin)/admin/partners/page.tsx +++ b/src/app/(admin)/admin/partners/page.tsx @@ -1,6 +1,9 @@ -import { Suspense } from 'react' +'use client' + +import { useState, useMemo } from 'react' +import { useDebounce } from '@/hooks/use-debounce' import Link from 'next/link' -import { api } from '@/lib/trpc/server' +import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' import { Card, @@ -8,6 +11,14 @@ import { } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Plus, Pencil, @@ -16,6 +27,7 @@ import { Eye, EyeOff, Globe, + Search, } from 'lucide-react' const visibilityIcons = { @@ -24,7 +36,7 @@ const visibilityIcons = { PUBLIC: Globe, } -const partnerTypeColors = { +const partnerTypeColors: Record = { SPONSOR: 'bg-yellow-100 text-yellow-800', PARTNER: 'bg-blue-100 text-blue-800', SUPPORTER: 'bg-green-100 text-green-800', @@ -32,115 +44,73 @@ const partnerTypeColors = { OTHER: 'bg-gray-100 text-gray-800', } -async function PartnersList() { - const caller = await api() - const { data: partners } = await caller.partner.list({ - perPage: 50, - }) +export default function PartnersPage() { + const { data, isLoading } = trpc.partner.list.useQuery({ perPage: 50 }) + const partners = data?.data - if (partners.length === 0) { + const [search, setSearch] = useState('') + const debouncedSearch = useDebounce(search, 300) + const [typeFilter, setTypeFilter] = useState('all') + const [activeFilter, setActiveFilter] = useState('all') + + const filteredPartners = useMemo(() => { + if (!partners) return [] + return partners.filter((partner) => { + const matchesSearch = + !debouncedSearch || + partner.name.toLowerCase().includes(debouncedSearch.toLowerCase()) || + partner.description?.toLowerCase().includes(debouncedSearch.toLowerCase()) + const matchesType = typeFilter === 'all' || partner.partnerType === typeFilter + const matchesActive = + activeFilter === 'all' || + (activeFilter === 'active' && partner.isActive) || + (activeFilter === 'inactive' && !partner.isActive) + return matchesSearch && matchesType && matchesActive + }) + }, [partners, debouncedSearch, typeFilter, activeFilter]) + + if (isLoading) { return ( - - - -

No partners yet

-

- Start by adding your first partner organization -

- - - -
-
+
+
+
+ + +
+ +
+ {/* Toolbar skeleton */} +
+ +
+ + +
+
+ {/* Partner cards skeleton */} +
+ {[...Array(6)].map((_, i) => ( + + +
+ +
+ + + +
+
+
+
+ ))} +
+
) } - return ( -
- {partners.map((partner) => { - const VisibilityIcon = visibilityIcons[partner.visibility] - return ( - - -
-
- -
-
-
-

{partner.name}

- {!partner.isActive && ( - Inactive - )} -
-
- - {partner.partnerType} - - -
- {partner.description && ( -

- {partner.description} -

- )} -
-
-
- {partner.website && ( - - - - )} - - - -
-
-
- ) - })} -
- ) -} - -function LoadingSkeleton() { - return ( -
- {[1, 2, 3, 4, 5, 6].map((i) => ( - - -
- -
- - - -
-
-
-
- ))} -
- ) -} - -export default function PartnersPage() { return (
+ {/* Header */}

Partners

@@ -156,9 +126,134 @@ export default function PartnersPage() {
- }> - - + {/* Toolbar */} +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + +
+
+ + {/* Results count */} + {partners && ( +

+ {filteredPartners.length} of {partners.length} partners +

+ )} + + {/* Partners Grid */} + {filteredPartners.length > 0 ? ( +
+ {filteredPartners.map((partner) => { + const VisibilityIcon = visibilityIcons[partner.visibility as keyof typeof visibilityIcons] || Eye + return ( + + +
+
+ +
+
+
+

{partner.name}

+ {!partner.isActive && ( + Inactive + )} +
+
+ + {partner.partnerType} + + +
+ {partner.description && ( +

+ {partner.description} +

+ )} +
+
+
+ {partner.website && ( + + + + )} + + + +
+
+
+ ) + })} +
+ ) : partners && partners.length > 0 ? ( + + + +

+ No partners match your filters +

+
+
+ ) : ( + + + +

No partners yet

+

+ Add sponsor and partner organizations to showcase on the platform. +

+ +
+
+ )}
) } diff --git a/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx b/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx index f530433..0903790 100644 --- a/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx +++ b/src/app/(admin)/admin/programs/[id]/apply-settings/page.tsx @@ -57,6 +57,7 @@ import { RotateCcw, Download, Upload, + ExternalLink, } from 'lucide-react' import { DndContext, @@ -643,7 +644,7 @@ export default function ApplySettingsPage() { {program?.name} {program?.year} / - Apply Settings + Apply Page
{/* Header */} @@ -665,6 +666,15 @@ export default function ApplySettingsPage() {
+ {/* View public apply page */} + {program?.slug && ( + + )} {/* Template controls */} + {/* Program & Round selection */} + + + Program & Round + + Select the program for this project. Round assignment is optional. + + + + {!programs || programs.length === 0 ? ( +
+ +

No Active Programs

+

+ Create a program first before adding projects +

+
+ ) : ( +
+
+ + - - )} - - - ) : ( - <> - {/* Selected round info */} - - -
-

{selectedRound?.programName}

-

{selectedRound?.name}

- -
-
+
+ + +
+
+ )} + + + + {selectedProgramId && ( + <>
{/* Basic Info */} @@ -265,6 +258,52 @@ function NewProjectPageContent() { maxTags={10} />
+ + {categoryOptions.length > 0 && ( +
+ + +
+ )} + + {oceanIssueOptions.length > 0 && ( +
+ + +
+ )} + +
+ + setInstitution(e.target.value)} + placeholder="e.g., University of Monaco" + /> +
@@ -299,11 +338,28 @@ function NewProjectPageContent() {
- - Contact Phone + +
+ +
+ + setCountry(e.target.value)} + onChange={setCountry} + /> +
+ +
+ + setCity(e.target.value)} placeholder="e.g., Monaco" />
@@ -311,65 +367,6 @@ function NewProjectPageContent() {
- {/* Custom Fields */} - - - - Additional Information - - - - Add custom metadata fields for this project - - - - {customFields.length === 0 ? ( -

- No additional fields. Click "Add Field" to add custom information. -

- ) : ( -
- {customFields.map((field, index) => ( -
- - updateCustomField(index, e.target.value, field.value) - } - className="flex-1" - /> - - updateCustomField(index, field.key, e.target.value) - } - className="flex-1" - /> - -
- ))} -
- )} -
-
- {/* Actions */}
+
@@ -857,6 +892,59 @@ export default function ProjectsPage() { + {/* Assign to Round Dialog */} + { + setAssignDialogOpen(open) + if (!open) { setProjectToAssign(null); setAssignRoundId('') } + }}> + + + Assign to Round + + Assign "{projectToAssign?.title}" to a round. + + +
+
+ + +
+
+
+ + +
+
+
+ {/* AI Tagging Dialog */} diff --git a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx index 77b6468..8dc4dab 100644 --- a/src/app/(admin)/admin/rounds/[id]/edit/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/edit/page.tsx @@ -32,7 +32,17 @@ import { type Criterion, } from '@/components/forms/evaluation-form-builder' import { RoundTypeSettings } from '@/components/forms/round-type-settings' -import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar } from 'lucide-react' +import { ArrowLeft, Loader2, AlertCircle, AlertTriangle, Bell, GitCompare, MessageSquare, FileText, Calendar, LayoutTemplate } from 'lucide-react' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { toast } from 'sonner' import { Switch } from '@/components/ui/switch' import { Slider } from '@/components/ui/slider' import { Label } from '@/components/ui/label' @@ -113,9 +123,23 @@ function EditRoundContent({ roundId }: { roundId: string }) { roundId, }) + const [saveTemplateOpen, setSaveTemplateOpen] = useState(false) + const [templateName, setTemplateName] = useState('') + const utils = trpc.useUtils() // Mutations + const saveAsTemplate = trpc.roundTemplate.create.useMutation({ + onSuccess: () => { + toast.success('Round saved as template') + setSaveTemplateOpen(false) + setTemplateName('') + }, + onError: (error) => { + toast.error(error.message) + }, + }) + const updateRound = trpc.round.update.useMutation({ onSuccess: () => { // Invalidate cache to ensure fresh data @@ -825,6 +849,58 @@ function EditRoundContent({ roundId }: { roundId: string }) { {/* Actions */}
+ + + + + + + Save as Template + + Save the current round configuration as a reusable template. + + +
+
+ + setTemplateName(e.target.value)} + placeholder="e.g., Standard Evaluation Round" + /> +
+
+ + + + +
+
diff --git a/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx b/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx index 7c6608f..cf4c3da 100644 --- a/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx +++ b/src/app/(admin)/admin/rounds/[id]/filtering/results/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { use, useState } from 'react' +import { use, useState, useCallback } from 'react' import Link from 'next/link' import { trpc } from '@/lib/trpc/client' import { Button } from '@/components/ui/button' @@ -43,6 +43,7 @@ import { CollapsibleTrigger, } from '@/components/ui/collapsible' import { Pagination } from '@/components/shared/pagination' +import { CsvExportDialog } from '@/components/shared/csv-export-dialog' import { toast } from 'sonner' import { ArrowLeft, @@ -114,37 +115,17 @@ export default function FilteringResultsPage({ { roundId }, { enabled: false } ) + const [showExportDialog, setShowExportDialog] = useState(false) - const handleExport = async () => { - const result = await exportResults.refetch() - if (result.data) { - const { data: rows, columns } = result.data - - const csvContent = [ - columns.join(','), - ...rows.map((row) => - columns - .map((col) => { - const value = row[col as keyof typeof row] - if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) { - return `"${value.replace(/"/g, '""')}"` - } - return value ?? '' - }) - .join(',') - ), - ].join('\n') - - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `filtering-results-${new Date().toISOString().split('T')[0]}.csv` - link.click() - URL.revokeObjectURL(url) - } + const handleExport = () => { + setShowExportDialog(true) } + const handleRequestExportData = useCallback(async () => { + const result = await exportResults.refetch() + return result.data ?? undefined + }, [exportResults]) + const toggleRow = (id: string) => { const next = new Set(expandedRows) if (next.has(id)) next.delete(id) @@ -601,6 +582,16 @@ export default function FilteringResultsPage({
+ + {/* CSV Export Dialog with Column Selection */} +
) } diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 137dd3f..eac5ab6 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -49,8 +49,12 @@ export default function LoginPage() { // Use window.location for external redirects or callback URLs window.location.href = callbackUrl } - } catch { - setError('An unexpected error occurred. Please try again.') + } catch (err: unknown) { + if (err instanceof Error && err.message.includes('429')) { + setError('Too many attempts. Please wait a few minutes before trying again.') + } else { + setError('An unexpected error occurred. Please try again.') + } } finally { setIsLoading(false) } @@ -84,8 +88,12 @@ export default function LoginPage() { } else { setError('Failed to send magic link. Please try again.') } - } catch { - setError('An unexpected error occurred. Please try again.') + } catch (err: unknown) { + if (err instanceof Error && err.message.includes('429')) { + setError('Too many attempts. Please wait a few minutes before trying again.') + } else { + setError('An unexpected error occurred. Please try again.') + } } finally { setIsLoading(false) } @@ -96,8 +104,8 @@ export default function LoginPage() { return ( -
- +
+
Check your email @@ -105,22 +113,27 @@ export default function LoginPage() { -

- Click the link in the email to sign in. The link will expire in 15 - minutes. -

-
+
+

Click the link in the email to sign in. The link will expire in 15 minutes.

+

If you don't see it, check your spam folder.

+
+
+

+ Having trouble?{' '} + + Contact support + +

diff --git a/src/app/(auth)/onboarding/page.tsx b/src/app/(auth)/onboarding/page.tsx index 4678a86..1c2199a 100644 --- a/src/app/(auth)/onboarding/page.tsx +++ b/src/app/(auth)/onboarding/page.tsx @@ -22,6 +22,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { cn } from '@/lib/utils' import { toast } from 'sonner' import { ExpertiseSelect } from '@/components/shared/expertise-select' import { AvatarUpload } from '@/components/shared/avatar-upload' @@ -195,6 +196,30 @@ export default function OnboardingPage() {
))}
+ {/* Step labels */} +
+ {steps.slice(0, -1).map((s, i) => { + const labels: Record = { + name: 'Name', + photo: 'Photo', + country: 'Country', + bio: 'About', + phone: 'Phone', + tags: 'Expertise', + preferences: 'Settings', + } + return ( +
+ + {labels[s] || s} + +
+ ) + })} +

Step {currentIndex + 1} of {totalVisibleSteps}

@@ -530,10 +555,12 @@ export default function OnboardingPage() { {/* Step 7: Complete */} {step === 'complete' && ( -
+
-

Welcome, {name}!

+

+ Welcome, {name}! +

Your profile is all set up. You'll be redirected to your dashboard shortly. diff --git a/src/app/(jury)/jury/assignments/page.tsx b/src/app/(jury)/jury/assignments/page.tsx index 8f453dd..3d0c0e0 100644 --- a/src/app/(jury)/jury/assignments/page.tsx +++ b/src/app/(jury)/jury/assignments/page.tsx @@ -30,7 +30,7 @@ import { AlertCircle, } from 'lucide-react' import { Progress } from '@/components/ui/progress' -import { formatDate, truncate } from '@/lib/utils' +import { cn, formatDate, truncate } from '@/lib/utils' function getCriteriaProgress(evaluation: { criterionScoresJson: unknown @@ -47,6 +47,17 @@ function getCriteriaProgress(evaluation: { return { completed, total } } +function getDeadlineUrgency(deadline: Date | null): { label: string; className: string } | null { + if (!deadline) return null + const now = new Date() + const diff = deadline.getTime() - now.getTime() + const daysLeft = Math.ceil(diff / (1000 * 60 * 60 * 24)) + if (daysLeft < 0) return { label: 'Overdue', className: 'text-muted-foreground' } + if (daysLeft <= 2) return { label: `${daysLeft}d left`, className: 'text-red-600 font-semibold' } + if (daysLeft <= 7) return { label: `${daysLeft}d left`, className: 'text-amber-600 font-medium' } + return { label: `${daysLeft}d left`, className: 'text-muted-foreground' } +} + async function AssignmentsContent({ roundId, }: { @@ -134,8 +145,46 @@ async function AssignmentsContent({ const now = new Date() + const completedCount = assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length + const inProgressCount = assignments.filter(a => a.evaluation?.status === 'DRAFT').length + const pendingCount = assignments.filter(a => !a.evaluation).length + const overallProgress = assignments.length > 0 ? Math.round((completedCount / assignments.length) * 100) : 0 + return (

+ {/* Progress Summary */} + + +
+
+ +
+

{completedCount}

+

Completed

+
+
+
+ +
+

{inProgressCount}

+

In Progress

+
+
+
+ +
+

{pendingCount}

+

Pending

+
+
+
+ +

{overallProgress}% complete

+
+
+
+
+ {/* Desktop table view */} @@ -185,15 +234,22 @@ async function AssignmentsContent({ {assignment.round.votingEndAt ? ( - - {formatDate(assignment.round.votingEndAt)} - +
+ + {formatDate(assignment.round.votingEndAt)} + + {(() => { + const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null) + if (!urgency || isCompleted) return null + return

{urgency.label}

+ })()} +
) : ( No deadline )} @@ -309,7 +365,14 @@ async function AssignmentsContent({ {assignment.round.votingEndAt && (
Deadline - {formatDate(assignment.round.votingEndAt)} +
+ {formatDate(assignment.round.votingEndAt)} + {(() => { + const urgency = getDeadlineUrgency(assignment.round.votingEndAt ? new Date(assignment.round.votingEndAt) : null) + if (!urgency || isCompleted) return null + return

{urgency.label}

+ })()} +
)} {isDraft && (() => { diff --git a/src/app/(jury)/jury/compare/page.tsx b/src/app/(jury)/jury/compare/page.tsx index fce154f..ec006f3 100644 --- a/src/app/(jury)/jury/compare/page.tsx +++ b/src/app/(jury)/jury/compare/page.tsx @@ -31,6 +31,7 @@ import { SelectValue, } from '@/components/ui/select' import { + AlertCircle, ArrowLeft, GitCompare, MapPin, @@ -354,6 +355,44 @@ export default function CompareProjectsPage() { scales={data.scales} /> )} + + {/* Divergence Summary */} + {data.criteria && (() => { + const scCriteria = data.criteria.filter((c) => c.type !== 'section_header') + const getMaxForCriterion = (criterion: Criterion) => { + if (criterion.scale && data.scales && data.scales[criterion.scale]) return data.scales[criterion.scale].max + return 10 + } + const getScoreForItem = (item: ComparisonItem, criterionId: string): number | null => { + const scores = (item.evaluation?.criterionScoresJson || item.evaluation?.scores) as Record | undefined + if (!scores) return null + const val = scores[criterionId] + if (val == null) return null + const num = Number(val) + return isNaN(num) ? null : num + } + const divergentCount = scCriteria.filter(criterion => { + const scores = data.items.map(item => getScoreForItem(item, criterion.id)).filter((s): s is number => s !== null) + if (scores.length < 2) return false + const max = Math.max(...scores) + const min = Math.min(...scores) + const range = getMaxForCriterion(criterion) + return range > 0 && (max - min) / range >= 0.4 + }).length + + if (divergentCount === 0) return null + + return ( +
+
+ +

+ {divergentCount} criterion{divergentCount > 1 ? 'a' : ''} with significant score divergence ({'>'}40% range difference) +

+
+
+ ) + })()} )} @@ -550,17 +589,22 @@ function CriterionComparisonTable({ const itemScores = items.map((item) => getScore(item, criterion.id)) const validScores = itemScores.filter((s): s is number => s !== null) const highestScore = validScores.length > 0 ? Math.max(...validScores) : null + const minScore = validScores.length > 0 ? Math.min(...validScores) : null + const divergence = highestScore !== null && minScore !== null ? highestScore - minScore : 0 + const maxPossibleDivergence = max + const isDivergent = validScores.length >= 2 && maxPossibleDivergence > 0 && (divergence / maxPossibleDivergence) >= 0.4 return ( - + -
+
{criterion.label} {criterion.weight && criterion.weight > 1 && ( (x{criterion.weight}) )} + {isDivergent && Divergent}
{items.map((item, idx) => { diff --git a/src/app/(jury)/jury/page.tsx b/src/app/(jury)/jury/page.tsx index 9091542..7774205 100644 --- a/src/app/(jury)/jury/page.tsx +++ b/src/app/(jury)/jury/page.tsx @@ -244,7 +244,7 @@ async function JuryDashboardContent() { {/* Stats */}
{stats.map((stat) => ( - +
@@ -432,7 +432,7 @@ async function JuryDashboardContent() {
diff --git a/src/app/(mentor)/mentor/page.tsx b/src/app/(mentor)/mentor/page.tsx index efa0208..a5e22bf 100644 --- a/src/app/(mentor)/mentor/page.tsx +++ b/src/app/(mentor)/mentor/page.tsx @@ -1,5 +1,6 @@ 'use client' +import { useState, useMemo } from 'react' import Link from 'next/link' import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' @@ -15,6 +16,14 @@ import { Badge } from '@/components/ui/badge' import { Skeleton } from '@/components/ui/skeleton' import { Progress } from '@/components/ui/progress' import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import { Users, Briefcase, @@ -27,6 +36,7 @@ import { CheckCircle2, Circle, Clock, + Search, } from 'lucide-react' import { formatDateOnly } from '@/lib/utils' @@ -72,15 +82,27 @@ function DashboardSkeleton() { export default function MentorDashboard() { const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery() - - if (isLoading) { - return - } + const [search, setSearch] = useState('') + const [statusFilter, setStatusFilter] = useState('all') const projects = assignments || [] const completedCount = projects.filter((a) => a.completionStatus === 'completed').length const inProgressCount = projects.filter((a) => a.completionStatus === 'in_progress').length + const filteredProjects = useMemo(() => { + return projects.filter(a => { + const matchesSearch = !search || + a.project.title.toLowerCase().includes(search.toLowerCase()) || + a.project.teamName?.toLowerCase().includes(search.toLowerCase()) + const matchesStatus = statusFilter === 'all' || a.completionStatus === statusFilter + return matchesSearch && matchesStatus + }) + }, [projects, search, statusFilter]) + + if (isLoading) { + return + } + return (
{/* Header */} @@ -154,10 +176,46 @@ export default function MentorDashboard() {
+ {/* Quick Actions */} +
+ +
+ {/* Projects List */}

Your Mentees

+ {/* Search and Filter */} + {projects.length > 0 && ( +
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ +
+ )} + {projects.length === 0 ? ( @@ -171,9 +229,26 @@ export default function MentorDashboard() {

+ ) : filteredProjects.length === 0 ? ( + + + +

+ No projects match your search criteria +

+ +
+
) : (
- {projects.map((assignment) => { + {filteredProjects.map((assignment) => { const project = assignment.project const teamLead = project.teamMembers?.find( (m) => m.role === 'LEAD' diff --git a/src/app/(observer)/observer/page.tsx b/src/app/(observer)/observer/page.tsx index 67cae03..eb64891 100644 --- a/src/app/(observer)/observer/page.tsx +++ b/src/app/(observer)/observer/page.tsx @@ -21,8 +21,9 @@ import { Users, CheckCircle2, Eye, + BarChart3, } from 'lucide-react' -import { formatDateOnly } from '@/lib/utils' +import { cn, formatDateOnly } from '@/lib/utils' async function ObserverDashboardContent() { const [ @@ -32,6 +33,7 @@ async function ObserverDashboardContent() { jurorCount, evaluationStats, recentRounds, + evaluationScores, ] = await Promise.all([ prisma.program.count(), prisma.round.count({ where: { status: 'ACTIVE' } }), @@ -52,8 +54,17 @@ async function ObserverDashboardContent() { assignments: true, }, }, + assignments: { + select: { + evaluation: { select: { status: true } }, + }, + }, }, }), + prisma.evaluation.findMany({ + where: { status: 'SUBMITTED', globalScore: { not: null } }, + select: { globalScore: true }, + }), ]) const submittedCount = @@ -64,6 +75,21 @@ async function ObserverDashboardContent() { const completionRate = totalEvaluations > 0 ? (submittedCount / totalEvaluations) * 100 : 0 + // Score distribution computation + const scores = evaluationScores.map(e => e.globalScore!).filter(s => s != null) + const buckets = [ + { label: '9-10', min: 9, max: 10, color: 'bg-green-500' }, + { label: '7-8', min: 7, max: 8.99, color: 'bg-emerald-400' }, + { label: '5-6', min: 5, max: 6.99, color: 'bg-amber-400' }, + { label: '3-4', min: 3, max: 4.99, color: 'bg-orange-400' }, + { label: '1-2', min: 1, max: 2.99, color: 'bg-red-400' }, + ] + const maxCount = Math.max(...buckets.map(b => scores.filter(s => s >= b.min && s <= b.max).length), 1) + const scoreDistribution = buckets.map(b => { + const count = scores.filter(s => s >= b.min && s <= b.max).length + return { ...b, count, percentage: (count / maxCount) * 100 } + }) + return ( <> {/* Observer Notice */} @@ -88,7 +114,7 @@ async function ObserverDashboardContent() { {/* Stats Grid */}
- + Programs @@ -101,7 +127,7 @@ async function ObserverDashboardContent() { - + Projects @@ -112,7 +138,7 @@ async function ObserverDashboardContent() { - + Jury Members @@ -123,7 +149,7 @@ async function ObserverDashboardContent() { - + Evaluations @@ -159,7 +185,7 @@ async function ObserverDashboardContent() { {recentRounds.map((round) => (
@@ -192,6 +218,74 @@ async function ObserverDashboardContent() { )} + + {/* Score Distribution */} + + + Score Distribution + Distribution of global scores across all evaluations + + + {scoreDistribution.length === 0 ? ( +
+ +

No completed evaluations yet

+
+ ) : ( +
+ {scoreDistribution.map((bucket) => ( +
+ {bucket.label} +
+
+
+ {bucket.count} +
+ ))} +
+ )} + + + + {/* Jury Completion by Round */} + + + Jury Completion by Round + Evaluation completion rate per round + + + {recentRounds.length === 0 ? ( +
+ +

No rounds available

+
+ ) : ( +
+ {recentRounds.map((round) => { + const submittedInRound = round.assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length + const totalAssignments = round.assignments.length + const percent = totalAssignments > 0 ? Math.round((submittedInRound / totalAssignments) * 100) : 0 + return ( +
+
+
+ {round.name} + {round.status} +
+ {percent}% +
+ +

{submittedInRound} of {totalAssignments} evaluations submitted

+
+ ) + })} +
+ )} +
+
) } diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index e61a014..f247cf2 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -28,7 +28,7 @@ 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 { formatDate } from '@/lib/utils' +import { formatRelativeTime } from '@/lib/utils' type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins' @@ -221,7 +221,9 @@ export function MembersContent() { {user.lastLoginAt ? ( - formatDate(user.lastLoginAt) + + {formatRelativeTime(user.lastLoginAt)} + ) : ( Never )} @@ -280,6 +282,16 @@ export function MembersContent() { : `${(user as unknown as { _count: { mentorAssignments: number; assignments: number } })._count.assignments} assigned`}
+
+ Last Login + + {user.lastLoginAt ? ( + formatRelativeTime(user.lastLoginAt) + ) : ( + Never + )} + +
{user.expertiseTags && user.expertiseTags.length > 0 && (
{user.expertiseTags.map((tag) => ( diff --git a/src/components/forms/evaluation-form.tsx b/src/components/forms/evaluation-form.tsx index 075ca8d..bb0fd1f 100644 --- a/src/components/forms/evaluation-form.tsx +++ b/src/components/forms/evaluation-form.tsx @@ -644,7 +644,35 @@ export function EvaluationForm({ {/* Bottom submit button for mobile */} {!isReadOnly && ( -
+
+ {/* Autosave Status */} +
+ {autosaveStatus === 'saved' && ( + + + All changes saved + + )} + {autosaveStatus === 'saving' && ( + + + Saving... + + )} + {autosaveStatus === 'error' && ( + + + Unsaved changes + + )} + {autosaveStatus === 'idle' && isDirty && ( + + + Unsaved changes + + )} +
+
)} diff --git a/src/components/layouts/admin-sidebar.tsx b/src/components/layouts/admin-sidebar.tsx index 1ae830d..308db07 100644 --- a/src/components/layouts/admin-sidebar.tsx +++ b/src/components/layouts/admin-sidebar.tsx @@ -32,7 +32,6 @@ import { History, Trophy, User, - LayoutTemplate, MessageSquare, Wand2, } from 'lucide-react' @@ -51,80 +50,85 @@ interface AdminSidebarProps { } } +type NavItem = { + name: string + href: string + icon: typeof LayoutDashboard + activeMatch?: string // pathname must include this to be active + activeExclude?: string // pathname must NOT include this to be active +} + // Main navigation - scoped to selected edition -const navigation = [ +const navigation: NavItem[] = [ { name: 'Dashboard', - href: '/admin' as const, + href: '/admin', icon: LayoutDashboard, }, { name: 'Rounds', - href: '/admin/rounds' as const, + href: '/admin/rounds', icon: CircleDot, }, - { - name: 'Templates', - href: '/admin/round-templates' as const, - icon: LayoutTemplate, - }, { name: 'Awards', - href: '/admin/awards' as const, + href: '/admin/awards', icon: Trophy, }, { name: 'Projects', - href: '/admin/projects' as const, + href: '/admin/projects', icon: ClipboardList, }, { name: 'Members', - href: '/admin/members' as const, + href: '/admin/members', icon: Users, }, { name: 'Reports', - href: '/admin/reports' as const, + href: '/admin/reports', icon: FileSpreadsheet, }, { name: 'Learning Hub', - href: '/admin/learning' as const, + href: '/admin/learning', icon: BookOpen, }, { name: 'Messages', - href: '/admin/messages' as const, + href: '/admin/messages', icon: MessageSquare, }, { name: 'Partners', - href: '/admin/partners' as const, + href: '/admin/partners', icon: Handshake, }, ] // Admin-only navigation -const adminNavigation = [ +const adminNavigation: NavItem[] = [ { name: 'Manage Editions', - href: '/admin/programs' as const, + href: '/admin/programs', icon: FolderKanban, + activeExclude: 'apply-settings', }, { - name: 'Apply Settings', - href: '/admin/programs' as const, + name: 'Apply Page', + href: '/admin/programs', icon: Wand2, + activeMatch: 'apply-settings', }, { name: 'Audit Log', - href: '/admin/audit' as const, + href: '/admin/audit', icon: History, }, { name: 'Settings', - href: '/admin/settings' as const, + href: '/admin/settings', icon: Settings, }, ] @@ -232,11 +236,16 @@ export function AdminSidebar({ user }: AdminSidebarProps) { Administration

{adminNavigation.map((item) => { - const isActive = pathname.startsWith(item.href) + let isActive = pathname.startsWith(item.href) + if (item.activeMatch) { + isActive = pathname.includes(item.activeMatch) + } else if (item.activeExclude && pathname.includes(item.activeExclude)) { + isActive = false + } return ( setIsMobileMenuOpen(false)} className={cn( 'group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150', diff --git a/src/components/shared/animated-container.tsx b/src/components/shared/animated-container.tsx new file mode 100644 index 0000000..472ea3d --- /dev/null +++ b/src/components/shared/animated-container.tsx @@ -0,0 +1,28 @@ +'use client' + +import { motion } from 'motion/react' +import { type ReactNode } from 'react' + +export function AnimatedCard({ children, index = 0 }: { children: ReactNode; index?: number }) { + return ( + + {children} + + ) +} + +export function AnimatedList({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/components/shared/csv-export-dialog.tsx b/src/components/shared/csv-export-dialog.tsx new file mode 100644 index 0000000..a22f384 --- /dev/null +++ b/src/components/shared/csv-export-dialog.tsx @@ -0,0 +1,212 @@ +'use client' + +import { useState, useEffect, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Download, Loader2 } from 'lucide-react' + +/** + * Converts a camelCase or snake_case column name to Title Case. + * e.g. "projectTitle" -> "Project Title", "ai_meetsCriteria" -> "Ai Meets Criteria" + */ +function formatColumnName(col: string): string { + // Replace underscores with spaces + let result = col.replace(/_/g, ' ') + // Insert space before uppercase letters (camelCase -> spaced) + result = result.replace(/([a-z])([A-Z])/g, '$1 $2') + // Capitalize first letter of each word + return result + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' ') +} + +type ExportData = { + data: Record[] + columns: string[] +} + +type CsvExportDialogProps = { + open: boolean + onOpenChange: (open: boolean) => void + exportData: ExportData | undefined + isLoading: boolean + filename: string + onRequestData: () => Promise +} + +export function CsvExportDialog({ + open, + onOpenChange, + exportData, + isLoading, + filename, + onRequestData, +}: CsvExportDialogProps) { + const [selectedColumns, setSelectedColumns] = useState>(new Set()) + const [dataLoaded, setDataLoaded] = useState(false) + + // When dialog opens, fetch data if not already loaded + useEffect(() => { + if (open && !dataLoaded) { + onRequestData().then((result) => { + if (result?.columns) { + setSelectedColumns(new Set(result.columns)) + } + setDataLoaded(true) + }) + } + }, [open, dataLoaded, onRequestData]) + + // Sync selected columns when export data changes + useEffect(() => { + if (exportData?.columns) { + setSelectedColumns(new Set(exportData.columns)) + } + }, [exportData]) + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setDataLoaded(false) + } + }, [open]) + + const toggleColumn = (col: string, checked: boolean) => { + const next = new Set(selectedColumns) + if (checked) { + next.add(col) + } else { + next.delete(col) + } + setSelectedColumns(next) + } + + const toggleAll = () => { + if (!exportData) return + if (selectedColumns.size === exportData.columns.length) { + setSelectedColumns(new Set()) + } else { + setSelectedColumns(new Set(exportData.columns)) + } + } + + const handleDownload = () => { + if (!exportData) return + + const columnsArray = exportData.columns.filter((col) => selectedColumns.has(col)) + + // Build CSV header with formatted names + const csvHeader = columnsArray.map((col) => { + const formatted = formatColumnName(col) + // Escape quotes in header + if (formatted.includes(',') || formatted.includes('"')) { + return `"${formatted.replace(/"/g, '""')}"` + } + return formatted + }) + + const csvContent = [ + csvHeader.join(','), + ...exportData.data.map((row) => + columnsArray + .map((col) => { + const value = row[col] + if (value === null || value === undefined) return '' + const str = String(value) + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"` + } + return str + }) + .join(',') + ), + ].join('\n') + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${filename}-${new Date().toISOString().split('T')[0]}.csv` + link.click() + URL.revokeObjectURL(url) + onOpenChange(false) + } + + const allSelected = exportData ? selectedColumns.size === exportData.columns.length : false + const noneSelected = selectedColumns.size === 0 + + return ( + + + + Export CSV + + Select which columns to include in the export + + + + {isLoading ? ( +
+ + Loading data... +
+ ) : exportData ? ( +
+
+ + +
+
+ {exportData.columns.map((col) => ( +
+ toggleColumn(col, !!checked)} + /> + +
+ ))} +
+

+ {exportData.data.length} row{exportData.data.length !== 1 ? 's' : ''} will be exported +

+
+ ) : ( +

+ No data available for export. +

+ )} + + + + + +
+
+ ) +} diff --git a/src/components/shared/file-viewer.tsx b/src/components/shared/file-viewer.tsx index ddf559d..8e72b7c 100644 --- a/src/components/shared/file-viewer.tsx +++ b/src/components/shared/file-viewer.tsx @@ -30,6 +30,21 @@ import { import { cn } from '@/lib/utils' import { toast } from 'sonner' +const OFFICE_MIME_TYPES = [ + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx + 'application/vnd.ms-powerpoint', // .ppt + 'application/msword', // .doc +] + +const OFFICE_EXTENSIONS = ['.pptx', '.ppt', '.docx', '.doc'] + +function isOfficeFile(mimeType: string, fileName: string): boolean { + if (OFFICE_MIME_TYPES.includes(mimeType)) return true + const ext = fileName.toLowerCase().slice(fileName.lastIndexOf('.')) + return OFFICE_EXTENSIONS.includes(ext) +} + interface ProjectFile { id: string fileType: 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC' @@ -210,7 +225,8 @@ function FileItem({ file }: { file: ProjectFile }) { const canPreview = file.mimeType.startsWith('video/') || file.mimeType === 'application/pdf' || - file.mimeType.startsWith('image/') + file.mimeType.startsWith('image/') || + isOfficeFile(file.mimeType, file.fileName) return (
@@ -264,6 +280,7 @@ function FileItem({ file }: { file: ProjectFile }) { )} )} +
@@ -462,6 +479,45 @@ function BulkDownloadButton({ projectId, fileIds }: { projectId: string; fileIds ) } +function FileOpenButton({ file }: { file: ProjectFile }) { + const [loading, setLoading] = useState(false) + + const { refetch } = trpc.file.getDownloadUrl.useQuery( + { bucket: file.bucket, objectKey: file.objectKey }, + { enabled: false } + ) + + const handleOpen = async () => { + setLoading(true) + try { + const result = await refetch() + if (result.data?.url) { + window.open(result.data.url, '_blank') + } + } catch (error) { + console.error('Failed to get URL:', error) + } finally { + setLoading(false) + } + } + + return ( + + ) +} + function FileDownloadButton({ file }: { file: ProjectFile }) { const [downloading, setDownloading] = useState(false) @@ -475,8 +531,14 @@ function FileDownloadButton({ file }: { file: ProjectFile }) { try { const result = await refetch() if (result.data?.url) { - // Open in new tab for download - window.open(result.data.url, '_blank') + // Force browser download via + const link = document.createElement('a') + link.href = result.data.url + link.download = file.fileName + link.rel = 'noopener noreferrer' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) } } catch (error) { console.error('Failed to get download URL:', error) @@ -562,6 +624,31 @@ function FilePreview({ file, url }: { file: ProjectFile; url: string }) { ) } + // Office documents (PPTX, DOCX, PPT, DOC) + if (isOfficeFile(file.mimeType, file.fileName)) { + const viewerUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(url)}` + return ( +
+