From 70cfad7d46dcedf5878cf687ad187827cf4f2d83 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 13 Feb 2026 23:45:21 +0100 Subject: [PATCH] Platform polish: bulk invite, file requirements, filtering redesign, UX fixes - F1: Set seed jury/mentors/observers to NONE status (not invited), remove passwords - F2: Add bulk invite UI with checkbox selection and floating toolbar - F3: Add getProjectRequirements backend query + requirement slots on project detail - F4: Redesign filtering section: AI criteria textarea, "What AI sees" card, field-aware eligibility rules with human-readable previews - F5: Auto-redirect to pipeline detail when only one pipeline exists - F6: Make project names clickable in pipeline intake panel - F7: Fix pipeline creation error: edition context fallback + .min(1) validation - Pipeline wizard sections: add isActive locking, info tooltips, UX improvements Co-Authored-By: Claude Opus 4.6 --- prisma/seed.ts | 15 +- src/app/(admin)/admin/projects/[id]/page.tsx | 113 ++++- .../admin/rounds/new-pipeline/page.tsx | 31 +- .../(admin)/admin/rounds/pipelines/page.tsx | 10 + src/components/admin/members-content.tsx | 149 +++++- .../pipeline/sections/assignment-section.tsx | 26 +- .../pipeline/sections/awards-section.tsx | 16 +- .../pipeline/sections/basics-section.tsx | 11 +- .../pipeline/sections/filtering-section.tsx | 469 ++++++++++++++---- .../pipeline/sections/intake-section.tsx | 89 +++- .../pipeline/sections/live-finals-section.tsx | 26 +- .../pipeline/sections/main-track-section.tsx | 15 +- .../sections/notifications-section.tsx | 4 +- .../pipeline/sections/review-section.tsx | 11 +- .../pipeline/stage-panels/filter-panel.tsx | 7 +- .../pipeline/stage-panels/intake-panel.tsx | 19 +- .../admin/pipeline/wizard-section.tsx | 14 +- src/components/ui/info-tooltip.tsx | 36 ++ src/lib/file-type-categories.ts | 29 ++ src/lib/pipeline-defaults.ts | 1 + src/server/routers/analytics.ts | 14 +- src/server/routers/assignment.ts | 201 +++++++- src/server/routers/file.ts | 126 +++++ src/server/routers/pipeline.ts | 2 +- src/server/routers/stageAssignment.ts | 42 +- src/server/services/ai-assignment.ts | 18 +- src/server/services/smart-assignment.ts | 17 +- src/types/pipeline-wizard.ts | 1 + 28 files changed, 1312 insertions(+), 200 deletions(-) create mode 100644 src/components/ui/info-tooltip.tsx create mode 100644 src/lib/file-type-categories.ts diff --git a/prisma/seed.ts b/prisma/seed.ts index 75ffab7..af7dad0 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -186,7 +186,6 @@ async function main() { const juryUserIds: string[] = [] for (const j of juryMembers) { - const passwordHash = await bcrypt.hash('Jury2026!', 12) const user = await prisma.user.upsert({ where: { email: j.email }, update: {}, @@ -194,11 +193,9 @@ async function main() { email: j.email, name: j.name, role: UserRole.JURY_MEMBER, - status: UserStatus.ACTIVE, + status: UserStatus.NONE, country: j.country, expertiseTags: j.tags, - passwordHash, - mustSetPassword: true, bio: `Expert in ${j.tags.join(', ')}`, }, }) @@ -218,7 +215,6 @@ async function main() { ] for (const m of mentors) { - const passwordHash = await bcrypt.hash('Mentor2026!', 12) await prisma.user.upsert({ where: { email: m.email }, update: {}, @@ -226,11 +222,9 @@ async function main() { email: m.email, name: m.name, role: UserRole.MENTOR, - status: UserStatus.ACTIVE, + status: UserStatus.NONE, country: m.country, expertiseTags: m.tags, - passwordHash, - mustSetPassword: true, }, }) console.log(` ✓ Mentor: ${m.name}`) @@ -247,7 +241,6 @@ async function main() { ] for (const o of observers) { - const passwordHash = await bcrypt.hash('Observer2026!', 12) await prisma.user.upsert({ where: { email: o.email }, update: {}, @@ -255,10 +248,8 @@ async function main() { email: o.email, name: o.name, role: UserRole.OBSERVER, - status: UserStatus.ACTIVE, + status: UserStatus.NONE, country: o.country, - passwordHash, - mustSetPassword: true, }, }) console.log(` ✓ Observer: ${o.name}`) diff --git a/src/app/(admin)/admin/projects/[id]/page.tsx b/src/app/(admin)/admin/projects/[id]/page.tsx index 64f2ceb..785f252 100644 --- a/src/app/(admin)/admin/projects/[id]/page.tsx +++ b/src/app/(admin)/admin/projects/[id]/page.tsx @@ -38,6 +38,7 @@ import { Calendar, CheckCircle2, XCircle, + Circle, Clock, BarChart3, ThumbsUp, @@ -86,6 +87,12 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { // Fetch files (flat list for backward compatibility) const { data: files } = trpc.file.listByProject.useQuery({ projectId }) + // Fetch file requirements from the pipeline's intake stage + const { data: requirementsData } = trpc.file.getProjectRequirements.useQuery( + { projectId }, + { enabled: !!project } + ) + // Fetch available stages for upload selector (if project has a programId) const { data: programData } = trpc.program.get.useQuery( { id: project?.programId || '' }, @@ -521,35 +528,105 @@ function ProjectDetailContent({ projectId }: { projectId: string }) { - {files && files.length > 0 ? ( - ({ - id: f.id, - fileName: f.fileName, - fileType: f.fileType, - mimeType: f.mimeType, - size: f.size, - bucket: f.bucket, - objectKey: f.objectKey, - }))} - /> - ) : ( -

No files uploaded yet

+ {/* Required Documents from Pipeline Intake Stage */} + {requirementsData && requirementsData.requirements.length > 0 && ( + <> +
+

Required Documents

+
+ {requirementsData.requirements.map((req, idx) => { + const isFulfilled = req.fulfilled + return ( +
+
+ {isFulfilled ? ( + + ) : ( + + )} +
+
+

{req.name}

+ {req.isRequired && ( + + Required + + )} +
+
+ {req.description && ( + {req.description} + )} + {req.maxSizeMB && ( + Max {req.maxSizeMB}MB + )} +
+ {isFulfilled && req.fulfilledFile && ( +

+ {req.fulfilledFile.fileName} +

+ )} +
+
+ {!isFulfilled && ( + + Missing + + )} +
+ ) + })} +
+
+ + )} - - + {/* Additional Documents Upload */}
-

Upload New Files

+

+ {requirementsData && requirementsData.requirements.length > 0 + ? 'Additional Documents' + : 'Upload New Files'} +

({ id: s.id, name: s.name }))} onUploadComplete={() => { utils.file.listByProject.invalidate({ projectId }) + utils.file.getProjectRequirements.invalidate({ projectId }) }} />
+ + {/* All Files list */} + {files && files.length > 0 && ( + <> + +
+

All Files

+ ({ + id: f.id, + fileName: f.fileName, + fileType: f.fileType, + mimeType: f.mimeType, + size: f.size, + bucket: f.bucket, + objectKey: f.objectKey, + }))} + /> +
+ + )}
diff --git a/src/app/(admin)/admin/rounds/new-pipeline/page.tsx b/src/app/(admin)/admin/rounds/new-pipeline/page.tsx index ee87a72..0290878 100644 --- a/src/app/(admin)/admin/rounds/new-pipeline/page.tsx +++ b/src/app/(admin)/admin/rounds/new-pipeline/page.tsx @@ -20,6 +20,7 @@ import { LiveFinalsSection } from '@/components/admin/pipeline/sections/live-fin import { NotificationsSection } from '@/components/admin/pipeline/sections/notifications-section' import { ReviewSection } from '@/components/admin/pipeline/sections/review-section' +import { useEdition } from '@/contexts/edition-context' import { defaultWizardState, defaultIntakeConfig, defaultFilterConfig, defaultEvaluationConfig, defaultLiveConfig } from '@/lib/pipeline-defaults' import { validateAll, validateBasics, validateTracks } from '@/lib/pipeline-validation' import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFinalConfig } from '@/types/pipeline-wizard' @@ -27,12 +28,20 @@ import type { WizardState, IntakeConfig, FilterConfig, EvaluationConfig, LiveFin export default function NewPipelinePage() { const router = useRouter() const searchParams = useSearchParams() - const programId = searchParams.get('programId') ?? '' + const { currentEdition } = useEdition() + const programId = searchParams.get('programId') || currentEdition?.id || '' const [state, setState] = useState(() => defaultWizardState(programId)) const [openSection, setOpenSection] = useState(0) const initialStateRef = useRef(JSON.stringify(state)) + // Update programId in state when edition context loads + useEffect(() => { + if (programId && !state.programId) { + setState((prev) => ({ ...prev, programId })) + } + }, [programId, state.programId]) + // Dirty tracking — warn on navigate away useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -161,6 +170,26 @@ export default function NewPipelinePage() { const isSaving = createMutation.isPending || publishMutation.isPending + if (!programId) { + return ( +
+
+ + + +
+

Create Pipeline

+

+ Please select an edition first to create a pipeline. +

+
+
+
+ ) + } + const sections = [ { title: 'Basics', diff --git a/src/app/(admin)/admin/rounds/pipelines/page.tsx b/src/app/(admin)/admin/rounds/pipelines/page.tsx index 9bc7cf5..241d1be 100644 --- a/src/app/(admin)/admin/rounds/pipelines/page.tsx +++ b/src/app/(admin)/admin/rounds/pipelines/page.tsx @@ -1,5 +1,7 @@ 'use client' +import { useEffect } from 'react' +import { useRouter } from 'next/navigation' import Link from 'next/link' import type { Route } from 'next' import { trpc } from '@/lib/trpc/client' @@ -40,6 +42,7 @@ const statusColors: Record = { } export default function PipelineListPage() { + const router = useRouter() const { currentEdition } = useEdition() const programId = currentEdition?.id @@ -48,6 +51,13 @@ export default function PipelineListPage() { { enabled: !!programId } ) + // Auto-redirect when there's exactly one pipeline + useEffect(() => { + if (!isLoading && pipelines && pipelines.length === 1) { + router.replace(`/admin/rounds/pipeline/${pipelines[0].id}` as Route) + } + }, [isLoading, pipelines, router]) + if (!programId) { return (
diff --git a/src/components/admin/members-content.tsx b/src/components/admin/members-content.tsx index da656ed..001b6ce 100644 --- a/src/components/admin/members-content.tsx +++ b/src/components/admin/members-content.tsx @@ -1,12 +1,13 @@ 'use client' -import { useState, useCallback, useEffect } from 'react' +import { useState, useCallback, useEffect, useMemo } from 'react' import Link from 'next/link' import { useSearchParams, usePathname } from 'next/navigation' import { trpc } from '@/lib/trpc/client' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' import { Card, CardContent, @@ -27,9 +28,10 @@ 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, Mail, Loader2 } from 'lucide-react' +import { Plus, Users, Search, Mail, Loader2, X, Send } from 'lucide-react' import { toast } from 'sonner' import { formatRelativeTime } from '@/lib/utils' +import { AnimatePresence, motion } from 'motion/react' type RoleValue = 'SUPER_ADMIN' | 'PROGRAM_ADMIN' | 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER' type TabKey = 'all' | 'jury' | 'mentors' | 'observers' | 'admins' @@ -131,6 +133,8 @@ export function MembersContent() { const roles = TAB_ROLES[tab] + const [selectedIds, setSelectedIds] = useState>(new Set()) + const { data: currentUser } = trpc.user.me.useQuery() const currentUserRole = currentUser?.role as RoleValue | undefined @@ -141,6 +145,75 @@ export function MembersContent() { perPage: 20, }) + const utils = trpc.useUtils() + + const bulkInvite = trpc.user.bulkSendInvitations.useMutation({ + onSuccess: (result) => { + const { sent, errors } = result as { sent: number; skipped: number; errors: string[] } + if (errors && errors.length > 0) { + toast.warning(`Sent ${sent} invitation${sent !== 1 ? 's' : ''}, ${errors.length} failed`) + } else { + toast.success(`Invitations sent to ${sent} member${sent !== 1 ? 's' : ''}`) + } + setSelectedIds(new Set()) + utils.user.list.invalidate() + }, + onError: (error) => { + toast.error(error.message || 'Failed to send invitations') + }, + }) + + // Users on the current page that are selectable (status NONE) + const selectableUsers = useMemo( + () => (data?.users ?? []).filter((u) => u.status === 'NONE'), + [data?.users] + ) + + const allSelectableSelected = + selectableUsers.length > 0 && selectableUsers.every((u) => selectedIds.has(u.id)) + + const someSelectableSelected = + selectableUsers.some((u) => selectedIds.has(u.id)) && !allSelectableSelected + + const toggleUser = useCallback((userId: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(userId)) { + next.delete(userId) + } else { + next.add(userId) + } + return next + }) + }, []) + + const toggleAll = useCallback(() => { + if (allSelectableSelected) { + // Deselect all on this page + setSelectedIds((prev) => { + const next = new Set(prev) + for (const u of selectableUsers) { + next.delete(u.id) + } + return next + }) + } else { + // Select all selectable on this page + setSelectedIds((prev) => { + const next = new Set(prev) + for (const u of selectableUsers) { + next.add(u.id) + } + return next + }) + } + }, [allSelectableSelected, selectableUsers]) + + // Clear selection when filters/page change + useEffect(() => { + setSelectedIds(new Set()) + }, [tab, search, page]) + const handleTabChange = (value: string) => { updateParams({ tab: value === 'all' ? null : value, page: '1' }) } @@ -197,6 +270,15 @@ export function MembersContent() { + + {selectableUsers.length > 0 && ( + + )} + Member Role Expertise @@ -209,6 +291,17 @@ export function MembersContent() { {data.users.map((user) => ( + + {user.status === 'NONE' ? ( + toggleUser(user.id)} + aria-label={`Select ${user.name || user.email}`} + /> + ) : ( + + )} +
+ {user.status === 'NONE' && ( + toggleUser(user.id)} + aria-label={`Select ${user.name || user.email}`} + className="mt-1" + /> + )} ).avatarUrl as string | undefined} @@ -395,6 +496,50 @@ export function MembersContent() { )} + + {/* Floating bulk invite toolbar */} + + {selectedIds.size > 0 && ( + + + + + {selectedIds.size} selected + + + + + + + )} +
) } diff --git a/src/components/admin/pipeline/sections/assignment-section.tsx b/src/components/admin/pipeline/sections/assignment-section.tsx index af68014..7150a54 100644 --- a/src/components/admin/pipeline/sections/assignment-section.tsx +++ b/src/components/admin/pipeline/sections/assignment-section.tsx @@ -10,6 +10,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { InfoTooltip } from '@/components/ui/info-tooltip' import type { EvaluationConfig } from '@/types/pipeline-wizard' type AssignmentSectionProps = { @@ -27,7 +28,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
- +
+ + +
- +
+ + +
- +
+ + +
- +
+ + +

Factor in juror availability when assigning projects

@@ -101,7 +114,10 @@ export function AssignmentSection({ config, onChange, isActive }: AssignmentSect
- +
+ + +
@@ -176,7 +180,10 @@ export function AwardsSection({ tracks, onChange, isActive }: AwardsSectionProps
- +
+ + +
- +
+ + +
- +
+ + +
updateRule(index, { field: e.target.value })} - /> - - updateRule(index, { value: e.target.value })} - /> -
- -
- - - ))} - - {rules.length === 0 && ( -

- No gate rules configured. All projects will pass through. -

- )} -
- - {/* AI Rubric */} + {/* ── AI Screening (Primary) ────────────────────────────────────── */}
- -

- Use AI to evaluate projects against rubric criteria +

+ + + +
+

+ Use AI to evaluate projects against your screening criteria

+ {/* Criteria Textarea (THE KEY MISSING PIECE) */}
- -
+ +

+ Describe what makes a project eligible or ineligible in natural language. + The AI will evaluate each project against these criteria. +

+