Platform-wide visual overhaul, team invites, analytics improvements, and deployment hardening

UI overhaul applying jury dashboard design patterns across all pages:
- Stat cards with border-l-4 accent + icon pills on admin, observer, mentor, applicant dashboards and reports
- Card section headers with color-coded icon pills throughout
- Hover lift effects (translate-y + shadow) on cards and list items
- Gradient progress bars (brand-teal to brand-blue) platform-wide
- AnimatedCard stagger animations on all dashboard sections
- Auth pages with gradient accent strip and polished icon containers
- EmptyState component upgraded with rounded icon pill containers
- Replaced AI-looking icons (Brain/Sparkles/Bot/Wand2/Cpu) with descriptive alternatives across 12 files
- Removed gradient overlay from jury dashboard header
- Quick actions restyled as card links with group hover effects

Backend improvements:
- Team member invite emails with account setup flow and notification logging
- Analytics routers accept edition-wide queries (programId) in addition to roundId
- Round detail endpoint returns inline progress data (eliminates extra getProgress call)
- Award voting endpoints parallelized with Promise.all
- Bulk invite supports optional sendInvitation flag
- AwardVote composite index migration for query performance

Infrastructure:
- Docker entrypoint with migration retry loop (configurable retries/delay)
- docker-compose pull_policy: always for automatic image refresh
- Simplified deploy/update scripts using docker compose up -d --pull always
- Updated deployment documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 13:20:52 +01:00
parent 98f4a957cc
commit ce4069bf92
59 changed files with 1949 additions and 913 deletions

View File

@@ -23,7 +23,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Search, Loader2, Plus, Package } from 'lucide-react'
import { Search, Loader2, Plus, Package, CheckCircle2 } from 'lucide-react'
import { getCountryName } from '@/lib/countries'
interface AssignProjectsDialogProps {
@@ -65,7 +65,6 @@ export function AssignProjectsDialog({
const { data, isLoading } = trpc.project.list.useQuery(
{
programId,
notInRoundId: roundId,
search: debouncedSearch || undefined,
page: 1,
perPage: 5000,
@@ -87,23 +86,28 @@ export function AssignProjectsDialog({
})
const projects = data?.projects ?? []
const alreadyInRound = new Set(
projects.filter((p) => p.round?.id === roundId).map((p) => p.id)
)
const assignableProjects = projects.filter((p) => !alreadyInRound.has(p.id))
const toggleProject = useCallback((id: string) => {
if (alreadyInRound.has(id)) return
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
}, [alreadyInRound])
const toggleAll = useCallback(() => {
if (selectedIds.size === projects.length) {
if (selectedIds.size === assignableProjects.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(projects.map((p) => p.id)))
setSelectedIds(new Set(assignableProjects.map((p) => p.id)))
}
}, [selectedIds.size, projects])
}, [selectedIds.size, assignableProjects])
const handleAssign = () => {
if (selectedIds.size === 0) return
@@ -144,9 +148,9 @@ export function AssignProjectsDialog({
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No available projects</p>
<p className="mt-2 font-medium">No projects found</p>
<p className="text-sm text-muted-foreground">
All program projects are already in this round.
{debouncedSearch ? 'No projects match your search.' : 'This program has no projects yet.'}
</p>
</div>
) : (
@@ -154,11 +158,15 @@ export function AssignProjectsDialog({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedIds.size === projects.length && projects.length > 0}
checked={assignableProjects.length > 0 && selectedIds.size === assignableProjects.length}
disabled={assignableProjects.length === 0}
onCheckedChange={toggleAll}
/>
<span className="text-sm text-muted-foreground">
{selectedIds.size} of {projects.length} selected
{selectedIds.size} of {assignableProjects.length} assignable selected
{alreadyInRound.size > 0 && (
<span className="ml-1">({alreadyInRound.size} already in round)</span>
)}
</span>
</div>
</div>
@@ -174,34 +182,54 @@ export function AssignProjectsDialog({
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow
key={project.id}
className={selectedIds.has(project.id) ? 'bg-muted/50' : 'cursor-pointer'}
onClick={() => toggleProject(project.id)}
>
<TableCell>
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
</TableCell>
<TableCell className="font-medium">
{project.title}
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
{project.country ? (
<Badge variant="outline" className="text-xs">
{getCountryName(project.country)}
</Badge>
) : '—'}
</TableCell>
</TableRow>
))}
{projects.map((project) => {
const isInRound = alreadyInRound.has(project.id)
return (
<TableRow
key={project.id}
className={
isInRound
? 'opacity-60'
: selectedIds.has(project.id)
? 'bg-muted/50'
: 'cursor-pointer'
}
onClick={() => toggleProject(project.id)}
>
<TableCell>
{isInRound ? (
<CheckCircle2 className="h-4 w-4 text-green-600" />
) : (
<Checkbox
checked={selectedIds.has(project.id)}
onCheckedChange={() => toggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
)}
</TableCell>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{project.title}
{isInRound && (
<Badge variant="secondary" className="text-xs">
In round
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{project.teamName || '—'}
</TableCell>
<TableCell>
{project.country ? (
<Badge variant="outline" className="text-xs">
{getCountryName(project.country)}
</Badge>
) : '—'}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>

View File

@@ -24,7 +24,7 @@ import {
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Sparkles,
FileText,
RefreshCw,
Loader2,
CheckCircle2,
@@ -119,7 +119,7 @@ export function EvaluationSummaryCard({
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5" />
<FileText className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription>
@@ -128,7 +128,7 @@ export function EvaluationSummaryCard({
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-6 text-center">
<Sparkles className="h-10 w-10 text-muted-foreground/50 mb-3" />
<FileText className="h-10 w-10 text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground mb-4">
No summary generated yet. Click below to analyze submitted evaluations.
</p>
@@ -136,7 +136,7 @@ export function EvaluationSummaryCard({
{isGenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
<FileText className="mr-2 h-4 w-4" />
)}
{isGenerating ? 'Generating...' : 'Generate Summary'}
</Button>
@@ -155,7 +155,7 @@ export function EvaluationSummaryCard({
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5" />
<FileText className="h-5 w-5" />
AI Evaluation Summary
</CardTitle>
<CardDescription className="flex items-center gap-2 mt-1">

View File

@@ -27,7 +27,8 @@ import { Skeleton } from '@/components/ui/skeleton'
import { UserAvatar } from '@/components/shared/user-avatar'
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
import { Pagination } from '@/components/shared/pagination'
import { Plus, Users, Search } from 'lucide-react'
import { Plus, Users, Search, Mail, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { formatRelativeTime } from '@/lib/utils'
type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
@@ -60,6 +61,39 @@ const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
SUPER_ADMIN: 'destructive' as 'default',
}
function InlineSendInvite({ userId, userEmail }: { userId: string; userEmail: string }) {
const utils = trpc.useUtils()
const sendInvitation = trpc.user.sendInvitation.useMutation({
onSuccess: () => {
toast.success(`Invitation sent to ${userEmail}`)
utils.user.list.invalidate()
},
onError: (error) => {
toast.error(error.message || 'Failed to send invitation')
},
})
return (
<Button
variant="outline"
size="sm"
className="h-6 text-xs gap-1 px-2"
onClick={(e) => {
e.stopPropagation()
sendInvitation.mutate({ userId })
}}
disabled={sendInvitation.isPending}
>
{sendInvitation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Mail className="h-3 w-3" />
)}
Send Invite
</Button>
)
}
export function MembersContent() {
const searchParams = useSearchParams()
@@ -124,7 +158,7 @@ export function MembersContent() {
<Button asChild>
<Link href="/admin/members/invite">
<Plus className="mr-2 h-4 w-4" />
Invite Member
Add Member
</Link>
</Button>
</div>
@@ -223,9 +257,14 @@ export function MembersContent() {
</div>
</TableCell>
<TableCell>
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
<div className="flex items-center gap-2">
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
{user.status === 'NONE' && (
<InlineSendInvite userId={user.id} userEmail={user.email} />
)}
</div>
</TableCell>
<TableCell>
{user.lastLoginAt ? (
@@ -272,9 +311,14 @@ export function MembersContent() {
</CardDescription>
</div>
</div>
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
<div className="flex flex-col items-end gap-1.5">
<Badge variant={statusColors[user.status] || 'secondary'}>
{statusLabels[user.status] || user.status}
</Badge>
{user.status === 'NONE' && (
<InlineSendInvite userId={user.id} userEmail={user.email} />
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">