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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Plus, Trophy, Users, CheckCircle2, Search } from 'lucide-react'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DRAFT: 'secondary',
|
||||
@@ -156,9 +157,10 @@ export default function AwardsListPage() {
|
||||
{/* Awards Grid */}
|
||||
{filteredAwards.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredAwards.map((award) => (
|
||||
<Link key={award.id} href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-all hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
{filteredAwards.map((award, index) => (
|
||||
<AnimatedCard key={award.id} index={index}>
|
||||
<Link href={`/admin/awards/${award.id}`}>
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md cursor-pointer h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
@@ -202,6 +204,7 @@ export default function AwardsListPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</AnimatedCard>
|
||||
))}
|
||||
</div>
|
||||
) : awards && awards.length > 0 ? (
|
||||
|
||||
Reference in New Issue
Block a user