Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Phase 1 — Critical bugs: - Fix deliberation participant selection (wire jury group query) - Fix reports "By Round" tab (inline content instead of 404 route) - Fix messages "Sent History" (add message.sent procedure, wire tab) - Add missing fields to competition award form (criteriaText, maxRankedPicks) - Wire LiveControlPanel buttons (cursor, voting, scores) - Fix ResultLockControls empty snapshot (fetch actual data before lock) - Fix SubmissionWindowManager losing fields on edit Phase 2 — Backend fixes: - Remove write-in-query from specialAward.get - Fix award eligibility job overwriting manual shortlist overrides - Fix filtering startJob deleting all prior results (defer cleanup to post-success) - Tighten access control: protectedProcedure → adminProcedure on 8 procedures - Add audit logging to deliberation mutations - Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete Phase 3 — Auto-refresh: - Add refetchInterval to 15+ admin pages/components (10s–30s) - Fix AI job polling: derive speed from job status for all viewers Phase 4 — Dead code cleanup: - Delete unused command-palette, pdf-report, admin-page-transition - Remove dead subItems sidebar code, unused GripVertical import - Replace redundant isGenerating state with mutation.isPending - Add Role column to jury members table - Remove misleading manual mentor assignment stub Phase 5 — UX improvements: - Fix rounds page single-competition assumption (add selector) - Remove raw UUID fallback in deliberation config - Fix programs page "Stage" → "Round" terminology Phase 6 — Backend hardening: - Complete logAudit calls (add prisma, ipAddress, userAgent) - Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear) - Batch user.bulkCreate writes (assignments, jury memberships, intents) - Remove any casts from deliberation service (typed PrismaClient + TransactionClient) - Fix stale DeliberationStatus enum values blocking build 40 files changed, 1010 insertions(+), 612 deletions(-) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -151,7 +151,7 @@ export default function AuditLogPage() {
|
||||
)
|
||||
|
||||
// Fetch audit logs
|
||||
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput)
|
||||
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput, { refetchInterval: 30_000 })
|
||||
|
||||
// Fetch users for filter dropdown
|
||||
const { data: usersData } = trpc.user.list.useQuery({
|
||||
|
||||
@@ -162,7 +162,7 @@ export default function AwardDetailPage({
|
||||
|
||||
// Core queries — lazy-load tab-specific data based on activeTab
|
||||
const { data: award, isLoading, refetch } =
|
||||
trpc.specialAward.get.useQuery({ id: awardId })
|
||||
trpc.specialAward.get.useQuery({ id: awardId }, { refetchInterval: 30_000 })
|
||||
const { data: eligibilityData, refetch: refetchEligibility } =
|
||||
trpc.specialAward.listEligible.useQuery({
|
||||
awardId,
|
||||
|
||||
@@ -40,7 +40,10 @@ const SCORING_LABELS: Record<string, string> = {
|
||||
}
|
||||
|
||||
export default function AwardsListPage() {
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
|
||||
const { data: awards, isLoading } = trpc.specialAward.list.useQuery(
|
||||
{},
|
||||
{ refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
const [search, setSearch] = useState('')
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
@@ -22,8 +22,10 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
criteriaText: '',
|
||||
useAiEligibility: false,
|
||||
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED'
|
||||
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
|
||||
maxRankedPicks: '3',
|
||||
});
|
||||
|
||||
const { data: competition } = trpc.competition.getById.useQuery({
|
||||
@@ -63,8 +65,10 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
||||
competitionId: params.competitionId,
|
||||
name: formData.name.trim(),
|
||||
description: formData.description.trim() || undefined,
|
||||
criteriaText: formData.criteriaText.trim() || undefined,
|
||||
scoringMode: formData.scoringMode,
|
||||
useAiEligibility: formData.useAiEligibility
|
||||
useAiEligibility: formData.useAiEligibility,
|
||||
maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -114,22 +118,17 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
||||
<Select
|
||||
value={formData.scoringMode}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoringMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked</SelectItem>
|
||||
<SelectItem value="SCORED">Scored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Label htmlFor="criteriaText">Eligibility Criteria</Label>
|
||||
<Textarea
|
||||
id="criteriaText"
|
||||
value={formData.criteriaText}
|
||||
onChange={(e) => setFormData({ ...formData, criteriaText: e.target.value })}
|
||||
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This text will be used by AI to determine which projects are eligible for this award.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -145,6 +144,41 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scoringMode">Scoring Mode</Label>
|
||||
<Select
|
||||
value={formData.scoringMode}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="scoringMode">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PICK_WINNER">Pick Winner — Each juror picks 1</SelectItem>
|
||||
<SelectItem value="RANKED">Ranked — Each juror ranks top N</SelectItem>
|
||||
<SelectItem value="SCORED">Scored — Use evaluation form</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.scoringMode === 'RANKED' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
|
||||
<Input
|
||||
id="maxPicks"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value={formData.maxRankedPicks}
|
||||
onChange={(e) => setFormData({ ...formData, maxRankedPicks: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -21,9 +21,10 @@ export default function DeliberationSessionPage({
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
|
||||
sessionId: params.sessionId
|
||||
});
|
||||
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
|
||||
{ sessionId: params.sessionId },
|
||||
{ refetchInterval: 10_000 }
|
||||
);
|
||||
|
||||
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -183,7 +184,7 @@ export default function DeliberationSessionPage({
|
||||
variant="destructive"
|
||||
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
|
||||
disabled={
|
||||
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING'
|
||||
closeVotingMutation.isPending || session.status !== 'VOTING'
|
||||
}
|
||||
className="flex-1"
|
||||
>
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function DeliberationListPage({
|
||||
const router = useRouter();
|
||||
const utils = trpc.useUtils();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
|
||||
const [formData, setFormData] = useState({
|
||||
roundId: '',
|
||||
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
|
||||
@@ -54,8 +55,17 @@ export default function DeliberationListPage({
|
||||
);
|
||||
const rounds = competition?.rounds || [];
|
||||
|
||||
// TODO: Add getJuryMembers endpoint if needed for participant selection
|
||||
const juryMembers: any[] = [];
|
||||
// Jury groups & members for participant selection
|
||||
const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
|
||||
{ competitionId: params.competitionId },
|
||||
{ enabled: !!params.competitionId }
|
||||
);
|
||||
|
||||
const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
|
||||
{ id: selectedJuryGroupId },
|
||||
{ enabled: !!selectedJuryGroupId }
|
||||
);
|
||||
const juryMembers = selectedJuryGroup?.members ?? [];
|
||||
|
||||
const createSessionMutation = trpc.deliberation.createSession.useMutation({
|
||||
onSuccess: (data) => {
|
||||
@@ -76,6 +86,10 @@ export default function DeliberationListPage({
|
||||
toast.error('Please select a round');
|
||||
return;
|
||||
}
|
||||
if (formData.participantUserIds.length === 0) {
|
||||
toast.error('Please select at least one participant');
|
||||
return;
|
||||
}
|
||||
|
||||
createSessionMutation.mutate({
|
||||
competitionId: params.competitionId,
|
||||
@@ -273,6 +287,78 @@ export default function DeliberationListPage({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Participant Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="juryGroup">Jury Group *</Label>
|
||||
<Select
|
||||
value={selectedJuryGroupId}
|
||||
onValueChange={(value) => {
|
||||
setSelectedJuryGroupId(value);
|
||||
setFormData({ ...formData, participantUserIds: [] });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="juryGroup">
|
||||
<SelectValue placeholder="Select jury group" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{juryGroups.map((group: any) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name} ({group._count?.members ?? 0} members)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{juryMembers.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Participants ({formData.participantUserIds.length}/{juryMembers.length})</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = juryMembers.map((m: any) => m.user.id);
|
||||
const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
|
||||
setFormData({
|
||||
...formData,
|
||||
participantUserIds: allSelected ? [] : allIds,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
|
||||
? 'Deselect All'
|
||||
: 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||
{juryMembers.map((member: any) => (
|
||||
<div key={member.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`member-${member.user.id}`}
|
||||
checked={formData.participantUserIds.includes(member.user.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
participantUserIds: checked
|
||||
? [...formData.participantUserIds, member.user.id]
|
||||
: formData.participantUserIds.filter((id: string) => id !== member.user.id),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={`member-${member.user.id}`} className="flex-1 font-normal">
|
||||
{member.user.name || member.user.email}
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
||||
@@ -15,7 +15,10 @@ export default function JuryGroupDetailPage() {
|
||||
const router = useRouter()
|
||||
const juryGroupId = params.juryGroupId as string
|
||||
|
||||
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery({ id: juryGroupId })
|
||||
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery(
|
||||
{ id: juryGroupId },
|
||||
{ refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -104,9 +104,10 @@ export default function CompetitionDetailPage() {
|
||||
roundType: '' as string,
|
||||
})
|
||||
|
||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
|
||||
id: competitionId,
|
||||
})
|
||||
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
|
||||
{ id: competitionId },
|
||||
{ refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
const updateMutation = trpc.competition.update.useMutation({
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function CompetitionListPage() {
|
||||
|
||||
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
{ enabled: !!programId, refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
if (!programId) {
|
||||
|
||||
@@ -104,9 +104,10 @@ export default function MessagesPage() {
|
||||
{ enabled: recipientType === 'USER' }
|
||||
)
|
||||
|
||||
// Fetch sent messages for history
|
||||
const { data: sentMessages, isLoading: loadingSent } = trpc.message.inbox.useQuery(
|
||||
{ page: 1, pageSize: 50 }
|
||||
// Fetch sent messages for history (messages sent BY this admin)
|
||||
const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery(
|
||||
{ page: 1, pageSize: 50 },
|
||||
{ refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
const sendMutation = trpc.message.send.useMutation({
|
||||
@@ -114,7 +115,7 @@ export default function MessagesPage() {
|
||||
const count = (data as Record<string, unknown>)?.recipientCount || ''
|
||||
toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`)
|
||||
resetForm()
|
||||
utils.message.inbox.invalidate()
|
||||
utils.message.sent.invalidate()
|
||||
},
|
||||
onError: (e) => toast.error(e.message),
|
||||
})
|
||||
@@ -564,57 +565,70 @@ export default function MessagesPage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Subject</TableHead>
|
||||
<TableHead className="hidden md:table-cell">From</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Channel</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Recipients</TableHead>
|
||||
<TableHead className="hidden md:table-cell">Channels</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">Status</TableHead>
|
||||
<TableHead className="text-right">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sentMessages.items.map((item: Record<string, unknown>) => {
|
||||
const msg = item.message as Record<string, unknown> | undefined
|
||||
const sender = msg?.sender as Record<string, unknown> | undefined
|
||||
const channel = String(item.channel || 'EMAIL')
|
||||
const isRead = !!item.isRead
|
||||
{sentMessages.items.map((msg: any) => {
|
||||
const channels = (msg.deliveryChannels as string[]) || []
|
||||
const recipientCount = msg._count?.recipients ?? 0
|
||||
const isSent = !!msg.sentAt
|
||||
|
||||
return (
|
||||
<TableRow key={String(item.id)}>
|
||||
<TableRow key={msg.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isRead && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
<span className={isRead ? 'text-muted-foreground' : 'font-medium'}>
|
||||
{String(msg?.subject || 'No subject')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-medium">
|
||||
{msg.subject || 'No subject'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
|
||||
{String(sender?.name || sender?.email || 'System')}
|
||||
{msg.recipientType === 'ALL'
|
||||
? 'All users'
|
||||
: msg.recipientType === 'ROLE'
|
||||
? `By role`
|
||||
: msg.recipientType === 'ROUND_JURY'
|
||||
? 'Round jury'
|
||||
: msg.recipientType === 'USER'
|
||||
? '1 user'
|
||||
: msg.recipientType}
|
||||
{recipientCount > 0 && ` (${recipientCount})`}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{channel === 'EMAIL' ? (
|
||||
<><Mail className="mr-1 h-3 w-3" />Email</>
|
||||
) : (
|
||||
<><Bell className="mr-1 h-3 w-3" />In-App</>
|
||||
<div className="flex gap-1">
|
||||
{channels.includes('EMAIL') && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Mail className="mr-1 h-3 w-3" />Email
|
||||
</Badge>
|
||||
)}
|
||||
</Badge>
|
||||
{channels.includes('IN_APP') && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Bell className="mr-1 h-3 w-3" />In-App
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
{isRead ? (
|
||||
{isSent ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
Read
|
||||
Sent
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-xs">New</Badge>
|
||||
<Badge variant="default" className="text-xs">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
Scheduled
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm text-muted-foreground">
|
||||
{msg?.createdAt
|
||||
? formatDate(msg.createdAt as string | Date)
|
||||
: ''}
|
||||
{msg.sentAt
|
||||
? formatDate(msg.sentAt)
|
||||
: msg.scheduledAt
|
||||
? formatDate(msg.scheduledAt)
|
||||
: ''}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
|
||||
@@ -108,7 +108,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Stage</TableHead>
|
||||
<TableHead>Round</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Projects</TableHead>
|
||||
<TableHead>Assignments</TableHead>
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Bot,
|
||||
Loader2,
|
||||
Users,
|
||||
User,
|
||||
Check,
|
||||
RefreshCw,
|
||||
} from 'lucide-react'
|
||||
@@ -338,24 +337,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Manual Assignment */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<User className="h-5 w-5" />
|
||||
Manual Assignment
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Search and select a mentor manually
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use the AI suggestions above or search for a specific user in the Users section
|
||||
to assign them as a mentor manually.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -88,9 +88,10 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
|
||||
|
||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
// Fetch project + assignments + stats in a single combined query
|
||||
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery({
|
||||
id: projectId,
|
||||
})
|
||||
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
|
||||
{ id: projectId },
|
||||
{ refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
const project = fullDetail?.project
|
||||
const assignments = fullDetail?.assignments
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -831,6 +830,97 @@ function DiversityTab() {
|
||||
)
|
||||
}
|
||||
|
||||
function RoundPipelineTab() {
|
||||
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
|
||||
|
||||
const rounds = programs?.flatMap(p =>
|
||||
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
|
||||
...s,
|
||||
programId: p.id,
|
||||
programName: `${p.year} Edition`,
|
||||
}))
|
||||
) || []
|
||||
|
||||
const roundIds = rounds.map(r => r.id)
|
||||
|
||||
const { data: comparison, isLoading: comparisonLoading } =
|
||||
trpc.analytics.getCrossRoundComparison.useQuery(
|
||||
{ roundIds },
|
||||
{ enabled: roundIds.length >= 1 }
|
||||
)
|
||||
|
||||
if (isLoading || comparisonLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-24" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!rounds.length) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<Layers className="h-12 w-12 text-muted-foreground/50" />
|
||||
<p className="mt-2 font-medium">No rounds available</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const comparisonMap = new Map(
|
||||
(comparison ?? []).map((c: any) => [c.roundId, c])
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Layers className="h-4 w-4 text-violet-600" />
|
||||
</div>
|
||||
Round Pipeline
|
||||
</CardTitle>
|
||||
<CardDescription>Project flow across competition rounds</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{rounds.map((round, idx) => {
|
||||
const stats = comparisonMap.get(round.id) as any
|
||||
return (
|
||||
<div key={round.id} className="flex items-center gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
||||
{idx + 1}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">{round.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{round.programName}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="tabular-nums">{stats?.projectCount ?? 0} projects</span>
|
||||
<span className="tabular-nums">{stats?.evaluationCount ?? 0} evals</span>
|
||||
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : round.status === 'ROUND_CLOSED' ? 'secondary' : 'outline'}>
|
||||
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{stats?.completionRate != null && (
|
||||
<Progress value={stats.completionRate} className="mt-2 h-2" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
|
||||
|
||||
@@ -879,11 +969,9 @@ export default function ReportsPage() {
|
||||
<Globe className="h-4 w-4" />
|
||||
Diversity
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pipeline" className="gap-2" asChild>
|
||||
<Link href={"/admin/reports/stages" as Route}>
|
||||
<Layers className="h-4 w-4" />
|
||||
By Round
|
||||
</Link>
|
||||
<TabsTrigger value="pipeline" className="gap-2">
|
||||
<Layers className="h-4 w-4" />
|
||||
By Round
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
|
||||
@@ -928,6 +1016,10 @@ export default function ReportsPage() {
|
||||
<TabsContent value="diversity">
|
||||
<DiversityTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pipeline">
|
||||
<RoundPipelineTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -122,18 +122,19 @@ export default function RoundsPage() {
|
||||
const [competitionEdits, setCompetitionEdits] = useState<Record<string, unknown>>({})
|
||||
const [editingCompId, setEditingCompId] = useState<string | null>(null)
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
const [selectedCompId, setSelectedCompId] = useState<string | null>(null)
|
||||
|
||||
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
{ enabled: !!programId, refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
// Use the first (and usually only) competition
|
||||
const comp = competitions?.[0]
|
||||
// Auto-select first competition, or use the user's selection
|
||||
const comp = competitions?.find((c: any) => c.id === selectedCompId) ?? competitions?.[0]
|
||||
|
||||
const { data: compDetail, isLoading: isLoadingDetail } = trpc.competition.getById.useQuery(
|
||||
{ id: comp?.id! },
|
||||
{ enabled: !!comp?.id }
|
||||
{ enabled: !!comp?.id, refetchInterval: 30_000 }
|
||||
)
|
||||
|
||||
const { data: awards } = trpc.specialAward.list.useQuery(
|
||||
@@ -289,6 +290,22 @@ export default function RoundsPage() {
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="space-y-5">
|
||||
{/* Competition selector (when multiple exist) */}
|
||||
{competitions && competitions.length > 1 && (
|
||||
<Select value={comp.id} onValueChange={setSelectedCompId}>
|
||||
<SelectTrigger className="w-[280px] mb-4">
|
||||
<SelectValue placeholder="Select competition" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{competitions.map((c: any) => (
|
||||
<SelectItem key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* ── Header Bar ──────────────────────────────────────────────── */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user