feat: router.back() navigation, read-only evaluation view, auth audit logging
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m53s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m53s
- Convert all Back buttons platform-wide (38 files) to use router.back() for natural browser-back behavior regardless of entry point - Add read-only view for submitted evaluations in closed rounds with blue banner, disabled inputs, and contextual back navigation - Add auth audit logs: MAGIC_LINK_SENT, PASSWORD_RESET_LINK_CLICKED, PASSWORD_RESET_LINK_EXPIRED, PASSWORD_RESET_LINK_INVALID - Learning Hub links navigate in same window for all roles - Update settings descriptions to reflect all-user scope Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -122,11 +122,9 @@ export default function EditAwardPage({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/awards/${awardId}`}>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Award
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -663,11 +663,9 @@ export default function AwardDetailPage({
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -69,11 +69,9 @@ export default function CreateAwardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -194,10 +194,8 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-muted-foreground">The requested jury group could not be found.</p>
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link href={'/admin/juries' as Route}>
|
||||
Back to Juries
|
||||
</Link>
|
||||
<Button className="mt-4" variant="outline" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -212,13 +210,11 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mb-2"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<Link href={'/admin/juries' as Route}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Juries
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -257,11 +256,9 @@ export default function EditLearningResourcePage() {
|
||||
The resource you're looking for does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/learning">
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -271,11 +268,9 @@ export default function EditLearningResourcePage() {
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Sticky toolbar */}
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/admin/learning">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -165,11 +164,9 @@ export default function NewLearningResourcePage() {
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Sticky toolbar */}
|
||||
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/admin/learning">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -224,11 +224,9 @@ export default function MemberDetailPage() {
|
||||
{error?.message || 'The member you\'re looking for does not exist.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/admin/members">
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Members
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -239,11 +237,9 @@ export default function MemberDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back nav */}
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/members">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Members
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{/* Header Hero */}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Papa from 'papaparse'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -257,6 +258,7 @@ function TagPicker({
|
||||
}
|
||||
|
||||
export default function MemberInvitePage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('input')
|
||||
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
||||
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||
@@ -1044,11 +1046,9 @@ export default function MemberInvitePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/members">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Members
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -83,6 +83,7 @@ const defaultForm: TemplateFormData = {
|
||||
}
|
||||
|
||||
export default function MessageTemplatesPage() {
|
||||
const router = useRouter()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
@@ -183,11 +184,9 @@ export default function MessageTemplatesPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/messages">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Messages
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -135,11 +135,9 @@ export default function EditPartnerPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -66,11 +66,9 @@ export default function NewPartnerPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -134,11 +134,9 @@ export default function EditProgramPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -68,6 +67,7 @@ const defaultMilestoneForm: MilestoneFormData = {
|
||||
|
||||
export default function MentorshipMilestonesPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const programId = params.id as string
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@@ -184,11 +184,9 @@ export default function MentorshipMilestonesPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,11 +56,9 @@ export default function NewProgramPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/programs">
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -300,19 +300,17 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">Back to Projects</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -330,11 +328,9 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -46,6 +46,7 @@ interface MentorSuggestion {
|
||||
}
|
||||
|
||||
function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
@@ -128,11 +129,9 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -102,6 +103,7 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
|
||||
}
|
||||
|
||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
// Fetch project + assignments + stats in a single combined query
|
||||
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
|
||||
{ id: projectId },
|
||||
@@ -199,19 +201,17 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">Back to Projects</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -223,11 +223,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -62,6 +62,7 @@ type UploadState = {
|
||||
type UploadMap = Record<string, UploadState>
|
||||
|
||||
export default function BulkUploadPage() {
|
||||
const router = useRouter()
|
||||
const [roundId, setRoundId] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
@@ -296,10 +297,8 @@ export default function BulkUploadPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/admin/projects">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>
|
||||
|
||||
@@ -59,11 +59,9 @@ function ImportPageContent() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -246,11 +246,9 @@ function NewProjectPageContent() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react'
|
||||
import { useParams, useSearchParams } from 'next/navigation'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -152,8 +152,7 @@ const stateColors: Record<string, string> = Object.fromEntries(
|
||||
export default function RoundDetailPage() {
|
||||
const params = useParams()
|
||||
const roundId = params.roundId as string
|
||||
const searchParams = useSearchParams()
|
||||
const backUrl = searchParams.get('from')
|
||||
const router = useRouter()
|
||||
|
||||
const [config, setConfig] = useState<Record<string, unknown>>({})
|
||||
const [autosaveStatus, setAutosaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
@@ -546,11 +545,9 @@ export default function RoundDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={'/admin/rounds' as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Round Not Found</h1>
|
||||
<p className="text-sm text-muted-foreground">This round does not exist.</p>
|
||||
@@ -622,12 +619,10 @@ export default function RoundDetailPage() {
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<Link href={(backUrl ?? (round.specialAwardId ? `/admin/awards/${round.specialAwardId}` : '/admin/rounds')) as Route} className="mt-0.5 shrink-0">
|
||||
<Button variant="ghost" size="sm" className="h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label={round.specialAwardId ? 'Back to Award' : 'Back to rounds'}>
|
||||
<Button variant="ghost" size="sm" className="mt-0.5 shrink-0 h-8 text-white/80 hover:text-white hover:bg-white/10 gap-1.5" aria-label="Back" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="text-xs hidden sm:inline">{round.specialAwardId ? 'Back to Award' : 'Back to Rounds'}</span>
|
||||
<span className="text-xs hidden sm:inline">Back</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{/* 4.6 Inline-editable round name */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -212,6 +212,7 @@ function SortableTagRow({
|
||||
}
|
||||
|
||||
export default function TagsSettingsPage() {
|
||||
const router = useRouter()
|
||||
const utils = trpc.useUtils()
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false)
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null)
|
||||
@@ -384,11 +385,9 @@ export default function TagsSettingsPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/settings">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Settings
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -86,6 +86,7 @@ const defaultForm: WebhookFormData = {
|
||||
}
|
||||
|
||||
export default function WebhooksPage() {
|
||||
const router = useRouter()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
@@ -254,11 +255,9 @@ export default function WebhooksPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/settings">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Settings
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
|
||||
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
|
||||
import { ArrowLeft, FileText } from 'lucide-react'
|
||||
|
||||
export default function ApplicantCompetitionPage() {
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
|
||||
enabled: !!session,
|
||||
@@ -36,11 +36,9 @@ export default function ApplicantCompetitionPage() {
|
||||
Track your progress through competition rounds
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/applicant' as Route} aria-label="Back to applicant dashboard">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -61,29 +59,6 @@ export default function ApplicantCompetitionPage() {
|
||||
<ApplicantCompetitionTimeline />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Button variant="outline" className="w-full justify-start" asChild>
|
||||
<Link href={'/applicant/documents' as Route}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
View Documents
|
||||
</Link>
|
||||
</Button>
|
||||
{myProject?.openRounds && myProject.openRounds.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground px-3 py-2 bg-muted/50 rounded-md">
|
||||
{myProject.openRounds.length} submission window
|
||||
{myProject.openRounds.length !== 1 ? 's' : ''} currently open
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline Info</CardTitle>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
||||
|
||||
export default function ApplicantResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
@@ -73,11 +73,9 @@ export default function ApplicantResourceDetailPage() {
|
||||
This resource may have been removed or you don't have access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/applicant/resources">
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -87,11 +85,9 @@ export default function ApplicantResourceDetailPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/applicant/resources">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@@ -29,6 +29,7 @@ export default function JuryAwardVotingPage({
|
||||
params: Promise<{ id: string }>
|
||||
}) {
|
||||
const { id: awardId } = use(params)
|
||||
const router = useRouter()
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
const { data, isLoading, refetch } =
|
||||
@@ -120,11 +121,9 @@ export default function JuryAwardVotingPage({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/awards">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -8,12 +8,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
|
||||
import { ArrowLeft, CheckCircle2, Clock, Circle, Eye } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
|
||||
|
||||
export default function JuryRoundDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const roundId = params.roundId as string
|
||||
|
||||
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
|
||||
@@ -38,11 +39,9 @@ export default function JuryRoundDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/jury/competitions' as Route} aria-label="Back to competitions list">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
@@ -82,10 +81,13 @@ export default function JuryRoundDetailPage() {
|
||||
const isDraft = assignment.evaluation?.status === 'DRAFT'
|
||||
|
||||
return (
|
||||
<Link
|
||||
<div
|
||||
key={assignment.id}
|
||||
href={`/jury/competitions/${roundId}/projects/${assignment.projectId}` as Route}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => router.push(`/jury/competitions/${roundId}/projects/${assignment.projectId}`)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') router.push(`/jury/competitions/${roundId}/projects/${assignment.projectId}`) }}
|
||||
className="flex items-center justify-between p-4 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{assignment.project.title}</p>
|
||||
@@ -97,12 +99,26 @@ export default function JuryRoundDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 ml-4">
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{isCompleted ? (
|
||||
<>
|
||||
<Badge variant="default" className="bg-emerald-50 text-emerald-700 border-emerald-200">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Completed
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
asChild
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Link href={`/jury/competitions/${roundId}/projects/${assignment.projectId}/evaluate` as Route}>
|
||||
<Eye className="mr-1 h-3 w-3" />
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : isDraft ? (
|
||||
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-amber-200">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
@@ -115,7 +131,7 @@ export default function JuryRoundDetailPage() {
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
|
||||
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
|
||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
|
||||
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert, Lock } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { EvaluationConfig } from '@/types/competition-configs'
|
||||
|
||||
@@ -468,8 +468,10 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
|
||||
// Check if round is active
|
||||
const isRoundActive = round.status === 'ROUND_ACTIVE'
|
||||
const isSubmittedEvaluation = existingEvaluation?.status === 'SUBMITTED'
|
||||
|
||||
if (!isRoundActive) {
|
||||
// If round is not active and no submitted evaluation to view, block access
|
||||
if (!isRoundActive && !isSubmittedEvaluation) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -502,8 +504,11 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// COI gate: if COI is required, not yet declared, and we have an assignment
|
||||
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
|
||||
// Read-only view for submitted evaluations in closed rounds
|
||||
const isReadOnly = !isRoundActive && isSubmittedEvaluation
|
||||
|
||||
// COI gate: if COI is required, not yet declared, and we have an assignment (skip for read-only views)
|
||||
if (coiRequired && !isReadOnly && myAssignment && !coiLoading && !coiDeclared) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -533,8 +538,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// COI conflict declared — block evaluation
|
||||
if (coiRequired && coiConflict) {
|
||||
// COI conflict declared — block evaluation (skip for read-only views)
|
||||
if (coiRequired && !isReadOnly && coiConflict) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -578,15 +583,22 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{isReadOnly ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
|
||||
Evaluate Project
|
||||
{isReadOnly ? 'Submitted Evaluation' : 'Evaluate Project'}
|
||||
</h1>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<p className="text-muted-foreground">{project.title}</p>
|
||||
@@ -606,9 +618,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isReadOnly && (
|
||||
<Card className="border-l-4 border-l-blue-500 bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<Lock className="h-5 w-5 text-blue-600 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">View-Only</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This evaluation has been submitted and the round is now closed. You are viewing a read-only copy of your submission.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Project Documents */}
|
||||
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
|
||||
|
||||
{!isReadOnly && (
|
||||
<Card className="border-l-4 border-l-amber-500">
|
||||
<CardContent className="flex items-start gap-3 p-4">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
|
||||
@@ -621,6 +648,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -673,12 +701,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-5 w-5" />
|
||||
@@ -686,12 +716,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-5 w-5" />
|
||||
@@ -718,12 +750,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, true)}
|
||||
className={cn(
|
||||
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||
currentValue === true
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
|
||||
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsUp className="mr-2 h-4 w-4" />
|
||||
@@ -731,12 +765,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, false)}
|
||||
className={cn(
|
||||
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
|
||||
currentValue === false
|
||||
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50'
|
||||
: 'border-border hover:border-red-300 hover:bg-red-50/50',
|
||||
isReadOnly && 'opacity-60 cursor-default'
|
||||
)}
|
||||
>
|
||||
<ThumbsDown className="mr-2 h-4 w-4" />
|
||||
@@ -766,6 +802,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
placeholder={criterion.placeholder || 'Enter your response...'}
|
||||
rows={4}
|
||||
maxLength={criterion.maxLength}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{currentValue.length}/{criterion.maxLength}
|
||||
@@ -807,6 +844,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
value={[sliderValue]}
|
||||
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
|
||||
className="flex-1"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-4">{max}</span>
|
||||
</div>
|
||||
@@ -816,6 +854,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleCriterionChange(criterion.id, num)}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
@@ -823,7 +862,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: displayValue !== undefined && displayValue > num
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
: 'bg-muted hover:bg-muted/80',
|
||||
isReadOnly && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
@@ -856,6 +896,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
value={[globalScore ? parseInt(globalScore, 10) : 5]}
|
||||
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
|
||||
className="flex-1"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">10</span>
|
||||
</div>
|
||||
@@ -866,6 +907,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<button
|
||||
key={num}
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => handleGlobalScoreChange(num.toString())}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
|
||||
@@ -873,7 +915,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: current > num
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
: 'bg-muted hover:bg-muted/80',
|
||||
isReadOnly && 'cursor-default'
|
||||
)}
|
||||
>
|
||||
{num}
|
||||
@@ -890,7 +933,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
<Label>
|
||||
Decision <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
|
||||
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')} disabled={isReadOnly}>
|
||||
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
|
||||
<RadioGroupItem value="accept" id="accept" />
|
||||
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
|
||||
@@ -921,6 +964,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
onChange={(e) => handleFeedbackChange(e.target.value)}
|
||||
placeholder="Provide your feedback on the project..."
|
||||
rows={8}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{requireFeedback && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -931,6 +975,17 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{isReadOnly ? (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -957,6 +1012,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
@@ -13,6 +13,7 @@ import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
|
||||
|
||||
export default function JuryProjectDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const roundId = params.roundId as string
|
||||
const projectId = params.projectId as string
|
||||
|
||||
@@ -42,11 +43,9 @@ export default function JuryProjectDetailPage() {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}` as Route}>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
@@ -61,11 +60,9 @@ export default function JuryProjectDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={`/jury/competitions/${roundId}` as Route}>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
|
||||
|
||||
export default function JuryAssignmentsPage() {
|
||||
const router = useRouter()
|
||||
const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
|
||||
|
||||
if (isLoading) {
|
||||
@@ -58,11 +60,9 @@ export default function JuryAssignmentsPage() {
|
||||
Projects assigned to you for evaluation
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
|
||||
<Link href={'/jury' as Route}>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()} className="hidden md:inline-flex">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
||||
|
||||
export default function JuryResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
@@ -73,11 +73,9 @@ export default function JuryResourceDetailPage() {
|
||||
This resource may have been removed or you don't have access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/jury/learning">
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -87,11 +85,9 @@ export default function JuryResourceDetailPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/jury/learning">
|
||||
<Button variant="ghost" onClick={() => router.back()} className="-ml-4">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
|
||||
@@ -623,7 +623,7 @@ async function JuryDashboardContent() {
|
||||
<div className="rounded-lg bg-brand-teal/10 p-1.5 dark:bg-brand-teal/20">
|
||||
<BarChart3 className="h-4 w-4 text-brand-teal" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">Stage Summary</CardTitle>
|
||||
<CardTitle className="text-lg">Round Summary</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -75,6 +74,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
||||
}
|
||||
|
||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const { data: project, isLoading, error } = trpc.mentor.getProjectDetail.useQuery({
|
||||
projectId,
|
||||
})
|
||||
@@ -106,11 +106,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
if (error || !project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={'/mentor' as Route}>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
@@ -122,8 +120,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
You may not have access to view this project.
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href={'/mentor' as Route}>Back to Dashboard</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -140,11 +138,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={'/mentor' as Route}>
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -27,6 +26,7 @@ const ResourceRenderer = dynamic(
|
||||
|
||||
export default function MentorResourceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const resourceId = params.id as string
|
||||
|
||||
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
|
||||
@@ -73,11 +73,9 @@ export default function MentorResourceDetailPage() {
|
||||
This resource may have been removed or you don't have access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild>
|
||||
<Link href="/mentor/resources">
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -87,11 +85,9 @@ export default function MentorResourceDetailPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/mentor/resources">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Resources
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{resource.externalUrl && (
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -16,6 +14,7 @@ import { toast } from 'sonner'
|
||||
|
||||
export default function MentorWorkspaceDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const projectId = params.projectId as string
|
||||
|
||||
// Get mentor assignment for this project
|
||||
@@ -39,11 +38,9 @@ export default function MentorWorkspaceDetailPage() {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor/workspace' as Route}>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
@@ -58,11 +55,9 @@ export default function MentorWorkspaceDetailPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor/workspace' as Route}>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -20,6 +21,7 @@ const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'ou
|
||||
}
|
||||
|
||||
export default function MentorWorkspacePage() {
|
||||
const router = useRouter()
|
||||
const { data: assignments, isLoading } = trpc.mentor.getMyProjects.useQuery()
|
||||
|
||||
if (isLoading) {
|
||||
@@ -46,11 +48,9 @@ export default function MentorWorkspacePage() {
|
||||
Collaborate with your assigned mentee projects
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href={'/mentor' as Route}>
|
||||
<Button variant="ghost" size="sm" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useSession } from 'next-auth/react'
|
||||
@@ -67,6 +67,7 @@ const fileTypeLabels: Record<string, string> = {
|
||||
|
||||
export function SubmissionDetailClient() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const projectId = params.id as string
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
@@ -116,11 +117,9 @@ export function SubmissionDetailClient() {
|
||||
{error?.message || 'Submission not found'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/my-submission">
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to My Submissions
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -133,11 +132,9 @@ export function SubmissionDetailClient() {
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/my-submission">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to My Submissions
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -203,10 +203,8 @@ export default function TeamManagementPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href={`/my-submission/${projectId}`}>
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -55,6 +56,7 @@ type SemiFinalistsContentProps = {
|
||||
}
|
||||
|
||||
export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
|
||||
const router = useRouter()
|
||||
const { data, isLoading } = trpc.dashboard.getSemiFinalistDetail.useQuery(
|
||||
{ editionId },
|
||||
{ enabled: !!editionId }
|
||||
@@ -116,11 +118,9 @@ export function SemiFinalistsContent({ editionId }: SemiFinalistsContentProps) {
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href={'/admin' as Route}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-tight md:text-2xl">
|
||||
Semi-Finalists
|
||||
|
||||
@@ -142,8 +142,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={className}
|
||||
onClick={() => logNavClick.mutate({ url: item.href })}
|
||||
>
|
||||
@@ -291,8 +289,6 @@ export function RoleNav({ navigation, roleName, user, basePath, statusBadge, edi
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={() => { logNavClick.mutate({ url: item.href }); setIsMobileMenuOpen(false) }}
|
||||
className={className}
|
||||
>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
import { cn, formatDate, formatDateOnly } from '@/lib/utils'
|
||||
|
||||
export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const { data, isLoading } = trpc.analytics.getProjectDetail.useQuery(
|
||||
{ id: projectId },
|
||||
{ refetchInterval: 30_000 },
|
||||
@@ -78,8 +80,8 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link href={'/observer' as Route}>Back to Dashboard</Link>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -152,11 +154,9 @@ export function ObserverProjectDetail({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back button */}
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 -ml-2 text-muted-foreground" asChild>
|
||||
<Link href={'/observer/projects' as Route}>
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 -ml-2 text-muted-foreground" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{/* Project Header */}
|
||||
|
||||
@@ -927,13 +927,13 @@ function PlatformFeaturesSection({ settings }: { settings: Record<string, string
|
||||
<Label className="text-sm font-medium">Learning Hub</Label>
|
||||
<SettingToggle
|
||||
label="Use External Learning Hub"
|
||||
description="When enabled, jury and mentor navigation links will open the external URL instead of the built-in Learning Hub"
|
||||
description="When enabled, all user navigation links (jury, mentor, applicant) will redirect to the external URL instead of the built-in Learning Hub"
|
||||
settingKey="learning_hub_external"
|
||||
value={settings.learning_hub_external || 'false'}
|
||||
/>
|
||||
<SettingInput
|
||||
label="External URL"
|
||||
description="The URL to redirect jury and mentor users to (e.g. Google Drive, Notion, etc.)"
|
||||
description="The URL to redirect users to (e.g. Google Drive, Notion, etc.)"
|
||||
settingKey="learning_hub_external_url"
|
||||
value={settings.learning_hub_external_url || ''}
|
||||
/>
|
||||
|
||||
@@ -64,7 +64,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
// (status NONE) who have no password yet.
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email: email.toLowerCase().trim() },
|
||||
select: { status: true },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
if (!existingUser || existingUser.status === 'SUSPENDED') {
|
||||
// Silently skip — don't reveal whether the email exists (prevents enumeration)
|
||||
@@ -72,6 +72,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
return
|
||||
}
|
||||
await sendMagicLinkEmail(email, url)
|
||||
|
||||
// Audit: magic link sent
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: existingUser.id,
|
||||
action: 'MAGIC_LINK_SENT',
|
||||
entityType: 'User',
|
||||
entityId: existingUser.id,
|
||||
detailsJson: { email, timestamp: new Date().toISOString() },
|
||||
},
|
||||
}).catch(() => {})
|
||||
},
|
||||
}),
|
||||
// Credentials provider for email/password login and invite token auth
|
||||
|
||||
@@ -1628,14 +1628,44 @@ export const userRouter = router({
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: null,
|
||||
action: 'PASSWORD_RESET_LINK_INVALID',
|
||||
entityType: 'User',
|
||||
entityId: 'unknown',
|
||||
detailsJson: { reason: 'token_not_found', timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}).catch(() => {})
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid or expired reset link. Please request a new one.' })
|
||||
}
|
||||
|
||||
if (user.status === 'SUSPENDED') {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: user.id,
|
||||
action: 'PASSWORD_RESET_LINK_INVALID',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { reason: 'account_suspended', email: user.email, timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}).catch(() => {})
|
||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'This account has been suspended.' })
|
||||
}
|
||||
|
||||
if (user.passwordResetExpiresAt && user.passwordResetExpiresAt < new Date()) {
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: user.id,
|
||||
action: 'PASSWORD_RESET_LINK_EXPIRED',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: user.email, expiredAt: user.passwordResetExpiresAt.toISOString(), timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}).catch(() => {})
|
||||
// Clear expired token
|
||||
await ctx.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -1644,6 +1674,18 @@ export const userRouter = router({
|
||||
throw new TRPCError({ code: 'BAD_REQUEST', message: 'This reset link has expired. Please request a new one.' })
|
||||
}
|
||||
|
||||
// Audit: reset link clicked and valid
|
||||
await logAudit({
|
||||
prisma: ctx.prisma,
|
||||
userId: user.id,
|
||||
action: 'PASSWORD_RESET_LINK_CLICKED',
|
||||
entityType: 'User',
|
||||
entityId: user.id,
|
||||
detailsJson: { email: user.email, timestamp: new Date().toISOString() },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
}).catch(() => {})
|
||||
|
||||
// Hash and save new password, clear reset token
|
||||
const passwordHash = await hashPassword(input.password)
|
||||
await ctx.prisma.user.update({
|
||||
|
||||
Reference in New Issue
Block a user