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

@@ -55,6 +55,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Progress } from '@/components/ui/progress'
import { UserAvatar } from '@/components/shared/user-avatar'
import { AnimatedCard } from '@/components/shared/animated-container'
import { Pagination } from '@/components/shared/pagination'
import { toast } from 'sonner'
import {
@@ -66,14 +67,13 @@ import {
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ArrowLeft,
Trophy,
Users,
CheckCircle2,
Brain,
ListChecks,
BarChart3,
Loader2,
Crown,
@@ -151,19 +151,29 @@ export default function AwardDetailPage({
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState('eligibility')
// Core queries
// Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1)
const eligibilityPerPage = 25
// Core queries — lazy-load tab-specific data based on activeTab
const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId })
const { data: eligibilityData, refetch: refetchEligibility } =
trpc.specialAward.listEligible.useQuery({
awardId,
page: 1,
perPage: 100,
page: eligibilityPage,
perPage: eligibilityPerPage,
}, {
enabled: activeTab === 'eligibility',
})
const { data: jurors, refetch: refetchJurors } =
trpc.specialAward.listJurors.useQuery({ awardId })
trpc.specialAward.listJurors.useQuery({ awardId }, {
enabled: activeTab === 'jurors',
})
const { data: voteResults } =
trpc.specialAward.getVoteResults.useQuery({ awardId })
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
enabled: activeTab === 'results',
})
// Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery(
@@ -539,8 +549,9 @@ export default function AwardDetailPage({
</div>
{/* Stats Cards */}
<AnimatedCard index={0}>
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Card className="border-l-4 border-l-emerald-500">
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
@@ -553,7 +564,7 @@ export default function AwardDetailPage({
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-blue-500">
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
@@ -561,12 +572,12 @@ export default function AwardDetailPage({
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
<Brain className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-violet-500">
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
@@ -579,7 +590,7 @@ export default function AwardDetailPage({
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500">
<Card className="border-l-4 border-l-amber-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
@@ -593,8 +604,10 @@ export default function AwardDetailPage({
</CardContent>
</Card>
</div>
</AnimatedCard>
{/* Tabs */}
<AnimatedCard index={1}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="eligibility">
@@ -637,7 +650,7 @@ export default function AwardDetailPage({
{runEligibility.isPending || isPollingJob ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Brain className="mr-2 h-4 w-4" />
<ListChecks className="mr-2 h-4 w-4" />
)}
{isPollingJob ? 'Processing...' : 'Run AI Eligibility'}
</Button>
@@ -779,6 +792,7 @@ export default function AwardDetailPage({
? ((jobStatus.eligibilityJobDone ?? 0) / jobStatus.eligibilityJobTotal) * 100
: 0
}
gradient
/>
</div>
</div>
@@ -841,15 +855,22 @@ export default function AwardDetailPage({
})
}} asChild>
<>
<TableRow className={`${!e.eligible ? 'opacity-50' : ''} ${hasReasoning ? 'cursor-pointer' : ''}`}>
<TableRow
className={`${!e.eligible ? 'opacity-50' : ''} ${hasReasoning ? 'cursor-pointer hover:bg-muted/50' : ''}`}
onClick={() => {
if (!hasReasoning) return
setExpandedRows((prev) => {
const next = new Set(prev)
if (next.has(e.id)) next.delete(e.id)
else next.add(e.id)
return next
})
}}
>
<TableCell>
<div className="flex items-center gap-2">
{hasReasoning && (
<CollapsibleTrigger asChild>
<button className="flex-shrink-0 p-0.5 rounded hover:bg-muted transition-colors">
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`} />
</button>
</CollapsibleTrigger>
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform duration-200 flex-shrink-0 ${isExpanded ? 'rotate-180' : ''}`} />
)}
<div>
<p className="font-medium">{e.project.title}</p>
@@ -892,7 +913,7 @@ export default function AwardDetailPage({
)}
</TableCell>
)}
<TableCell>
<TableCell onClick={(ev) => ev.stopPropagation()}>
<Switch
checked={e.eligible}
onCheckedChange={(checked) =>
@@ -900,7 +921,7 @@ export default function AwardDetailPage({
}
/>
</TableCell>
<TableCell className="text-right">
<TableCell className="text-right" onClick={(ev) => ev.stopPropagation()}>
<Button
variant="ghost"
size="sm"
@@ -917,7 +938,7 @@ export default function AwardDetailPage({
<td colSpan={award.useAiEligibility ? 7 : 6} className="p-0">
<div className="border-t bg-muted/30 px-6 py-3">
<div className="flex items-start gap-2">
<Brain className="h-4 w-4 text-brand-teal mt-0.5 flex-shrink-0" />
<ListChecks className="h-4 w-4 text-brand-teal mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Reasoning</p>
<p className="text-sm leading-relaxed">{aiReasoning?.reasoning}</p>
@@ -934,12 +955,23 @@ export default function AwardDetailPage({
})}
</TableBody>
</Table>
{eligibilityData.totalPages > 1 && (
<div className="p-4 border-t">
<Pagination
page={eligibilityData.page}
totalPages={eligibilityData.totalPages}
total={eligibilityData.total}
perPage={eligibilityPerPage}
onPageChange={setEligibilityPage}
/>
</div>
)}
</Card>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-4">
<Brain className="h-8 w-8 text-muted-foreground/60" />
<ListChecks className="h-8 w-8 text-muted-foreground/60" />
</div>
<p className="text-lg font-medium">No eligibility data yet</p>
<p className="text-sm text-muted-foreground mt-1 max-w-sm">
@@ -950,7 +982,7 @@ export default function AwardDetailPage({
<div className="flex gap-2 mt-4">
<Button onClick={handleRunEligibility} disabled={runEligibility.isPending || isPollingJob} size="sm">
{award.useAiEligibility ? (
<><Brain className="mr-2 h-4 w-4" />Run AI Eligibility</>
<><ListChecks className="mr-2 h-4 w-4" />Run AI Eligibility</>
) : (
<><CheckCircle2 className="mr-2 h-4 w-4" />Load Projects</>
)}
@@ -1185,6 +1217,7 @@ export default function AwardDetailPage({
)}
</TabsContent>
</Tabs>
</AnimatedCard>
</div>
)
}