Jury dashboard compact layout, assignment redesign, auth fixes
Build and Push Docker Image / build (push) Successful in 10m25s
Details
Build and Push Docker Image / build (push) Successful in 10m25s
Details
- Jury dashboard: collapse zero-assignment state into single welcome card with inline quick actions; merge completion bar into stats row; tighten spacing - Manual assignment: replace tiny Dialog modal with inline collapsible section featuring searchable juror combobox and multi-select project list with bulk assign - Fix applicant invite URL path (/auth/accept-invite -> /accept-invite) - Add APPLICANT role redirect to /my-submission from root page - Add Applicant label to accept-invite role display - Fix a/an grammar in invitation emails and accept-invite page - Set-password page: use MOPC logo instead of lock icon - Notification bell: remove filter tabs, always show all notifications Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
74515768f5
commit
09091d7c08
|
|
@ -3,6 +3,7 @@
|
||||||
import { Suspense, use, useState, useEffect } from 'react'
|
import { Suspense, use, useState, useEffect } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -42,21 +43,20 @@ import {
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Command,
|
||||||
DialogContent,
|
CommandEmpty,
|
||||||
DialogDescription,
|
CommandGroup,
|
||||||
DialogFooter,
|
CommandInput,
|
||||||
DialogHeader,
|
CommandItem,
|
||||||
DialogTitle,
|
CommandList,
|
||||||
DialogTrigger,
|
} from '@/components/ui/command'
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Popover,
|
||||||
SelectContent,
|
PopoverContent,
|
||||||
SelectItem,
|
PopoverTrigger,
|
||||||
SelectTrigger,
|
} from '@/components/ui/popover'
|
||||||
SelectValue,
|
import { Input } from '@/components/ui/input'
|
||||||
} from '@/components/ui/select'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Users,
|
Users,
|
||||||
|
|
@ -72,6 +72,10 @@ import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Cpu,
|
Cpu,
|
||||||
Brain,
|
Brain,
|
||||||
|
Search,
|
||||||
|
ChevronsUpDown,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
|
@ -195,9 +199,11 @@ interface PageProps {
|
||||||
|
|
||||||
function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
|
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
|
||||||
const [manualDialogOpen, setManualDialogOpen] = useState(false)
|
const [manualOpen, setManualOpen] = useState(false)
|
||||||
const [selectedJuror, setSelectedJuror] = useState<string>('')
|
const [selectedJuror, setSelectedJuror] = useState<string>('')
|
||||||
const [selectedProject, setSelectedProject] = useState<string>('')
|
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
|
||||||
|
const [projectSearch, setProjectSearch] = useState('')
|
||||||
|
const [selectedProjects, setSelectedProjects] = useState<Set<string>>(new Set())
|
||||||
const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm')
|
const [activeTab, setActiveTab] = useState<'algorithm' | 'ai'>('algorithm')
|
||||||
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
const [activeJobId, setActiveJobId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -302,13 +308,13 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
// Get available jurors for manual assignment
|
// Get available jurors for manual assignment
|
||||||
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
|
const { data: availableJurors } = trpc.user.getJuryMembers.useQuery(
|
||||||
{ roundId },
|
{ roundId },
|
||||||
{ enabled: manualDialogOpen }
|
{ enabled: manualOpen }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get projects in this round for manual assignment
|
// Get projects in this round for manual assignment
|
||||||
const { data: roundProjects } = trpc.project.list.useQuery(
|
const { data: roundProjects } = trpc.project.list.useQuery(
|
||||||
{ roundId, perPage: 500 },
|
{ roundId, perPage: 500 },
|
||||||
{ enabled: manualDialogOpen }
|
{ enabled: manualOpen }
|
||||||
)
|
)
|
||||||
|
|
||||||
const utils = trpc.useUtils()
|
const utils = trpc.useUtils()
|
||||||
|
|
@ -335,26 +341,45 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
utils.assignment.listByRound.invalidate({ roundId })
|
utils.assignment.listByRound.invalidate({ roundId })
|
||||||
utils.assignment.getStats.invalidate({ roundId })
|
utils.assignment.getStats.invalidate({ roundId })
|
||||||
utils.assignment.getSuggestions.invalidate({ roundId })
|
utils.assignment.getSuggestions.invalidate({ roundId })
|
||||||
setManualDialogOpen(false)
|
|
||||||
setSelectedJuror('')
|
|
||||||
setSelectedProject('')
|
|
||||||
toast.success('Assignment created successfully')
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(error.message || 'Failed to create assignment')
|
toast.error(error.message || 'Failed to create assignment')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleCreateManualAssignment = () => {
|
const [bulkAssigning, setBulkAssigning] = useState(false)
|
||||||
if (!selectedJuror || !selectedProject) {
|
|
||||||
toast.error('Please select both a juror and a project')
|
const handleBulkAssign = async () => {
|
||||||
|
if (!selectedJuror || selectedProjects.size === 0) {
|
||||||
|
toast.error('Please select a juror and at least one project')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
createAssignment.mutate({
|
setBulkAssigning(true)
|
||||||
userId: selectedJuror,
|
let successCount = 0
|
||||||
projectId: selectedProject,
|
let errorCount = 0
|
||||||
roundId,
|
for (const projectId of selectedProjects) {
|
||||||
})
|
try {
|
||||||
|
await createAssignment.mutateAsync({
|
||||||
|
userId: selectedJuror,
|
||||||
|
projectId,
|
||||||
|
roundId,
|
||||||
|
})
|
||||||
|
successCount++
|
||||||
|
} catch {
|
||||||
|
errorCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setBulkAssigning(false)
|
||||||
|
setSelectedProjects(new Set())
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(`${successCount} assignment${successCount > 1 ? 's' : ''} created successfully`)
|
||||||
|
}
|
||||||
|
if (errorCount > 0) {
|
||||||
|
toast.error(`${errorCount} assignment${errorCount > 1 ? 's' : ''} failed`)
|
||||||
|
}
|
||||||
|
utils.assignment.listByRound.invalidate({ roundId })
|
||||||
|
utils.assignment.getStats.invalidate({ roundId })
|
||||||
|
utils.assignment.getSuggestions.invalidate({ roundId })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadingRound || loadingAssignments) {
|
if (loadingRound || loadingAssignments) {
|
||||||
|
|
@ -457,124 +482,278 @@ function AssignmentManagementContent({ roundId }: { roundId: string }) {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manual Assignment Button */}
|
{/* Manual Assignment Toggle */}
|
||||||
<Dialog open={manualDialogOpen} onOpenChange={setManualDialogOpen}>
|
<Button
|
||||||
<DialogTrigger asChild>
|
variant={manualOpen ? 'secondary' : 'default'}
|
||||||
<Button>
|
onClick={() => {
|
||||||
|
setManualOpen(!manualOpen)
|
||||||
|
if (!manualOpen) {
|
||||||
|
setSelectedJuror('')
|
||||||
|
setSelectedProjects(new Set())
|
||||||
|
setProjectSearch('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{manualOpen ? (
|
||||||
|
<>
|
||||||
|
<X className="mr-2 h-4 w-4" />
|
||||||
|
Close
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
<UserPlus className="mr-2 h-4 w-4" />
|
||||||
Manual Assignment
|
Manual Assignment
|
||||||
</Button>
|
</>
|
||||||
</DialogTrigger>
|
)}
|
||||||
<DialogContent>
|
</Button>
|
||||||
<DialogHeader>
|
</div>
|
||||||
<DialogTitle>Create Manual Assignment</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Assign a jury member to evaluate a specific project
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
{/* Inline Manual Assignment Section */}
|
||||||
{/* Juror Select */}
|
{manualOpen && (
|
||||||
<div className="space-y-2">
|
<Card>
|
||||||
<Label htmlFor="juror">Jury Member</Label>
|
<CardHeader className="pb-3">
|
||||||
<Select value={selectedJuror} onValueChange={setSelectedJuror}>
|
<div className="flex items-center justify-between">
|
||||||
<SelectTrigger id="juror">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<SelectValue placeholder="Select a jury member..." />
|
<UserPlus className="h-5 w-5" />
|
||||||
</SelectTrigger>
|
Manual Assignment
|
||||||
<SelectContent>
|
</CardTitle>
|
||||||
{availableJurors?.map((juror) => {
|
<Button variant="ghost" size="sm" onClick={() => setManualOpen(false)}>
|
||||||
const maxAllowed = juror.maxAssignments ?? 10
|
<X className="h-4 w-4" />
|
||||||
const isFull = juror.currentAssignments >= maxAllowed
|
</Button>
|
||||||
return (
|
</div>
|
||||||
<SelectItem key={juror.id} value={juror.id} disabled={isFull}>
|
<CardDescription>
|
||||||
<div className="flex items-center justify-between gap-4">
|
Select a jury member, then pick projects to assign
|
||||||
<span className={isFull ? 'opacity-50' : ''}>{juror.name || juror.email}</span>
|
</CardDescription>
|
||||||
<span className="text-xs text-muted-foreground">
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5">
|
||||||
|
{/* Step 1: Juror Picker */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Step 1: Select Jury Member</Label>
|
||||||
|
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={jurorPopoverOpen}
|
||||||
|
className="w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
{selectedJuror && availableJurors ? (
|
||||||
|
(() => {
|
||||||
|
const juror = availableJurors.find(j => j.id === selectedJuror)
|
||||||
|
if (!juror) return 'Select a jury member...'
|
||||||
|
const maxAllowed = juror.maxAssignments ?? 10
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span>{juror.name || juror.email}</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
{juror.currentAssignments}/{maxAllowed} assigned
|
{juror.currentAssignments}/{maxAllowed} assigned
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</span>
|
||||||
</SelectItem>
|
)
|
||||||
)
|
})()
|
||||||
})}
|
) : (
|
||||||
{availableJurors?.length === 0 && (
|
'Select a jury member...'
|
||||||
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
|
|
||||||
No jury members available
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Select>
|
</Button>
|
||||||
{selectedJuror && availableJurors && (
|
</PopoverTrigger>
|
||||||
<p className="text-xs text-muted-foreground">
|
<PopoverContent className="w-[400px] p-0" align="start">
|
||||||
{(() => {
|
<Command>
|
||||||
const juror = availableJurors.find(j => j.id === selectedJuror)
|
<CommandInput placeholder="Search jurors..." />
|
||||||
if (!juror) return null
|
<CommandList>
|
||||||
const available = (juror.maxAssignments ?? 10) - juror.currentAssignments
|
<CommandEmpty>No jurors found.</CommandEmpty>
|
||||||
return `${available} assignment slot${available !== 1 ? 's' : ''} available`
|
<CommandGroup>
|
||||||
})()}
|
{availableJurors?.map((juror) => {
|
||||||
</p>
|
const maxAllowed = juror.maxAssignments ?? 10
|
||||||
)}
|
const isFull = juror.currentAssignments >= maxAllowed
|
||||||
</div>
|
return (
|
||||||
|
<CommandItem
|
||||||
{/* Project Select */}
|
key={juror.id}
|
||||||
<div className="space-y-2">
|
value={`${juror.name || ''} ${juror.email}`}
|
||||||
<Label htmlFor="project">Project</Label>
|
onSelect={() => {
|
||||||
<Select value={selectedProject} onValueChange={setSelectedProject}>
|
setSelectedJuror(juror.id === selectedJuror ? '' : juror.id)
|
||||||
<SelectTrigger id="project">
|
setSelectedProjects(new Set())
|
||||||
<SelectValue placeholder="Select a project..." />
|
setJurorPopoverOpen(false)
|
||||||
</SelectTrigger>
|
}}
|
||||||
<SelectContent>
|
disabled={isFull}
|
||||||
{roundProjects?.projects.map((project) => {
|
className={isFull ? 'opacity-50' : ''}
|
||||||
const assignmentCount = assignments?.filter(a => a.projectId === project.id).length ?? 0
|
>
|
||||||
const isAlreadyAssigned = selectedJuror && assignments?.some(
|
<Check
|
||||||
a => a.userId === selectedJuror && a.projectId === project.id
|
className={cn(
|
||||||
)
|
'mr-2 h-4 w-4',
|
||||||
return (
|
selectedJuror === juror.id ? 'opacity-100' : 'opacity-0'
|
||||||
<SelectItem
|
)}
|
||||||
key={project.id}
|
/>
|
||||||
value={project.id}
|
<div className="flex items-center justify-between w-full">
|
||||||
disabled={!!isAlreadyAssigned}
|
<div className="min-w-0">
|
||||||
>
|
<p className="text-sm font-medium truncate">{juror.name || juror.email}</p>
|
||||||
<div className="flex items-center justify-between gap-4">
|
{juror.name && (
|
||||||
<span className={isAlreadyAssigned ? 'line-through opacity-50' : ''}>
|
<p className="text-xs text-muted-foreground truncate">{juror.email}</p>
|
||||||
{project.title}
|
)}
|
||||||
</span>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
<Badge variant={isFull ? 'destructive' : 'secondary'} className="text-xs ml-2 shrink-0">
|
||||||
{assignmentCount}/{round.requiredReviews} reviewers
|
{juror.currentAssignments}/{maxAllowed}
|
||||||
</span>
|
{isFull && ' Full'}
|
||||||
</div>
|
</Badge>
|
||||||
</SelectItem>
|
</div>
|
||||||
)
|
</CommandItem>
|
||||||
})}
|
)
|
||||||
{roundProjects?.projects.length === 0 && (
|
})}
|
||||||
<div className="px-2 py-4 text-sm text-muted-foreground text-center">
|
</CommandGroup>
|
||||||
No projects in this round
|
</CommandList>
|
||||||
</div>
|
</Command>
|
||||||
)}
|
</PopoverContent>
|
||||||
</SelectContent>
|
</Popover>
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
{/* Step 2: Project Multi-Select */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold">Step 2: Select Projects</Label>
|
||||||
|
{!selectedJuror ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||||
|
Select a jury member first to see available projects
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search projects..."
|
||||||
|
value={projectSearch}
|
||||||
|
onChange={(e) => setProjectSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const projects = roundProjects?.projects ?? []
|
||||||
|
const filtered = projects.filter(p =>
|
||||||
|
p.title.toLowerCase().includes(projectSearch.toLowerCase())
|
||||||
|
)
|
||||||
|
const unassignedToJuror = filtered.filter(p =>
|
||||||
|
!assignments?.some(a => a.userId === selectedJuror && a.projectId === p.id)
|
||||||
|
)
|
||||||
|
const allUnassignedSelected = unassignedToJuror.length > 0 &&
|
||||||
|
unassignedToJuror.every(p => selectedProjects.has(p.id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 py-1">
|
||||||
|
<Checkbox
|
||||||
|
checked={allUnassignedSelected}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (allUnassignedSelected) {
|
||||||
|
setSelectedProjects(new Set())
|
||||||
|
} else {
|
||||||
|
setSelectedProjects(new Set(unassignedToJuror.map(p => p.id)))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Select all unassigned ({unassignedToJuror.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[280px] rounded-lg border">
|
||||||
|
<div className="divide-y">
|
||||||
|
{filtered.map((project) => {
|
||||||
|
const assignmentCount = assignments?.filter(a => a.projectId === project.id).length ?? 0
|
||||||
|
const isAlreadyAssigned = assignments?.some(
|
||||||
|
a => a.userId === selectedJuror && a.projectId === project.id
|
||||||
|
)
|
||||||
|
const isFullCoverage = assignmentCount >= round.requiredReviews
|
||||||
|
const isChecked = selectedProjects.has(project.id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 transition-colors',
|
||||||
|
isAlreadyAssigned ? 'opacity-40 bg-muted/30' : 'hover:bg-muted/50',
|
||||||
|
isChecked && !isAlreadyAssigned && 'bg-blue-50/50 dark:bg-blue-950/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isChecked}
|
||||||
|
disabled={!!isAlreadyAssigned}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
setSelectedProjects(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(project.id)) {
|
||||||
|
next.delete(project.id)
|
||||||
|
} else {
|
||||||
|
next.add(project.id)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className={cn(
|
||||||
|
'flex-1 text-sm truncate',
|
||||||
|
isAlreadyAssigned && 'line-through'
|
||||||
|
)}>
|
||||||
|
{project.title}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={isFullCoverage ? 'default' : 'outline'}
|
||||||
|
className={cn(
|
||||||
|
'text-xs shrink-0',
|
||||||
|
isFullCoverage && 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400 border-0',
|
||||||
|
isAlreadyAssigned && 'bg-muted text-muted-foreground border-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAlreadyAssigned ? (
|
||||||
|
<>
|
||||||
|
<Check className="mr-1 h-3 w-3" />
|
||||||
|
Assigned
|
||||||
|
</>
|
||||||
|
) : isFullCoverage ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||||
|
{assignmentCount}/{round.requiredReviews}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
`${assignmentCount}/${round.requiredReviews} reviewers`
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No projects match your search
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assign Button */}
|
||||||
|
{selectedJuror && selectedProjects.size > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
onClick={handleBulkAssign}
|
||||||
onClick={() => setManualDialogOpen(false)}
|
disabled={bulkAssigning}
|
||||||
|
className="w-full"
|
||||||
|
size="lg"
|
||||||
>
|
>
|
||||||
Cancel
|
{bulkAssigning ? (
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateManualAssignment}
|
|
||||||
disabled={!selectedJuror || !selectedProject || createAssignment.isPending}
|
|
||||||
>
|
|
||||||
{createAssignment.isPending && (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
Create Assignment
|
Assign {selectedProjects.size} Project{selectedProjects.size > 1 ? 's' : ''}
|
||||||
|
{availableJurors && (() => {
|
||||||
|
const juror = availableJurors.find(j => j.id === selectedJuror)
|
||||||
|
return juror ? ` to ${juror.name || juror.email}` : ''
|
||||||
|
})()}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
)}
|
||||||
</DialogContent>
|
</CardContent>
|
||||||
</Dialog>
|
</Card>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
{stats && (
|
{stats && (
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ function AcceptInviteContent() {
|
||||||
case 'PROGRAM_ADMIN': return 'Program Admin'
|
case 'PROGRAM_ADMIN': return 'Program Admin'
|
||||||
case 'MENTOR': return 'Mentor'
|
case 'MENTOR': return 'Mentor'
|
||||||
case 'OBSERVER': return 'Observer'
|
case 'OBSERVER': return 'Observer'
|
||||||
|
case 'APPLICANT': return 'Applicant'
|
||||||
default: return role
|
default: return role
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +183,7 @@ function AcceptInviteContent() {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-base">
|
<CardDescription className="text-base">
|
||||||
You've been invited to join the Monaco Ocean Protection Challenge platform
|
You've been invited to join the Monaco Ocean Protection Challenge platform
|
||||||
{user?.role ? ` as a ${getRoleLabel(user.role)}.` : '.'}
|
{user?.role ? ` as ${/^[aeiou]/i.test(getRoleLabel(user.role)) ? 'an' : 'a'} ${getRoleLabel(user.role)}.` : '.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
|
import { Loader2, Lock, CheckCircle2, AlertCircle, Eye, EyeOff } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
import { trpc } from '@/lib/trpc/client'
|
import { trpc } from '@/lib/trpc/client'
|
||||||
|
|
||||||
export default function SetPasswordPage() {
|
export default function SetPasswordPage() {
|
||||||
|
|
@ -149,8 +150,8 @@ export default function SetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-white shadow-sm border">
|
||||||
<Lock className="h-6 w-6 text-primary" />
|
<Image src="/images/MOPC-blue-small.png" alt="MOPC" width={32} height={32} className="object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl">Set Your Password</CardTitle>
|
<CardTitle className="text-xl">Set Your Password</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,54 @@ async function JuryDashboardContent() {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Zero-assignment state: compact welcome card
|
||||||
|
if (totalAssignments === 0) {
|
||||||
|
return (
|
||||||
|
<AnimatedCard index={0}>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
|
||||||
|
<CardContent className="py-8 px-6">
|
||||||
|
<div className="flex flex-col items-center text-center mb-6">
|
||||||
|
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-4 mb-3 dark:from-brand-teal/20 dark:to-brand-blue/20">
|
||||||
|
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-semibold">No assignments yet</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
|
||||||
|
Your project assignments will appear here once an administrator assigns them to you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
|
||||||
|
<Link
|
||||||
|
href="/jury/assignments"
|
||||||
|
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg bg-blue-50 p-2 transition-colors group-hover:bg-blue-100 dark:bg-blue-950/40">
|
||||||
|
<ClipboardList className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-semibold text-sm group-hover:text-brand-blue dark:group-hover:text-brand-teal transition-colors">All Assignments</p>
|
||||||
|
<p className="text-xs text-muted-foreground">View evaluations</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/jury/compare"
|
||||||
|
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="rounded-lg bg-teal-50 p-2 transition-colors group-hover:bg-teal-100 dark:bg-teal-950/40">
|
||||||
|
<GitCompare className="h-4 w-4 text-brand-teal" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="font-semibold text-sm group-hover:text-brand-teal transition-colors">Compare Projects</p>
|
||||||
|
<p className="text-xs text-muted-foreground">Side-by-side view</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero CTA - Jump to next evaluation */}
|
{/* Hero CTA - Jump to next evaluation */}
|
||||||
|
|
@ -248,8 +296,8 @@ async function JuryDashboardContent() {
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats + Overall Completion in one row */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
{stats.map((stat, i) => (
|
{stats.map((stat, i) => (
|
||||||
<AnimatedCard key={stat.label} index={i + 1}>
|
<AnimatedCard key={stat.label} index={i + 1}>
|
||||||
<Card className={cn(
|
<Card className={cn(
|
||||||
|
|
@ -268,43 +316,33 @@ async function JuryDashboardContent() {
|
||||||
</Card>
|
</Card>
|
||||||
</AnimatedCard>
|
</AnimatedCard>
|
||||||
))}
|
))}
|
||||||
|
{/* Overall completion as 5th stat card */}
|
||||||
|
<AnimatedCard index={5}>
|
||||||
|
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
|
||||||
|
<CardContent className="flex items-center gap-4 py-5 px-5">
|
||||||
|
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
|
||||||
|
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
|
||||||
|
{completionRate.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/60 mt-1">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${completionRate}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AnimatedCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Overall Progress */}
|
|
||||||
<AnimatedCard index={5}>
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-teal via-brand-blue to-brand-teal" />
|
|
||||||
<CardContent className="py-5 px-6">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="rounded-lg bg-brand-blue/10 p-2 dark:bg-brand-blue/20">
|
|
||||||
<BarChart3 className="h-4 w-4 text-brand-blue dark:text-brand-teal" />
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-semibold">Overall Completion</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span className="text-2xl font-bold tabular-nums text-brand-blue dark:text-brand-teal">
|
|
||||||
{completionRate.toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground ml-1">
|
|
||||||
({completedAssignments}/{totalAssignments})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-3 w-full overflow-hidden rounded-full bg-muted/60">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${completionRate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
|
|
||||||
{/* Main content -- two column layout */}
|
{/* Main content -- two column layout */}
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-4 lg:grid-cols-12">
|
||||||
{/* Left column */}
|
{/* Left column */}
|
||||||
<div className="lg:col-span-7 space-y-6">
|
<div className="lg:col-span-7 space-y-4">
|
||||||
{/* Recent Assignments */}
|
{/* Recent Assignments */}
|
||||||
<AnimatedCard index={6}>
|
<AnimatedCard index={6}>
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -402,11 +440,11 @@ async function JuryDashboardContent() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-center">
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3">
|
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2">
|
||||||
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
|
<ClipboardList className="h-6 w-6 text-brand-teal/60" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-muted-foreground">
|
<p className="font-medium text-sm text-muted-foreground">
|
||||||
No assignments yet
|
No assignments yet
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1 max-w-[240px]">
|
<p className="text-xs text-muted-foreground/70 mt-1 max-w-[240px]">
|
||||||
|
|
@ -462,7 +500,7 @@ async function JuryDashboardContent() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column */}
|
{/* Right column */}
|
||||||
<div className="lg:col-span-5 space-y-6">
|
<div className="lg:col-span-5 space-y-4">
|
||||||
{/* Active Rounds */}
|
{/* Active Rounds */}
|
||||||
{activeRounds.length > 0 && (
|
{activeRounds.length > 0 && (
|
||||||
<AnimatedCard index={8}>
|
<AnimatedCard index={8}>
|
||||||
|
|
@ -561,12 +599,12 @@ async function JuryDashboardContent() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No active rounds */}
|
{/* No active rounds */}
|
||||||
{activeRounds.length === 0 && totalAssignments > 0 && (
|
{activeRounds.length === 0 && (
|
||||||
<AnimatedCard index={8}>
|
<AnimatedCard index={8}>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
|
<CardContent className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-3 dark:bg-brand-teal/20">
|
<div className="rounded-2xl bg-brand-teal/10 p-3 mb-2 dark:bg-brand-teal/20">
|
||||||
<Clock className="h-7 w-7 text-brand-teal/70" />
|
<Clock className="h-6 w-6 text-brand-teal/70" />
|
||||||
</div>
|
</div>
|
||||||
<p className="font-semibold text-sm">No active voting rounds</p>
|
<p className="font-semibold text-sm">No active voting rounds</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1 max-w-[220px]">
|
<p className="text-xs text-muted-foreground mt-1 max-w-[220px]">
|
||||||
|
|
@ -618,24 +656,6 @@ async function JuryDashboardContent() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* No assignments at all */}
|
|
||||||
{totalAssignments === 0 && (
|
|
||||||
<AnimatedCard index={1}>
|
|
||||||
<Card className="overflow-hidden">
|
|
||||||
<div className="h-1 w-full bg-gradient-to-r from-brand-teal/40 via-brand-blue/40 to-brand-teal/40" />
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-14 text-center">
|
|
||||||
<div className="rounded-2xl bg-gradient-to-br from-brand-teal/10 to-brand-blue/10 p-5 mb-4 dark:from-brand-teal/20 dark:to-brand-blue/20">
|
|
||||||
<ClipboardList className="h-10 w-10 text-brand-teal/60" />
|
|
||||||
</div>
|
|
||||||
<p className="text-lg font-semibold">No assignments yet</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1.5 max-w-sm">
|
|
||||||
You'll see your project assignments here once they're assigned to you by an administrator.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</AnimatedCard>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -721,7 +741,7 @@ export default async function JuryDashboardPage() {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute -top-6 -left-6 -right-6 h-32 bg-gradient-to-b from-brand-blue/[0.03] to-transparent dark:from-brand-blue/[0.06] pointer-events-none rounded-xl" />
|
<div className="absolute -top-6 -left-6 -right-6 h-32 bg-gradient-to-b from-brand-blue/[0.03] to-transparent dark:from-brand-blue/[0.06] pointer-events-none rounded-xl" />
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ export default async function HomePage() {
|
||||||
redirect('/mentor' as Route)
|
redirect('/mentor' as Route)
|
||||||
} else if (session.user.role === 'OBSERVER') {
|
} else if (session.user.role === 'OBSERVER') {
|
||||||
redirect('/observer')
|
redirect('/observer')
|
||||||
|
} else if (session.user.role === 'APPLICANT') {
|
||||||
|
redirect('/my-submission' as Route)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -210,7 +210,6 @@ function NotificationItem({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const [filter, setFilter] = useState<'all' | 'unread'>('all')
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
|
@ -238,7 +237,7 @@ export function NotificationBell() {
|
||||||
|
|
||||||
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
|
const { data: notificationData, refetch } = trpc.notification.list.useQuery(
|
||||||
{
|
{
|
||||||
unreadOnly: filter === 'unread',
|
unreadOnly: false,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -379,32 +378,6 @@ export function NotificationBell() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter tabs */}
|
|
||||||
<div className="flex border-b">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'flex-1 py-2 text-sm transition-colors',
|
|
||||||
filter === 'all'
|
|
||||||
? 'border-b-2 border-primary font-medium'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() => setFilter('all')}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'flex-1 py-2 text-sm transition-colors',
|
|
||||||
filter === 'unread'
|
|
||||||
? 'border-b-2 border-primary font-medium'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
)}
|
|
||||||
onClick={() => setFilter('unread')}
|
|
||||||
>
|
|
||||||
Unread ({unreadCount})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notification list */}
|
{/* Notification list */}
|
||||||
<ScrollArea className="h-[400px]">
|
<ScrollArea className="h-[400px]">
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
|
|
@ -424,9 +397,7 @@ export function NotificationBell() {
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
|
<Bell className="h-10 w-10 mx-auto text-muted-foreground/30" />
|
||||||
<p className="mt-2 text-muted-foreground">
|
<p className="mt-2 text-muted-foreground">
|
||||||
{filter === 'unread'
|
No notifications yet
|
||||||
? 'No unread notifications'
|
|
||||||
: 'No notifications yet'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -291,11 +291,12 @@ function getGenericInvitationTemplate(
|
||||||
role: string
|
role: string
|
||||||
): EmailTemplate {
|
): EmailTemplate {
|
||||||
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
|
const roleLabel = role === 'JURY_MEMBER' ? 'jury member' : role.toLowerCase().replace('_', ' ')
|
||||||
|
const article = /^[aeiou]/i.test(roleLabel) ? 'an' : 'a'
|
||||||
const greeting = name ? `Hello ${name},` : 'Hello,'
|
const greeting = name ? `Hello ${name},` : 'Hello,'
|
||||||
|
|
||||||
const content = `
|
const content = `
|
||||||
${sectionTitle(greeting)}
|
${sectionTitle(greeting)}
|
||||||
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as a <strong>${roleLabel}</strong>.`)}
|
${paragraph(`You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} <strong>${roleLabel}</strong>.`)}
|
||||||
${paragraph('Click the button below to set up your account and get started.')}
|
${paragraph('Click the button below to set up your account and get started.')}
|
||||||
${ctaButton(url, 'Accept Invitation')}
|
${ctaButton(url, 'Accept Invitation')}
|
||||||
${infoBox('This link will expire in 7 days.', 'info')}
|
${infoBox('This link will expire in 7 days.', 'info')}
|
||||||
|
|
@ -307,7 +308,7 @@ function getGenericInvitationTemplate(
|
||||||
text: `
|
text: `
|
||||||
${greeting}
|
${greeting}
|
||||||
|
|
||||||
You've been invited to join the Monaco Ocean Protection Challenge platform as a ${roleLabel}.
|
You've been invited to join the Monaco Ocean Protection Challenge platform as ${article} ${roleLabel}.
|
||||||
|
|
||||||
Click the link below to set up your account and get started:
|
Click the link below to set up your account and get started:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -561,7 +561,7 @@ export const projectRouter = router({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const inviteUrl = `${baseUrl}/auth/accept-invite?token=${token}`
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
||||||
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
|
await sendInvitationEmail(member.email, member.name, inviteUrl, 'APPLICANT')
|
||||||
|
|
||||||
// Log notification
|
// Log notification
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue