Compare commits

..

1 Commits

Author SHA1 Message Date
3e70de3a5a Add Anthropic API, test environment, remove locale settings
Feature 1: Anthropic API Integration
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option

Feature 2: Remove Locale Settings UI
- Strip Localization tab from admin settings
- Remove i18n settings from router inferCategory and getFeatureFlags
- Keep franc document language detection intact

Feature 3: Test Environment with Role Impersonation
- Add isTest field to User, Program, Project, Competition models
- Test environment service: create/teardown with realistic dummy data
- JWT-based impersonation for test users (@test.local emails)
- Impersonation banner with quick-switch between test roles
- Test environment panel in admin settings (SUPER_ADMIN only)
- Email redirect: @test.local emails routed to admin with [TEST] prefix
- Complete data isolation: 45+ isTest:false filters across platform
  - All global queries on User/Project/Program/Competition
  - AI services blocked from processing test data
  - Cron jobs skip test rounds/users
  - Analytics/exports exclude test data
  - Admin layout/pickers hide test programs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:28:07 +01:00
175 changed files with 9471 additions and 16990 deletions

View File

@@ -1,85 +0,0 @@
# MOPC — AI Ranking, Advancement & Mentoring During Rounds
## What This Is
An enhancement to the MOPC jury voting platform that adds AI-powered project ranking after evaluation rounds, an admin dashboard for reviewing/adjusting rankings and advancing projects to the next round, and the ability to assign mentors during non-mentoring rounds (e.g., during document submission or evaluation) with automatic carryover across rounds.
## Core Value
Admins can describe ranking criteria in natural language, the system interprets and ranks projects accordingly, and they can advance the top projects to the next round with one click — all with full override control.
## Requirements
### Validated
<!-- Inferred from existing codebase -->
- ✓ Competition system with ordered rounds (INTAKE → FILTERING → EVALUATION → SUBMISSION → MENTORING → LIVE_FINAL → DELIBERATION) — existing
- ✓ Jury evaluation with scoring forms and pass/fail criteria — existing
- ✓ AdvancementRule model with configurable rule types (AUTO_ADVANCE, SCORE_THRESHOLD, TOP_N, ADMIN_SELECTION) — existing
- ✓ ProjectRoundState tracking per project per round — existing
- ✓ JuryGroup and JuryAssignment for panel management — existing
- ✓ CompetitionCategory enum (STARTUP, BUSINESS_CONCEPT) with per-project categorization — existing
- ✓ Email notification system with Nodemailer/Poste.io — existing
- ✓ Mentor dashboard route group `(mentor)` — existing
- ✓ Round engine state machine for round transitions — existing
- ✓ AI services with anonymization layer — existing
### Active
<!-- Current scope. Building toward these. -->
- [ ] AI ranking engine that interprets natural-language criteria into ranking logic
- [ ] Admin ranking dashboard with drag-and-drop reordering per competition category
- [ ] Side panel detail view showing evaluation data for selected project in ranking list
- [ ] "Advance top X" button to promote selected projects to next round
- [ ] Admin choice per-batch: send advancement/rejection email OR update status silently
- [ ] Admin-editable email templates with variable insertion ({{firstName}}, {{teamName}}, etc.)
- [ ] AI criteria preview mode: admin sees parsed rules before applying
- [ ] Quick rank mode: AI interprets and ranks directly, admin adjusts after
- [ ] Mentor assignment during non-MENTORING rounds (evaluation, submission, etc.)
- [ ] Auto-persist mentor assignments across rounds (unless project eliminated)
- [ ] Admin override for mentor assignments at any time
- [ ] AI-suggested mentor-to-project matching with admin confirmation
- [ ] Notification awareness: warn admin if next round doesn't have auto-emails, so they know to send manually
### Out of Scope
- Award eligibility (Spotlight on Africa, etc.) — separate workflow, later milestone
- Changes to the juror evaluation interface — already built
- Real-time collaborative ranking (multi-admin simultaneous drag) — unnecessary complexity
- Public-facing ranking results — admin-only feature
## Context
The competition is actively running. Evaluations for the first round are complete and the client needs to rank projects and advance semi-finalists urgently (by Monday). The ranking criteria were communicated in a mix of French and English by the organizers:
- 2 yes votes → semi-finalist
- 2 no votes → not semi-finalist
- 1 yes + 1 no with ≥6/10 overall → consider as semi-finalist (depending on total count)
- Special attention to whether evaluations included at least 1 internal + 1 external juror
Categories are STARTUP and BUSINESS_CONCEPT — rankings and advancement happen per-category within a single competition.
The platform already has `AdvancementRule` with rule types but no AI interpretation layer. The `MentorAssignment` concept doesn't yet support cross-round persistence or assignment during non-mentoring rounds.
## Constraints
- **Timeline**: Semi-finalist notifications need to go out by Monday — ranking and advancement are highest priority
- **Tech stack**: Must use existing stack (Next.js 15, tRPC, Prisma, OpenAI)
- **Data model**: CompetitionCategory (STARTUP/BUSINESS_CONCEPT) is on the Project model, rankings must respect this split
- **Security**: AI ranking criteria go through OpenAI — must anonymize project data before sending
- **Existing patterns**: Follow tRPC router + Zod validation + service layer pattern
## Key Decisions
| Decision | Rationale | Outcome |
|----------|-----------|---------|
| AI interprets natural-language criteria rather than hardcoded rules | Client changes criteria between rounds; flexible system avoids code changes | — Pending |
| Rankings per CompetitionCategory, not per JuryGroup | Categories (Startup vs Business Concept) are the meaningful split for advancement | — Pending |
| Mentor assignments auto-persist across rounds | Reduces admin work; mentors build relationship with teams over time | — Pending |
| Admin-editable email templates with variables | Client sends personalized emails in French/English; templates must be customizable | — Pending |
| Side panel for ranking detail view | Keeps drag-and-drop list compact while providing full evaluation context on demand | — Pending |
---
*Last updated: 2026-02-26 after initialization*

View File

@@ -1,127 +0,0 @@
# Requirements: MOPC — AI Ranking, Advancement & Mentoring
**Defined:** 2026-02-26
**Core Value:** Admins can describe ranking criteria in natural language, the system interprets and ranks projects accordingly, and they can advance the top projects to the next round with one click — all with full override control.
## v1 Requirements
Requirements for this milestone. Each maps to roadmap phases.
### AI Ranking Engine
- [x] **RANK-01**: Admin can write ranking criteria in natural language (free text) for any evaluation round
- [x] **RANK-02**: AI interprets natural-language criteria into structured ranking rules (vote thresholds, score cutoffs, conditional logic)
- [x] **RANK-03**: AI presents parsed rules to admin for review and confirmation before applying (preview mode)
- [x] **RANK-04**: Admin can use quick-rank mode where AI interprets and ranks directly without preview
- [x] **RANK-05**: System ranks projects per CompetitionCategory (STARTUP, BUSINESS_CONCEPT) separately
- [x] **RANK-06**: Ranking considers jury evaluation scores, pass/fail votes, and any criteria defined by admin
- [x] **RANK-07**: AI ranking service anonymizes project data before sending to OpenAI (follows existing anonymization pattern)
- [x] **RANK-08**: Ranking results are stored as snapshots for audit trail (RankingSnapshot model)
- [x] **RANK-09**: Ranking auto-triggers when all jury assignments for a round are completed (all evaluations submitted)
- [x] **RANK-10**: Auto-trigger works retroactively for rounds where all assignments are already complete
### Ranking Dashboard
- [x] **DASH-01**: Admin sees ranked project list per category on the round detail page (new tab)
- [x] **DASH-02**: Admin can drag-and-drop to reorder projects in the ranked list
- [x] **DASH-03**: Drag-and-drop state is isolated from server state to prevent snap-back race conditions
- [x] **DASH-04**: Admin can click a project to see full evaluation data in a side panel (scores, votes, juror comments, pass/fail)
- [x] **DASH-05**: Admin can select "Advance top X" to promote the top N projects to the next round
- [x] **DASH-06**: Admin can batch-reject remaining non-advanced projects
- [x] **DASH-07**: Advance button is disabled until any pending reorder mutations settle
### Email & Notifications
- [ ] **MAIL-01**: Admin can edit email text content for advancement/rejection notifications (follows existing email styling)
- [ ] **MAIL-02**: Email templates support variable insertion ({{firstName}}, {{teamName}}, {{competitionName}}, {{roundName}}, etc.) with simple text editor
- [ ] **MAIL-03**: Variable substitution uses whitelist-only approach (no Handlebars/Mustache engine) to prevent template injection
- [ ] **MAIL-04**: Admin chooses per-batch whether to send advancement email, rejection email, or just update status silently
- [ ] **MAIL-05**: System warns admin if the next round does not have automated welcome emails configured
- [ ] **MAIL-06**: EmailTemplate model stores templates in database, associated with competition/round
### Mentor Management
- [ ] **MENT-01**: Admin can assign mentors to projects during any round type (not just MENTORING rounds)
- [ ] **MENT-02**: Mentor assignments auto-persist across rounds unless the project is eliminated
- [ ] **MENT-03**: Admin can override or change mentor assignments at any time
- [ ] **MENT-04**: AI suggests mentor-project matches based on expertise, with admin confirmation
- [ ] **MENT-05**: System re-validates conflict of interest when mentor assignment carries over to a new round
- [ ] **MENT-06**: Mentor assignment status is visible in the ranking dashboard for context
## v2 Requirements
Deferred to future release. Tracked but not in current roadmap.
### Advanced Ranking
- **RANK-V2-01**: Ranking history comparison (compare snapshots across re-rankings)
- **RANK-V2-02**: Export ranking results to CSV/PDF
- **RANK-V2-03**: Multi-language criteria support (French/English auto-detect)
### Advanced Email
- **MAIL-V2-01**: Email template versioning and rollback
- **MAIL-V2-02**: Email preview with sample data before sending
- **MAIL-V2-03**: Email delivery tracking (sent, opened, bounced)
### Advanced Mentoring
- **MENT-V2-01**: Mentor capacity management (max projects per mentor)
- **MENT-V2-02**: Mentor-applicant messaging through the platform
- **MENT-V2-03**: Mentor feedback forms per round
## Out of Scope
| Feature | Reason |
|---------|--------|
| Real-time collaborative ranking | CRDT complexity, single admin typically manages rankings |
| Fully automated advancement without review | Accountability concern — human must confirm |
| WYSIWYG email editor | XSS risk + complexity; Tiptap with variable chips is sufficient |
| Auto-send emails on round transition | Risk of accidental mass-emails in production |
| Award eligibility (Spotlight on Africa) | Separate workflow, later milestone |
| Public-facing ranking results | Admin-only feature, no external visibility needed |
## Traceability
Which phases cover which requirements. Updated during roadmap creation.
| Requirement | Phase | Status |
|-------------|-------|--------|
| RANK-01 | Phase 1 | Complete |
| RANK-02 | Phase 1 | Complete |
| RANK-03 | Phase 1 | Complete |
| RANK-04 | Phase 1 | Complete |
| RANK-05 | Phase 1 | Complete |
| RANK-06 | Phase 1 | Complete |
| RANK-07 | Phase 1 | Complete |
| RANK-08 | Phase 1 | Complete |
| RANK-09 | Phase 1 | Complete |
| RANK-10 | Phase 1 | Complete |
| DASH-01 | Phase 2 | Complete |
| DASH-02 | Phase 2 | Complete |
| DASH-03 | Phase 2 | Complete |
| DASH-04 | Phase 2 | Complete |
| DASH-05 | Phase 2 | Complete |
| DASH-06 | Phase 2 | Complete |
| DASH-07 | Phase 2 | Complete |
| MAIL-01 | Phase 3 | Pending |
| MAIL-02 | Phase 3 | Pending |
| MAIL-03 | Phase 3 | Pending |
| MAIL-04 | Phase 3 | Pending |
| MAIL-05 | Phase 3 | Pending |
| MAIL-06 | Phase 3 | Pending |
| MENT-01 | Phase 4 | Pending |
| MENT-02 | Phase 4 | Pending |
| MENT-03 | Phase 4 | Pending |
| MENT-04 | Phase 4 | Pending |
| MENT-05 | Phase 4 | Pending |
| MENT-06 | Phase 4 | Pending |
**Coverage:**
- v1 requirements: 29 total
- Mapped to phases: 29
- Unmapped: 0 ✓
---
*Requirements defined: 2026-02-26*
*Last updated: 2026-02-26 after roadmap creation — all 29 requirements mapped*

View File

@@ -1,81 +0,0 @@
# Roadmap: MOPC — AI Ranking, Advancement & Mentoring
## Overview
This milestone extends the MOPC jury voting platform with four capabilities: an AI-powered ranking engine that interprets natural-language criteria and ranks projects per competition category, a drag-and-drop ranking dashboard for admin review and override, a bulk advancement flow with optional email notification, and cross-round mentor persistence that allows mentors to be assigned during any round type. Phases 1-3 are sequential and time-critical (Monday deadline for ranking and advancement). Phase 4 is independent and can run in parallel with Phases 2-3.
## Phases
**Phase Numbering:**
- Integer phases (1, 2, 3): Planned milestone work
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
Decimal phases appear between their surrounding integers in numeric order.
- [x] **Phase 1: AI Ranking Backend** - AI service, tRPC router, schema, and auto-trigger for ranking projects per category using natural-language criteria
(completed 2026-02-27)
- [x] **Phase 2: Ranking Dashboard UI** - Drag-and-drop ranking tab on the round detail page with criteria input, preview-confirm dialog, and project detail side panel
(completed 2026-02-27)
- [ ] **Phase 3: Advancement + Email** - Bulk advancement flow with reject/advance in one operation, notify-vs-silent toggle, and admin-editable email templates
- [ ] **Phase 4: Mentor Persistence** - Mentor assignment during any round type with cross-round carryover and COI re-validation
## Phase Details
### Phase 1: AI Ranking Backend
**Goal**: Admin can call an AI ranking API that interprets natural-language criteria and returns per-category ranked project lists, persisted for audit
**Depends on**: Nothing (first phase)
**Requirements**: RANK-01, RANK-02, RANK-03, RANK-04, RANK-05, RANK-06, RANK-07, RANK-08, RANK-09, RANK-10
**Success Criteria** (what must be TRUE):
1. Admin can submit natural-language ranking criteria and receive a structured preview of parsed rules before any ranking is applied
2. AI ranking executes per CompetitionCategory (STARTUP and BUSINESS_CONCEPT ranked separately) using jury scores, pass/fail votes, and admin-defined criteria
3. All project data is anonymized before leaving the server; OpenAI receives no PII
4. Ranking results are stored as a RankingSnapshot with full audit trail (who ran it, when, with what criteria)
5. Ranking auto-triggers when all jury assignments for a round are complete, including rounds where all assignments were already submitted before this feature shipped
**Plans**: TBD
### Phase 2: Ranking Dashboard UI
**Goal**: Admin has a working ranking interface on the round detail page where they can review, reorder, and prepare projects for advancement
**Depends on**: Phase 1
**Requirements**: DASH-01, DASH-02, DASH-03, DASH-04, DASH-05, DASH-06, DASH-07
**Success Criteria** (what must be TRUE):
1. Admin sees a ranked project list per category on a new Ranking tab of the round detail page, showing AI-suggested order and any admin overrides as visually distinct states
2. Admin can drag-and-drop projects to reorder within each category; local order is preserved while drag mutations are in-flight and does not snap back
3. Admin can click any project row to see full evaluation detail in a side panel without leaving the ranking list
4. Advance button is disabled while any reorder mutation is unsettled; once enabled, admin can select top N projects and advance them in one action
5. Admin can batch-reject all non-advanced projects from the same interface
**Plans**: TBD
### Phase 3: Advancement + Email
**Goal**: Admin can advance and reject projects in one operation with full control over whether and what notification emails are sent to applicants
**Depends on**: Phase 2
**Requirements**: MAIL-01, MAIL-02, MAIL-03, MAIL-04, MAIL-05, MAIL-06
**Success Criteria** (what must be TRUE):
1. Admin can choose per advancement batch whether to send an advancement email, a rejection email, both, or update statuses silently with no emails
2. Admin can edit the text of advancement and rejection email templates directly in the platform, with supported variables shown inline
3. Variable substitution uses a fixed whitelist ({{firstName}}, {{teamName}}, {{competitionName}}, {{roundName}}); no arbitrary template expressions are evaluated
4. If the next round has no automated welcome emails configured, the system warns admin before they confirm advancement
5. Email templates are stored in the database per competition/round and persist across sessions
**Plans**: TBD
### Phase 4: Mentor Persistence
**Goal**: Admin can assign mentors to projects from any round type, and those assignments carry forward to subsequent rounds unless the project is eliminated
**Depends on**: Nothing (independent track — can run in parallel with Phases 2-3)
**Requirements**: MENT-01, MENT-02, MENT-03, MENT-04, MENT-05, MENT-06
**Success Criteria** (what must be TRUE):
1. Admin can open the mentor assignment UI from any round detail page, not only rounds of type MENTORING
2. When a project advances to the next round, existing mentor assignments carry over automatically without admin action
3. Before any mentor assignment carries over, the system re-validates that no conflict of interest exists between the mentor and the project in the new round; if one is found, the carryover is blocked and admin is notified
4. Admin can override, change, or remove a mentor assignment at any point from any round
5. AI suggests mentor-project matches based on expertise with admin confirmation required before the assignment is saved
6. The ranking dashboard shows current mentor assignment status for each project in the ranked list
**Plans**: TBD
## Progress
**Execution Order:**
Phases execute in numeric order: 1 → 2 → 3 → 4 (Phase 4 can be parallelized with 2-3)
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. AI Ranking Backend | 4/4 | Complete | 2026-02-27 |
| 2. Ranking Dashboard UI | 3/3 | Complete | 2026-02-27 |

View File

@@ -1,99 +0,0 @@
---
gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: in_progress
last_updated: "2026-02-27T08:56:00Z"
progress:
total_phases: 4
completed_phases: 2
total_plans: 10
completed_plans: 7
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-02-26)
**Core value:** Admins can describe ranking criteria in natural language, the system interprets and ranks projects accordingly, and they can advance the top projects to the next round with one click — all with full override control.
**Current focus:** Phase 2 — Ranking Dashboard UI
## Current Position
Phase: 2 of 4 (Ranking Dashboard UI) — COMPLETE
Plan: 3 of 3 in current phase (Phase 2 all plans complete)
Status: In progress (Phase 3 next)
Last activity: 2026-02-27 — Plan 02-03 complete: Advance Top N dialog + batch-reject wired to tRPC mutations
Progress: [███████░░░] ~70%
## Performance Metrics
**Velocity:**
- Total plans completed: 5
- Average duration: ~5 min
- Total execution time: ~23 min
**By Phase:**
| Phase | Plans | Total | Avg/Plan |
|-------|-------|-------|----------|
| 01-ai-ranking-backend | 4 | ~18 min | ~5 min |
| 02-ranking-dashboard-ui | 3 | ~18 min | ~6 min |
**Recent Trend:**
- Last 5 plans: 01-04 (~8 min), 02-01 (~5 min), 02-02 (~8 min), 02-03 (~5 min)
- Trend: Fast (service-layer + UI implementation tasks)
*Updated after each plan completion*
| Phase 02-ranking-dashboard-ui P01 | 5 | 2 tasks | 3 files |
| Phase 02-ranking-dashboard-ui P02 | 8 | 1 task | 1 file |
| Phase 02-ranking-dashboard-ui P03 | 5 | 1 task | 1 file |
## Accumulated Context
### Decisions
Decisions are logged in PROJECT.md Key Decisions table.
Recent decisions affecting current work:
- [Init]: RankingSnapshot model (new table) preferred over Round.metadataJson for audit history — confirm with client before writing migration
- [Init]: Per-category processing (STARTUP / BUSINESS_CONCEPT) — two parallel AI calls, not one combined call
- [Init]: Phase 4 is independent and can be parallelized with Phases 2-3
- [01-01]: Used separate relation names per FK pair (RoundRankingSnapshots / TriggeredRankingSnapshots) — Prisma requires unique relation names per FK, not per target model
- [01-01]: All EvaluationConfig ranking fields are optional/defaulted for backward compatibility with existing rounds
- [01-01]: Created migration SQL manually (20260227000000_add_ranking_snapshot) — local DB credentials unavailable; migration applies on next deploy
- [01-02]: fetchAndRankCategory exported (not private) so tRPC router can execute pre-parsed rules without re-parsing
- [01-02]: Projects with zero SUBMITTED evaluations excluded from ranking entirely (not ranked last)
- [01-02]: PrismaClient imported as real type (not import type) so transaction clients are compatible
- [01-03]: Typed arrays cast to Prisma.InputJsonValue via `unknown` (direct cast rejected by strict TS — no index signature)
- [01-03]: getSnapshot uses findUnique + manual TRPCError NOT_FOUND (findUniqueOrThrow gives INTERNAL_SERVER_ERROR)
- [Phase 01-04]: triggerAutoRankIfComplete defined as module-level non-exported function in evaluation.ts — avoids circular imports, colocated with the mutation it serves
- [Phase 01-04]: EvaluationConfig null fallback typed as {} as EvaluationConfig — required for TypeScript strict mode to recognize rankingCriteria and autoRankOnComplete fields
- [Phase 01-04]: retroactiveScan uses RETROACTIVE triggerType to distinguish from MANUAL/AUTO/QUICK — prevents duplicate re-runs on subsequent scans
- [02-01]: ReorderEvent type defined locally in ranking.ts (not exported) — only consumed by saveReorder procedure
- [02-01]: saveReorder is append-only: full ordered list stored per event, latest entry per category = current admin order, gives full audit trail
- [02-02]: Double cast (as unknown as RankedProjectEntry[]) required for Prisma JsonValue — direct cast rejected by TypeScript strict mode
- [02-02]: getFullDetail returns { project, assignments, stats } shape — title accessed via .project.title not root level
- [02-02]: saveReorder has no onSuccess invalidation — avoids re-fetch that would reset localOrder and cause snap-back
- [02-03]: Advance button disabled via saveReorderMutation.isPending (reactive) not pendingReorderCount.current (ref, non-reactive)
- [02-03]: topNStartup + topNConceptual === 0 disables confirm button — prevents no-op advance calls
- [02-03]: batchRejectMutation fires conditionally (only if includeReject and rejectIds.length > 0)
### Pending Todos
None yet.
### Blockers/Concerns
- [Phase 1]: French/English variable naming for EmailTemplate bilingual fields needs client confirmation before Phase 3 schema migration
- [Phase 1]: Poste.io bulk-send rate limits need verification before Phase 3 load testing
- [Phase 3]: Tiptap Mention extension API in v3.x should be validated against Tiptap v3 docs before implementation
## Session Continuity
Last session: 2026-02-27
Stopped at: Completed 02-03-PLAN.md (Advance Top N dialog + batch-reject — Phase 2 complete)
Resume file: None

View File

@@ -1,193 +0,0 @@
# Architecture
**Analysis Date:** 2026-02-26
## Pattern Overview
**Overall:** Role-gated multi-tenant monolith with end-to-end type safety (Prisma → tRPC → React)
**Key Characteristics:**
- Single Next.js 15 App Router application serving all roles (admin, jury, applicant, mentor, observer, public)
- All API calls go through tRPC with superjson serialization; no separate REST API for client data
- Role enforcement happens at two levels: layout-level (`requireRole()`) and procedure-level (tRPC middleware)
- Service layer (`src/server/services/`) contains all business logic — routers delegate immediately to services
- All state machine transitions are audited via `DecisionAuditLog` through `src/server/utils/audit.ts`
## Layers
**Data Layer:**
- Purpose: Schema definition, migrations, query client
- Location: `prisma/schema.prisma`, `src/lib/prisma.ts`
- Contains: Prisma schema, singleton PrismaClient with connection pool (limit=20, timeout=10)
- Depends on: PostgreSQL 16
- Used by: Service layer, tRPC context
**Service Layer:**
- Purpose: Business logic, state machines, external integrations
- Location: `src/server/services/`
- Contains: Round engine, deliberation, assignment, AI services, submission manager, live control, result lock, notification
- Depends on: Prisma client (passed as param to allow transactional usage), `src/lib/` utilities
- Used by: tRPC routers only
**API Layer (tRPC):**
- Purpose: Type-safe RPC procedures with role-based access control
- Location: `src/server/routers/`, `src/server/trpc.ts`, `src/server/context.ts`
- Contains: 44+ domain routers assembled in `src/server/routers/_app.ts`, middleware hierarchy, tRPC context
- Depends on: Service layer, Prisma (via context)
- Used by: Client components via `src/lib/trpc/client.ts`
**UI Layer:**
- Purpose: Server and client React components, pages, layouts
- Location: `src/app/`, `src/components/`
- Contains: Route groups per role, layouts with role guards, client components using tRPC hooks
- Depends on: tRPC client, shadcn/ui, Tailwind CSS
- Used by: Browser clients
**Shared Utilities:**
- Purpose: Cross-cutting helpers available everywhere
- Location: `src/lib/`
- Contains: Auth config, Prisma singleton, email, MinIO client, OpenAI client, logger, rate limiter, feature flags, storage provider abstraction
- Depends on: External services
- Used by: Service layer, routers, layouts
## Data Flow
**Client Query Flow:**
1. React component calls `trpc.domain.procedure.useQuery()` via `src/lib/trpc/client.ts`
2. Request hits `src/app/api/trpc/[trpc]/route.ts` — rate limited at 100 req/min per IP
3. tRPC resolves context (`src/server/context.ts`): auth session + prisma singleton + IP/UA
4. Middleware chain runs: authentication check → role check → procedure handler
5. Router delegates to service (e.g., `roundEngineRouter``src/server/services/round-engine.ts`)
6. Service queries Prisma, may call external APIs, writes audit log
7. Superjson-serialized result returns to React Query cache
**Mutation Flow (with audit trail):**
1. Component calls `trpc.domain.action.useMutation()`
2. tRPC middleware validates auth + role
3. Router calls `logAudit()` before or after service call
4. Service performs database work inside `prisma.$transaction()` when atomicity required
5. Service writes its own `logAudit()` for state machine transitions
6. Cache invalidated via `utils.trpc.invalidate()`
**Server-Sent Events Flow (live voting/deliberation):**
1. Client subscribes via `src/hooks/use-live-voting-sse.ts` or `src/hooks/use-stage-live-sse.ts`
2. SSE route `src/app/api/live-voting/stream/route.ts` polls database on interval
3. Events emitted for vote count changes, cursor position changes, status changes
4. Admin cursor controlled via `src/server/services/live-control.ts` → tRPC `liveRouter`
**State Management:**
- Server state: React Query via tRPC hooks (cache + invalidation)
- Edition/program selection: `src/contexts/edition-context.tsx` (localStorage + URL param + React Context)
- Form state: Local React state with autosave timers (evaluation page uses refs to prevent race conditions)
- No global client state library (no Redux/Zustand)
## Key Abstractions
**Competition/Round State Machine:**
- Purpose: Governs round lifecycle and per-project states within rounds
- Examples: `src/server/services/round-engine.ts`
- Pattern: Pure functions with explicit transition maps; `VALID_ROUND_TRANSITIONS` and `VALID_PROJECT_TRANSITIONS` constants define allowed moves. All transitions are transactional and audited.
- Round transitions: `ROUND_DRAFT → ROUND_ACTIVE → ROUND_CLOSED → ROUND_ARCHIVED`
- Project-in-round transitions: `PENDING → IN_PROGRESS → PASSED/REJECTED → COMPLETED/WITHDRAWN`
**tRPC Procedure Types (RBAC middleware):**
- Purpose: Enforce role-based access at the API boundary
- Examples: `src/server/trpc.ts`
- Pattern: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`, `juryProcedure`, `mentorProcedure`, `observerProcedure`, `awardMasterProcedure`, `audienceProcedure`
- Each is a pre-configured middleware chain; routers simply choose the correct type
**CompetitionContext resolver:**
- Purpose: Loads the full typed context for a round (competition + round + parsed configJson + juryGroup + submissionWindows)
- Examples: `src/server/services/competition-context.ts`
- Pattern: `resolveCompetitionContext(roundId)` used by any service that needs the full picture. Also provides `resolveMemberContext()` for jury-member-specific context with assignment counts.
**Round-Type Config Schemas:**
- Purpose: Each RoundType (`INTAKE`, `FILTERING`, `EVALUATION`, etc.) has a dedicated Zod config schema stored in `Round.configJson`
- Examples: `src/types/competition-configs.ts``IntakeConfigSchema`, `FilteringConfigSchema`, `EvaluationConfigSchema`, etc.
- Pattern: `safeValidateRoundConfig(roundType, configJson)` returns typed config or null; `validateRoundConfig()` throws on invalid
**Storage Provider Abstraction:**
- Purpose: Swap MinIO (production) for local filesystem (dev/test) without changing service code
- Examples: `src/lib/storage/types.ts`, `src/lib/storage/s3-provider.ts`, `src/lib/storage/local-provider.ts`, `src/lib/storage/index.ts`
- Pattern: `StorageProvider` interface with `getUploadUrl`, `getDownloadUrl`, `deleteObject`, `putObject`, `getObject`
**AI Pipeline with Anonymization:**
- Purpose: All AI calls strip PII before sending to OpenAI
- Examples: `src/server/services/anonymization.ts` + any `src/server/services/ai-*.ts`
- Pattern: `anonymizeProjectsForAI()` returns `AnonymizedProjectForAI[]` + `ProjectAIMapping`; AI service uses anonymous IDs; results mapped back via mapping object
## Entry Points
**tRPC API Handler:**
- Location: `src/app/api/trpc/[trpc]/route.ts`
- Triggers: All client data queries and mutations
- Responsibilities: Rate limiting (100 req/min), `fetchRequestHandler` with `appRouter` + `createContext`, error logging
**Auth Handler:**
- Location: `src/app/api/auth/[...nextauth]/route.ts`
- Triggers: Login, magic link verification, session management
- Responsibilities: NextAuth v5 with Email + Credentials providers, 5-attempt lockout
**Cron Endpoints:**
- Location: `src/app/api/cron/` (audit-cleanup, digest, draft-cleanup, reminders)
- Triggers: External scheduler via `CRON_SECRET` header check
- Responsibilities: Periodic maintenance — evaluation reminders, draft cleanup, digest emails, audit log rotation
**SSE Stream:**
- Location: `src/app/api/live-voting/stream/route.ts`
- Triggers: Live voting/deliberation pages subscribe as long-running GET connections
- Responsibilities: Poll DB for changes, push events for vote counts, cursor position, status transitions
**Next.js Middleware:**
- Location: `middleware.ts` (root, uses `src/lib/auth.config.ts`)
- Triggers: Every request
- Responsibilities: Auth check (edge-compatible), redirect to `/login` if unauthenticated, redirect to `/set-password` if `mustSetPassword` flag set
**Role Layout Guards:**
- Location: `src/app/(admin)/layout.tsx`, `src/app/(jury)/layout.tsx`, etc.
- Triggers: Navigation into role-specific route group
- Responsibilities: Server-side `requireRole()` call, redirect to role dashboard if unauthorized, onboarding gate (jury)
## Error Handling
**Strategy:** Boundary-based — tRPC errors propagate to React Query; service errors use `TRPCError`; audit never throws
**Patterns:**
- Services return typed result objects (`{ success: boolean, errors?: string[] }`) for state machine operations — no throwing
- tRPC procedures throw `TRPCError` with code (`NOT_FOUND`, `FORBIDDEN`, `CONFLICT`, `BAD_REQUEST`) for client-visible errors
- `logAudit()` is wrapped in try-catch — audit failures are logged to console but never surface to users
- AI services use `classifyAIError()` from `src/server/services/ai-errors.ts` to translate OpenAI errors
- Round notification functions explicitly catch all errors and log them — notifications never block round transitions
- Client uses `toast.error()` from sonner for user-facing error display
## Cross-Cutting Concerns
**Logging:**
- `src/lib/logger.ts` — tagged, level-aware (`debug/info/warn/error`), respects `LOG_LEVEL` env var, defaults to `debug` in development and `warn` in production
- Pattern: `logger.info('ServiceName', 'message', { data })` — tag identifies the calling service
**Validation:**
- Zod schemas on all tRPC procedure inputs (`.input(z.object({...}))`)
- `ZodError` is formatted and included in tRPC error response via `errorFormatter` in `src/server/trpc.ts`
- Round config Zod schemas in `src/types/competition-configs.ts` validate `configJson` at activation time
**Authentication:**
- NextAuth v5 with JWT strategy; session available server-side via `auth()` from `src/lib/auth.ts`
- `requireRole(...roles)` in `src/lib/auth-redirect.ts` used by all role layouts — checks `user.roles[]` array with `user.role` fallback
- `userHasRole()` in `src/server/trpc.ts` used inline for fine-grained procedure-level checks
**Audit Trail:**
- `logAudit()` in `src/server/utils/audit.ts` — writes to `AuditLog` table
- Called from both routers (with `ctx.prisma` to share transaction) and services
- Never throws — always wrapped in try-catch
**Feature Flags:**
- `src/lib/feature-flags.ts` — reads `SystemSetting` records with `category: FEATURE_FLAGS`
- Currently one active flag: `feature.useCompetitionModel` (defaults to `true`)
---
*Architecture analysis: 2026-02-26*

View File

@@ -1,217 +0,0 @@
# Codebase Concerns
**Analysis Date:** 2026-02-26
---
## Tech Debt
**Award Router is a Stub:**
- Issue: Entire award router is commented out and non-functional. All award procedures were deleted when the Pipeline/Track models were removed and have not been reimplemented.
- Files: `src/server/routers/award.ts`
- Impact: Any UI that references award management procedures will fail at runtime. The `SpecialAward` model still exists in the schema but has no tRPC exposure via this router.
- Fix approach: Reimplement the router procedures against the current `SpecialAward``Competition` FK relationship. See the TODO comment at line 9 for the list of procedures to reimplement.
**Deliberation Page Has Incomplete Implementation:**
- Issue: The jury-facing deliberation page has two hardcoded stub values that break actual vote submission.
- `juryMemberId: ''` — submitted vote will have an empty juror ID.
- `const hasVoted = false` — the "already voted" guard never fires, allowing duplicate vote submissions.
- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` lines 34 and 66
- Impact: Jury members can submit blank/duplicate votes in deliberation sessions. The submitted vote will be associated with an empty string `juryMemberId`, which will likely fail at the Prisma level or silently create bad data.
- Fix approach: Derive `juryMemberId` from `session.participants` by matching `ctx.user.id`. Derive `hasVoted` by checking if a `DeliberationVote` with the current user's jury member ID already exists in `session.votes`.
**Audit Middleware is a No-Op:**
- Issue: The `withAuditLog` middleware in `src/server/trpc.ts` (lines 99114) identifies mutation calls by path pattern but does nothing with them — the body contains only a comment: `// We'll implement this in the audit service`.
- Files: `src/server/trpc.ts`
- Impact: Automatic centralised audit logging for all admin mutations does not occur through this middleware. Manual `logAudit()` / `DecisionAuditLog.create()` calls are present in many routers but coverage is inconsistent.
- Fix approach: Implement the middleware body to call `logAudit()` with `ctx.user.id`, `path`, and serialized input/output. This provides a safety net for any procedure that doesn't call `logAudit` manually.
**In-Memory Rate Limiter Not Suitable for Multi-Instance Deployment:**
- Issue: `src/lib/rate-limit.ts` uses a module-level `Map` for rate limit state. This works in a single process but does not share state across multiple Node.js instances or after a process restart.
- Files: `src/lib/rate-limit.ts`
- Impact: Rate limits can be trivially bypassed by hitting different server instances. Auth brute-force protection (5-attempt lockout) also uses this same in-memory store (`src/lib/auth.ts` line 12).
- Fix approach: Replace with Redis-based rate limiting (e.g., `@upstash/ratelimit` or `ioredis`). The comment at line 5 already acknowledges this: "For production with multiple instances, replace with Redis-based solution."
**`configJson` Widely Cast Without Validation:**
- Issue: `configJson` (a Prisma `Json` field) is cast directly to `Record<string, unknown>` in 65 locations across server routers and services without running it through the Zod validators. The validators (`safeValidateRoundConfig`, `EvaluationConfigSchema.safeParse`) are only called in 4 locations.
- Files: `src/server/routers/assignment.ts`, `src/server/routers/evaluation.ts`, `src/server/routers/filtering.ts`, `src/server/services/round-engine.ts`, and many others.
- Impact: Stale or malformed config JSON stored in the database can cause silent runtime failures deep in business logic (e.g., missing criteria, wrong field names) without a clear validation error.
- Fix approach: Extract a typed `parseRoundConfig(roundType, configJson)` utility that returns a typed config or throws a `TRPCError`. Replace bare `as Record<string, unknown>` casts with this utility at query boundaries.
---
## Known Bugs
**Tag Rename Performs N+1 Database Writes:**
- Symptoms: Renaming a tag iterates over every user and every project that has the tag, issuing one `UPDATE` per record.
- Files: `src/server/routers/tag.ts` lines 361389 and 421438
- Trigger: Admin renames any tag that is widely used.
- Workaround: None. Will time out for large datasets.
- Fix approach: Use a raw SQL `UPDATE ... SET tags = array_replace(tags, $old, $new)` or a Prisma `$executeRaw` to perform the rename in a single query.
**Jury Deliberation Vote: `juryMemberId` Is Hardcoded Empty String:**
- Symptoms: Votes submitted via the jury deliberation page will have `juryMemberId: ''`.
- Files: `src/app/(jury)/jury/competitions/deliberation/[sessionId]/page.tsx` line 34
- Trigger: Any jury member visits a deliberation session and submits a vote.
- Workaround: None — the vote will silently pass or fail depending on Prisma validation.
---
## Security Considerations
**IP Header Spoofing on Rate Limiter:**
- Risk: All rate limiters extract the client IP from `x-forwarded-for` without validating that the header originates from a trusted proxy. A client can set this header to any value, bypassing per-IP rate limits.
- Files: `src/app/api/trpc/[trpc]/route.ts` lines 1318, `src/app/api/auth/[...nextauth]/route.ts` lines 710, `src/app/api/email/change-password/route.ts` line 36, `src/app/api/email/verify-credentials/route.ts` line 20, `src/server/context.ts` line 13.
- Current mitigation: Nginx passes `X-Forwarded-For` from upstream; in single-proxy deployment this reduces (but does not eliminate) risk.
- Recommendations: Pin IP extraction to `req.headers.get('x-real-ip')` set by Nginx only, or validate the forwarded-for chain against a trusted proxy list.
**Cron Secret Compared with `!==` (NonTiming-Safe):**
- Risk: String equality check `cronSecret !== process.env.CRON_SECRET` is vulnerable to timing side-channel attacks on the secret value.
- Files: `src/app/api/cron/audit-cleanup/route.ts`, `src/app/api/cron/digest/route.ts`, `src/app/api/cron/draft-cleanup/route.ts`, `src/app/api/cron/reminders/route.ts` — all at line 8.
- Current mitigation: Cron endpoints are not user-facing and rate limited at Nginx level.
- Recommendations: Replace with `timingSafeEqual(Buffer.from(cronSecret), Buffer.from(process.env.CRON_SECRET))` — the same approach already used in `src/lib/storage/local-provider.ts` line 75.
**No Content-Security-Policy Header:**
- Risk: No CSP is set in `next.config.ts` or via middleware headers. If an XSS vector exists, there is no second line of defence to limit script execution.
- Files: `next.config.ts` (missing `headers()` function), `docker/nginx/mopc-platform.conf` (missing CSP directive).
- Current mitigation: Nginx sets `X-Frame-Options`, `X-Content-Type-Options`, and `X-XSS-Protection`, but these are legacy headers. No HSTS header is configured in Nginx either (only set post-certbot).
- Recommendations: Add `Content-Security-Policy` and `Strict-Transport-Security` via the Next.js `headers()` config function.
**MinIO Fallback Credentials Hardcoded:**
- Risk: When `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` are not set in non-production environments, the client defaults to `minioadmin`/`minioadmin`.
- Files: `src/lib/minio.ts` lines 2829
- Current mitigation: Production throws an error if credentials are missing (line 2022). The fallback only applies in development.
- Recommendations: Remove the hardcoded fallback entirely; require credentials in all environments to prevent accidental exposure of a non-production MinIO instance.
**`NEXT_PUBLIC_MINIO_ENDPOINT` Undefined in Production:**
- Risk: Two admin learning pages read `process.env.NEXT_PUBLIC_MINIO_ENDPOINT` at runtime. This variable is not defined in `docker-compose.yml` and has no `NEXT_PUBLIC_` entry. Next.js requires public env vars to be present at build time; at runtime this will always resolve to the fallback `http://localhost:9000`, making file previews broken in production.
- Files: `src/app/(admin)/admin/learning/new/page.tsx` line 112, `src/app/(admin)/admin/learning/[id]/page.tsx` line 165.
- Fix approach: Add `NEXT_PUBLIC_MINIO_ENDPOINT` to `docker-compose.yml` env section, or use the server-side `MINIO_PUBLIC_ENDPOINT` via a tRPC query rather than client-side env.
---
## Performance Bottlenecks
**Unbounded `findMany` Queries in Analytics Router:**
- Problem: `src/server/routers/analytics.ts` contains approximately 25 `findMany` calls with no `take` limit. With large competitions (hundreds of projects, thousands of evaluations) these will perform full table scans filtered only by `roundId` or `competitionId`.
- Files: `src/server/routers/analytics.ts` — queries at lines 38, 80, 265, 421, 539, 582, 649, 749, 794, 1207, 1227, 1346, 1406, 1481, 1498, 1654, 1677, 1700.
- Cause: Analytics queries are built for correctness, not scale. They load entire result sets into Node.js memory before aggregation.
- Improvement path: Move aggregation into the database using Prisma `groupBy` and `_count`/`_avg` aggregations, or write `$queryRaw` SQL for complex analytics. Add pagination or date-range limits to the procedure inputs.
**Tag Rename N+1 Pattern:**
- Problem: Renaming a tag issues one DB write per entity (user or project) that carries the tag rather than a single bulk update.
- Files: `src/server/routers/tag.ts` lines 355390
- Cause: Prisma does not support `array_replace` natively; the current implementation works around this with a loop.
- Improvement path: Use `prisma.$executeRaw` with PostgreSQL's `array_replace` function.
**`assignment.ts` Router is 3,337 Lines:**
- Problem: The assignment router is the single largest file and handles jury assignments, AI assignment, manual overrides, transfer, COI, and coverage checks in one module.
- Files: `src/server/routers/assignment.ts`
- Cause: Organic growth without module splitting.
- Improvement path: Extract into separate files: `src/server/routers/assignment/manual.ts`, `assignment/ai.ts`, `assignment/coverage.ts`. This will also improve IDE performance and test isolation.
---
## Fragile Areas
**Round Engine: COMPLETED State Has No Guards on Re-Entry:**
- Files: `src/server/services/round-engine.ts` lines 5764
- Why fragile: `COMPLETED` is defined as a terminal state (empty transitions array). However, there is no server-side guard preventing a direct Prisma update to a `COMPLETED` project state outside of `transitionProjectState()`. If a bug or data migration bypasses the state machine, projects can end up in unexpected states.
- Safe modification: Always transition through `transitionProjectState()`. Any admin data repair scripts should call this function rather than using `prisma.projectRoundState.update` directly.
- Test coverage: No unit tests for project state transitions. Only `tests/unit/assignment-policy.test.ts` exists, covering a different subsystem.
**`Prisma.$transaction` Typed as `any`:**
- Files: `src/server/services/round-engine.ts` line 129, `src/server/services/result-lock.ts` lines 87, 169, `src/server/services/mentor-workspace.ts` lines 39, 254, and 50+ other locations.
- Why fragile: `tx: any` disables all type-checking inside transaction callbacks. A mistakenly called method on `tx` (e.g., `tx.round.delete` instead of `tx.round.update`) will compile successfully but may cause silent data corruption.
- Safe modification: Type the callback as `(tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0]) => ...` or define a `TransactionalPrisma` type alias. The `PrismaClient | any` union also defeats the purpose of typing.
**`email.ts` is 2,175 Lines:**
- Files: `src/lib/email.ts`
- Why fragile: All email templates, SMTP transport logic, and dynamic config loading are in one file. Adding a new email type requires navigating 2,000+ lines, and any change to transport setup affects all templates.
- Safe modification: Extract individual email functions into `src/lib/email/` subdirectory with one file per template type. Keep shared transport logic in `src/lib/email/transport.ts`.
- Test coverage: No tests for email sending. Email errors are caught and logged but not surfaced to callers consistently.
**`admin/rounds/[roundId]/page.tsx` is 2,398 Lines:**
- Files: `src/app/(admin)/admin/rounds/[roundId]/page.tsx`
- Why fragile: The entire round management UI (config, assignments, filtering, deliberation controls) lives in a single client component. State from one section can accidentally affect another, and the component re-renders on any state change.
- Safe modification: Extract tab sections into separate `'use client'` components with scoped state. Consider converting to a tab-based layout with lazy loading.
**SSE Live Voting Stream Relies on Polling:**
- Files: `src/app/api/live-voting/stream/route.ts` lines 184194
- Why fragile: The SSE endpoint polls the database every 2 seconds per connected client. Under live ceremony conditions with many simultaneous audience connections, this can produce significant database load.
- Safe modification: Introduce a Redis pub/sub channel that the vote submission path writes to, and have the SSE stream subscribe to the channel rather than polling. Alternatively, implement a debounce on the poll and share the result across all open SSE connections via a singleton broadcaster.
---
## Scaling Limits
**In-Memory State (Rate Limiter, Login Attempts):**
- Current capacity: Works correctly for a single Node.js process.
- Limit: Breaks under horizontal scaling or after a process restart (all rate limit windows reset).
- Scaling path: Replace `src/lib/rate-limit.ts` with a Redis-backed solution. Replace the `failedAttempts` Map in `src/lib/auth.ts` with Redis counters or database fields on the `User` model.
**SSE Connection Count vs. Database Poll Rate:**
- Current capacity: Each SSE client issues 1 database query per 2 seconds.
- Limit: At 100 concurrent audience connections, this is 50 queries/second to `liveVotingSession` and related tables during a ceremony.
- Scaling path: Shared broadcaster pattern (one database poll, fan-out to all SSE streams) or Redis pub/sub as described above.
---
## Dependencies at Risk
**`next-auth` v5 (Auth.js) — Beta API:**
- Risk: Auth.js v5 was in release candidate status at time of integration. The API surface (`authConfig` + `handlers` + `auth`) differs significantly from v4. Upgrading to a stable v5 release may require changes to `src/lib/auth.ts` and `src/lib/auth.config.ts`.
- Impact: Session type definitions, adapter interfaces, and middleware patterns may change.
- Migration plan: Monitor the Auth.js v5 stable release. Changes are likely limited to `src/lib/auth.ts` and `src/types/next-auth.d.ts`.
---
## Missing Critical Features
**No Database Backup Configuration:**
- Problem: `docker-compose.yml` has no scheduled backup service or volume snapshot configuration for the PostgreSQL container.
- Blocks: Point-in-time recovery after data loss or accidental deletion.
- Recommendation: Add a sidecar backup service (e.g., `prodrigestivill/postgres-backup-local`) or configure WAL archiving to MinIO.
**No Error Monitoring / Observability:**
- Problem: There is no Sentry, Datadog, or equivalent error monitoring integration. Application errors are only logged to stdout via `console.error`. In production, these are only visible if the Docker logs are actively monitored.
- Files: No integration found in `src/instrumentation.ts` or anywhere else.
- Blocks: Proactive detection of runtime errors, AI service failures, and payment/submission edge cases.
- Recommendation: Add Sentry (`@sentry/nextjs`) in `src/instrumentation.ts` — Next.js has native support for this. Filter out expected errors (e.g., `TRPCError` with `NOT_FOUND`) to reduce noise.
**No Automated Tests for Core Business Logic (Round Engine, Evaluation, Filtering):**
- Problem: Only one test file exists: `tests/unit/assignment-policy.test.ts`. The round engine state machine (`src/server/services/round-engine.ts`), evaluation submission flow (`src/server/routers/evaluation.ts`), and AI filtering pipeline (`src/server/services/ai-filtering.ts`) have no automated tests.
- Blocks: Confident refactoring of the state machine, advance-criterion logic, and scoring.
- Recommendation: Add unit tests for `activateRound`, `closeRound`, `transitionProjectState` (happy path + guard failures), and `submitEvaluation` (COI check, advance criterion logic, score validation).
---
## Test Coverage Gaps
**Round Engine State Machine:**
- What's not tested: All `activateRound`, `closeRound`, `archiveRound`, `transitionProjectState` transitions, including guard conditions (e.g., activating an ARCHIVED round, transitioning a COMPLETED project).
- Files: `src/server/services/round-engine.ts`
- Risk: A regression in state transition guards could allow data corruption (e.g., re-activating a closed round, double-passing a project).
- Priority: High
**Evaluation Submission (advance criterion, COI, scoring):**
- What's not tested: `submitEvaluation` mutation — specifically the advance criterion auto-transition logic (lines 16371646 of `src/server/routers/evaluation.ts`), COI auto-reassignment on `declareCOI`, and `upsertForm` criterion validation.
- Files: `src/server/routers/evaluation.ts`
- Risk: Regression in advance criterion will silently skip project advancement. COI declaration failures are caught and logged but untested.
- Priority: High
**AI Anonymization:**
- What's not tested: `sanitizeText`, `anonymizeProject`, `validateNoPersonalInfo` in `src/server/services/anonymization.ts`. These are GDPR-critical functions.
- Files: `src/server/services/anonymization.ts`
- Risk: A PII leak in AI calls would violate GDPR without detection.
- Priority: High
**Assignment Policy Execution:**
- What's not tested: End-to-end `executeAssignment` flow in `src/server/services/round-assignment.ts` — specifically the COI filtering, geo-diversity penalty, familiarity bonus, and under-coverage gap-fill.
- Files: `src/server/services/round-assignment.ts`, `src/server/services/smart-assignment.ts`
- Risk: Silent over- or under-assignment when constraints interact.
- Priority: Medium
---
*Concerns audit: 2026-02-26*

View File

@@ -1,267 +0,0 @@
# Coding Conventions
**Analysis Date:** 2026-02-26
## Naming Patterns
**Files:**
- `kebab-case` for all TypeScript/TSX source files: `round-engine.ts`, `filtering-dashboard.tsx`, `ai-errors.ts`
- `kebab-case` for route directories under `src/app/`: `(admin)/admin/juries/`, `[roundId]/`
- Exception: Next.js reserved names remain as-is: `page.tsx`, `layout.tsx`
**Components:**
- `PascalCase` for component functions: `FilteringDashboard`, `EmptyState`, `JuriesPage`
- Page components follow the suffix pattern `XxxPage`: `JuriesPage`, `RoundDetailPage`, `AuditLogPage`
- Sub-components within a file follow `XxxSection`, `XxxCard`, `XxxDialog`
**Functions:**
- `camelCase` for all functions and methods: `activateRound`, `resolveEffectiveCap`, `createTestUser`
- Service functions are named by operation + domain: `activateRound`, `closeRound`, `batchTransitionProjects`
- Boolean functions prefixed with `is`, `has`, `should`, `can`: `shouldRetry`, `isParseError`, `shouldLog`
**Variables:**
- `camelCase` for all local variables
- `SCREAMING_SNAKE_CASE` for module-level constants: `BATCH_SIZE = 50`, `SYSTEM_DEFAULT_CAP`, `VALID_ROUND_TRANSITIONS`
- Enum-like lookup objects in `SCREAMING_SNAKE_CASE`: `ERROR_PATTERNS`, `LOG_LEVELS`
**Types:**
- `type` keyword preferred over `interface` per CLAUDE.md — but both exist in practice
- `interface` is used for component props in some files (e.g., `ButtonProps`, `EmptyStateProps`), `type` used in others
- Prisma-derived types use `type` aliases with `z.infer<typeof Schema>`: `type EvaluationConfig = z.infer<typeof EvaluationConfigSchema>`
- Prop types: `type XxxProps = { ... }` (preferred in most components), `interface XxxProps { ... }` (used in deliberation, some UI components)
- Export complex input types from `src/types/competition.ts`: `CreateCompetitionInput`, `UpdateRoundInput`
## Code Style
**Formatting:**
- Prettier 3.4.2 with `prettier-plugin-tailwindcss` for class sorting
- No `.prettierrc` found — uses Prettier defaults: 2-space indent, double quotes, trailing commas (ES5), 80-char print width
- Single quotes confirmed absent in codebase: all string literals use double quotes
- Tailwind classes sorted automatically by the plugin on format
**Linting:**
- ESLint 9.x with `eslint-config-next` (Next.js configuration)
- Run via `npm run lint` (calls `next lint`)
- No custom rules file found — relies on Next.js default rules
**TypeScript:**
- Strict mode enabled in `tsconfig.json` (`"strict": true`)
- `noEmit: true` — TypeScript used for type checking only, not transpilation
- Target: ES2022
- Module resolution: `bundler` (Next.js Turbopack compatible)
- Path alias: `@/*` maps to `./src/*`
## Import Organization
**Order (observed in client components):**
1. `'use client'` directive (if needed)
2. React/framework hooks: `import { useState, useEffect } from 'react'`
3. Next.js imports: `import { useRouter } from 'next/navigation'`, `import Link from 'next/link'`
4. tRPC client: `import { trpc } from '@/lib/trpc/client'`
5. UI libraries: shadcn/ui components `import { Button } from '@/components/ui/button'`
6. Icons: `import { Loader2, Save } from 'lucide-react'`
7. Internal utilities/helpers: `import { cn } from '@/lib/utils'`
8. Internal components: `import { FilteringDashboard } from '@/components/admin/round/...'`
9. Types: `import type { EvaluationConfig } from '@/types/competition-configs'`
**Order (observed in server/service files):**
1. `import { z } from 'zod'` (validation)
2. `import { TRPCError } from '@trpc/server'` (errors)
3. tRPC router/procedures: `import { router, adminProcedure } from '../trpc'`
4. Internal services/utilities: `import { logAudit } from '@/server/utils/audit'`
5. Type imports at end: `import type { PrismaClient } from '@prisma/client'`
**Path Aliases:**
- Use `@/` prefix for all internal imports: `@/components/ui/button`, `@/server/services/round-engine`
- Never use relative `../../` paths for cross-directory imports
- Relative paths (`./`, `../`) only within the same directory level
## React Component Conventions
**Server vs Client Components:**
- Default to **Server Components** — do not add `'use client'` unless needed
- Layouts (`layout.tsx`) are server components: they call `await requireRole()`, fetch data directly from Prisma, and pass to client wrappers
- Pages that use tRPC hooks, `useState`, or browser APIs must be `'use client'`
- The pattern: server layout fetches session/editions → passes to client wrapper → client components handle interactive state
**Client Component Pattern:**
```tsx
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
type XxxProps = {
competitionId: string
roundId: string
}
export default function XxxPage() { ... }
// Sub-components in same file as local functions (not exported)
function XxxSection({ competition }: XxxSectionProps) { ... }
```
**Server Layout Pattern:**
```tsx
import { requireRole } from '@/lib/auth-redirect'
import { prisma } from '@/lib/prisma'
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await requireRole('SUPER_ADMIN', 'PROGRAM_ADMIN')
const data = await prisma.program.findMany({ ... })
return <ClientWrapper data={data}>{children}</ClientWrapper>
}
```
**Props with Params (Next.js 15):**
```tsx
type PageProps = {
params: Promise<{ roundId: string; projectId: string }>
}
export default function Page({ params: paramsPromise }: PageProps) {
const params = use(paramsPromise) // React.use() for async params
...
}
```
## tRPC Router Conventions
**Procedure Selection:**
- `adminProcedure` for CRUD on competition/round/jury entities
- `protectedProcedure` for shared read access across roles
- `juryProcedure` for jury-only operations
- Role checks within procedure body use `userHasRole(ctx.user, 'ROLE')` for per-entity authorization
**Input Validation:**
- All inputs validated with Zod `.input(z.object({ ... }))`
- Use `.min()`, `.max()`, `.regex()` for strings
- Use `.int().positive()` for ID/count integers
- Use `.optional().nullable()` for optional fields with null support
- Inline schema definition (not shared schema objects) per router
**Mutation Pattern:**
```typescript
create: adminProcedure
.input(z.object({
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
}))
.mutation(async ({ ctx, input }) => {
// 1. Check business rules (conflict, not found)
const existing = await ctx.prisma.xxx.findUnique({ where: { slug: input.slug } })
if (existing) {
throw new TRPCError({ code: 'CONFLICT', message: '...' })
}
// 2. Perform operation
const result = await ctx.prisma.xxx.create({ data: input })
// 3. Audit log (for mutations)
await logAudit({ prisma: ctx.prisma, userId: ctx.user.id, action: 'CREATE', ... })
// 4. Return result
return result
})
```
**Error Codes:**
- `NOT_FOUND` — entity doesn't exist
- `CONFLICT` — duplicate slug/unique constraint
- `FORBIDDEN` — user lacks permission for specific entity
- `UNAUTHORIZED` — not logged in (handled by middleware)
- `BAD_REQUEST` — invalid business state (e.g., no active form)
## Error Handling
**tRPC Routers (user-facing errors):**
- Always throw `TRPCError` with `{ code, message }` — never plain `throw new Error()`
- Message should be human-readable: `'Competition not found'`, not `'Competition_NOT_FOUND'`
- Use `findUniqueOrThrow` / `findFirstOrThrow` for implicit 404s on required relations
**Service Layer (internal errors):**
- Services return result objects `{ success: boolean, errors?: string[] }` — they do NOT throw
- Callers check `result.success` before proceeding
- Error message pattern: `error instanceof Error ? error.message : 'Unknown error during X'`
- Non-fatal side effects (notifications, emails) are wrapped in separate try/catch and logged but never propagate
**AI Services:**
- Use `classifyAIError()` from `src/server/services/ai-errors.ts` for all OpenAI errors
- Wrap AI calls with `withAIErrorHandling(fn, fallback)` for unified error + fallback handling
- All AI errors logged with service tag: `console.error('[AI Assignment] failed:', ...)`
**Client-Side:**
- Mutation errors displayed via `toast.error(err.message)` from Sonner
- Success via `toast.success('...')` with query invalidation: `utils.xxx.yyy.invalidate()`
- Loading states tracked via `mutation.isPending` and `query.isLoading`
## Logging
**Framework:** Custom structured logger at `src/lib/logger.ts`
**Usage:**
```typescript
import { logger } from '@/lib/logger'
logger.info('RoundEngine', 'Round activated', { roundId, competitionId })
logger.error('Storage', 'Upload failed', error)
logger.warn('Filtering', 'Non-fatal error in document check', retroError)
```
**Tag Convention:** `[ServiceName]` prefix in brackets — `'RoundEngine'`, `'AIFiltering'`, `'Storage'`
**Direct console usage** (still common in routers, not yet migrated to logger):
- Tagged format: `console.log('[FeatureName] message', data)`
- Always uses bracket tag: `'[Filtering]'`, `'[Assignment]'`, `'[COI]'`
**Log Level Defaults:**
- Development: `debug` (all logs)
- Production: `warn` (warns and errors only)
- Override via `LOG_LEVEL` env var
## Comments
**When to Comment:**
- All exported service functions get a JSDoc-style comment explaining purpose and invariants
- Inline comments for non-obvious business logic: `// re-include after rejection`, `// Bounded to admin max`
- Section header separators in large files using box-drawing chars: `// ─── Section Name ──────`
- `// =====` separators for major logical sections in long files
**JSDoc/TSDoc:**
- Used on exported functions in services and utilities
- Standard `/**` block with plain description — no `@param`/`@returns` annotations in most code
- Routers use `/** procedure description */` above each procedure for documentation
**TODO Comments:**
- Present but sparse — only 3 found in entire codebase (deliberation page, award router)
- Format: `// TODO: description`
## Function Design
**Size:** Service functions can be long (100-200+ lines) for complex state machines; router procedures typically 20-60 lines
**Parameters:**
- Services accept `(entityId: string, actorId: string, prisma: PrismaClient)` — explicit prisma injection for testability
- Router procedures destructure `{ ctx, input }` — never access `ctx.prisma` outside routers
**Return Values:**
- Queries return data directly or throw `TRPCError`
- Mutations return the created/updated record
- Services return typed result objects: `RoundTransitionResult`, `BatchProjectTransitionResult`
- Async service results always typed: `Promise<RoundTransitionResult>`
## Module Design
**Exports:**
- Single responsibility: each service file exports one domain's functions
- Named exports preferred over default exports for services and utilities
- Default exports used only for React components (`export default function Page()`)
**Barrel Files:**
- Used sparingly — only for chart components (`src/components/charts/index.ts`), form steps, and storage utilities
- Most imports are direct path imports: `import { FilteringDashboard } from '@/components/admin/round/filtering-dashboard'`
**Prisma Transactions:**
- Use `ctx.prisma.$transaction(async (tx) => { ... })` for multi-step mutations
- Always pass `tx` (transaction client) through nested operations
- Sequential array syntax `ctx.prisma.$transaction([op1, op2])` for simple atomic batches
---
*Convention analysis: 2026-02-26*

View File

@@ -1,213 +0,0 @@
# External Integrations
**Analysis Date:** 2026-02-26
## APIs & External Services
**AI Providers (configurable via SystemSettings DB table):**
- OpenAI - AI filtering, jury assignment suggestions, evaluation summaries, project tagging, award eligibility, shortlist recommendations
- SDK: `openai` ^6.16.0
- Auth: `OPENAI_API_KEY` env var or `openai_api_key` SystemSetting
- Base URL: `OPENAI_BASE_URL` env var or `openai_base_url` SystemSetting (for OpenAI-compatible proxies)
- Model: `OPENAI_MODEL` env var or `ai_model` SystemSetting (default: `gpt-4o`)
- Client: `src/lib/openai.ts` - lazy singleton, reset via `resetOpenAIClient()`
- Anthropic Claude - Alternative AI provider, same AI feature set as OpenAI
- SDK: `@anthropic-ai/sdk` ^0.78.0
- Auth: `ANTHROPIC_API_KEY` env var or `anthropic_api_key` SystemSetting
- Adapter: `src/lib/openai.ts` wraps Anthropic SDK behind OpenAI `.chat.completions.create()` interface
- Supported models: `claude-opus-4-5-20250514`, `claude-sonnet-4-5-20250514`, `claude-haiku-3-5-20241022`, `claude-opus-4-20250514`, `claude-sonnet-4-20250514`
- Extended thinking enabled automatically for Opus models
- LiteLLM proxy - Third option for ChatGPT subscription routing (no real API key needed)
- Config: `ai_provider = 'litellm'` in SystemSettings + `openai_base_url` pointing to proxy
- Token limit fields stripped for `chatgpt/*` model prefix
- AI provider selection: `getConfiguredProvider()` in `src/lib/openai.ts` reads `ai_provider` SystemSetting; defaults to `openai`
**All AI data is anonymized before sending** via `src/server/services/anonymization.ts`
**Notion:**
- Used for project import (alternative to CSV)
- SDK: `@notionhq/client` ^2.3.0
- Auth: API key stored in SystemSettings (`notion_api_key`), per-import flow
- Client: `src/lib/notion.ts` - `createNotionClient(apiKey)` per-request (not singleton)
- Router: `src/server/routers/notion-import.ts`
**Typeform:**
- Used for project import from form responses
- Auth: API key stored in SystemSettings per-import
- Client: `src/lib/typeform.ts` - plain fetch against `https://api.typeform.com`, no SDK
- Router: `src/server/routers/typeform-import.ts`
**WhatsApp (optional, configurable):**
- Two provider options: Meta WhatsApp Business Cloud API or Twilio WhatsApp
- Meta provider: `src/lib/whatsapp/meta-provider.ts`
- Twilio provider: `src/lib/whatsapp/twilio-provider.ts`
- Abstraction: `src/lib/whatsapp/index.ts` - `getWhatsAppProvider()` reads `whatsapp_provider` SystemSetting
- Auth: API keys stored in SystemSettings (`whatsapp_enabled`, `whatsapp_provider`, provider-specific keys)
- Used for: notification delivery (alternative to email)
## Data Storage
**Databases:**
- PostgreSQL 16 (primary datastore)
- Connection: `DATABASE_URL` env var (e.g., `postgresql://mopc:${password}@postgres:5432/mopc`)
- Client: Prisma 6 ORM, `src/lib/prisma.ts` singleton with connection pool (limit=20, timeout=10)
- Schema: `prisma/schema.prisma` (~95KB, ~100+ models)
- Migrations: `prisma/migrations/` directory, deployed via `prisma migrate deploy` on startup
- Test DB: `DATABASE_URL_TEST` env var (falls back to `DATABASE_URL` in test setup)
**File Storage:**
- MinIO (S3-compatible, self-hosted on VPS)
- Internal endpoint: `MINIO_ENDPOINT` env var (server-to-server)
- Public endpoint: `MINIO_PUBLIC_ENDPOINT` env var (browser-accessible pre-signed URLs)
- Auth: `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`
- Bucket: `MINIO_BUCKET` (default: `mopc-files`)
- Client: `src/lib/minio.ts` - lazy singleton via Proxy, `getMinioClient()`
- Access pattern: pre-signed URLs only (15-minute expiry by default), never direct public bucket
- Key structure: `{ProjectName}/{RoundName}/{timestamp}-{fileName}`
- File types stored: EXEC_SUMMARY, PRESENTATION, VIDEO, BUSINESS_PLAN, VIDEO_PITCH, SUPPORTING_DOC, OTHER
**Caching:**
- None (in-memory rate limiter in `src/lib/rate-limit.ts`, not a caching layer)
- Note: rate limiter is in-memory only — not suitable for multi-instance deployments
## Authentication & Identity
**Auth Provider: NextAuth v5 (self-hosted)**
- Implementation: `src/lib/auth.ts` + `src/lib/auth.config.ts`
- Adapter: `@auth/prisma-adapter` (stores sessions/tokens in PostgreSQL)
- Strategy: JWT sessions (24-hour default, configurable via `SESSION_MAX_AGE`)
- Session includes: `user.id`, `user.email`, `user.name`, `user.role`, `user.roles[]`, `user.mustSetPassword`
**Auth Providers:**
1. Email (Magic Links)
- NextAuth `EmailProvider` — magic link sent via Nodemailer
- Link expiry: 15 minutes (`MAGIC_LINK_EXPIRY` env var or default 900s)
- Custom send function: `sendMagicLinkEmail()` in `src/lib/email.ts`
2. Credentials (Password + Invite Token)
- Email/password with bcryptjs hashing (`src/lib/password.ts`)
- Invite token flow: one-time token clears on first use, sets `mustSetPassword: true`
- Failed login tracking: 5-attempt lockout, 15-minute duration (in-memory, not persistent)
- `mustSetPassword` flag forces redirect to `/set-password` before any other page
**Role System:**
- User model has `role` (primary, legacy scalar) and `roles` (array, multi-role)
- `userHasRole()` helper in `src/server/trpc.ts` checks `roles[]` with `[role]` fallback
- 8 roles: `SUPER_ADMIN`, `PROGRAM_ADMIN`, `JURY_MEMBER`, `MENTOR`, `OBSERVER`, `APPLICANT`, `AWARD_MASTER`, `AUDIENCE`
## Monitoring & Observability
**Error Tracking:**
- Not detected (no Sentry, Datadog, or similar third-party service)
**Logs:**
- Custom structured logger: `src/lib/logger.ts`
- Tagged format: `{timestamp} [LEVEL] [Tag] message data`
- Levels: debug, info, warn, error
- Default: `debug` in development, `warn` in production
- Configurable via `LOG_LEVEL` env var
- All output to `console.*` (stdout/stderr)
**Audit Logging:**
- All auth events and admin mutations logged to `AuditLog` DB table
- `src/server/utils/audit.ts` - `logAudit()` helper
- Events tracked: LOGIN_SUCCESS, LOGIN_FAILED, INVITATION_ACCEPTED, all CRUD operations on major entities
- Cron for cleanup: `src/app/api/cron/audit-cleanup/`
**Application Metrics:**
- Health check endpoint: `GET /api/health` (used by Docker healthcheck)
## CI/CD & Deployment
**Hosting:**
- Self-hosted VPS with Docker
- Nginx reverse proxy with SSL (external to compose stack)
- Domain: `monaco-opc.com`
**CI Pipeline:**
- Gitea Actions (self-hosted Gitea at `code.monaco-opc.com/MOPC/MOPC-Portal`)
- Pipeline builds Docker image and pushes to private container registry
- `REGISTRY_URL` env var configures the registry in `docker/docker-compose.yml`
**Docker Setup:**
- Production: `docker/docker-compose.yml` — app + postgres services
- Dev: `docker/docker-compose.dev.yml` — dev stack variant
- App image: standalone Next.js build (`output: 'standalone'`)
- Entrypoint: `docker/docker-entrypoint.sh` — migrations → generate → seed → start
## Environment Configuration
**Required env vars:**
- `DATABASE_URL` - PostgreSQL connection string
- `NEXTAUTH_URL` - Full URL of the app (e.g., `https://monaco-opc.com`)
- `NEXTAUTH_SECRET` / `AUTH_SECRET` - JWT signing secret
- `MINIO_ENDPOINT` - MinIO server URL (internal)
- `MINIO_ACCESS_KEY` - MinIO access key
- `MINIO_SECRET_KEY` - MinIO secret key
- `MINIO_BUCKET` - MinIO bucket name
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` - SMTP credentials
- `EMAIL_FROM` - Sender address
- `CRON_SECRET` - Shared secret for cron endpoint authentication
**Optional env vars:**
- `MINIO_PUBLIC_ENDPOINT` - Public-facing MinIO URL for pre-signed URLs
- `OPENAI_API_KEY` - OpenAI API key (also in SystemSettings)
- `OPENAI_MODEL` - Default AI model (default: `gpt-4o`)
- `OPENAI_BASE_URL` - Custom base URL for OpenAI-compatible providers
- `ANTHROPIC_API_KEY` - Anthropic Claude API key
- `POSTE_API_URL` - Poste.io mail server API URL
- `POSTE_ADMIN_EMAIL`, `POSTE_ADMIN_PASSWORD`, `POSTE_MAIL_DOMAIN` - Poste.io admin
- `SESSION_MAX_AGE` - JWT session duration in seconds (default: 86400)
- `MAX_FILE_SIZE` - Max upload size in bytes (default: 524288000 = 500MB)
- `LOG_LEVEL` - Logging verbosity (debug/info/warn/error)
- `MAGIC_LINK_EXPIRY` - Magic link lifetime in seconds (default: 900)
**Secrets location:**
- `.env` file at repo root (read by Docker Compose via `env_file: .env`)
- Runtime secrets also configurable via `SystemSettings` DB table (admin UI)
## Webhooks & Callbacks
**Incoming:**
- `/api/auth/[...nextauth]` - NextAuth callback routes (magic link verification, OAuth if added)
- No third-party webhook receivers detected
**Outgoing:**
- Configurable webhooks via `Webhook` DB model and `src/server/services/webhook-dispatcher.ts`
- Admin-managed via `src/server/routers/webhook.ts` (SUPER_ADMIN only)
- Signed with HMAC-SHA256 (`X-Webhook-Signature: sha256={sig}`)
- Events dispatched: `evaluation.submitted`, `evaluation.updated`, `project.created`, `project.statusChanged`, `round.activated`, `round.closed`, `assignment.created`, `assignment.completed`, `user.invited`, `user.activated`
- Retry logic: configurable max retries per webhook (010), retry via cron
## Real-Time
**Server-Sent Events (SSE):**
- Endpoint: `/api/sse/` - in-app notifications push
- Used for: real-time notification delivery to connected clients
**Live Voting Stream:**
- Endpoint: `/api/live-voting/stream/` - SSE stream for live ceremony voting cursor
- Service: `src/server/services/live-control.ts`
## Cron Jobs
All cron endpoints protected by `CRON_SECRET` header check:
- `GET /api/cron/reminders` - Evaluation reminders via `src/server/services/evaluation-reminders.ts`
- `GET /api/cron/digest` - Email digests via `src/server/services/email-digest.ts`
- `GET /api/cron/draft-cleanup` - Remove stale draft evaluations
- `GET /api/cron/audit-cleanup` - Purge old audit log entries
## Email
**SMTP Transport:**
- Provider: Poste.io (self-hosted mail server, port 587)
- Client: Nodemailer 7 via `src/lib/email.ts`
- Config priority: SystemSettings DB > env vars
- Transporter cached, rebuilt when config hash changes
- Error handling: email errors logged but never thrown (non-blocking)
---
*Integration audit: 2026-02-26*

View File

@@ -1,141 +0,0 @@
# Technology Stack
**Analysis Date:** 2026-02-26
## Languages
**Primary:**
- TypeScript 5.7 - All application code (strict mode, `noEmit`, `ES2022` target)
**Secondary:**
- CSS (Tailwind utility classes only, no custom CSS files)
## Runtime
**Environment:**
- Node.js >=20.0.0 (engines field in `package.json`)
**Package Manager:**
- npm (standard)
- Lockfile: `package-lock.json` present
## Frameworks
**Core:**
- Next.js 15.1 - App Router, standalone output, Turbopack dev mode
- React 19.0 - Server Components by default; `'use client'` only where needed
**API Layer:**
- tRPC 11 (RC build `11.0.0-rc.678`) - End-to-end typed RPC, superjson transformer
- `@trpc/server`, `@trpc/client`, `@trpc/react-query` - All at same version
**Data:**
- Prisma 6.19 - ORM and schema-first migrations; binary targets: `native`, `windows`, `linux-musl-openssl-3.0.x`
- `@prisma/client` 6.19 - Generated client with connection pool (limit=20, timeout=10)
**Auth:**
- NextAuth v5 (Beta 25) - JWT strategy, 24-hour sessions; Prisma adapter via `@auth/prisma-adapter`
**Forms & Validation:**
- Zod 3.24 - Input validation for all tRPC procedures
- React Hook Form 7.54 - Client-side form state; `@hookform/resolvers` for Zod integration
**UI Components:**
- shadcn/ui (configured via `components.json`) - Radix UI primitives styled with Tailwind
- Radix UI primitives: alert-dialog, avatar, checkbox, collapsible, dialog, dropdown-menu, label, popover, progress, radio-group, scroll-area, select, separator, slider, slot, switch, tabs, toggle, tooltip (all `^1.x` or `^2.x`)
- Tailwind CSS 4.1 - Utility-first, `@tailwindcss/postcss` plugin
- Lucide React 0.563 - Icon library (import-optimized via `next.config.ts`)
- Framer Motion 11 (`motion` package) - Animation
- Tremor 3.18 - Data visualization / chart components
- `@blocknote/react`, `@blocknote/core`, `@blocknote/shadcn` 0.46 - Rich text block editor
- next-themes 0.4 - Dark/light mode switching
- Sonner 2.0 - Toast notifications
**Testing:**
- Vitest 4.0 - Test runner, `fileParallelism: false`, `pool: 'forks'`
- `@playwright/test` 1.49 - E2E test runner
**Build/Dev:**
- Turbopack (built into Next.js 15) - Dev server via `next dev --turbopack`
- tsx 4.19 - Direct TypeScript execution for scripts and seeds
- ESLint 9.17 + `eslint-config-next` 15.1 - Linting
- Prettier 3.4 + `prettier-plugin-tailwindcss` 0.7 - Formatting
## Key Dependencies
**Critical:**
- `superjson` 2.2 - tRPC transformer; enables Date, Map, Set serialization over the wire
- `bcryptjs` 3.0 - Password hashing (no native bcrypt — pure JS for portability)
- `minio` 8.0 - S3-compatible object storage client
- `nodemailer` 7.0 - SMTP email delivery
- `openai` 6.16 - OpenAI SDK for AI features
- `@anthropic-ai/sdk` 0.78 - Anthropic Claude SDK; wrapped in adapter matching OpenAI interface
- `@notionhq/client` 2.3 - Notion API for project import
- `csv-parse` 6.1 - CSV import for candidatures seed
**Infrastructure:**
- `date-fns` 4.1 - Date manipulation
- `use-debounce` 10.0 - Input debouncing
- `@tanstack/react-query` 5.62 - Server state caching (used via tRPC)
- `@dnd-kit/core`, `@dnd-kit/sortable` - Drag-and-drop ordering UI
- `leaflet` 1.9 + `react-leaflet` 5.0 - Map rendering
- `mammoth` 1.11 - DOCX to HTML conversion for file content extraction
- `pdf-parse` 2.4, `unpdf` 1.4 - PDF text extraction
- `html2canvas` 1.4, `jspdf` 4.1, `jspdf-autotable` 5.0 - PDF export for reports
- `franc` 6.2 - Language detection for multilingual project content
- `papaparse` 5.4 - CSV parsing in browser
- `cmdk` 1.0 - Command palette component
- `react-easy-crop` 5.5 - Avatar image cropping
- `react-phone-number-input` 3.4 - International phone number input
- `react-day-picker` 9.13 - Date picker calendar
## Configuration
**TypeScript:**
- `src/tsconfig.json`: strict mode, `ES2022` target, path alias `@/*``./src/*`, bundler module resolution
- Config file: `tsconfig.json`
**Next.js:**
- Config file: `next.config.ts`
- `output: 'standalone'` for Docker deployment
- `typedRoutes: true` for compile-time route safety
- `serverExternalPackages: ['@prisma/client', 'minio']` — not bundled
**Tailwind:**
- Config file: `tailwind.config.ts`
- PostCSS via `postcss.config.mjs` + `@tailwindcss/postcss`
- Brand palette: Primary Red `#de0f1e`, Dark Blue `#053d57`, White `#fefefe`, Teal `#557f8c`
**Vitest:**
- Config file: `vitest.config.ts`
- `environment: 'node'`, `testTimeout: 30000`, sequential execution
- Path alias mirrors tsconfig
**Environment:**
- Required vars: `DATABASE_URL`, `NEXTAUTH_URL`, `NEXTAUTH_SECRET`, `MINIO_ENDPOINT`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY`, `MINIO_BUCKET`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `EMAIL_FROM`, `OPENAI_API_KEY`, `CRON_SECRET`
- Optional vars: `MINIO_PUBLIC_ENDPOINT`, `OPENAI_MODEL` (default: `gpt-4o`), `OPENAI_BASE_URL`, `ANTHROPIC_API_KEY`, `SESSION_MAX_AGE` (default: 86400), `MAX_FILE_SIZE` (default: 524288000), `LOG_LEVEL`, `MAGIC_LINK_EXPIRY` (default: 900)
- All settings also configurable via `SystemSettings` DB table (DB takes priority over env vars)
**Build:**
- `npm run build``next build` → produces `.next/standalone/` output
- `npm run typecheck``tsc --noEmit` (no emit, type checking only)
## Platform Requirements
**Development:**
- Node.js >=20.0.0
- PostgreSQL 16 (via Docker or local)
- MinIO instance (optional in dev, defaults to `localhost:9000`)
**Production:**
- Docker (compose file: `docker/docker-compose.yml`)
- PostgreSQL 16 (`postgres:16-alpine` image) in Docker network
- Next.js app runs as standalone Node.js server on port 7600
- MinIO and Poste.io are external pre-existing services on VPS
- Nginx reverse proxy with SSL (external, not in compose)
- CI/CD: Gitea Actions (image pushed to container registry, `pull_policy: always`)
- App entrypoint (`docker/docker-entrypoint.sh`): runs `prisma migrate deploy``prisma generate` → auto-seeds if DB empty → `node server.js`
---
*Stack analysis: 2026-02-26*

View File

@@ -1,327 +0,0 @@
# Codebase Structure
**Analysis Date:** 2026-02-26
## Directory Layout
```
MOPC/
├── prisma/ # Database schema and migrations
│ ├── schema.prisma # Single source of truth for all models
│ ├── seed.ts # Seed script (imports from docs/CSV files)
│ └── migrations/ # Prisma migration history (auto-generated)
├── src/
│ ├── app/ # Next.js App Router — all routes
│ │ ├── (admin)/ # Admin dashboard route group (SUPER_ADMIN, PROGRAM_ADMIN)
│ │ ├── (jury)/ # Jury evaluation route group (JURY_MEMBER)
│ │ ├── (applicant)/ # Applicant dashboard route group (APPLICANT)
│ │ ├── (mentor)/ # Mentor workspace route group (MENTOR)
│ │ ├── (observer)/ # Observer read-only route group (OBSERVER)
│ │ ├── (auth)/ # Public auth pages (login, verify, onboarding)
│ │ ├── (public)/ # Fully public pages (apply, vote, live-scores)
│ │ ├── (settings)/ # User settings (profile)
│ │ └── api/ # API routes (tRPC, auth, cron, SSE, files, health)
│ ├── components/ # React components organized by domain
│ │ ├── admin/ # Admin-specific components
│ │ ├── jury/ # Jury-specific components
│ │ ├── applicant/ # Applicant-specific components
│ │ ├── mentor/ # Mentor-specific components
│ │ ├── observer/ # Observer-specific components
│ │ ├── public/ # Public-facing components
│ │ ├── forms/ # Shared form components (apply wizard, COI dialog)
│ │ ├── charts/ # Chart/visualization components
│ │ ├── dashboard/ # Dashboard widgets
│ │ ├── layouts/ # Navigation layouts per role (sidebar, nav bars)
│ │ ├── shared/ # Reusable cross-domain components
│ │ └── ui/ # shadcn/ui primitives
│ ├── server/ # Server-only code
│ │ ├── routers/ # tRPC domain routers (44+ files)
│ │ │ └── _app.ts # Root router composing all domains
│ │ ├── services/ # Business logic services
│ │ ├── utils/ # Server utilities (audit, ai-usage, image-upload)
│ │ ├── trpc.ts # tRPC init, middleware, procedure types
│ │ └── context.ts # tRPC context factory (session + prisma + IP)
│ ├── lib/ # Shared libraries (client + server)
│ │ ├── trpc/ # tRPC client (client.ts), server caller (server.ts)
│ │ ├── storage/ # Storage provider abstraction (S3/local)
│ │ ├── whatsapp/ # WhatsApp notification client
│ │ ├── auth.ts # NextAuth full configuration
│ │ ├── auth.config.ts # Edge-compatible auth config (middleware)
│ │ ├── auth-redirect.ts # requireRole() server helper
│ │ ├── prisma.ts # Prisma singleton with connection pooling
│ │ ├── logger.ts # Structured logger (tagged, level-aware)
│ │ ├── email.ts # Nodemailer email sender
│ │ ├── minio.ts # MinIO client initialization
│ │ ├── openai.ts # OpenAI client initialization
│ │ ├── rate-limit.ts # In-memory rate limiter
│ │ ├── feature-flags.ts # DB-backed feature flags
│ │ ├── round-config.ts # Round config helper utilities
│ │ ├── utils.ts # General utilities (cn, formatters)
│ │ └── [others] # countries, pdf-generator, typeform, notion, etc.
│ ├── types/ # TypeScript type definitions
│ │ ├── competition.ts # Composite types for Competition/Round domain
│ │ ├── competition-configs.ts # Per-RoundType Zod schemas + inferred types
│ │ └── wizard-config.ts # Application wizard configuration types
│ ├── hooks/ # Custom React hooks
│ │ ├── use-debounce.ts
│ │ ├── use-live-voting-sse.ts # SSE subscription for live voting
│ │ └── use-stage-live-sse.ts # SSE subscription for live stage
│ └── contexts/ # React contexts
│ └── edition-context.tsx # Edition/Program selector context
├── tests/ # Test files (Vitest)
│ ├── setup.ts # Test setup (prisma client, helpers)
│ ├── helpers.ts # Test factories (createTestUser, createTestCompetition, etc.)
│ └── unit/ # Unit test files
├── docs/ # Internal documentation and architecture notes
├── docker/ # Docker Compose configs and Nginx config
├── public/ # Static assets (fonts, images, maps)
├── scripts/ # Utility scripts
├── middleware.ts # Next.js edge middleware (auth check)
├── next.config.ts # Next.js config (standalone output, legacy redirects)
└── prisma/ # (see above)
```
## Directory Purposes
**`src/app/(admin)/admin/`:**
- Purpose: All admin pages behind SUPER_ADMIN/PROGRAM_ADMIN role gate
- Contains: Competition management, round config, project management, jury groups, members, programs, reports, audit, awards, settings, messages, mentors, partners, learning
- Key files: `layout.tsx` (role guard + edition selector), `admin/page.tsx` (dashboard), `admin/rounds/[roundId]/page.tsx` (round detail — largest page)
**`src/app/(jury)/jury/`:**
- Purpose: Jury evaluation interface behind JURY_MEMBER role gate
- Contains: Competitions list, round overview, project list, evaluate page, deliberation, live voting, learning resources, awards
- Key files: `layout.tsx` (role guard + onboarding check), `competitions/[roundId]/projects/[projectId]/evaluate/page.tsx` (evaluation form)
**`src/app/(applicant)/applicant/`:**
- Purpose: Applicant dashboard behind APPLICANT role gate
- Contains: Competition progress, documents, evaluations received, mentor chat, resources, team
- Key files: `layout.tsx`, `applicant/page.tsx`
**`src/app/(mentor)/mentor/`:**
- Purpose: Mentor workspace behind MENTOR role gate
- Contains: Project list, workspace per project, resources
- Key files: `layout.tsx`, `mentor/workspace/[projectId]/page.tsx`
**`src/app/(observer)/observer/`:**
- Purpose: Read-only view behind OBSERVER role gate
- Contains: Projects, reports
- Key files: `layout.tsx`
**`src/app/(public)/`:**
- Purpose: No-auth-required pages
- Contains: Application form (`apply/[slug]`), edition application (`apply/edition/[programSlug]`), live scores display (`live-scores/[sessionId]`), audience vote (`vote/[sessionId]`), submission status (`my-submission/[id]`), email change password
- Key files: `apply/[slug]/page.tsx` (application wizard)
**`src/app/(auth)/`:**
- Purpose: Auth flow pages
- Contains: Login, verify (magic link), verify-email, accept-invite, onboarding, set-password, error
- Key files: `login/page.tsx`, `onboarding/page.tsx`, `accept-invite/page.tsx`
**`src/app/api/`:**
- Purpose: Next.js route handlers for non-tRPC API
- Contains:
- `trpc/[trpc]/` — tRPC HTTP adapter (GET + POST)
- `auth/[...nextauth]/` — NextAuth handler
- `cron/` — Cron job endpoints (audit-cleanup, digest, draft-cleanup, reminders)
- `live-voting/stream/` — SSE stream for live voting
- `files/bulk-download/` — Bulk file download handler
- `storage/local/` — Local dev storage handler
- `health/` — DB health check endpoint
**`src/server/routers/`:**
- Purpose: tRPC domain routers, one file per domain
- Contains: 44+ router files assembled in `_app.ts`
- Key files: `competition.ts`, `round.ts`, `roundEngine.ts`, `evaluation.ts`, `filtering.ts`, `deliberation.ts`, `resultLock.ts`, `roundAssignment.ts`, `assignment.ts`, `project.ts`, `user.ts`, `program.ts`
**`src/server/services/`:**
- Purpose: All business logic — state machines, AI integrations, external service calls
- Contains:
- `round-engine.ts` — Round and project state machine
- `deliberation.ts` — Deliberation session lifecycle (DELIB_OPEN → VOTING → TALLYING → DELIB_LOCKED)
- `round-assignment.ts` — Jury assignment generation with policy enforcement
- `smart-assignment.ts` — Scoring algorithm (tag overlap, bio match, workload, geo-diversity, COI, availability)
- `submission-manager.ts` — Submission window lifecycle and file requirement enforcement
- `result-lock.ts` — Immutable result locking with snapshot
- `live-control.ts` — Live ceremony cursor management
- `competition-context.ts` — Cross-cutting context resolver
- `ai-filtering.ts`, `ai-assignment.ts`, `ai-evaluation-summary.ts`, `ai-tagging.ts`, `ai-award-eligibility.ts`, `ai-shortlist.ts` — AI feature services
- `anonymization.ts` — PII stripping before AI calls
- `notification.ts`, `in-app-notification.ts`, `evaluation-reminders.ts`, `email-digest.ts` — Notification services
- `assignment-policy.ts`, `assignment-intent.ts` — Policy governance for assignments
- `mentor-matching.ts`, `mentor-workspace.ts` — Mentor domain services
**`src/components/admin/round/`:**
- Purpose: Components for the round detail page (the most complex admin page)
- Key files: `filtering-dashboard.tsx`, `project-states-table.tsx`
**`src/components/admin/rounds/config/`:**
- Purpose: Per-RoundType config form sections
- Contains: Config UI for each round type (`intake-config.tsx`, `evaluation-config.tsx`, etc.)
**`src/components/shared/`:**
- Purpose: Domain-agnostic reusable components
- Contains: `page-header.tsx`, `status-badge.tsx`, `file-upload.tsx`, `file-viewer.tsx`, `pagination.tsx`, `notification-bell.tsx`, `edition-selector.tsx`, `empty-state.tsx`, `loading-spinner.tsx`, and others
**`src/components/ui/`:**
- Purpose: shadcn/ui primitive components (never modified directly)
- Contains: `button.tsx`, `card.tsx`, `dialog.tsx`, `form.tsx`, `select.tsx`, `table.tsx`, etc.
**`src/components/layouts/`:**
- Purpose: Role-specific navigation shells
- Contains: `admin-sidebar.tsx`, `jury-nav.tsx`, `mentor-nav.tsx`, `observer-nav.tsx`, `applicant-nav.tsx`, `role-nav.tsx`, `admin-edition-wrapper.tsx`
**`src/lib/trpc/`:**
- Purpose: tRPC client configuration
- Contains:
- `client.ts``createTRPCReact<AppRouter>()` export (client components use `import { trpc } from '@/lib/trpc/client'`)
- `server.ts` — Server-side caller for Server Components
- `index.ts` — Provider setup (TRPCProvider + QueryClientProvider)
**`src/types/`:**
- Purpose: Shared TypeScript types not generated by Prisma
- Contains:
- `competition.ts` — Composite types with nested relations (e.g., `CompetitionWithRounds`, `RoundWithRelations`)
- `competition-configs.ts` — Per-RoundType Zod config schemas and inferred TypeScript types
- `wizard-config.ts` — Application wizard step configuration types
## Key File Locations
**Entry Points:**
- `middleware.ts` — Edge middleware (auth check before every request)
- `src/app/api/trpc/[trpc]/route.ts` — tRPC HTTP handler
- `src/app/api/auth/[...nextauth]/route.ts` — Auth handler
- `src/server/routers/_app.ts` — Root tRPC router
**Configuration:**
- `prisma/schema.prisma` — Database schema
- `next.config.ts` — Next.js configuration + legacy route redirects
- `src/lib/auth.config.ts` — Edge-compatible NextAuth config + Session type augmentations
- `src/lib/auth.ts` — Full NextAuth configuration with providers
- `src/server/trpc.ts` — tRPC initialization and all procedure type definitions
- `src/server/context.ts` — tRPC context (session, prisma, ip, userAgent)
- `tsconfig.json` — TypeScript strict mode config with `@/` path alias
**Core Logic:**
- `src/server/services/round-engine.ts` — Round state machine
- `src/server/services/deliberation.ts` — Deliberation state machine
- `src/server/services/round-assignment.ts` — Assignment generation
- `src/server/services/smart-assignment.ts` — Scoring algorithm
- `src/server/services/competition-context.ts` — Context resolver
- `src/types/competition-configs.ts` — Zod schemas for round configs
- `src/server/utils/audit.ts` — Audit logging utility
**Testing:**
- `tests/setup.ts` — Vitest setup with Prisma client
- `tests/helpers.ts` — Test data factories
- `tests/unit/` — Unit test files
- `vitest.config.ts` — Vitest configuration
## Naming Conventions
**Files:**
- kebab-case for all source files: `round-engine.ts`, `admin-sidebar.tsx`, `use-live-voting-sse.ts`
- Router files match domain name: `competition.ts`, `roundEngine.ts` (camelCase variants also seen for compound names)
- Service files use kebab-case: `round-assignment.ts`, `ai-filtering.ts`, `result-lock.ts`
**Directories:**
- kebab-case for all directories: `admin/`, `round-assignment/`, `apply-steps/`
- Route group segments use parentheses per Next.js convention: `(admin)`, `(jury)`, `(public)`
- Dynamic segments use square brackets: `[roundId]`, `[projectId]`, `[trpc]`
**Components:**
- PascalCase exports: `AdminSidebar`, `FilteringDashboard`, `JurorProgressDashboard`
- Component files kebab-case: `admin-sidebar.tsx`, `filtering-dashboard.tsx`
**Types:**
- `type` keyword preferred over `interface` (TypeScript strict mode project)
- Prisma-generated types used directly where possible; composite types in `src/types/`
- Zod schemas named `[Domain]ConfigSchema`; inferred types named `[Domain]Config`
## Where to Add New Code
**New tRPC Domain Router:**
- Router file: `src/server/routers/[domain].ts`
- Register in: `src/server/routers/_app.ts`
- Follow pattern: import from `../trpc`, use typed procedure (`adminProcedure`, `juryProcedure`, etc.), call `logAudit()` on mutations
**New Business Logic Service:**
- Implementation: `src/server/services/[domain].ts`
- Accept `prisma: PrismaClient | any` as parameter (for transaction compatibility)
- Return typed result objects `{ success: boolean, errors?: string[] }` for state machine functions
- Call `logAudit()` for all state changes
- Never import tRPC types — services are tRPC-agnostic
**New Admin Page:**
- Page file: `src/app/(admin)/admin/[section]/page.tsx`
- Layout guard is inherited from `src/app/(admin)/layout.tsx` — no additional role check needed
- Use `export const dynamic = 'force-dynamic'` for data-fetching pages
- Fetch data server-side in page component using `auth()` + `prisma` directly, or use client component with tRPC hooks
**New Jury Page:**
- Page file: `src/app/(jury)/jury/[section]/page.tsx`
- Layout guard in `src/app/(jury)/layout.tsx` checks `JURY_MEMBER` role and onboarding completion
**New Public Page:**
- Page file: `src/app/(public)/[section]/page.tsx`
- No auth guard — fully public
**New Component (domain-specific):**
- Admin component: `src/components/admin/[subdomain]/[component-name].tsx`
- Jury component: `src/components/jury/[component-name].tsx`
- Shared component: `src/components/shared/[component-name].tsx`
**New shadcn/ui Primitive:**
- Location: `src/components/ui/[component].tsx` (generated via `npx shadcn@latest add [component]`)
**New Round Config Schema:**
- Add Zod schema to `src/types/competition-configs.ts` following existing pattern
- Add to `RoundConfigMap` discriminated union
- Update `validateRoundConfig()` and `safeValidateRoundConfig()` switch statements
- Add config UI component to `src/components/admin/rounds/config/`
**Utilities:**
- Shared server+client helpers: `src/lib/utils.ts` or new `src/lib/[utility].ts`
- Server-only utilities: `src/server/utils/[utility].ts`
- Custom React hooks: `src/hooks/use-[name].ts`
## Special Directories
**`prisma/migrations/`:**
- Purpose: Auto-generated SQL migration files
- Generated: Yes (by `prisma migrate dev`)
- Committed: Yes
**`.next/`:**
- Purpose: Next.js build output cache
- Generated: Yes
- Committed: No
**`docs/`:**
- Purpose: Internal architecture notes, redesign plans, GDPR documentation, feature plans
- Generated: No
- Committed: Yes
**`prototypes/`:**
- Purpose: HTML/CSS prototype mockups for admin redesign
- Generated: No
- Committed: Yes
**`docker/`:**
- Purpose: Docker Compose files for production and dev stacks; Nginx reverse proxy config
- Generated: No
- Committed: Yes
**`.planning/`:**
- Purpose: GSD planning documents (codebase analysis, implementation plans)
- Generated: By GSD tooling
- Committed: No (gitignored)
**`.serena/`:**
- Purpose: Serena MCP project cache and memories
- Generated: Yes
- Committed: No
---
*Structure analysis: 2026-02-26*

View File

@@ -1,289 +0,0 @@
# Testing Patterns
**Analysis Date:** 2026-02-26
## Test Framework
**Runner:**
- Vitest 4.0.18
- Config: `vitest.config.ts`
- Environment: `node` (no jsdom — tests are server-side only)
- Globals: `true``describe`, `it`, `expect` available without imports (but explicit imports are used in practice)
- `fileParallelism: false` — test files run sequentially
- `pool: 'forks'` — each test file in isolated subprocess
**Assertion Library:**
- Vitest built-in (`expect`)
**Path Aliases:**
- `@/` resolves to `./src/` in test files (configured in `vitest.config.ts` via `resolve.alias`)
**Run Commands:**
```bash
npx vitest # Watch mode (all tests)
npx vitest run # Run all tests once
npx vitest run tests/unit/assignment-policy.test.ts # Single file
npx vitest run -t 'test name' # Single test by name/pattern
```
**Timeout:**
- Default `testTimeout: 30000` (30 seconds) — allows for database operations
## Test File Organization
**Location:**
- All tests live under `tests/` (not co-located with source files)
- `tests/unit/` — pure-logic tests, no database
- `tests/integration/` — database-backed tests using real Prisma client (currently `assignment-policy.test.ts` in both directories)
- Setup: `tests/setup.ts`
- Factories: `tests/helpers.ts`
**Naming:**
- `{domain}.test.ts` — matches domain name: `assignment-policy.test.ts`, `round-engine.test.ts`
- No `.spec.ts` files — exclusively `.test.ts`
**Structure:**
```
tests/
├── setup.ts # Global test context, prisma client, createTestContext()
├── helpers.ts # Test data factories (createTestUser, createTestRound, etc.)
├── unit/
│ └── assignment-policy.test.ts # Pure logic, no DB
└── integration/
└── assignment-policy.test.ts # DB-backed tests
```
## Test Structure
**Suite Organization:**
```typescript
import { describe, it, expect } from 'vitest'
import type { CapMode } from '@prisma/client'
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
// ============================================================================
// Section Title with box dividers
// ============================================================================
describe('functionName', () => {
it('returns expected value when condition', () => {
const result = functionName(input)
expect(result.value).toBe(expected)
expect(result.source).toBe('system')
})
describe('nested scenario group', () => {
it('specific behavior', () => { ... })
})
})
```
**Helper/Stub Pattern:**
```typescript
// Builder functions at top of file construct minimal test objects
function baseMemberContext(overrides: Partial<MemberContext> = {}): MemberContext {
return {
competition: {} as any,
round: {} as any,
member: { id: 'member-1', role: 'MEMBER', ... } as any,
currentAssignmentCount: 0,
...overrides,
}
}
function withJuryGroup(ctx: MemberContext, groupOverrides = {}): MemberContext {
return { ...ctx, juryGroup: { id: 'jg-1', defaultMaxAssignments: 20, ...groupOverrides } as any }
}
```
**Patterns:**
- Build minimal context objects inline — no heavy mocking frameworks
- Use spread + override: `{ ...ctx, member: { ...ctx.member, maxAssignmentsOverride: 10 } }`
- Assert on both value AND metadata: `expect(result.value).toBe(25)` + `expect(result.source).toBe('jury_group')`
- Tests are descriptive: `'admin per-member override takes precedence over group default'`
## Mocking
**Framework:** None — unit tests avoid mocking entirely by testing pure functions.
**Approach:**
- Unit tests pass plain JavaScript objects (`{} as any`) for unused dependencies
- No `vi.mock()`, `vi.fn()`, or `vi.spyOn()` observed in current test files
- Prisma is a real client connected to a test database (see integration tests)
- tRPC context is constructed via `createTestContext(user)` — a plain object, not mocked
**What to Mock:**
- External I/O (email, MinIO, OpenAI) — not currently tested; fire-and-forget pattern used
- Anything not relevant to the assertion being made (`{} as any` for unused context fields)
**What NOT to Mock:**
- Business logic functions under test
- Prisma in integration tests — use real database with `DATABASE_URL_TEST`
- The `createTestContext` / `createCaller` — these are lightweight stubs, not mocks
## Fixtures and Factories
**Test Data (from `tests/helpers.ts`):**
```typescript
// uid() creates unique prefixed IDs to avoid collisions
export function uid(prefix = 'test'): string {
return `${prefix}-${randomUUID().slice(0, 12)}`
}
// Factories accept overrides for specific test scenarios
export async function createTestUser(
role: UserRole = 'JURY_MEMBER',
overrides: Partial<{ email: string; name: string; ... }> = {}
) {
const id = uid('user')
return prisma.user.create({
data: {
id,
email: overrides.email ?? `${id}@test.local`,
role,
...
}
})
}
```
**Available Factories:**
- `createTestUser(role, overrides)` — creates User in database
- `createTestProgram(overrides)` — creates Program
- `createTestCompetition(programId, overrides)` — creates Competition
- `createTestRound(competitionId, overrides)` — creates Round (default: EVALUATION, ROUND_ACTIVE)
- `createTestProject(programId, overrides)` — creates Project
- `createTestProjectRoundState(projectId, roundId, overrides)` — creates ProjectRoundState
- `createTestAssignment(userId, projectId, roundId, overrides)` — creates Assignment
- `createTestEvaluation(assignmentId, formId, overrides)` — creates Evaluation
- `createTestEvaluationForm(roundId, criteria)` — creates EvaluationForm
- `createTestFilteringRule(roundId, overrides)` — creates FilteringRule
- `createTestCOI(assignmentId, userId, projectId, hasConflict)` — creates ConflictOfInterest
- `createTestCohort(roundId, overrides)` — creates Cohort
- `createTestCohortProject(cohortId, projectId)` — creates CohortProject
**Location:**
- Factories in `tests/helpers.ts`
- Shared Prisma client in `tests/setup.ts`
## Coverage
**Requirements:** None enforced — no coverage thresholds configured.
**View Coverage:**
```bash
npx vitest run --coverage # Requires @vitest/coverage-v8 (not currently installed)
```
## Test Types
**Unit Tests (`tests/unit/`):**
- Scope: Pure business logic functions with no I/O
- Approach: Construct in-memory objects, call function, assert return value
- Examples: `assignment-policy.test.ts` tests `resolveEffectiveCap`, `evaluateAssignmentPolicy`
- No database, no HTTP, no file system
**Integration Tests (`tests/integration/`):**
- Scope: tRPC router procedures via `createCaller`
- Approach: Create real database records → call procedure → assert DB state or return value → cleanup
- Uses `DATABASE_URL_TEST` (or falls back to `DATABASE_URL`)
- Sequential execution (`fileParallelism: false`) to avoid DB conflicts
**E2E Tests:**
- Playwright configured (`@playwright/test` installed, `npm run test:e2e` script)
- No test files found yet — framework is available but not implemented
## Common Patterns
**Integration Test Pattern (calling tRPC procedures):**
```typescript
import { describe, it, expect, afterAll } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, createTestCompetition, cleanupTestData, uid } from '../helpers'
import { roundRouter } from '@/server/routers/round'
describe('round procedures', () => {
let programId: string
let adminUser: Awaited<ReturnType<typeof createTestUser>>
beforeAll(async () => {
adminUser = await createTestUser('SUPER_ADMIN')
const program = await createTestProgram()
programId = program.id
})
it('activates a round', async () => {
const competition = await createTestCompetition(programId)
const caller = createCaller(roundRouter, adminUser)
const result = await caller.activate({ roundId: round.id })
expect(result.status).toBe('ROUND_ACTIVE')
})
afterAll(async () => {
await cleanupTestData(programId, [adminUser.id])
})
})
```
**Unit Test Pattern (pure logic):**
```typescript
import { describe, it, expect } from 'vitest'
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
describe('resolveEffectiveCap', () => {
it('returns system default when no jury group', () => {
const ctx = baseMemberContext() // local builder function
const result = resolveEffectiveCap(ctx)
expect(result.value).toBe(SYSTEM_DEFAULT_CAP)
expect(result.source).toBe('system')
})
})
```
**Async Testing:**
```typescript
it('creates evaluation', async () => {
const result = await caller.evaluation.start({ assignmentId: assignment.id })
expect(result.status).toBe('DRAFT')
})
```
**Error Testing:**
```typescript
it('throws FORBIDDEN when accessing others evaluation', async () => {
const otherUser = await createTestUser('JURY_MEMBER')
const caller = createCaller(evaluationRouter, otherUser)
await expect(
caller.get({ assignmentId: assignment.id })
).rejects.toThrow('FORBIDDEN')
})
```
**Cleanup (afterAll):**
```typescript
afterAll(async () => {
// Pass programId to cascade-delete competition data, plus explicit user IDs
await cleanupTestData(programId, [adminUser.id, jurorUser.id])
})
```
## Test Infrastructure Details
**`createTestContext(user)`** in `tests/setup.ts`:
- Builds a fake tRPC context matching `{ session: { user, expires }, prisma, ip, userAgent }`
- `prisma` is the shared test client
- Used internally by `createCaller`
**`createCaller(routerModule, user)`** in `tests/setup.ts`:
- Shorthand: `const caller = createCaller(evaluationRouter, adminUser)`
- Returns type-safe caller — procedures called as `await caller.procedureName(input)`
- Import the router module directly, not `appRouter`
**Database Isolation:**
- Tests share one database — isolation is by unique IDs (via `uid()`)
- `cleanupTestData(programId)` does ordered deletion respecting FK constraints
- Always call `cleanupTestData` in `afterAll`, never skip
---
*Testing analysis: 2026-02-26*

View File

@@ -1,118 +0,0 @@
---
phase: 01-ai-ranking-backend
plan: "01"
subsystem: schema
tags: [prisma, schema, migration, zod, ranking]
dependency_graph:
requires: []
provides: [RankingSnapshot-model, RankingSnapshot-enums, EvaluationConfig-ranking-fields]
affects: [01-02, 01-03, 01-04]
tech_stack:
added: [RankingSnapshot Prisma model, RankingTriggerType enum, RankingMode enum, RankingSnapshotStatus enum]
patterns: [FilteringJob pattern for job models, Zod optional fields with defaults for backward compatibility]
key_files:
created:
- prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql
modified:
- prisma/schema.prisma
- src/types/competition-configs.ts
decisions:
- "Used separate relation names: RoundRankingSnapshots (Round FK) and TriggeredRankingSnapshots (User FK) to avoid Prisma ambiguous relation error — each FK pair on RankingSnapshot gets its own named relation"
- "Created migration SQL manually (20260227000000) since local DB credentials were unavailable; migration file is correct and will apply cleanly on next deploy"
- "All three ranking fields (rankingEnabled, rankingCriteria, autoRankOnComplete) are optional/defaulted for zero-migration compatibility with existing EvaluationConfig data"
metrics:
duration: "~7 minutes"
completed: "2026-02-27"
tasks_completed: 2
files_changed: 3
---
# Phase 1 Plan 01: RankingSnapshot Schema + EvaluationConfig Ranking Fields Summary
**One-liner:** Added RankingSnapshot Prisma model with 3 enums and migration SQL, plus 3 ranking fields to EvaluationConfigSchema, establishing the data contracts for Plans 02-04.
## What Was Built
### Task 1: RankingSnapshot model + enums (schema.prisma)
Added three new enums to `prisma/schema.prisma`:
- `RankingTriggerType` — MANUAL, AUTO, RETROACTIVE, QUICK
- `RankingMode` — PREVIEW, CONFIRMED, QUICK
- `RankingSnapshotStatus` — PENDING, RUNNING, COMPLETED, FAILED
Added `RankingSnapshot` model with:
- `roundId` FK → Round (Cascade delete, named relation "RoundRankingSnapshots")
- `triggeredById` FK → User (SetNull on delete, named relation "TriggeredRankingSnapshots")
- `criteriaText` (Text), `parsedRulesJson` (JsonB) — criteria + parsed rules
- `startupRankingJson`, `conceptRankingJson`, `evaluationDataJson` (optional JsonB) — results per category
- `mode`, `status` — with sensible defaults (PREVIEW, COMPLETED)
- `reordersJson` (optional JsonB) — for Phase 2 drag-and-drop
- `model`, `tokensUsed` — AI metadata
- Indexes on roundId, triggeredById, createdAt
Added back-relations:
- `Round.rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")`
- `User.rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")`
Created migration: `prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql`
### Task 2: EvaluationConfigSchema ranking fields (competition-configs.ts)
Appended to `EvaluationConfigSchema` in `src/types/competition-configs.ts`:
```typescript
// Ranking (Phase 1)
rankingEnabled: z.boolean().default(false),
rankingCriteria: z.string().optional(),
autoRankOnComplete: z.boolean().default(false),
```
All fields are intentionally optional/defaulted so existing rounds parse without errors.
## TDD Verification Results
All four TDD cases from the plan pass:
- `EvaluationConfigSchema.parse({})``{rankingEnabled: false, autoRankOnComplete: false, rankingCriteria: undefined}`
- `EvaluationConfigSchema.parse({rankingEnabled: true, rankingCriteria: "rank by score"})` → succeeds ✓
- `EvaluationConfigSchema.parse({rankingCriteria: 123})` → throws ZodError ✓
- `prisma.rankingSnapshot` accessible in generated client ✓
## Key Decisions
1. **Separate relation names per FK pair:** Used `RoundRankingSnapshots` for the Round → RankingSnapshot relation and `TriggeredRankingSnapshots` for the User → RankingSnapshot relation. Each FK pair requires its own named relation in Prisma to avoid ambiguous relation errors.
2. **Manual migration file:** Local PostgreSQL credentials were unavailable (DB running but `mopc:devpassword` rejected). Created migration SQL manually following the exact Prisma-generated format. The migration will apply on next `prisma migrate deploy` or Docker restart.
3. **Backward-compatible defaults:** All three EvaluationConfig ranking fields default to `false`/`undefined` so existing round configs parse cleanly without migration.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Database authentication unavailable for migration**
- **Found during:** Task 1 (migration step)
- **Issue:** PostgreSQL running locally but `mopc:devpassword` credentials rejected — P1000 auth error on `npx prisma migrate dev`
- **Fix:** Created migration SQL file manually at `prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql` following exact Prisma format. Ran `npx prisma generate` separately (no DB needed) to regenerate client.
- **Impact:** Migration file is correct and complete; will apply on first DB connection or Docker deploy. TypeScript typecheck passes confirming no schema errors.
- **Files modified:** `prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql` (created)
**2. [Rule 2 - Schema] Separate relation names per FK pair**
- **Found during:** Task 1 (schema design)
- **Issue:** Plan's implementation note mentioned "TriggeredRankingSnapshots" for both the Round and User relations, but Prisma requires unique relation names per FK pair (not per target model)
- **Fix:** Used `RoundRankingSnapshots` for Round FK and `TriggeredRankingSnapshots` for User FK — distinct names per FK pair as Prisma requires
- **Files modified:** `prisma/schema.prisma`
## Self-Check
### Files Exist
- [x] `prisma/schema.prisma` — contains `model RankingSnapshot`, all 3 enums, back-relations on Round and User
- [x] `prisma/migrations/20260227000000_add_ranking_snapshot/migration.sql` — migration SQL created
- [x] `src/types/competition-configs.ts` — EvaluationConfigSchema has rankingEnabled, rankingCriteria, autoRankOnComplete
### Commits Exist
- [x] `91bc100` — feat(01-01): add RankingSnapshot model + enums to schema.prisma
- [x] `af9528d` — feat(01-01): extend EvaluationConfigSchema with ranking fields
### TypeScript Clean
- [x] `npm run typecheck` exits 0 — no errors
## Self-Check: PASSED

View File

@@ -1,134 +0,0 @@
---
phase: 01-ai-ranking-backend
plan: 04
subsystem: ai-ranking
tags: [auto-trigger, notifications, ranking, evaluation, tRPC]
requirements: [RANK-09, RANK-10]
dependency_graph:
requires: [01-01, 01-02, 01-03]
provides: [auto-trigger-on-evaluation-complete, retroactive-scan, admin-notifications]
affects: [evaluation-submit-mutation, ranking-router, in-app-notifications]
tech_stack:
added: []
patterns: [fire-and-forget async, cooldown-guard, module-level-helper-function]
key_files:
created: []
modified:
- src/server/services/in-app-notification.ts
- src/server/routers/evaluation.ts
- src/server/routers/ranking.ts
decisions:
- "triggerAutoRankIfComplete defined as module-level (non-exported) function in evaluation.ts — avoids circular imports and keeps the auto-trigger logic colocated with the mutation it serves"
- "EvaluationConfig null fallback typed as {} as EvaluationConfig rather than just {} — required for TypeScript strict mode to recognize rankingCriteria and autoRankOnComplete fields"
- "ParsedRankingRule[] cast via unknown as Prisma.InputJsonValue — Prisma InputJsonValue does not overlap with typed arrays, double-cast is the correct pattern throughout the codebase"
- "retroactiveScan uses RETROACTIVE triggerType to distinguish from MANUAL/AUTO/QUICK — prevents duplicate re-runs on subsequent scans"
- "triggerAutoRank procedure in ranking.ts uses MANUAL triggerType (not AUTO) because it is admin-initiated, not system-initiated"
metrics:
duration: ~8min
completed_date: "2026-02-27"
tasks_completed: 2
files_modified: 3
---
# Phase 1 Plan 04: Auto-Trigger + Retroactive Scan Summary
**One-liner:** Fire-and-forget auto-ranking on evaluation completion with 5-minute cooldown guard, plus retroactive scan procedure for rounds already complete at deploy time.
## What Was Built
### Task 1: AI_RANKING_COMPLETE + AI_RANKING_FAILED notification types
Added two new entries to `src/server/services/in-app-notification.ts`:
- `NotificationTypes.AI_RANKING_COMPLETE` — type string + `BarChart3` icon + `normal` priority
- `NotificationTypes.AI_RANKING_FAILED` — type string + `AlertTriangle` icon + `high` priority
Pattern follows existing FILTERING_COMPLETE / FILTERING_FAILED entries exactly.
### Task 2: Auto-trigger hook + new ranking procedures
**evaluation.ts — `triggerAutoRankIfComplete` function:**
```typescript
async function triggerAutoRankIfComplete(
roundId: string,
prisma: PrismaClient,
userId: string,
): Promise<void>
```
Logic flow:
1. Count required assignments — skip if not all complete
2. Read `round.configJson` → check `autoRankOnComplete` + `rankingCriteria` — skip silently if not configured
3. Cooldown guard — skip if AUTO snapshot exists within last 5 minutes
4. Call `aiQuickRank()` — executes in 10-30s asynchronously
5. Create `RankingSnapshot` with `triggerType: 'AUTO'`, `triggeredById: null`
6. Notify admins via `AI_RANKING_COMPLETE` notification
7. Catch-all: any error sends `AI_RANKING_FAILED` notification, never rethrows
**Fire-and-forget call in submit mutation (line 378):**
```typescript
void triggerAutoRankIfComplete(evaluation.assignment.roundId, ctx.prisma, ctx.user.id)
```
Placed after `$transaction([evaluation.update, assignment.update])`, before `logAudit`. The submission returns immediately — ranking runs asynchronously.
**ranking.ts — 7 total procedures:**
| Procedure | Type | Trigger | Description |
|-----------|------|---------|-------------|
| `parseRankingCriteria` | mutation | admin | Preview-only parse (RANK-01, RANK-03) |
| `executeRanking` | mutation | admin | Confirmed ranking with pre-parsed rules (RANK-05, RANK-06, RANK-08) |
| `quickRank` | mutation | admin | Parse + execute in one step (RANK-04) |
| `listSnapshots` | query | admin | List snapshots for round, most recent first |
| `getSnapshot` | query | admin | Retrieve single snapshot by ID |
| `triggerAutoRank` | mutation | admin | Manual trigger from round config (RANK-09) |
| `retroactiveScan` | mutation | admin | Scan all active/closed rounds (RANK-10) |
**retroactiveScan logic:**
- Finds all `ROUND_ACTIVE` and `ROUND_CLOSED` rounds
- Checks each for `autoRankOnComplete + rankingCriteria` config
- Checks if all required assignments are complete
- Skips rounds that already have a `RETROACTIVE` snapshot
- Executes ranking sequentially (not parallel) to avoid OpenAI rate limits
- Returns `{ results[], total, triggered }` summary
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] TypeScript strict mode: `?? {}` loses EvaluationConfig type**
- **Found during:** Task 2 typecheck
- **Issue:** `(configJson as EvaluationConfig | null) ?? {}` — the `{}` fallback widens the type to `{}` which doesn't have `rankingCriteria` / `autoRankOnComplete` fields, causing TS2339 errors
- **Fix:** Changed to `?? ({} as EvaluationConfig)` in all three locations (evaluation.ts + two in ranking.ts)
- **Files modified:** `src/server/routers/evaluation.ts`, `src/server/routers/ranking.ts`
- **Commit:** c310631
**2. [Rule 1 - Bug] TypeScript: ParsedRankingRule[] requires double-cast to InputJsonValue**
- **Found during:** Task 2 typecheck
- **Issue:** `result.parsedRules as Prisma.InputJsonValue` produces TS2352 — neither type overlaps
- **Fix:** Changed to `result.parsedRules as unknown as Prisma.InputJsonValue` (matching the pattern already used for `rankedProjects` arrays)
- **Files modified:** `src/server/routers/ranking.ts` (triggerAutoRank + retroactiveScan)
- **Commit:** c310631
## Build Status
- `npm run typecheck` — PASSED (0 errors)
- `npm run build` — PASSED (full production build)
## Key Links Implemented
| From | To | Via |
|------|----|-----|
| `evaluation.ts submit` | `triggerAutoRankIfComplete` | `void` fire-and-forget after `isCompleted: true` |
| `triggerAutoRankIfComplete` | `ai-ranking.ts quickRank` | `aiQuickRank(criteriaText, roundId, prisma, userId)` |
| `triggerAutoRankIfComplete` | `in-app-notification.ts` | `notifyAdmins({ type: AI_RANKING_COMPLETE })` |
| `ranking.ts triggerAutoRank` | `ai-ranking.ts quickRank` | admin-initiated, creates MANUAL snapshot |
| `ranking.ts retroactiveScan` | `ai-ranking.ts quickRank` | sequential per-round, creates RETROACTIVE snapshot |
## Self-Check: PASSED
- `src/server/routers/evaluation.ts` — modified, contains `void triggerAutoRankIfComplete(...)`
- `src/server/routers/ranking.ts` — modified, contains `triggerAutoRank` and `retroactiveScan`
- `src/server/services/in-app-notification.ts` — modified, contains `AI_RANKING_COMPLETE` and `AI_RANKING_FAILED`
- Commits: 4683bb8 (Task 1), c310631 (Task 2)
- Build: PASSED

View File

@@ -1,100 +0,0 @@
---
phase: 02-ranking-dashboard-ui
plan: 01
subsystem: ui
tags: [trpc, prisma, ranking, react, nextjs]
# Dependency graph
requires:
- phase: 01-ai-ranking-backend
provides: rankingRouter with listSnapshots/getSnapshot, RankingSnapshot model with reordersJson field
provides:
- saveReorder mutation on rankingRouter (append-only audit log of admin drag-reorders)
- Ranking tab visible on EVALUATION round detail pages
- RankingDashboard stub component (Plan 02 will flesh out)
affects: [02-02-ranking-dashboard-ui]
# Tech tracking
tech-stack:
added: []
patterns:
- "Append-only JSON log pattern: read existing array, push new event, write full array as Prisma.InputJsonValue"
- "isEvaluation guard for tab/content conditional rendering (matches isFiltering pattern)"
key-files:
created:
- src/components/admin/round/ranking-dashboard.tsx
modified:
- src/server/routers/ranking.ts
- src/app/(admin)/admin/rounds/[roundId]/page.tsx
key-decisions:
- "ReorderEvent type defined locally in ranking.ts (not exported) — only used by saveReorder"
- "saveReorder is append-only: full ordered list stored per event, latest entry per category = current admin order, gives full audit trail"
patterns-established:
- "Tab conditional: ...(isEvaluation ? [{ value: 'ranking', label: 'Ranking', icon: BarChart3 }] : []) follows existing isFiltering pattern"
requirements-completed: [DASH-01]
# Metrics
duration: 5min
completed: 2026-02-27
---
# Phase 2 Plan 01: Ranking Tab Entry Point Summary
**saveReorder append-only audit mutation + Ranking tab registered on EVALUATION round detail pages with RankingDashboard stub component**
## Performance
- **Duration:** ~5 min
- **Started:** 2026-02-27T00:15:00Z
- **Completed:** 2026-02-27T00:20:00Z
- **Tasks:** 2
- **Files modified:** 3
## Accomplishments
- Added `saveReorder` adminProcedure to rankingRouter — accepts snapshotId, category, orderedProjectIds; appends ReorderEvent to reordersJson (append-only audit log)
- Registered Ranking tab in round detail page guarded by `isEvaluation` with BarChart3 icon (already imported)
- Created minimal `RankingDashboard` stub component that compiles and renders placeholder text
## Task Commits
Each task was committed atomically:
1. **Task 1: Add saveReorder mutation to ranking router** - `68422e6` (feat)
2. **Task 2: Register Ranking tab in round detail page + create component stub** - `8f71527` (feat)
**Plan metadata:** (docs commit to follow)
## Files Created/Modified
- `src/server/routers/ranking.ts` - Added ReorderEvent local type and saveReorder adminProcedure
- `src/components/admin/round/ranking-dashboard.tsx` - New stub component exporting RankingDashboard with competitionId+roundId props
- `src/app/(admin)/admin/rounds/[roundId]/page.tsx` - Import RankingDashboard, add Ranking tab to tab array, add TabsContent block
## Decisions Made
- `ReorderEvent` type defined locally in ranking.ts (not exported) — only consumed by saveReorder
- Stub uses `_competitionId` / `_roundId` underscore prefix to avoid TypeScript unused-var warnings while keeping the correct prop signature for Plan 02 to use
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- saveReorder backend contract established and type-safe
- Ranking tab entry point wired — visible when viewing EVALUATION rounds
- Plan 02 can now replace the stub body with the full RankingDashboard component
- Build and typecheck both pass with 0 errors
---
*Phase: 02-ranking-dashboard-ui*
*Completed: 2026-02-27*

View File

@@ -1,123 +0,0 @@
---
phase: 02-ranking-dashboard-ui
plan: 02
subsystem: ui
tags: [react, dnd-kit, trpc, ranking, drag-and-drop, sheet-panel]
# Dependency graph
requires:
- phase: 02-01
provides: RankingDashboard stub, saveReorder mutation, Ranking tab entry point
provides:
- Full RankingDashboard component with drag-and-drop reorder, AI vs override visual states, and Sheet-based juror evaluation detail panel
affects: [02-03-advance-projects]
# Tech tracking
tech-stack:
added: []
patterns:
- "useRef init guard: initialized.current prevents localOrder re-init from server data on every re-render — eliminates snap-back"
- "Fire-and-forget mutation inside setLocalOrder callback: setLocalOrder runs synchronously first, mutation fires async, no onSuccess invalidation"
- "Double cast via unknown: Prisma JsonValue cast to RankedProjectEntry[] requires (json ?? []) as unknown as RankedProjectEntry[]"
- "getFullDetail response shape: { project, assignments, stats } — title accessed as projectDetail.project.title"
key-files:
created: []
modified:
- src/components/admin/round/ranking-dashboard.tsx
key-decisions:
- "Double cast (as unknown as RankedProjectEntry[]) required for Prisma JsonValue — direct cast rejected by TypeScript strict mode"
- "getFullDetail returns { project, assignments, stats } shape, not flat — project title accessed via .project.title"
- "saveReorder mutation has no onSuccess invalidation — avoids triggering re-fetch that would reset localOrder"
patterns-established:
- "SortableProjectRow sub-component defined above export in same file (no separate file needed for inline sub-components)"
- "Per-category drag context: separate DndContext per category prevents cross-category drag"
requirements-completed: [DASH-01, DASH-02, DASH-03, DASH-04]
# Metrics
duration: 8min
completed: 2026-02-27
---
# Phase 2 Plan 02: Full RankingDashboard Component Summary
**Full RankingDashboard with per-category drag-and-drop (dnd-kit), AI vs override rank badges, snap-back-proof localOrder state, and lazy-loaded Sheet detail panel showing per-juror evaluation breakdown**
## Performance
- **Duration:** ~8 min
- **Started:** 2026-02-27T08:40:00Z
- **Completed:** 2026-02-27T08:48:11Z
- **Tasks:** 1
- **Files modified:** 1
## Accomplishments
- Replaced RankingDashboard stub with full 486-line implementation
- DASH-01: Ranked project list per category (STARTUP / BUSINESS_CONCEPT) with composite score, pass rate, and evaluator count displayed per row
- DASH-02: Drag-and-drop reorder via GripVertical handle using dnd-kit (DndContext + SortableContext + useSortable), fire-and-forget saveReorder mutation
- DASH-03: localOrder stored in useState with useRef guard (`initialized.current`) — init fires once on first snapshot load, never re-initialized from server data; no snap-back
- DASH-04: Sheet panel opens on row click, lazy-loads `trpc.project.getFullDetail` (enabled only when selectedProjectId is set), displays stats summary and per-juror evaluation list filtered to SUBMITTED assignments for the current round
- AI-order rows display dark-blue rank badge (#N); admin-reordered rows display amber `#N (override)` badge
- "Run Ranking" button in header card calls `triggerAutoRank`, resets `initialized.current` to allow re-init on new snapshot
- Empty categories show a placeholder message instead of an empty drag zone
- TypeScript strict mode: 0 errors; build: PASSED
## Task Commits
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Build full RankingDashboard component | 6512e4e | src/components/admin/round/ranking-dashboard.tsx |
## Files Created/Modified
- `src/components/admin/round/ranking-dashboard.tsx` — Full component replacing stub (486 lines → includes SortableProjectRow sub-component + RankingDashboard main export)
## Decisions Made
- **Double cast via unknown**: `(json ?? []) as unknown as RankedProjectEntry[]` — TypeScript strict mode rejects direct cast from Prisma `JsonValue`; intermediate `unknown` is required. Matches pattern from Phase 01-03.
- **getFullDetail response shape**: The procedure returns `{ project, assignments, stats }` (not flat) — `projectDetail.project.title`, not `projectDetail.title`.
- **No onSuccess invalidation in saveReorder**: Calling `utils.ranking.getSnapshot.invalidate()` in `onSuccess` would trigger a re-fetch that resets `localOrder` to server data, causing snap-back. Mutation only shows toast on error.
- **Per-category DndContext**: Separate `DndContext` per category prevents accidental cross-category drags.
## Deviations from Plan
None — plan executed exactly as written. All type errors encountered were auto-fixed inline (Rule 1 — double cast pattern, Rule 1 — response shape).
### Auto-fixed Issues
**1. [Rule 1 - Bug] Prisma JsonValue cast requires double cast via unknown**
- **Found during:** Task 1 (typecheck)
- **Issue:** `(snapshot.startupRankingJson ?? []) as RankedProjectEntry[]` — TypeScript strict mode rejects because `JsonValue` and `RankedProjectEntry[]` don't sufficiently overlap
- **Fix:** Changed to `as unknown as RankedProjectEntry[]` (identical pattern used in Phase 01-03)
- **Files modified:** ranking-dashboard.tsx
- **Commit:** 6512e4e (same task commit)
**2. [Rule 1 - Bug] getFullDetail response shape — title not on root**
- **Found during:** Task 1 (typecheck)
- **Issue:** `projectDetail?.title` — getFullDetail returns `{ project, assignments, stats }`, not a flat object
- **Fix:** Changed to `projectDetail?.project.title`
- **Files modified:** ranking-dashboard.tsx
- **Commit:** 6512e4e (same task commit)
## Issues Encountered
None beyond the two auto-fixed type errors above.
## User Setup Required
None.
## Next Phase Readiness
- RankingDashboard fully functional — admin can view ranked projects, drag to reorder, see juror-level evaluation details in Sheet panel
- Plan 03 can now add the "Advance Projects" action button to the dashboard header
- saveReorder mutation is append-only audit log — Plan 03 can read latest reorder per category to determine final advance order
- Build and typecheck both pass with 0 errors
---
*Phase: 02-ranking-dashboard-ui*
*Completed: 2026-02-27*

View File

@@ -1,104 +0,0 @@
---
phase: 02-ranking-dashboard-ui
plan: 03
subsystem: ui
tags: [react, trpc, dialog, shadcn, ranking, advance-projects, batch-reject]
# Dependency graph
requires:
- phase: 02-02
provides: Full RankingDashboard with drag-and-drop, AI vs override badges, Sheet detail panel, saveReorderMutation
provides:
- Advance Top N dialog wired to trpc.round.advanceProjects with per-category N inputs
- Batch-reject checkbox in advance dialog wired to trpc.roundEngine.batchTransition
- DASH-07 disabled state: Advance button disabled while saveReorderMutation.isPending
affects: []
# Tech tracking
tech-stack:
added: []
patterns:
- "pendingReorderCount useRef pattern: onMutate increments, onSettled decrements — provides belt-and-suspenders for rapid drag scenarios alongside isPending reactive signal"
- "Parallel mutation from single handler: advanceMutation.mutate + batchRejectMutation.mutate fired in same handleAdvance — fire-and-forget both, each handles own onSuccess/onError"
key-files:
created: []
modified:
- src/components/admin/round/ranking-dashboard.tsx
key-decisions:
- "Advance button disabled via saveReorderMutation.isPending (reactive) not pendingReorderCount.current (ref, non-reactive) — ref used for belt-and-suspenders coverage, boolean state for actual UI"
- "topNStartup + topNConceptual === 0 disables the Advance button inside the dialog — prevents no-op advance calls"
- "batchRejectMutation fired conditionally (only if includeReject and rejectIds.length > 0) — avoids empty batch call"
patterns-established:
- "Per-category N inputs with min/max clamping via Math.max/Math.min on parseInt — prevents out-of-range values"
- "Preview section in dialog: live count of advancing/rejecting projects — feedback before confirmation"
requirements-completed: [DASH-05, DASH-06, DASH-07]
# Metrics
duration: 5min
completed: 2026-02-27
---
# Phase 2 Plan 03: Advance Top N Dialog + Batch-Reject Summary
**Advance Top N dialog with per-category numeric inputs, optional batch-reject checkbox, and disabled state tied to pending reorder mutations — completing the full advancement workflow for the RankingDashboard**
## Performance
- **Duration:** ~5 min
- **Started:** 2026-02-27T08:51:01Z
- **Completed:** 2026-02-27T08:56:00Z
- **Tasks:** 1
- **Files modified:** 1
## Accomplishments
- DASH-05: Admin can click "Advance Top N" button to open a dialog, enter a number N per category, and advance the top N projects from each category to the next round
- DASH-06: Admin can enable "Also batch-reject non-advanced projects" checkbox in the dialog — fires batchTransition for non-advanced projects; toasts use `.succeeded.length` per MEMORY.md
- DASH-07: Advance button is disabled while `saveReorderMutation.isPending` is true (reorder in flight) and when no snapshot exists
- pendingReorderCount ref added to saveReorderMutation (onMutate++, onSettled--) for belt-and-suspenders reorder tracking
- Full dialog: per-category N inputs with range clamping, live preview of advance/reject counts, cancel/confirm buttons
- Both mutations invalidate `roundEngine.getProjectStates` on success to refresh downstream state
- TypeScript strict mode: 0 errors; build: PASSED
## Task Commits
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Add Advance Top N dialog + batch-reject to RankingDashboard | a6f3945 | src/components/admin/round/ranking-dashboard.tsx |
## Files Created/Modified
- `src/components/admin/round/ranking-dashboard.tsx` — Added Dialog, Input, Label imports; pendingReorderCount ref; advanceMutation; batchRejectMutation; handleAdvance; Advance Top N button; Dialog JSX (681 lines total, +197 insertions)
## Decisions Made
- **Advance button disabled state uses `saveReorderMutation.isPending`** (reactive) as the primary signal, not `pendingReorderCount.current` (useRef is not reactive — React won't re-render on ref mutation). The ref still provides belt-and-suspenders for rapid multi-drag scenarios but is not the gating signal.
- **topNStartup + topNConceptual === 0** disables the confirm button inside the dialog — prevents a no-op advance call when both inputs are zero.
- **batchRejectMutation fires conditionally** — only when `includeReject` is true AND `rejectIds.length > 0`. Avoids sending an empty projectIds array to the mutation.
## Deviations from Plan
None — plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- All 7 DASH requirements (DASH-01 through DASH-07) completed across plans 01-03
- Phase 2 (Ranking Dashboard UI) is fully complete
- Phase 3 (Notification Templates) or Phase 4 (Juror Criterion Progress) can begin next
- Full build and typecheck pass with 0 errors
---
*Phase: 02-ranking-dashboard-ui*
*Completed: 2026-02-27*

View File

@@ -33,9 +33,5 @@ else
echo "==> Database already seeded ($USER_COUNT users found), skipping seed."
fi
# Always sync notification email settings (upsert — safe for existing data)
echo "==> Syncing notification email settings..."
npx tsx prisma/seed-notification-settings.ts || echo "WARNING: Notification settings sync failed."
echo "==> Starting application..."
exec node server.js

View File

@@ -1,108 +0,0 @@
# Advance Criterion & Juror Progress Dashboard
**Date:** 2026-02-25
**Status:** Approved
## Problem
Jurors have no visibility into their evaluation progress — specifically how many YES/NO advancement decisions they've made and for which projects. Admins similarly lack a quick summary of advancement votes across the jury.
## Solution
A new `advance` criterion type, a juror-facing progress dashboard, and admin dashboard enhancements.
## Design
### 1. New Criterion Type: `advance`
Added alongside `numeric`, `text`, `boolean`, `section_header`.
**Shape in `criteriaJson`:**
```ts
{
id: string,
label: string, // default "Advance to next round?"
description?: string,
type: "advance",
trueLabel: string, // default "Yes"
falseLabel: string, // default "No"
required: true // always required, not configurable
}
```
**Storage:** `criterionScoresJson` as `{ [criterionId]: true | false }`.
**Constraints:**
- Max one per `EvaluationForm` (enforced in form builder UI and server-side on upsert)
- Always `required: true`
- No `weight` — does not factor into numeric average
- No `condition` — always visible, never conditional
### 2. Round Config Addition
New field in `EvaluationConfig` (JSON column, no migration needed):
```ts
showJurorProgressDashboard: boolean // default false
```
Admin toggle in round config to enable/disable the juror progress view.
### 3. Juror Progress Dashboard
**Location:** Collapsible card above the project assignment cards on `/jury/competitions/[roundId]`, gated by `showJurorProgressDashboard`.
**Data source:** New tRPC query `evaluation.getMyProgress(roundId)` returning:
```ts
{
total: number,
completed: number,
advanceCounts: { yes: number, no: number },
submissions: Array<{
projectId: string,
projectName: string,
submittedAt: Date,
advanceDecision: boolean | null,
criterionScores: Array<{ label: string, value: number }>,
numericAverage: number | null,
}>
}
```
**UI:**
- Progress bar: `completed / total` with percentage (shadcn Progress)
- Advance summary: `X YES · Y NO` inline badges
- Submissions table: Project Name | Numeric Average | per-criterion scores | Advance (green YES / red NO badge) | Date — sorted by `submittedAt` DESC, submitted evaluations only
### 4. Admin Dashboard Changes
**Summary card** (`AdvancementSummaryCard`):
- Renders on round detail page for EVALUATION rounds when form contains an `advance` criterion
- Donut/bar visual: YES / NO / Pending counts with percentages
**Assignments table:**
- New "Advance" column after Score column
- Green YES / red NO / gray "—" badges
### 5. Form Builder Changes
- New button `+ Advance to Next Round?` alongside existing add buttons
- Disabled with tooltip when one already exists in the form
- Edit mode: `trueLabel`/`falseLabel` customization, description field, `required` locked true, no weight/condition
- Juror rendering: two prominent buttons with green/red color treatment
### 6. Scope Boundaries
**In scope:**
- `advance` criterion type (form builder, juror rendering, server validation)
- Juror progress dashboard (gated by round config toggle)
- Admin summary card and table column
- One new tRPC query
**Out of scope:**
- No changes to `binaryDecision` field or `scoringMode: "binary"`
- No changes to AI summary generation
- No schema migration (all JSON columns)
- Export unchanged (advance values flow through `criterionScoresJson` automatically)

View File

@@ -1,844 +0,0 @@
# Advance Criterion & Juror Progress Dashboard — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add an `advance` criterion type to the evaluation form system, a juror-facing progress dashboard showing past submissions with scores and advance decisions, and admin-facing summary card + table column for advancement votes.
**Architecture:** The `advance` type is added to the existing criterion type union and flows through the same `criteriaJson`/`criterionScoresJson` JSON columns — no Prisma schema migration. A new `showJurorProgressDashboard` field in `EvaluationConfig` gates the juror view. A new tRPC query aggregates the juror's submissions. Admin components get an extra column and a summary card.
**Tech Stack:** TypeScript, tRPC, Prisma (JSON columns), React, shadcn/ui, Tailwind CSS, Zod
---
## Task 1: Add `advance` to CriterionType and Form Builder Types
**Files:**
- Modify: `src/components/forms/evaluation-form-builder.tsx:57` (CriterionType union)
- Modify: `src/components/forms/evaluation-form-builder.tsx:96-114` (createDefaultCriterion)
- Modify: `src/components/forms/evaluation-form-builder.tsx:117-122` (CRITERION_TYPE_OPTIONS)
**Step 1: Update the CriterionType union**
In `evaluation-form-builder.tsx` line 57, change:
```ts
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'section_header'
```
to:
```ts
export type CriterionType = 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header'
```
**Step 2: Add default creation for `advance` type**
In `createDefaultCriterion` (line 96), add a new case before `section_header`:
```ts
case 'advance':
return { ...base, label: 'Advance to next round?', trueLabel: 'Yes', falseLabel: 'No', required: true }
```
**Step 3: Add `advance` to the type options array**
In `CRITERION_TYPE_OPTIONS` (line 117), add an import for a suitable icon (e.g., `ArrowUpCircle` from lucide-react) and add the entry. Note: this button will be rendered separately with disable logic, so do NOT add it to `CRITERION_TYPE_OPTIONS`. Instead, we'll add a standalone button in Task 2.
Actually — to keep things clean, do NOT add `advance` to `CRITERION_TYPE_OPTIONS`. The advance button is rendered separately with one-per-form enforcement. See Task 2.
**Step 4: Commit**
```bash
git add src/components/forms/evaluation-form-builder.tsx
git commit -m "feat: add advance criterion type to CriterionType union and defaults"
```
---
## Task 2: Add "Advance to Next Round?" Button in Form Builder
**Files:**
- Modify: `src/components/forms/evaluation-form-builder.tsx:39-54` (imports — add ArrowUpCircle)
- Modify: `src/components/forms/evaluation-form-builder.tsx:671-690` (add buttons section)
**Step 1: Add the `ArrowUpCircle` icon import**
At line 39 in the lucide-react import block, add `ArrowUpCircle` to the imports.
**Step 2: Add the advance button with one-per-form enforcement**
After the `CRITERION_TYPE_OPTIONS.map(...)` buttons (around line 685), before the PreviewDialog, add:
```tsx
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addCriterion('advance')}
disabled={editingId !== null || criteria.some((c) => c.type === 'advance')}
title={criteria.some((c) => c.type === 'advance') ? 'Only one advance criterion allowed per form' : undefined}
className={cn(
'border-brand-blue/40 text-brand-blue hover:bg-brand-blue/5',
criteria.some((c) => c.type === 'advance') && 'opacity-50 cursor-not-allowed'
)}
>
<ArrowUpCircle className="mr-1 h-4 w-4" />
Advance to Next Round?
</Button>
```
**Step 3: Commit**
```bash
git add src/components/forms/evaluation-form-builder.tsx
git commit -m "feat: add advance criterion button with one-per-form enforcement"
```
---
## Task 3: Add Edit Mode and Preview for `advance` Criterion
**Files:**
- Modify: `src/components/forms/evaluation-form-builder.tsx` — edit mode section (around lines 237-414)
- Modify: `src/components/forms/evaluation-form-builder.tsx` — preview dialog (around lines 787-798)
- Modify: `src/components/forms/evaluation-form-builder.tsx` — type badge display in list view
**Step 1: Add edit mode fields for `advance` type**
In the edit mode form (after the `boolean` block ending around line 414), add a block for `advance`:
```tsx
{(editDraft.type) === 'advance' && (
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`trueLabel-${criterion.id}`}>Yes Label</Label>
<Input
id={`trueLabel-${criterion.id}`}
value={editDraft.trueLabel || 'Yes'}
onChange={(e) => updateDraft({ trueLabel: e.target.value })}
placeholder="Yes"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`falseLabel-${criterion.id}`}>No Label</Label>
<Input
id={`falseLabel-${criterion.id}`}
value={editDraft.falseLabel || 'No'}
onChange={(e) => updateDraft({ falseLabel: e.target.value })}
placeholder="No"
/>
</div>
</div>
)}
```
Note: No `required` toggle (always true), no `weight`, no `condition` fields for advance type.
**Step 2: Add the type badge rendering**
Find where the type badge is shown in list view (around line 237-240). The existing code uses `CRITERION_TYPE_OPTIONS.find(...)`. For `advance`, it won't find a match so will show nothing. Add a fallback or handle it. Where the badge text is resolved, add:
```ts
editDraft.type === 'advance' ? 'Advance to Next Round?' : CRITERION_TYPE_OPTIONS.find(...)?.label ?? 'Numeric Score'
```
**Step 3: Add preview rendering for `advance` type**
In the PreviewDialog (around line 787), after the `boolean` rendering block, add:
```tsx
{type === 'advance' && (
<div className="flex gap-4">
<div className="flex-1 h-14 rounded-lg border-2 border-emerald-300 bg-emerald-50/50 flex items-center justify-center text-sm font-semibold text-emerald-700">
<ThumbsUp className="mr-2 h-4 w-4" />
{criterion.trueLabel || 'Yes'}
</div>
<div className="flex-1 h-14 rounded-lg border-2 border-red-300 bg-red-50/50 flex items-center justify-center text-sm font-semibold text-red-700">
<ThumbsDown className="mr-2 h-4 w-4" />
{criterion.falseLabel || 'No'}
</div>
</div>
)}
```
**Step 4: Commit**
```bash
git add src/components/forms/evaluation-form-builder.tsx
git commit -m "feat: add edit mode and preview rendering for advance criterion type"
```
---
## Task 4: Server-Side — Accept `advance` in `upsertForm` and `submit` Validation
**Files:**
- Modify: `src/server/routers/evaluation.ts:1230` (upsertForm Zod input — add 'advance' to type enum)
- Modify: `src/server/routers/evaluation.ts:1270-1304` (criteriaJson builder — add advance case)
- Modify: `src/server/routers/evaluation.ts:238-260` (submit validation — handle advance type)
**Step 1: Add `advance` to the Zod type enum in upsertForm input**
At line 1230, change:
```ts
type: z.enum(['numeric', 'text', 'boolean', 'section_header']).optional(),
```
to:
```ts
type: z.enum(['numeric', 'text', 'boolean', 'advance', 'section_header']).optional(),
```
**Step 2: Add advance case in criteriaJson builder**
After the `boolean` case (line 1295-1300), add:
```ts
if (type === 'advance') {
return {
...base,
required: true, // always required, override any input
trueLabel: c.trueLabel || 'Yes',
falseLabel: c.falseLabel || 'No',
}
}
```
**Step 3: Add server-side one-per-form validation**
In the `upsertForm` mutation, after line 1256 (`const { roundId, criteria } = input`), add:
```ts
// Enforce max one advance criterion per form
const advanceCount = criteria.filter((c) => c.type === 'advance').length
if (advanceCount > 1) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Only one advance criterion is allowed per evaluation form',
})
}
```
**Step 4: Handle `advance` in submit validation**
In the `requireAllCriteriaScored` block (line 242-252), the `scorableCriteria` filter excludes `section_header` and `text`. The `advance` type should be treated like `boolean` — it's a required boolean. Update the missing criteria check:
At line 250, change:
```ts
if (c.type === 'boolean') return typeof val !== 'boolean'
```
to:
```ts
if (c.type === 'boolean' || c.type === 'advance') return typeof val !== 'boolean'
```
**Step 5: Commit**
```bash
git add src/server/routers/evaluation.ts
git commit -m "feat: server-side support for advance criterion type in upsertForm and submit"
```
---
## Task 5: Juror Evaluation Page — Render `advance` Criterion
**Files:**
- Modify: `src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx:660-703` (boolean rendering — add advance case)
- Modify: same file, client-side validation (around line 355-360)
**Step 1: Add advance criterion rendering in the evaluation form**
After the boolean rendering block (line 660-703), add a new block for `advance`. It should look similar to boolean but with larger, more prominent buttons and a colored border:
```tsx
if (criterion.type === 'advance') {
const currentValue = criteriaValues[criterion.id]
return (
<div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
<div className="space-y-1">
<Label className="text-base font-semibold text-brand-blue">
{criterion.label}
<span className="text-destructive ml-1">*</span>
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
)}
>
<ThumbsUp className="mr-2 h-5 w-5" />
{criterion.trueLabel || 'Yes'}
</button>
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
)}
>
<ThumbsDown className="mr-2 h-5 w-5" />
{criterion.falseLabel || 'No'}
</button>
</div>
</div>
)
}
```
**Step 2: Update client-side validation**
In the client-side submit validation (around line 355-360), where boolean required criteria are checked, ensure `advance` is also handled. Find the block that checks for boolean criteria values and add `|| c.type === 'advance'` to the condition.
**Step 3: Commit**
```bash
git add "src/app/(jury)/jury/competitions/[roundId]/projects/[projectId]/evaluate/page.tsx"
git commit -m "feat: render advance criterion on juror evaluation page with prominent styling"
```
---
## Task 6: Add `showJurorProgressDashboard` to EvaluationConfig
**Files:**
- Modify: `src/types/competition-configs.ts:90-141` (EvaluationConfigSchema — add field)
- Modify: `src/components/admin/rounds/config/evaluation-config.tsx` (add toggle)
**Step 1: Add the field to the Zod schema**
In `EvaluationConfigSchema` (line 90), add after line 103 (`peerReviewEnabled`):
```ts
showJurorProgressDashboard: z.boolean().default(false),
```
**Step 2: Add the toggle in the admin config UI**
In `evaluation-config.tsx`, in the Feedback Requirements card (after the `peerReviewEnabled` switch, around line 176), add:
```tsx
<div className="flex items-center justify-between">
<div>
<Label htmlFor="showJurorProgressDashboard">Juror Progress Dashboard</Label>
<p className="text-xs text-muted-foreground">Show jurors a dashboard with their past evaluations, scores, and advance decisions</p>
</div>
<Switch
id="showJurorProgressDashboard"
checked={(config.showJurorProgressDashboard as boolean) ?? false}
onCheckedChange={(v) => update('showJurorProgressDashboard', v)}
/>
</div>
```
**Step 3: Commit**
```bash
git add src/types/competition-configs.ts src/components/admin/rounds/config/evaluation-config.tsx
git commit -m "feat: add showJurorProgressDashboard toggle to EvaluationConfig"
```
---
## Task 7: New tRPC Query — `evaluation.getMyProgress`
**Files:**
- Modify: `src/server/routers/evaluation.ts` (add new juryProcedure query at the end of the router)
**Step 1: Add the query**
Add this query to the `evaluationRouter` (before the closing `})` of the router):
```ts
getMyProgress: juryProcedure
.input(z.object({ roundId: z.string() }))
.query(async ({ ctx, input }) => {
const { roundId } = input
const userId = ctx.user.id
// Get all assignments for this juror in this round
const assignments = await ctx.prisma.assignment.findMany({
where: { roundId, userId },
include: {
project: { select: { id: true, title: true } },
evaluation: {
include: { form: { select: { criteriaJson: true } } },
},
},
})
const total = assignments.length
let completed = 0
let advanceYes = 0
let advanceNo = 0
const submissions: Array<{
projectId: string
projectName: string
submittedAt: Date | null
advanceDecision: boolean | null
criterionScores: Array<{ label: string; value: number }>
numericAverage: number | null
}> = []
for (const a of assignments) {
const ev = a.evaluation
if (!ev || ev.status !== 'SUBMITTED') continue
completed++
const criteria = (ev.form?.criteriaJson ?? []) as Array<{
id: string; label: string; type?: string; weight?: number
}>
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
// Find the advance criterion
const advanceCriterion = criteria.find((c) => c.type === 'advance')
let advanceDecision: boolean | null = null
if (advanceCriterion) {
const val = scores[advanceCriterion.id]
if (typeof val === 'boolean') {
advanceDecision = val
if (val) advanceYes++
else advanceNo++
}
}
// Collect numeric criterion scores
const numericScores: Array<{ label: string; value: number }> = []
for (const c of criteria) {
if (c.type === 'numeric' || (!c.type && c.weight !== undefined)) {
const val = scores[c.id]
if (typeof val === 'number') {
numericScores.push({ label: c.label, value: val })
}
}
}
const numericAverage = numericScores.length > 0
? Math.round((numericScores.reduce((sum, s) => sum + s.value, 0) / numericScores.length) * 10) / 10
: null
submissions.push({
projectId: a.project.id,
projectName: a.project.title,
submittedAt: ev.submittedAt,
advanceDecision,
criterionScores: numericScores,
numericAverage,
})
}
// Sort by most recent first
submissions.sort((a, b) => {
if (!a.submittedAt) return 1
if (!b.submittedAt) return -1
return b.submittedAt.getTime() - a.submittedAt.getTime()
})
return {
total,
completed,
advanceCounts: { yes: advanceYes, no: advanceNo },
submissions,
}
}),
```
**Step 2: Commit**
```bash
git add src/server/routers/evaluation.ts
git commit -m "feat: add evaluation.getMyProgress tRPC query for juror dashboard"
```
---
## Task 8: Juror Progress Dashboard Component
**Files:**
- Create: `src/components/jury/juror-progress-dashboard.tsx`
**Step 1: Create the component**
```tsx
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ChevronDown, ChevronUp, ThumbsUp, ThumbsDown } from 'lucide-react'
import { cn } from '@/lib/utils'
export function JurorProgressDashboard({ roundId }: { roundId: string }) {
const [expanded, setExpanded] = useState(true)
const { data, isLoading } = trpc.evaluation.getMyProgress.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
if (isLoading) {
return <Skeleton className="h-32 w-full" />
}
if (!data || data.total === 0) return null
const pct = Math.round((data.completed / data.total) * 100)
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Your Progress</CardTitle>
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Progress bar */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{data.completed} / {data.total} evaluated
</span>
<span className="font-medium">{pct}%</span>
</div>
<Progress value={pct} className="h-2" />
</div>
{/* Advance summary */}
{(data.advanceCounts.yes > 0 || data.advanceCounts.no > 0) && (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">Advance:</span>
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200">
<ThumbsUp className="mr-1 h-3 w-3" />
{data.advanceCounts.yes} Yes
</Badge>
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200">
<ThumbsDown className="mr-1 h-3 w-3" />
{data.advanceCounts.no} No
</Badge>
</div>
)}
{/* Submissions table */}
{expanded && data.submissions.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/30">
<th className="text-left px-3 py-2 font-medium">Project</th>
<th className="text-center px-3 py-2 font-medium">Avg Score</th>
{data.submissions[0]?.criterionScores.map((cs, i) => (
<th key={i} className="text-center px-2 py-2 font-medium text-xs max-w-[80px] truncate" title={cs.label}>
{cs.label}
</th>
))}
<th className="text-center px-3 py-2 font-medium">Advance</th>
<th className="text-right px-3 py-2 font-medium">Date</th>
</tr>
</thead>
<tbody>
{data.submissions.map((s) => (
<tr key={s.projectId} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-3 py-2 font-medium truncate max-w-[200px]">{s.projectName}</td>
<td className="text-center px-3 py-2">
{s.numericAverage != null ? (
<span className="font-semibold">{s.numericAverage}</span>
) : '—'}
</td>
{s.criterionScores.map((cs, i) => (
<td key={i} className="text-center px-2 py-2 text-muted-foreground">{cs.value}</td>
))}
<td className="text-center px-3 py-2">
{s.advanceDecision === true ? (
<Badge variant="outline" className="bg-emerald-50 text-emerald-700 border-emerald-200 text-xs">YES</Badge>
) : s.advanceDecision === false ? (
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs">NO</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="text-right px-3 py-2 text-muted-foreground text-xs whitespace-nowrap">
{s.submittedAt ? new Date(s.submittedAt).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</CardContent>
</Card>
)
}
```
**Step 2: Commit**
```bash
git add src/components/jury/juror-progress-dashboard.tsx
git commit -m "feat: create JurorProgressDashboard component"
```
---
## Task 9: Wire Juror Progress Dashboard into Round Page
**Files:**
- Modify: `src/app/(jury)/jury/competitions/[roundId]/page.tsx`
**Step 1: Import the component and add it to the page**
Add import at the top:
```ts
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
```
**Step 2: Fetch round config and conditionally render**
The page already fetches `round` via `trpc.round.getById.useQuery`. Use it to check the config:
After the heading `<div>` (around line 53) and before the `<Card>` with "Assigned Projects" (line 56), add:
```tsx
{(() => {
const config = (round?.configJson as Record<string, unknown>) ?? {}
if (config.showJurorProgressDashboard) {
return <JurorProgressDashboard roundId={roundId} />
}
return null
})()}
```
**Step 3: Commit**
```bash
git add "src/app/(jury)/jury/competitions/[roundId]/page.tsx"
git commit -m "feat: wire JurorProgressDashboard into jury round detail page"
```
---
## Task 10: Admin — Add "Advance" Column to Assignments Table
**Files:**
- Modify: `src/components/admin/assignment/individual-assignments-table.tsx:315-319` (column header)
- Modify: `src/components/admin/assignment/individual-assignments-table.tsx:325-351` (row rendering)
**Step 1: Add the column header**
At line 315, change the grid from `grid-cols-[1fr_1fr_100px_70px]` to `grid-cols-[1fr_1fr_80px_80px_70px]` and add an "Advance" header:
```tsx
<div className="grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
<span>Juror</span>
<span>Project</span>
<span>Status</span>
<span>Advance</span>
<span>Actions</span>
</div>
```
**Step 2: Update row grid and add the advance cell**
At line 325, update the grid class to match: `grid-cols-[1fr_1fr_80px_80px_70px]`.
After the Status cell (line 351 `</div>`) and before the DropdownMenu (line 352), add:
```tsx
<div className="flex items-center justify-center">
{(() => {
const ev = a.evaluation
if (!ev || ev.status !== 'SUBMITTED') return <span className="text-muted-foreground text-xs"></span>
const criteria = (ev.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
const advCrit = criteria.find((c) => c.type === 'advance')
if (!advCrit) return <span className="text-muted-foreground text-xs"></span>
const val = scores[advCrit.id]
if (val === true) return <Badge variant="outline" className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200">YES</Badge>
if (val === false) return <Badge variant="outline" className="text-[10px] bg-red-50 text-red-700 border-red-200">NO</Badge>
return <span className="text-muted-foreground text-xs"></span>
})()}
</div>
```
**Step 3: Ensure the query includes form data**
Check that `trpc.assignment.listByStage` includes `evaluation.form` in its response. If it doesn't, we need to add `form: { select: { criteriaJson: true } }` to the evaluation include in the `listByStage` query in `src/server/routers/assignment.ts`. Look for the `listByStage` procedure and update its evaluation include.
**Step 4: Commit**
```bash
git add src/components/admin/assignment/individual-assignments-table.tsx
git add src/server/routers/assignment.ts # if modified
git commit -m "feat: add Advance column to admin individual assignments table"
```
---
## Task 11: Admin — Advancement Summary Card
**Files:**
- Create: `src/components/admin/round/advancement-summary-card.tsx`
**Step 1: Create the component**
```tsx
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ThumbsUp, ThumbsDown, Clock } from 'lucide-react'
export function AdvancementSummaryCard({ roundId }: { roundId: string }) {
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
)
if (isLoading) return <Skeleton className="h-40 w-full" />
if (!assignments || assignments.length === 0) return null
// Check if form has an advance criterion
const firstSubmitted = assignments.find(
(a: any) => a.evaluation?.status === 'SUBMITTED' && a.evaluation?.form?.criteriaJson
)
if (!firstSubmitted) return null
const criteria = ((firstSubmitted as any).evaluation?.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
const advanceCriterion = criteria.find((c) => c.type === 'advance')
if (!advanceCriterion) return null
let yesCount = 0
let noCount = 0
let pendingCount = 0
for (const a of assignments as any[]) {
const ev = a.evaluation
if (!ev || ev.status !== 'SUBMITTED') {
pendingCount++
continue
}
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
const val = scores[advanceCriterion.id]
if (val === true) yesCount++
else if (val === false) noCount++
else pendingCount++
}
const total = yesCount + noCount + pendingCount
const yesPct = total > 0 ? Math.round((yesCount / total) * 100) : 0
const noPct = total > 0 ? Math.round((noCount / total) * 100) : 0
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Advancement Votes</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
<ThumbsUp className="h-5 w-5 text-emerald-700" />
</div>
<div>
<p className="text-2xl font-bold text-emerald-700">{yesCount}</p>
<p className="text-xs text-muted-foreground">Yes ({yesPct}%)</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center">
<ThumbsDown className="h-5 w-5 text-red-700" />
</div>
<div>
<p className="text-2xl font-bold text-red-700">{noCount}</p>
<p className="text-xs text-muted-foreground">No ({noPct}%)</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center">
<Clock className="h-5 w-5 text-gray-500" />
</div>
<div>
<p className="text-2xl font-bold text-gray-600">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</div>
</div>
{/* Stacked bar */}
<div className="mt-4 h-3 rounded-full bg-gray-100 overflow-hidden flex">
{yesPct > 0 && <div className="bg-emerald-500 transition-all" style={{ width: `${yesPct}%` }} />}
{noPct > 0 && <div className="bg-red-500 transition-all" style={{ width: `${noPct}%` }} />}
</div>
</CardContent>
</Card>
)
}
```
**Step 2: Commit**
```bash
git add src/components/admin/round/advancement-summary-card.tsx
git commit -m "feat: create AdvancementSummaryCard admin component"
```
---
## Task 12: Wire Advancement Summary Card into Admin Round Detail
**Files:**
- Modify: `src/app/(admin)/admin/rounds/[roundId]/page.tsx` (overview tab, around line 871)
**Step 1: Import the component**
Add at the imports section:
```ts
import { AdvancementSummaryCard } from '@/components/admin/round/advancement-summary-card'
```
**Step 2: Add it to the overview tab**
In the overview tab content (after the Launch Readiness card, around line 943), add:
```tsx
{isEvaluation && <AdvancementSummaryCard roundId={roundId} />}
```
Where `isEvaluation` is the existing variable that checks `round.roundType === 'EVALUATION'`.
**Step 3: Commit**
```bash
git add "src/app/(admin)/admin/rounds/[roundId]/page.tsx"
git commit -m "feat: wire AdvancementSummaryCard into admin round overview tab"
```
---
## Task 13: Build and Typecheck
**Step 1: Run typecheck**
```bash
npm run typecheck
```
Expected: No errors (fix any that appear).
**Step 2: Run build**
```bash
npm run build
```
Expected: Successful build.
**Step 3: Fix any issues and commit**
```bash
git add -A
git commit -m "fix: resolve any type or build errors from advance criterion feature"
```
---
## Task 14: Manual QA Checklist
Run `npm run dev` and verify:
1. **Form builder**: Admin can add "Advance to Next Round?" criterion. Button disables after one is added. Edit mode shows trueLabel/falseLabel. Preview renders correctly.
2. **Juror evaluation**: Advance criterion renders with prominent green/red buttons. Required validation works. Autosave works. Submit stores value in `criterionScoresJson`.
3. **Juror dashboard**: When `showJurorProgressDashboard` is enabled in round config, the progress card appears with progress bar, YES/NO counts, and submissions table sorted by date.
4. **Admin config**: The "Juror Progress Dashboard" toggle appears in the Evaluation round config.
5. **Admin assignments table**: "Advance" column appears with YES/NO/— badges.
6. **Admin overview**: `AdvancementSummaryCard` renders with correct counts and stacked bar.

View File

@@ -40,12 +40,12 @@ const nextConfig: NextConfig = {
},
{
source: '/applicant/pipeline',
destination: '/applicant/competition',
destination: '/applicant/competitions',
permanent: true,
},
{
source: '/applicant/pipeline/:path*',
destination: '/applicant/competition',
destination: '/applicant/competitions',
permanent: true,
},
]

334
package-lock.json generated
View File

@@ -11,12 +11,14 @@
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@blocknote/shadcn": "^0.46.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13",
"@mantine/hooks": "^8.3.13",
"@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
@@ -244,6 +246,24 @@
}
}
},
"node_modules/@blocknote/mantine": {
"version": "0.46.2",
"resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.46.2.tgz",
"integrity": "sha512-2/A82VIby8NNuQbJrXZURnGsksVMWiGWtUOfhvaawCTiB2thYDOV1XONFF1G4xZ2UreodOKLUTwhLm3u25lGrw==",
"license": "MPL-2.0",
"dependencies": {
"@blocknote/core": "0.46.2",
"@blocknote/react": "0.46.2",
"react-icons": "^5.5.0"
},
"peerDependencies": {
"@mantine/core": "^8.3.11",
"@mantine/hooks": "^8.3.11",
"@mantine/utils": "^6.0.22",
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc"
}
},
"node_modules/@blocknote/react": {
"version": "0.46.2",
"resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz",
@@ -276,55 +296,6 @@
"integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==",
"license": "MIT"
},
"node_modules/@blocknote/shadcn": {
"version": "0.46.2",
"resolved": "https://registry.npmjs.org/@blocknote/shadcn/-/shadcn-0.46.2.tgz",
"integrity": "sha512-rCmML5M814D/dcLrm5/6REQeEJKPNyygls4n751F0jBLpk8IuWsQYvGqnU6iPW2Cz+9IchA2ivg4Vltna83J1Q==",
"license": "MPL-2.0",
"dependencies": {
"@blocknote/core": "0.46.2",
"@blocknote/react": "0.46.2",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"autoprefixer": "^10.4.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"react-hook-form": "^7.65.0",
"tailwind-merge": "^2.6.0"
},
"peerDependencies": {
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc",
"tailwindcss": "^4.1.12"
}
},
"node_modules/@blocknote/shadcn/node_modules/lucide-react": {
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@blocknote/shadcn/node_modules/tailwind-merge": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
@@ -1681,6 +1652,34 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mantine/core": {
"version": "8.3.13",
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.13.tgz",
"integrity": "sha512-ZgW4vqN4meaPyIMxzAufBvsgmJRfYZdTpsrAOcS8pWy7m9e8i685E7XsAxnwJCOIHudpvpvt+7Bx7VaIjEsYEw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
"react-number-format": "^5.4.4",
"react-remove-scroll": "^2.7.1",
"react-textarea-autosize": "8.5.9",
"type-fest": "^4.41.0"
},
"peerDependencies": {
"@mantine/hooks": "8.3.13",
"react": "^18.x || ^19.x",
"react-dom": "^18.x || ^19.x"
}
},
"node_modules/@mantine/hooks": {
"version": "8.3.13",
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.13.tgz",
"integrity": "sha512-7YMbMW/tR9E8m/9DbBW01+3RNApm2mE/JbRKXf9s9+KxgbjQvq0FYGWV8Y4+Sjz48AO4vtWk2qBriUTgBMKAyg==",
"license": "MIT",
"peerDependencies": {
"react": "^18.x || ^19.x"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
@@ -6011,42 +6010,6 @@
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": {
"version": "10.4.24",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz",
"integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"browserslist": "^4.28.1",
"caniuse-lite": "^1.0.30001766",
"fraction.js": "^5.3.4",
"picocolors": "^1.1.1",
"postcss-value-parser": "^4.2.0"
},
"bin": {
"autoprefixer": "bin/autoprefixer"
},
"engines": {
"node": "^10 || ^12 || >=14"
},
"peerDependencies": {
"postcss": "^8.1.0"
}
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -6128,18 +6091,6 @@
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -6194,39 +6145,6 @@
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
},
"engines": {
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
@@ -7102,12 +7020,6 @@
"fast-check": "^3.23.1"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.302",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
"license": "ISC"
},
"node_modules/emoji-mart": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
@@ -7378,15 +7290,6 @@
"@esbuild/win32-x64": "0.27.2"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -8112,19 +8015,6 @@
"node": ">= 6"
}
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
"integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
@@ -11210,12 +11100,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
@@ -11744,12 +11628,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-value-parser": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"license": "MIT"
},
"node_modules/preact": {
"version": "10.24.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
@@ -12372,6 +12250,16 @@
"react-dom": "^19.0.0"
}
},
"node_modules/react-number-format": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.4.tgz",
"integrity": "sha512-wOmoNZoOpvMminhifQYiYSTCLUDOiUbBunrMrMjA+dV52sY+vck1S4UhR6PkgnoCquvvMSeJjErXZ4qSaWCliA==",
"license": "MIT",
"peerDependencies": {
"react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-phone-number-input": {
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.14.tgz",
@@ -12473,6 +12361,23 @@
}
}
},
"node_modules/react-textarea-autosize": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz",
"integrity": "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.13",
"use-composed-ref": "^1.3.0",
"use-latest": "^1.2.1"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -13824,6 +13729,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -14103,36 +14020,6 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/browserslist"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"escalade": "^3.2.0",
"picocolors": "^1.1.1"
},
"bin": {
"update-browserslist-db": "cli.js"
},
"peerDependencies": {
"browserslist": ">= 4.21.0"
}
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -14164,6 +14051,20 @@
}
}
},
"node_modules/use-composed-ref": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-composed-ref/-/use-composed-ref-1.4.0.tgz",
"integrity": "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-debounce": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz",
@@ -14176,6 +14077,37 @@
"react": "*"
}
},
"node_modules/use-isomorphic-layout-effect": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-latest": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/use-latest/-/use-latest-1.3.0.tgz",
"integrity": "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==",
"license": "MIT",
"dependencies": {
"use-isomorphic-layout-effect": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/use-sidecar": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz",

View File

@@ -24,12 +24,14 @@
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@blocknote/shadcn": "^0.46.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13",
"@mantine/hooks": "^8.3.13",
"@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4",

View File

@@ -0,0 +1,8 @@
-- Add isTest field to User, Program, Project, Competition for test environment isolation
ALTER TABLE "User" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Program" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Project" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "Competition" ADD COLUMN "isTest" BOOLEAN NOT NULL DEFAULT false;
-- Index for efficient test data filtering
CREATE INDEX "Competition_isTest_idx" ON "Competition"("isTest");

View File

@@ -1,16 +0,0 @@
-- Learning Hub Overhaul: Remove ResourceType/CohortLevel enums, add accessJson + coverImageKey
-- Drop columns that reference the enums
ALTER TABLE "LearningResource" DROP COLUMN "resourceType";
ALTER TABLE "LearningResource" DROP COLUMN "cohortLevel";
-- Drop the cohortLevel index
DROP INDEX IF EXISTS "LearningResource_cohortLevel_idx";
-- Add new columns
ALTER TABLE "LearningResource" ADD COLUMN "accessJson" JSONB;
ALTER TABLE "LearningResource" ADD COLUMN "coverImageKey" TEXT;
-- Drop the enum types
DROP TYPE IF EXISTS "ResourceType";
DROP TYPE IF EXISTS "CohortLevel";

View File

@@ -1,59 +0,0 @@
-- DropForeignKey
ALTER TABLE "ConflictOfInterest" DROP CONSTRAINT "ConflictOfInterest_roundId_fkey";
-- DropForeignKey
ALTER TABLE "Project" DROP CONSTRAINT "Project_programId_fkey";
-- DropForeignKey
ALTER TABLE "TaggingJob" DROP CONSTRAINT "TaggingJob_roundId_fkey";
-- DropIndex
DROP INDEX "DiscussionComment_discussionId_createdAt_idx";
-- DropIndex
DROP INDEX "EvaluationDiscussion_status_idx";
-- DropIndex
DROP INDEX "LiveVote_isAudienceVote_idx";
-- DropIndex
DROP INDEX "Message_scheduledAt_idx";
-- DropIndex
DROP INDEX "MessageRecipient_messageId_idx";
-- DropIndex
DROP INDEX "MessageRecipient_userId_isRead_idx";
-- DropIndex
DROP INDEX "Project_programId_roundId_idx";
-- DropIndex
DROP INDEX "Project_roundId_idx";
-- DropIndex
DROP INDEX "ProjectFile_projectId_roundId_idx";
-- DropIndex
DROP INDEX "ProjectFile_roundId_idx";
-- DropIndex
DROP INDEX "TaggingJob_roundId_idx";
-- DropIndex
DROP INDEX "WebhookDelivery_createdAt_idx";
-- AlterTable
ALTER TABLE "User" ADD COLUMN "roles" "UserRole"[] DEFAULT ARRAY[]::"UserRole"[];
-- Backfill: populate roles array from existing role column
UPDATE "User" SET "roles" = ARRAY["role"]::"UserRole"[];
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MessageTemplate" ADD CONSTRAINT "MessageTemplate_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- RenameIndex
ALTER INDEX "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" RENAME TO "DeliberationVote_sessionId_juryMemberId_projectId_runoffRou_key";

View File

@@ -1,45 +0,0 @@
-- CreateEnum
CREATE TYPE "RankingTriggerType" AS ENUM ('MANUAL', 'AUTO', 'RETROACTIVE', 'QUICK');
-- CreateEnum
CREATE TYPE "RankingMode" AS ENUM ('PREVIEW', 'CONFIRMED', 'QUICK');
-- CreateEnum
CREATE TYPE "RankingSnapshotStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
-- CreateTable
CREATE TABLE "RankingSnapshot" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"triggeredById" TEXT,
"triggerType" "RankingTriggerType" NOT NULL DEFAULT 'MANUAL',
"criteriaText" TEXT NOT NULL,
"parsedRulesJson" JSONB NOT NULL,
"startupRankingJson" JSONB,
"conceptRankingJson" JSONB,
"evaluationDataJson" JSONB,
"mode" "RankingMode" NOT NULL DEFAULT 'PREVIEW',
"status" "RankingSnapshotStatus" NOT NULL DEFAULT 'COMPLETED',
"reordersJson" JSONB,
"model" TEXT,
"tokensUsed" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RankingSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RankingSnapshot_roundId_idx" ON "RankingSnapshot"("roundId");
-- CreateIndex
CREATE INDEX "RankingSnapshot_triggeredById_idx" ON "RankingSnapshot"("triggeredById");
-- CreateIndex
CREATE INDEX "RankingSnapshot_createdAt_idx" ON "RankingSnapshot"("createdAt");
-- AddForeignKey
ALTER TABLE "RankingSnapshot" ADD CONSTRAINT "RankingSnapshot_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RankingSnapshot" ADD CONSTRAINT "RankingSnapshot_triggeredById_fkey" FOREIGN KEY ("triggeredById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,38 +0,0 @@
-- Insert missing notification email settings into production DB
-- Run manually: psql -d mopc -f prisma/migrations/insert-dropout-reassigned-setting.sql
-- Safe to run multiple times (uses ON CONFLICT to skip if already exists)
INSERT INTO "NotificationEmailSetting" (
"id", "notificationType", "category", "label", "description", "sendEmail", "createdAt", "updatedAt"
) VALUES
(
gen_random_uuid()::text,
'COI_REASSIGNED',
'jury',
'COI Reassignment',
'When a project is reassigned to you due to another juror''s conflict of interest',
true,
NOW(),
NOW()
),
(
gen_random_uuid()::text,
'MANUAL_REASSIGNED',
'jury',
'Manual Reassignment',
'When an admin manually reassigns a project to you',
true,
NOW(),
NOW()
),
(
gen_random_uuid()::text,
'DROPOUT_REASSIGNED',
'jury',
'Juror Dropout Reassignment',
'When projects are reassigned to you because a juror dropped out or became unavailable',
true,
NOW(),
NOW()
)
ON CONFLICT ("notificationType") DO NOTHING;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -115,6 +115,19 @@ enum NotificationChannel {
NONE
}
enum ResourceType {
PDF
VIDEO
DOCUMENT
LINK
OTHER
}
enum CohortLevel {
ALL
SEMIFINALIST
FINALIST
}
enum PartnerVisibility {
ADMIN_ONLY
@@ -303,7 +316,6 @@ model User {
name String?
emailVerified DateTime? // Required by NextAuth Prisma adapter
role UserRole @default(JURY_MEMBER)
roles UserRole[] @default([])
status UserStatus @default(INVITED)
expertiseTags String[] @default([])
maxAssignments Int? // Per-round limit
@@ -338,6 +350,9 @@ model User {
preferredWorkload Int?
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
@@ -425,9 +440,6 @@ model User {
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
// AI Ranking
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
@@index([role])
@@index([status])
}
@@ -485,6 +497,9 @@ model Program {
description String?
settingsJson Json? @db.JsonB
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -609,6 +624,9 @@ model Project {
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1001,12 +1019,13 @@ model NotificationEmailSetting {
// =============================================================================
model LearningResource {
id String @id @default(cuid())
id String @id @default(cuid())
programId String? // null = global resource
title String
description String? @db.Text
contentJson Json? @db.JsonB // BlockNote document structure
accessJson Json? @db.JsonB // Fine-grained access rules
description String? @db.Text
contentJson Json? @db.JsonB // BlockNote document structure
resourceType ResourceType
cohortLevel CohortLevel @default(ALL)
// File storage (for uploaded resources)
fileName String?
@@ -1015,9 +1034,6 @@ model LearningResource {
bucket String?
objectKey String?
// Cover image (stored in MinIO)
coverImageKey String?
// External link
externalUrl String?
@@ -1034,6 +1050,7 @@ model LearningResource {
accessLogs ResourceAccess[]
@@index([programId])
@@index([cohortLevel])
@@index([isPublished])
@@index([sortOrder])
}
@@ -1408,74 +1425,6 @@ enum AssignmentJobStatus {
FAILED
}
// =============================================================================
// AI RANKING MODELS
// =============================================================================
enum RankingTriggerType {
MANUAL // Admin clicked "Run ranking"
AUTO // Auto-triggered by assignment completion
RETROACTIVE // Retroactive scan on deployment
QUICK // Quick-rank mode (no preview)
}
enum RankingMode {
PREVIEW // Parsed rules shown to admin (not yet applied)
CONFIRMED // Admin confirmed rules, ranking applied
QUICK // Quick-rank: parse + apply without preview
}
enum RankingSnapshotStatus {
PENDING
RUNNING
COMPLETED
FAILED
}
// Captures a point-in-time AI ranking run for a round
model RankingSnapshot {
id String @id @default(cuid())
roundId String
// Trigger metadata
triggeredById String? // null = auto-triggered
triggerType RankingTriggerType @default(MANUAL)
// Criteria used
criteriaText String @db.Text
parsedRulesJson Json @db.JsonB
// Results per category (either can be null/empty if no projects in that category)
startupRankingJson Json? @db.JsonB
conceptRankingJson Json? @db.JsonB
// Evaluation data freeze (raw scores at time of ranking)
evaluationDataJson Json? @db.JsonB
// Mode and status
mode RankingMode @default(PREVIEW)
status RankingSnapshotStatus @default(COMPLETED)
// Post-drag-and-drop reorders (Phase 2 will populate this)
reordersJson Json? @db.JsonB
// AI metadata
model String?
tokensUsed Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation("RoundRankingSnapshots", fields: [roundId], references: [id], onDelete: Cascade)
triggeredBy User? @relation("TriggeredRankingSnapshots", fields: [triggeredById], references: [id], onDelete: SetNull)
@@index([roundId])
@@index([triggeredById])
@@index([createdAt])
}
// Tracks progress of long-running AI tagging jobs
model TaggingJob {
id String @id @default(cuid())
@@ -2150,6 +2099,9 @@ model Competition {
notifyOnDeadlineApproach Boolean @default(true)
deadlineReminderDays Int[] @default([7, 3, 1])
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2164,6 +2116,7 @@ model Competition {
@@index([programId])
@@index([status])
@@index([isTest])
}
model Round {
@@ -2217,7 +2170,6 @@ model Round {
filteringResults FilteringResult[]
filteringJobs FilteringJob[]
assignmentJobs AssignmentJob[]
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
reminderLogs ReminderLog[]
evaluationSummaries EvaluationSummary[]
evaluationDiscussions EvaluationDiscussion[]

View File

@@ -90,27 +90,6 @@ const NOTIFICATION_EMAIL_SETTINGS = [
description: 'When multiple projects are assigned at once',
sendEmail: true,
},
{
notificationType: 'COI_REASSIGNED',
category: 'jury',
label: 'COI Reassignment',
description: 'When a project is reassigned to you due to another juror\'s conflict of interest',
sendEmail: true,
},
{
notificationType: 'MANUAL_REASSIGNED',
category: 'jury',
label: 'Manual Reassignment',
description: 'When an admin manually reassigns a project to you',
sendEmail: true,
},
{
notificationType: 'DROPOUT_REASSIGNED',
category: 'jury',
label: 'Juror Dropout Reassignment',
description: 'When projects are reassigned to you because a juror dropped out or became unavailable',
sendEmail: true,
},
{
notificationType: 'ROUND_NOW_OPEN',
category: 'jury',

View File

@@ -347,7 +347,6 @@ async function main() {
email: account.email,
name: account.name,
role: account.role,
roles: [account.role],
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
passwordHash: isSuperAdmin ? passwordHash : null,
mustSetPassword: !isSuperAdmin,
@@ -386,7 +385,6 @@ async function main() {
email: j.email,
name: j.name,
role: UserRole.JURY_MEMBER,
roles: [UserRole.JURY_MEMBER],
status: UserStatus.NONE,
country: j.country,
expertiseTags: j.tags,
@@ -418,7 +416,6 @@ async function main() {
email: m.email,
name: m.name,
role: UserRole.MENTOR,
roles: [UserRole.MENTOR],
status: UserStatus.NONE,
country: m.country,
expertiseTags: m.tags,
@@ -447,7 +444,6 @@ async function main() {
email: o.email,
name: o.name,
role: UserRole.OBSERVER,
roles: [UserRole.OBSERVER],
status: UserStatus.NONE,
country: o.country,
},
@@ -549,7 +545,6 @@ async function main() {
email,
name: name || `Applicant ${rowIdx + 1}`,
role: UserRole.APPLICANT,
roles: [UserRole.APPLICANT],
status: UserStatus.NONE,
phoneNumber: phone,
country,
@@ -925,8 +920,6 @@ async function main() {
{ notificationType: 'REMINDER_1H', category: 'jury', label: 'Reminder (1h)', description: 'Urgent reminder 1 hour before deadline', sendEmail: true },
{ notificationType: 'ROUND_CLOSED', category: 'jury', label: 'Round Closed', description: 'When a round closes', sendEmail: false },
{ notificationType: 'AWARD_VOTING_OPEN', category: 'jury', label: 'Award Voting Open', description: 'When special award voting opens', sendEmail: true },
{ notificationType: 'COI_REASSIGNED', category: 'jury', label: 'COI Reassignment', description: 'When a project is reassigned to you due to another juror\'s conflict of interest', sendEmail: true },
{ notificationType: 'MANUAL_REASSIGNED', category: 'jury', label: 'Manual Reassignment', description: 'When an admin manually reassigns a project to you', sendEmail: true },
// Mentor notifications
{ notificationType: 'MENTEE_ASSIGNED', category: 'mentor', label: 'Mentee Assigned', description: 'When assigned as mentor to a project', sendEmail: true },
{ notificationType: 'MENTEE_UPLOADED_DOCS', category: 'mentor', label: 'Mentee Documents Updated', description: 'When a mentee uploads new documents', sendEmail: false },

View File

@@ -1,84 +0,0 @@
/**
* One-off script: backfill binaryDecision from custom boolean criterion
* "Move to the Next Stage?" for evaluations where binaryDecision is null.
*
* Usage: npx tsx scripts/backfill-binary-decision.ts
*
* What it does:
* 1. Finds all rounds with a boolean criterion labeled "Move to the Next Stage?"
* 2. For evaluations in those rounds where binaryDecision IS NULL,
* copies the boolean value from criterionScoresJson into binaryDecision
*/
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
type CriterionConfig = {
id: string
label: string
type?: string
}
async function main() {
// Find all rounds that have evaluation config with criteria
const rounds = await prisma.round.findMany({
where: { roundType: 'EVALUATION' },
select: { id: true, name: true, configJson: true },
})
let totalUpdated = 0
for (const round of rounds) {
const config = round.configJson as Record<string, unknown> | null
if (!config) continue
const criteria = (config.criteria ?? config.evaluationCriteria ?? []) as CriterionConfig[]
// Find the boolean criterion for "Move to the Next Stage?"
const boolCriterion = criteria.find(
(c) =>
(c.type === 'boolean') &&
c.label?.toLowerCase().includes('move to the next stage'),
)
if (!boolCriterion) continue
console.log(`Round "${round.name}" (${round.id}): found criterion "${boolCriterion.label}" (${boolCriterion.id})`)
// Find evaluations in this round where binaryDecision is null
const evaluations = await prisma.evaluation.findMany({
where: {
assignment: { roundId: round.id },
binaryDecision: null,
status: 'SUBMITTED',
criterionScoresJson: { not: undefined },
},
select: { id: true, criterionScoresJson: true },
})
let updated = 0
for (const ev of evaluations) {
const scores = ev.criterionScoresJson as Record<string, unknown> | null
if (!scores) continue
const value = scores[boolCriterion.id]
if (typeof value !== 'boolean') continue
await prisma.evaluation.update({
where: { id: ev.id },
data: { binaryDecision: value },
})
updated++
}
console.log(` Updated ${updated}/${evaluations.length} evaluations`)
totalUpdated += updated
}
console.log(`\nDone. Total evaluations updated: ${totalUpdated}`)
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect())

View File

@@ -0,0 +1,217 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ArrowLeft, Loader2, PlayCircle, Zap } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { CoverageReport } from '@/components/admin/assignment/coverage-report'
import { AssignmentPreviewSheet } from '@/components/admin/assignment/assignment-preview-sheet'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
export default function AssignmentsDashboardPage() {
const params = useParams()
const router = useRouter()
const competitionId = params.competitionId as string
const [selectedRoundId, setSelectedRoundId] = useState<string>('')
const [previewSheetOpen, setPreviewSheetOpen] = useState(false)
const aiAssignmentMutation = trpc.roundAssignment.aiPreview.useMutation({
onSuccess: () => {
toast.success('AI assignments ready!', {
action: { label: 'Review', onClick: () => setPreviewSheetOpen(true) },
duration: 10000,
})
},
onError: (err) => toast.error(`AI generation failed: ${err.message}`),
})
const { data: competition, isLoading: isLoadingCompetition } = trpc.competition.getById.useQuery({
id: competitionId,
})
const { data: selectedRound } = trpc.round.getById.useQuery(
{ id: selectedRoundId },
{ enabled: !!selectedRoundId }
)
const requiredReviews = (selectedRound?.configJson as Record<string, unknown>)?.requiredReviewsPerProject as number || 3
const { data: unassignedQueue, isLoading: isLoadingQueue } =
trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId: selectedRoundId, requiredReviews },
{ enabled: !!selectedRoundId }
)
const rounds = competition?.rounds || []
const currentRound = rounds.find((r) => r.id === selectedRoundId)
if (isLoadingCompetition) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!competition) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="font-medium">Competition not found</p>
<p className="text-sm text-muted-foreground mt-1">
The requested competition does not exist or you don&apos;t have access.
</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to competition details">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Competition
</Button>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Assignment Dashboard</h1>
<p className="text-muted-foreground">Manage jury assignments for rounds</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Select Round</CardTitle>
<CardDescription>Choose a round to view and manage assignments</CardDescription>
</CardHeader>
<CardContent>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a round..." />
</SelectTrigger>
<SelectContent>
{rounds.length === 0 ? (
<div className="px-2 py-1 text-sm text-muted-foreground">No rounds available</div>
) : (
rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.roundType})
</SelectItem>
))
)}
</SelectContent>
</Select>
</CardContent>
</Card>
{selectedRoundId && (
<div className="space-y-6">
<div className="flex justify-end gap-2">
<Button
onClick={() => {
aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })
}}
disabled={aiAssignmentMutation.isPending}
>
{aiAssignmentMutation.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Generating...</>
) : (
<><Zap className="mr-2 h-4 w-4" />{aiAssignmentMutation.data ? 'Regenerate' : 'Generate with AI'}</>
)}
</Button>
{aiAssignmentMutation.data && (
<Button variant="outline" onClick={() => setPreviewSheetOpen(true)}>
Review Assignments
</Button>
)}
</div>
<Tabs defaultValue="coverage" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="coverage">Coverage Report</TabsTrigger>
<TabsTrigger value="unassigned">Unassigned Queue</TabsTrigger>
</TabsList>
<TabsContent value="coverage" className="mt-6">
<CoverageReport roundId={selectedRoundId} requiredReviews={requiredReviews} />
</TabsContent>
<TabsContent value="unassigned" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Unassigned Projects</CardTitle>
<CardDescription>
Projects with fewer than {requiredReviews} assignments
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingQueue ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
) : unassignedQueue && unassignedQueue.length > 0 ? (
<div className="space-y-2">
{unassignedQueue.map((project: any) => (
<div
key={project.id}
className="flex justify-between items-center p-3 border rounded-md"
>
<div>
<p className="font-medium">{project.title}</p>
<p className="text-sm text-muted-foreground">
{project.competitionCategory || 'No category'}
</p>
</div>
<div className="text-sm text-muted-foreground">
{project.assignmentCount || 0} / {requiredReviews} assignments
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
All projects have sufficient assignments
</p>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
<AssignmentPreviewSheet
roundId={selectedRoundId}
open={previewSheetOpen}
onOpenChange={setPreviewSheetOpen}
requiredReviews={requiredReviews}
aiResult={aiAssignmentMutation.data ?? null}
isAIGenerating={aiAssignmentMutation.isPending}
onGenerateAI={() => aiAssignmentMutation.mutate({ roundId: selectedRoundId, requiredReviews })}
onResetAI={() => aiAssignmentMutation.reset()}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,154 @@
'use client';
import { use } from 'react';
import { useRouter } from 'next/navigation';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft } from 'lucide-react';
import type { Route } from 'next';
export default function AwardDetailPage({
params: paramsPromise
}: {
params: Promise<{ competitionId: string; awardId: string }>;
}) {
const params = use(paramsPromise);
const router = useRouter();
const { data: award, isLoading } = trpc.specialAward.get.useQuery({
id: params.awardId
});
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Loading...</h1>
</div>
</div>
</div>
);
}
if (!award) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Award Not Found</h1>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() =>
router.push(`/admin/competitions/${params.competitionId}/awards` as Route)
}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="text-3xl font-bold">{award.name}</h1>
<p className="text-muted-foreground">{award.description || 'No description'}</p>
</div>
</div>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="eligible">Eligible Projects</TabsTrigger>
<TabsTrigger value="winners">Winners</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Award Information</CardTitle>
<CardDescription>Configuration and settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Scoring Mode</p>
<Badge variant="outline" className="mt-1">
{award.scoringMode}
</Badge>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">AI Eligibility</p>
<Badge variant="outline" className="mt-1">
{award.useAiEligibility ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Status</p>
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'} className="mt-1">
{award.status}
</Badge>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Program</p>
<p className="mt-1 text-sm">{award.program?.name}</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="eligible" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Eligible Projects</CardTitle>
<CardDescription>
Projects that qualify for this award ({award?.eligibleCount || 0})
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-center text-muted-foreground">
{award?.eligibleCount || 0} eligible projects
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="winners" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Award Winners</CardTitle>
<CardDescription>Selected winners for this award</CardDescription>
</CardHeader>
<CardContent>
{award?.winnerProject ? (
<div className="rounded-lg border p-4">
<div>
<p className="font-medium">{award.winnerProject.title}</p>
<p className="text-sm text-muted-foreground">{award.winnerProject.teamName}</p>
</div>
<Badge className="mt-2">Winner</Badge>
</div>
) : (
<p className="text-center text-muted-foreground">No winner selected yet</p>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import { use, useState } from 'react';
import { useRouter } from 'next/navigation';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
import type { Route } from 'next';
export default function NewAwardPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
const params = use(paramsPromise);
const router = useRouter();
const utils = trpc.useUtils();
const [formData, setFormData] = useState({
name: '',
description: '',
criteriaText: '',
useAiEligibility: false,
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
maxRankedPicks: '3',
});
const { data: competition } = trpc.competition.getById.useQuery({
id: params.competitionId
});
const { data: juryGroups } = trpc.juryGroup.list.useQuery({
competitionId: params.competitionId
});
const createMutation = trpc.specialAward.create.useMutation({
onSuccess: () => {
utils.specialAward.list.invalidate();
toast.success('Award created successfully');
router.push(`/admin/competitions/${params.competitionId}/awards` as Route);
},
onError: (err) => {
toast.error(err.message);
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
toast.error('Award name is required');
return;
}
if (!competition?.programId) {
toast.error('Competition data not loaded');
return;
}
createMutation.mutate({
programId: competition.programId,
competitionId: params.competitionId,
name: formData.name.trim(),
description: formData.description.trim() || undefined,
criteriaText: formData.criteriaText.trim() || undefined,
scoringMode: formData.scoringMode,
useAiEligibility: formData.useAiEligibility,
maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
});
};
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Create Special Award</h1>
<p className="text-muted-foreground">Define a new award for this competition</p>
</div>
</div>
<form onSubmit={handleSubmit}>
<Card>
<CardHeader>
<CardTitle>Award Details</CardTitle>
<CardDescription>Configure the award properties and eligibility</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Award Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Best Innovation Award"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe the award criteria and purpose"
rows={3}
/>
</div>
<div className="space-y-2">
<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">
<Checkbox
id="useAiEligibility"
checked={formData.useAiEligibility}
onCheckedChange={(checked) =>
setFormData({ ...formData, useAiEligibility: checked as boolean })
}
/>
<Label htmlFor="useAiEligibility" className="font-normal">
Use AI-based eligibility assessment
</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"
variant="outline"
onClick={() => router.push(`/admin/competitions/${params.competitionId}/awards` as Route)}
>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create Award'}
</Button>
</div>
</CardContent>
</Card>
</form>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
import { use } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, Plus } from 'lucide-react';
import type { Route } from 'next';
export default function AwardsPage({ params: paramsPromise }: { params: Promise<{ competitionId: string }> }) {
const params = use(paramsPromise);
const router = useRouter();
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
id: params.competitionId
});
const { data: awards, isLoading, isError: isAwardsError } = trpc.specialAward.list.useQuery({
programId: competition?.programId
}, {
enabled: !!competition?.programId
});
if (isCompError || isAwardsError) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Awards</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or awards data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Special Awards</h1>
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Special Awards</h1>
<p className="text-muted-foreground">
Manage special awards and prizes for this competition
</p>
</div>
</div>
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Award
</Button>
</Link>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{awards?.map((award) => (
<Link
key={award.id}
href={`/admin/competitions/${params.competitionId}/awards/${award.id}` as Route}
>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<span className="line-clamp-1">{award.name}</span>
</CardTitle>
<CardDescription className="line-clamp-2">
{award.description || 'No description'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{award.scoringMode}</Badge>
<Badge variant={award.status === 'DRAFT' ? 'secondary' : 'default'}>
{award.status}
</Badge>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{awards?.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">No awards created yet</p>
<Link href={`/admin/competitions/${params.competitionId}/awards/new` as Route}>
<Button variant="link">Create your first award</Button>
</Link>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,255 @@
'use client';
import { use, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
import { ResultsPanel } from '@/components/admin/deliberation/results-panel';
import type { Route } from 'next';
const STATUS_LABELS: Record<string, string> = {
DELIB_OPEN: 'Open',
VOTING: 'Voting',
TALLYING: 'Tallying',
RUNOFF: 'Runoff',
DELIB_LOCKED: 'Locked',
};
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DELIB_OPEN: 'outline',
VOTING: 'default',
TALLYING: 'secondary',
RUNOFF: 'secondary',
DELIB_LOCKED: 'secondary',
};
const CATEGORY_LABELS: Record<string, string> = {
STARTUP: 'Startup',
BUSINESS_CONCEPT: 'Business Concept',
};
const TIE_BREAK_LABELS: Record<string, string> = {
TIE_RUNOFF: 'Runoff Vote',
TIE_ADMIN_DECIDES: 'Admin Decides',
SCORE_FALLBACK: 'Score Fallback',
};
export default function DeliberationSessionPage({
params: paramsPromise
}: {
params: Promise<{ competitionId: string; sessionId: string }>;
}) {
const params = use(paramsPromise);
const router = useRouter();
const utils = trpc.useUtils();
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
{ sessionId: params.sessionId },
{ refetchInterval: 10_000 }
);
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Voting opened');
},
onError: (err) => {
toast.error(err.message);
}
});
const closeVotingMutation = trpc.deliberation.closeVoting.useMutation({
onSuccess: () => {
utils.deliberation.getSession.invalidate();
toast.success('Voting closed');
},
onError: (err) => {
toast.error(err.message);
}
});
// Derive which participants have voted from the votes array
const voterUserIds = useMemo(() => {
if (!session?.votes) return new Set<string>();
return new Set(session.votes.map((v: any) => v.juryMember?.user?.id).filter(Boolean));
}, [session?.votes]);
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Loading...</h1>
</div>
</div>
</div>
);
}
if (!session) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Session Not Found</h1>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() =>
router.push(`/admin/competitions/${params.competitionId}/deliberation` as Route)
}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">Deliberation Session</h1>
<Badge variant={STATUS_VARIANTS[session.status] ?? 'outline'}>{STATUS_LABELS[session.status] ?? session.status}</Badge>
</div>
<p className="text-muted-foreground">
{session.round?.name} - {CATEGORY_LABELS[session.category] ?? session.category}
</p>
</div>
</div>
<Tabs defaultValue="setup" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="setup">Setup</TabsTrigger>
<TabsTrigger value="voting">Voting Control</TabsTrigger>
<TabsTrigger value="results">Results</TabsTrigger>
</TabsList>
<TabsContent value="setup" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Session Configuration</CardTitle>
<CardDescription>Deliberation settings and participants</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<p className="text-sm font-medium text-muted-foreground">Mode</p>
<p className="mt-1">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
<p className="mt-1">{TIE_BREAK_LABELS[session.tieBreakMethod] ?? session.tieBreakMethod}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
Show Collective Rankings
</p>
<p className="mt-1">{session.showCollectiveRankings ? 'Yes' : 'No'}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Show Prior Jury Data</p>
<p className="mt-1">{session.showPriorJuryData ? 'Yes' : 'No'}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Participants ({session.participants?.length || 0})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{session.participants?.map((participant: any) => (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{participant.user?.user?.name ?? 'Unknown'}</p>
<p className="text-sm text-muted-foreground">{participant.user?.user?.email}</p>
</div>
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
{voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="voting" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Voting Controls</CardTitle>
<CardDescription>Manage the voting window for jury members</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row">
<Button
onClick={() => openVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={
openVotingMutation.isPending || session.status !== 'DELIB_OPEN'
}
className="flex-1"
>
Open Voting
</Button>
<Button
variant="destructive"
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={
closeVotingMutation.isPending || session.status !== 'VOTING'
}
className="flex-1"
>
Close Voting
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Voting Status</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{session.participants?.map((participant: any) => (
<div
key={participant.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<span>{participant.user?.user?.name ?? 'Unknown'}</span>
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'secondary'}>
{voterUserIds.has(participant.user?.user?.id) ? 'Submitted' : 'Not Voted'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="results" className="space-y-4">
<ResultsPanel sessionId={params.sessionId} />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,429 @@
'use client';
import { use, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ArrowLeft, Plus } from 'lucide-react';
import { toast } from 'sonner';
import type { Route } from 'next';
export default function DeliberationListPage({
params: paramsPromise
}: {
params: Promise<{ competitionId: string }>;
}) {
const params = use(paramsPromise);
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',
mode: 'SINGLE_WINNER_VOTE' as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING',
tieBreakMethod: 'TIE_RUNOFF' as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK',
showCollectiveRankings: false,
showPriorJuryData: false,
participantUserIds: [] as string[]
});
const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
{ competitionId: params.competitionId },
{ enabled: !!params.competitionId }
);
// Get rounds for this competition
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
{ id: params.competitionId },
{ enabled: !!params.competitionId }
);
const rounds = competition?.rounds || [];
// 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) => {
utils.deliberation.listSessions.invalidate({ competitionId: params.competitionId });
toast.success('Deliberation session created');
setCreateDialogOpen(false);
router.push(
`/admin/competitions/${params.competitionId}/deliberation/${data.id}` as Route
);
},
onError: (err) => {
toast.error(err.message);
}
});
const handleCreateSession = () => {
if (!formData.roundId) {
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,
roundId: formData.roundId,
category: formData.category,
mode: formData.mode,
tieBreakMethod: formData.tieBreakMethod,
showCollectiveRankings: formData.showCollectiveRankings,
showPriorJuryData: formData.showPriorJuryData,
participantUserIds: formData.participantUserIds
});
};
const getStatusBadge = (status: string) => {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DELIB_OPEN: 'outline',
VOTING: 'default',
TALLYING: 'secondary',
RUNOFF: 'secondary',
DELIB_LOCKED: 'secondary',
};
const labels: Record<string, string> = {
DELIB_OPEN: 'Open',
VOTING: 'Voting',
TALLYING: 'Tallying',
RUNOFF: 'Runoff',
DELIB_LOCKED: 'Locked',
};
return <Badge variant={variants[status] || 'outline'}>{labels[status] || status}</Badge>;
};
if (isCompError || isSessionsError) {
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Go back">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">Error Loading Deliberations</h1>
<p className="text-sm text-muted-foreground">
Could not load competition or deliberation data. Please try again.
</p>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex items-center gap-4">
<Skeleton className="h-8 w-8 shrink-0" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-96 max-w-full" />
</div>
</div>
<div className="grid grid-cols-1 gap-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
</div>
);
}
return (
<div className="space-y-6 p-4 sm:p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label="Back to competition details">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Deliberation Sessions</h1>
<p className="text-muted-foreground">
Manage final jury deliberations and winner selection
</p>
</div>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
New Session
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{sessions?.map((session: any) => (
<Link
key={session.id}
href={
`/admin/competitions/${params.competitionId}/deliberation/${session.id}` as Route
}
>
<Card className="cursor-pointer transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>
{session.round?.name} - {session.category === 'BUSINESS_CONCEPT' ? 'Business Concept' : session.category === 'STARTUP' ? 'Startup' : session.category}
</CardTitle>
<CardDescription className="mt-1">
{session.mode === 'SINGLE_WINNER_VOTE' ? 'Single Winner Vote' : 'Full Ranking'}
</CardDescription>
</div>
{getStatusBadge(session.status)}
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2 text-sm text-muted-foreground">
<span>{session.participants?.length || 0} participants</span>
<span></span>
<span>Tie break: {session.tieBreakMethod === 'TIE_RUNOFF' ? 'Runoff Vote' : session.tieBreakMethod === 'TIE_ADMIN_DECIDES' ? 'Admin Decides' : session.tieBreakMethod === 'SCORE_FALLBACK' ? 'Score Fallback' : session.tieBreakMethod}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{sessions?.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground">No deliberation sessions yet</p>
<Button variant="link" onClick={() => setCreateDialogOpen(true)}>
Create your first session
</Button>
</CardContent>
</Card>
)}
{/* Create Session Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Deliberation Session</DialogTitle>
<DialogDescription>
Set up a new deliberation session for final winner selection
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="round">Round *</Label>
<Select value={formData.roundId} onValueChange={(value) => setFormData({ ...formData, roundId: value })}>
<SelectTrigger id="round">
<SelectValue placeholder="Select round" />
</SelectTrigger>
<SelectContent>
{rounds?.map((round: any) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Select
value={formData.category}
onValueChange={(value) =>
setFormData({ ...formData, category: value as 'STARTUP' | 'BUSINESS_CONCEPT' })
}
>
<SelectTrigger id="category">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="mode">Voting Mode *</Label>
<Select
value={formData.mode}
onValueChange={(value) =>
setFormData({
...formData,
mode: value as 'SINGLE_WINNER_VOTE' | 'FULL_RANKING'
})
}
>
<SelectTrigger id="mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SINGLE_WINNER_VOTE">Single Winner Vote</SelectItem>
<SelectItem value="FULL_RANKING">Full Ranking</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tieBreak">Tie Break Method *</Label>
<Select
value={formData.tieBreakMethod}
onValueChange={(value) =>
setFormData({
...formData,
tieBreakMethod: value as 'TIE_RUNOFF' | 'TIE_ADMIN_DECIDES' | 'SCORE_FALLBACK'
})
}
>
<SelectTrigger id="tieBreak">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="TIE_RUNOFF">Runoff Vote</SelectItem>
<SelectItem value="TIE_ADMIN_DECIDES">Admin Decides</SelectItem>
<SelectItem value="SCORE_FALLBACK">Score Fallback</SelectItem>
</SelectContent>
</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
id="showCollective"
checked={formData.showCollectiveRankings}
onCheckedChange={(checked) =>
setFormData({ ...formData, showCollectiveRankings: checked as boolean })
}
/>
<Label htmlFor="showCollective" className="font-normal">
Show collective rankings during voting
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showPrior"
checked={formData.showPriorJuryData}
onCheckedChange={(checked) =>
setFormData({ ...formData, showPriorJuryData: checked as boolean })
}
/>
<Label htmlFor="showPrior" className="font-normal">
Show prior jury evaluation data
</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateSession} disabled={createSessionMutation.isPending}>
{createSessionMutation.isPending ? 'Creating...' : 'Create Session'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import { ArrowLeft } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { JuryMembersTable } from '@/components/admin/jury/jury-members-table'
import { Badge } from '@/components/ui/badge'
export default function JuryGroupDetailPage() {
const params = useParams()
const router = useRouter()
const juryGroupId = params.juryGroupId as string
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery(
{ id: juryGroupId },
{ refetchInterval: 30_000 }
)
if (isLoading) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
if (!juryGroup) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<p>Jury group not found</p>
</div>
)
}
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Button variant="ghost" onClick={() => router.back()} className="mb-4" aria-label="Back to jury groups list">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Juries
</Button>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">{juryGroup.name}</h1>
<p className="text-muted-foreground">{juryGroup.slug}</p>
</div>
<Badge variant="secondary" className="text-lg px-4 py-2">
{juryGroup.defaultCapMode}
</Badge>
</div>
<Tabs defaultValue="members" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="members" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Jury Members</CardTitle>
<CardDescription>
Manage the members of this jury group
</CardDescription>
</CardHeader>
<CardContent>
<JuryMembersTable
juryGroupId={juryGroupId}
members={juryGroup.members}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Jury Group Settings</CardTitle>
<CardDescription>
View and edit settings for this jury group
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<div>
<h3 className="text-sm font-medium text-muted-foreground">Name</h3>
<p className="text-base font-medium">{juryGroup.name}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Slug</h3>
<p className="text-base font-medium">{juryGroup.slug}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Default Max Assignments</h3>
<p className="text-base font-medium">{juryGroup.defaultMaxAssignments}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Default Cap Mode</h3>
<Badge variant="secondary">{juryGroup.defaultCapMode}</Badge>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Soft Cap Buffer</h3>
<p className="text-base font-medium">{juryGroup.softCapBuffer}</p>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Allow Juror Cap Adjustment</h3>
<Badge variant={juryGroup.allowJurorCapAdjustment ? 'default' : 'secondary'}>
{juryGroup.allowJurorCapAdjustment ? 'Yes' : 'No'}
</Badge>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Allow Ratio Adjustment</h3>
<Badge variant={juryGroup.allowJurorRatioAdjustment ? 'default' : 'secondary'}>
{juryGroup.allowJurorRatioAdjustment ? 'Yes' : 'No'}
</Badge>
</div>
<div>
<h3 className="text-sm font-medium text-muted-foreground">Category Quotas Enabled</h3>
<Badge variant={juryGroup.categoryQuotasEnabled ? 'default' : 'secondary'}>
{juryGroup.categoryQuotasEnabled ? 'Yes' : 'No'}
</Badge>
</div>
</div>
{juryGroup.description && (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Description</h3>
<p className="text-base">{juryGroup.description}</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -0,0 +1,187 @@
'use client'
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { ArrowLeft, Plus, Users } from 'lucide-react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'
export default function JuriesListPage() {
const params = useParams()
const router = useRouter()
const competitionId = params.competitionId as string
const utils = trpc.useUtils()
const [createOpen, setCreateOpen] = useState(false)
const [formData, setFormData] = useState({
name: '',
description: '',
})
const { data: juryGroups, isLoading } = trpc.juryGroup.list.useQuery({ competitionId })
const createMutation = trpc.juryGroup.create.useMutation({
onSuccess: () => {
utils.juryGroup.list.invalidate({ competitionId })
toast.success('Jury group created')
setCreateOpen(false)
setFormData({ name: '', description: '' })
},
onError: (err) => toast.error(err.message),
})
const handleCreate = () => {
if (!formData.name.trim()) {
toast.error('Name is required')
return
}
const slug = formData.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
createMutation.mutate({
competitionId,
name: formData.name.trim(),
slug,
description: formData.description.trim() || undefined,
})
}
if (isLoading) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Skeleton className="h-10 w-full" />
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-40" />
))}
</div>
</div>
)
}
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<Button
variant="ghost"
onClick={() => router.back()}
className="mb-4"
aria-label="Back to competition details"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 className="text-3xl font-bold">Jury Groups</h1>
<p className="text-muted-foreground">Manage jury groups and members for this competition</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Jury Group
</Button>
</div>
{juryGroups && juryGroups.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Users className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground text-center">No jury groups yet. Create one to get started.</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{juryGroups?.map((group) => (
<Link
key={group.id}
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
>
<Card className="hover:bg-accent/50 transition-colors cursor-pointer h-full">
<CardHeader>
<CardTitle className="flex items-center justify-between">
{group.name}
<Badge variant="secondary">{group.defaultCapMode}</Badge>
</CardTitle>
<CardDescription>{group.slug}</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Members</span>
<span className="font-medium">{group._count.members}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span className="font-medium">{group._count.assignments || 0}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Default Max</span>
<span className="font-medium">{group.defaultMaxAssignments}</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
{/* Create Jury Group Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Jury Group</DialogTitle>
<DialogDescription>
Create a new jury group for this competition. You can add members after creation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="jury-name">Name *</Label>
<Input
id="jury-name"
placeholder="e.g. Main Jury Panel"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="jury-description">Description</Label>
<Textarea
id="jury-description"
placeholder="Optional description of this jury group's role"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client';
import { use } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
import { LiveControlPanel } from '@/components/admin/live/live-control-panel';
import type { Route } from 'next';
export default function LiveFinalsPage({
params: paramsPromise
}: {
params: Promise<{ competitionId: string; roundId: string }>;
}) {
const params = use(paramsPromise);
const router = useRouter();
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => router.push(`/admin/competitions/${params.competitionId}` as Route)}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-3xl font-bold">Live Finals Control</h1>
<p className="text-muted-foreground">Manage live ceremony presentation and voting</p>
</div>
</div>
<LiveControlPanel roundId={params.roundId} competitionId={params.competitionId} />
</div>
);
}

View File

@@ -0,0 +1,599 @@
'use client'
import { useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
ArrowLeft,
ChevronDown,
Layers,
Users,
FolderKanban,
ClipboardList,
Settings,
MoreHorizontal,
Archive,
Loader2,
Plus,
CalendarDays,
Radio,
} from 'lucide-react'
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
const ROUND_TYPES = [
{ value: 'INTAKE', label: 'Intake' },
{ value: 'FILTERING', label: 'Filtering' },
{ value: 'EVALUATION', label: 'Evaluation' },
{ value: 'SUBMISSION', label: 'Submission' },
{ value: 'MENTORING', label: 'Mentoring' },
{ value: 'LIVE_FINAL', label: 'Live Final' },
{ value: 'DELIBERATION', label: 'Deliberation' },
] as const
const statusConfig = {
DRAFT: {
label: 'Draft',
bgClass: 'bg-gray-100 text-gray-700',
dotClass: 'bg-gray-500',
},
ACTIVE: {
label: 'Active',
bgClass: 'bg-emerald-100 text-emerald-700',
dotClass: 'bg-emerald-500',
},
CLOSED: {
label: 'Closed',
bgClass: 'bg-blue-100 text-blue-700',
dotClass: 'bg-blue-500',
},
ARCHIVED: {
label: 'Archived',
bgClass: 'bg-muted text-muted-foreground',
dotClass: 'bg-muted-foreground',
},
} as const
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
export default function CompetitionDetailPage() {
const params = useParams()
const competitionId = params.competitionId as string
const utils = trpc.useUtils()
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({
name: '',
roundType: '' as string,
})
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ refetchInterval: 30_000 }
)
const updateMutation = trpc.competition.update.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Competition updated')
},
onError: (err) => toast.error(err.message),
})
const createRoundMutation = trpc.round.create.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Round created')
setAddRoundOpen(false)
setRoundForm({ name: '', roundType: '' })
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = (newStatus: 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED') => {
updateMutation.mutate({ id: competitionId, status: newStatus })
}
const handleCreateRound = () => {
if (!roundForm.name.trim() || !roundForm.roundType) {
toast.error('Name and type are required')
return
}
const slug = roundForm.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
const nextOrder = competition?.rounds.length ?? 0
createRoundMutation.mutate({
competitionId,
name: roundForm.name.trim(),
slug,
roundType: roundForm.roundType as any,
sortOrder: nextOrder,
})
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8" />
<div>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-32 mt-1" />
</div>
</div>
<Skeleton className="h-10 w-full" />
<Skeleton className="h-64 w-full" />
</div>
)
}
if (!competition) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={"/admin/competitions" as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Competition Not Found</h1>
<p className="text-sm text-muted-foreground">
The requested competition does not exist
</p>
</div>
</div>
</div>
)
}
const status = competition.status as keyof typeof statusConfig
const config = statusConfig[status] || statusConfig.DRAFT
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<Link href={"/admin/competitions" as Route} className="mt-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-xl font-bold truncate">{competition.name}</h1>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-full transition-colors shrink-0',
config.bgClass,
'hover:opacity-80'
)}
>
{config.label}
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{(['DRAFT', 'ACTIVE', 'CLOSED'] as const).map((s) => (
<DropdownMenuItem
key={s}
onClick={() => handleStatusChange(s)}
disabled={competition.status === s || updateMutation.isPending}
>
{s.charAt(0) + s.slice(1).toLowerCase()}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleStatusChange('ARCHIVED')}
disabled={competition.status === 'ARCHIVED' || updateMutation.isPending}
>
<Archive className="h-4 w-4 mr-2" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<p className="text-sm text-muted-foreground font-mono">{competition.slug}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8" aria-label="More actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/competitions/${competitionId}/assignments` as Route}>
<ClipboardList className="h-4 w-4 mr-2" />
Assignments
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/competitions/${competitionId}/deliberation` as Route}>
<Users className="h-4 w-4 mr-2" />
Deliberation
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleStatusChange('ARCHIVED')}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Archive className="h-4 w-4 mr-2" />
)}
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">Rounds</span>
</div>
<p className="text-2xl font-bold mt-1">{competition.rounds.filter((r: any) => !r.specialAwardId).length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium">Juries</span>
</div>
<p className="text-2xl font-bold mt-1">{competition.juryGroups.length}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<FolderKanban className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Projects</span>
</div>
<p className="text-2xl font-bold mt-1">
{(competition as any).distinctProjectCount ?? 0}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<Settings className="h-4 w-4 text-amber-500" />
<span className="text-sm font-medium">Category</span>
</div>
<p className="text-lg font-bold mt-1 truncate">{competition.categoryMode}</p>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="rounds">Rounds</TabsTrigger>
<TabsTrigger value="juries">Juries</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-6">
<CompetitionTimeline
competitionId={competitionId}
rounds={competition.rounds.filter((r: any) => !r.specialAwardId)}
/>
</TabsContent>
{/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Rounds ({competition.rounds.filter((r: any) => !r.specialAwardId).length})</h2>
<Button size="sm" variant="outline" className="w-full sm:w-auto" onClick={() => setAddRoundOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</div>
{competition.rounds.filter((r: any) => !r.specialAwardId).length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds configured. Add rounds to define the competition flow.
</CardContent>
</Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{competition.rounds.filter((r: any) => !r.specialAwardId).map((round: any, index: number) => {
const projectCount = round._count?.projectRoundStates ?? 0
const assignmentCount = round._count?.assignments ?? 0
const statusLabel = round.status.replace('ROUND_', '')
const statusColors: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-600',
ACTIVE: 'bg-emerald-100 text-emerald-700',
CLOSED: 'bg-blue-100 text-blue-700',
ARCHIVED: 'bg-muted text-muted-foreground',
}
return (
<Link
key={round.id}
href={`/admin/rounds/${round.id}` as Route}
>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="pt-4 pb-3 space-y-3">
{/* Top: number + name + badges */}
<div className="flex items-start gap-2.5">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-muted text-xs font-bold shrink-0 mt-0.5">
{index + 1}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">{round.name}</p>
<div className="flex flex-wrap gap-1.5 mt-1">
<Badge
variant="secondary"
className={cn(
'text-[10px]',
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
)}
>
{round.roundType.replace(/_/g, ' ')}
</Badge>
<Badge
variant="outline"
className={cn('text-[10px]', statusColors[statusLabel])}
>
{statusLabel}
</Badge>
</div>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Layers className="h-3.5 w-3.5" />
<span>{projectCount} project{projectCount !== 1 ? 's' : ''}</span>
</div>
{(round.roundType === 'EVALUATION' || round.roundType === 'FILTERING') && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<ClipboardList className="h-3.5 w-3.5" />
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
</div>
)}
</div>
{/* Dates */}
{(round.windowOpenAt || round.windowCloseAt) && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
<span>
{round.windowOpenAt
? new Date(round.windowOpenAt).toLocaleDateString()
: '?'}
{' \u2014 '}
{round.windowCloseAt
? new Date(round.windowCloseAt).toLocaleDateString()
: '?'}
</span>
</div>
)}
{/* Jury group */}
{round.juryGroup && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Users className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{round.juryGroup.name}</span>
</div>
)}
{/* Live Control link for LIVE_FINAL rounds */}
{round.roundType === 'LIVE_FINAL' && (
<Link
href={`/admin/competitions/${competitionId}/live/${round.id}` as Route}
onClick={(e) => e.stopPropagation()}
>
<Button size="sm" variant="outline" className="w-full text-xs gap-1.5">
<Radio className="h-3.5 w-3.5" />
Live Control
</Button>
</Link>
)}
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</TabsContent>
{/* Juries Tab */}
<TabsContent value="juries" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Jury Groups ({competition.juryGroups.length})</h2>
<Link href={`/admin/competitions/${competitionId}/juries` as Route}>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Users className="h-4 w-4 mr-1" />
Manage Juries
</Button>
</Link>
</div>
{competition.juryGroups.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No jury groups configured. Create jury groups to assign evaluators.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{competition.juryGroups.map((group) => (
<Link
key={group.id}
href={`/admin/competitions/${competitionId}/juries/${group.id}` as Route}
>
<Card className="hover:shadow-sm transition-shadow cursor-pointer h-full">
<CardHeader className="pb-2">
<CardTitle className="text-sm">{group.name}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{group._count.members} members</span>
<Badge variant="outline" className="text-[10px]">
Cap: {group.defaultCapMode}
</Badge>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</TabsContent>
{/* Settings Tab */}
<TabsContent value="settings" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Competition Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="text-sm font-medium text-muted-foreground">Category Mode</label>
<p className="text-sm mt-1">{competition.categoryMode}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Startup Finalists</label>
<p className="text-sm mt-1">{competition.startupFinalistCount}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Concept Finalists</label>
<p className="text-sm mt-1">{competition.conceptFinalistCount}</p>
</div>
<div>
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
<div className="flex flex-wrap gap-2 mt-1">
{competition.notifyOnDeadlineApproach && (
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
)}
</div>
</div>
</div>
{competition.deadlineReminderDays && (
<div>
<label className="text-sm font-medium text-muted-foreground">
Reminder Days
</label>
<p className="text-sm mt-1">
{(competition.deadlineReminderDays as number[]).join(', ')} days before deadline
</p>
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Add Round Dialog */}
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Round</DialogTitle>
<DialogDescription>
Add a new round to this competition. It will be appended to the current round sequence.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="round-name">Name *</Label>
<Input
id="round-name"
placeholder="e.g. Initial Screening"
value={roundForm.name}
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="round-type">Round Type *</Label>
<Select
value={roundForm.roundType}
onValueChange={(value) => setRoundForm({ ...roundForm, roundType: value })}
>
<SelectTrigger id="round-type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{ROUND_TYPES.map((rt) => (
<SelectItem key={rt.value} value={rt.value}>
{rt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateRound} disabled={createRoundMutation.isPending}>
{createRoundMutation.isPending ? 'Creating...' : 'Create Round'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,307 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { SidebarStepper } from '@/components/ui/sidebar-stepper'
import type { StepConfig } from '@/components/ui/sidebar-stepper'
import { ArrowLeft } from 'lucide-react'
import { BasicsSection } from '@/components/admin/competition/sections/basics-section'
import { RoundsSection } from '@/components/admin/competition/sections/rounds-section'
import { JuryGroupsSection } from '@/components/admin/competition/sections/jury-groups-section'
import { ReviewSection } from '@/components/admin/competition/sections/review-section'
import { useEdition } from '@/contexts/edition-context'
type WizardRound = {
tempId: string
name: string
slug: string
roundType: string
sortOrder: number
configJson: Record<string, unknown>
}
type WizardJuryGroup = {
tempId: string
name: string
slug: string
defaultMaxAssignments: number
defaultCapMode: string
sortOrder: number
}
type WizardState = {
programId: string
name: string
slug: string
categoryMode: string
startupFinalistCount: number
conceptFinalistCount: number
notifyOnRoundAdvance: boolean
notifyOnDeadlineApproach: boolean
deadlineReminderDays: number[]
rounds: WizardRound[]
juryGroups: WizardJuryGroup[]
}
const defaultRounds: WizardRound[] = [
{
tempId: crypto.randomUUID(),
name: 'Intake',
slug: 'intake',
roundType: 'INTAKE',
sortOrder: 0,
configJson: {},
},
{
tempId: crypto.randomUUID(),
name: 'Filtering',
slug: 'filtering',
roundType: 'FILTERING',
sortOrder: 1,
configJson: {},
},
{
tempId: crypto.randomUUID(),
name: 'Evaluation (Jury 1)',
slug: 'evaluation-jury-1',
roundType: 'EVALUATION',
sortOrder: 2,
configJson: {},
},
{
tempId: crypto.randomUUID(),
name: 'Submission',
slug: 'submission',
roundType: 'SUBMISSION',
sortOrder: 3,
configJson: {},
},
{
tempId: crypto.randomUUID(),
name: 'Evaluation (Jury 2)',
slug: 'evaluation-jury-2',
roundType: 'EVALUATION',
sortOrder: 4,
configJson: {},
},
{
tempId: crypto.randomUUID(),
name: 'Mentoring',
slug: 'mentoring',
roundType: 'MENTORING',
sortOrder: 5,
configJson: {},
},
{
tempId: crypto.randomUUID(),
name: 'Live Final',
slug: 'live-final',
roundType: 'LIVE_FINAL',
sortOrder: 6,
configJson: {},
},
{
tempId: crypto.randomUUID(),
name: 'Deliberation',
slug: 'deliberation',
roundType: 'DELIBERATION',
sortOrder: 7,
configJson: {},
},
]
export default function NewCompetitionPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { currentEdition } = useEdition()
const paramProgramId = searchParams.get('programId')
const programId = paramProgramId || currentEdition?.id || ''
const [currentStep, setCurrentStep] = useState(0)
const [isDirty, setIsDirty] = useState(false)
const [state, setState] = useState<WizardState>({
programId,
name: '',
slug: '',
categoryMode: 'SHARED',
startupFinalistCount: 3,
conceptFinalistCount: 3,
notifyOnRoundAdvance: true,
notifyOnDeadlineApproach: true,
deadlineReminderDays: [7, 3, 1],
rounds: defaultRounds,
juryGroups: [],
})
useEffect(() => {
if (programId) {
setState((prev) => ({ ...prev, programId }))
}
}, [programId])
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
const utils = trpc.useUtils()
const createCompetitionMutation = trpc.competition.create.useMutation()
const createRoundMutation = trpc.round.create.useMutation()
const createJuryGroupMutation = trpc.juryGroup.create.useMutation()
const handleStateChange = (updates: Partial<WizardState>) => {
setState((prev) => ({ ...prev, ...updates }))
setIsDirty(true)
// Auto-generate slug from name if name changed
if (updates.name !== undefined && updates.slug === undefined) {
const autoSlug = updates.name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setState((prev) => ({ ...prev, slug: autoSlug }))
}
}
const handleSubmit = async () => {
if (!state.name.trim()) {
toast.error('Competition name is required')
setCurrentStep(0)
return
}
if (!state.slug.trim()) {
toast.error('Competition slug is required')
setCurrentStep(0)
return
}
if (state.rounds.length === 0) {
toast.error('At least one round is required')
setCurrentStep(1)
return
}
try {
// Create competition
const competition = await createCompetitionMutation.mutateAsync({
programId: state.programId,
name: state.name,
slug: state.slug,
categoryMode: state.categoryMode,
startupFinalistCount: state.startupFinalistCount,
conceptFinalistCount: state.conceptFinalistCount,
notifyOnRoundAdvance: state.notifyOnRoundAdvance,
notifyOnDeadlineApproach: state.notifyOnDeadlineApproach,
deadlineReminderDays: state.deadlineReminderDays,
})
// Create rounds
for (const round of state.rounds) {
await createRoundMutation.mutateAsync({
competitionId: competition.id,
name: round.name,
slug: round.slug,
roundType: round.roundType as any,
sortOrder: round.sortOrder,
configJson: round.configJson,
})
}
// Create jury groups
for (const group of state.juryGroups) {
await createJuryGroupMutation.mutateAsync({
competitionId: competition.id,
name: group.name,
slug: group.slug,
defaultMaxAssignments: group.defaultMaxAssignments,
defaultCapMode: group.defaultCapMode as any,
sortOrder: group.sortOrder,
})
}
toast.success('Competition created successfully')
setIsDirty(false)
utils.competition.list.invalidate()
router.push(`/admin/competitions/${competition.id}` as Route)
} catch (err: any) {
toast.error(err.message || 'Failed to create competition')
}
}
const steps: StepConfig[] = [
{
title: 'Basics',
description: 'Name and settings',
isValid: !!state.name && !!state.slug,
},
{
title: 'Rounds',
description: 'Configure rounds',
isValid: state.rounds.length > 0,
},
{
title: 'Jury Groups',
description: 'Add jury groups',
isValid: true, // Optional
},
{
title: 'Review',
description: 'Confirm and create',
isValid: !!state.name && !!state.slug && state.rounds.length > 0,
},
]
const canSubmit = steps.every((s) => s.isValid)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link href={'/admin/competitions' as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competitions list">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">New Competition</h1>
<p className="text-sm text-muted-foreground">
Create a multi-round competition workflow
</p>
</div>
</div>
{/* Wizard */}
<SidebarStepper
steps={steps}
currentStep={currentStep}
onStepChange={setCurrentStep}
onSubmit={handleSubmit}
isSubmitting={
createCompetitionMutation.isPending ||
createRoundMutation.isPending ||
createJuryGroupMutation.isPending
}
submitLabel="Create Competition"
canSubmit={canSubmit}
>
<BasicsSection state={state} onChange={handleStateChange} />
<RoundsSection rounds={state.rounds} onChange={(rounds) => handleStateChange({ rounds })} />
<JuryGroupsSection
juryGroups={state.juryGroups}
onChange={(juryGroups) => handleStateChange({ juryGroups })}
/>
<ReviewSection state={state} />
</SidebarStepper>
</div>
)
}

View File

@@ -0,0 +1,201 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Medal,
Calendar,
Users,
Layers,
FileBox,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
import { useEdition } from '@/contexts/edition-context'
const statusConfig = {
DRAFT: {
label: 'Draft',
bgClass: 'bg-gray-100 text-gray-700',
dotClass: 'bg-gray-500',
},
ACTIVE: {
label: 'Active',
bgClass: 'bg-emerald-100 text-emerald-700',
dotClass: 'bg-emerald-500',
},
CLOSED: {
label: 'Closed',
bgClass: 'bg-blue-100 text-blue-700',
dotClass: 'bg-blue-500',
},
ARCHIVED: {
label: 'Archived',
bgClass: 'bg-muted text-muted-foreground',
dotClass: 'bg-muted-foreground',
},
} as const
export default function CompetitionListPage() {
const { currentEdition } = useEdition()
const programId = currentEdition?.id
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
{ programId: programId! },
{ enabled: !!programId, refetchInterval: 30_000 }
)
if (!programId) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold">Competitions</h1>
<p className="text-sm text-muted-foreground">
Select an edition to view competitions
</p>
</div>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Edition Selected</p>
<p className="text-sm text-muted-foreground">
Select an edition from the sidebar to view its competitions
</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6 px-4 sm:px-0">
{/* Header */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl font-bold">Competitions</h1>
<p className="text-sm text-muted-foreground">
Manage competitions for {currentEdition?.name}
</p>
</div>
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
<Button size="sm" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
New Competition
</Button>
</Link>
</div>
{/* Loading */}
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20 mt-1" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4 mt-2" />
</CardContent>
</Card>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!competitions || competitions.length === 0) && (
<Card className="border-2 border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="rounded-full bg-primary/10 p-4 mb-4">
<Medal className="h-10 w-10 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">No Competitions Yet</h3>
<p className="text-sm text-muted-foreground max-w-md mb-6">
Competitions organize your multi-round evaluation workflow with jury groups,
submission windows, and scoring. Create your first competition to get started.
</p>
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Your First Competition
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Competition Cards */}
{competitions && competitions.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{competitions.map((competition) => {
const status = competition.status as keyof typeof statusConfig
const config = statusConfig[status] || statusConfig.DRAFT
return (
<Link key={competition.id} href={`/admin/competitions/${competition.id}` as Route}>
<Card className="group cursor-pointer hover:shadow-md transition-shadow h-full flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<CardTitle className="text-base leading-tight">
{competition.name}
</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">
{competition.slug}
</p>
</div>
<Badge
variant="secondary"
className={cn(
'text-[10px] shrink-0 flex items-center gap-1.5',
config.bgClass
)}
>
<span className={cn('h-1.5 w-1.5 rounded-full', config.dotClass)} />
{config.label}
</Badge>
</div>
</CardHeader>
<CardContent className="mt-auto">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<Layers className="h-3.5 w-3.5" />
<span>{competition._count.rounds} rounds</span>
</div>
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" />
<span>{competition._count.juryGroups} juries</span>
</div>
<div className="flex items-center gap-1.5">
<FileBox className="h-3.5 w-3.5" />
<span>{competition._count.submissionWindows} windows</span>
</div>
</div>
<div className="mt-2 text-xs text-muted-foreground">
Updated {formatDistanceToNow(new Date(competition.updatedAt))} ago
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,6 @@
'use client'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -10,17 +9,6 @@ import {
AlertTriangle,
Upload,
UserPlus,
Settings,
ClipboardCheck,
Users,
Send,
FileDown,
Calendar,
Eye,
Presentation,
Vote,
Play,
Lock,
} from 'lucide-react'
import { GeographicSummaryCard } from '@/components/charts'
import { AnimatedCard } from '@/components/shared/animated-container'
@@ -41,77 +29,6 @@ type DashboardContentProps = {
sessionName: string
}
type QuickAction = {
label: string
href: string
icon: React.ElementType
}
function getContextualActions(
activeRound: { id: string; roundType: string } | null
): QuickAction[] {
if (!activeRound) {
return [
{ label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
{ label: 'Import', href: '/admin/projects/new', icon: Upload },
{ label: 'Invite', href: '/admin/members', icon: UserPlus },
]
}
const roundHref = `/admin/rounds/${activeRound.id}`
switch (activeRound.roundType) {
case 'INTAKE':
return [
{ label: 'Import Projects', href: '/admin/projects/new', icon: Upload },
{ label: 'Review', href: roundHref, icon: ClipboardCheck },
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
]
case 'FILTERING':
return [
{ label: 'Run Screening', href: roundHref, icon: ClipboardCheck },
{ label: 'Review Results', href: `${roundHref}?tab=filtering`, icon: Eye },
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
]
case 'EVALUATION':
return [
{ label: 'Assignments', href: `${roundHref}?tab=assignments`, icon: Users },
{ label: 'Send Reminders', href: `${roundHref}?tab=assignments`, icon: Send },
{ label: 'Export', href: roundHref, icon: FileDown },
]
case 'SUBMISSION':
return [
{ label: 'Submissions', href: roundHref, icon: ClipboardCheck },
{ label: 'Deadlines', href: `${roundHref}?tab=config`, icon: Calendar },
{ label: 'Status', href: `${roundHref}?tab=projects`, icon: Eye },
]
case 'MENTORING':
return [
{ label: 'Mentors', href: `${roundHref}?tab=projects`, icon: Users },
{ label: 'Progress', href: roundHref, icon: Eye },
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
]
case 'LIVE_FINAL':
return [
{ label: 'Live Control', href: roundHref, icon: Presentation },
{ label: 'Results', href: `${roundHref}?tab=projects`, icon: Vote },
{ label: 'Configure', href: `${roundHref}?tab=config`, icon: Settings },
]
case 'DELIBERATION':
return [
{ label: 'Sessions', href: roundHref, icon: Play },
{ label: 'Results', href: `${roundHref}?tab=projects`, icon: Eye },
{ label: 'Lock Results', href: roundHref, icon: Lock },
]
default:
return [
{ label: 'Rounds', href: '/admin/rounds', icon: CircleDot },
{ label: 'Import', href: '/admin/projects/new', icon: Upload },
{ label: 'Invite', href: '/admin/members', icon: UserPlus },
]
}
}
export function DashboardContent({ editionId, sessionName }: DashboardContentProps) {
const { data, isLoading, error } = trpc.dashboard.getStats.useQuery(
{ editionId },
@@ -121,10 +38,6 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
{ editionId, limit: 8 },
{ enabled: !!editionId, refetchInterval: 30_000 }
)
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
{ limit: 8 },
{ enabled: !!editionId, refetchInterval: 5_000 }
)
if (isLoading) {
return <DashboardSkeleton />
@@ -170,7 +83,6 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
evaluationStats,
totalAssignments,
latestProjects,
recentlyActiveProjects,
categoryBreakdown,
oceanIssueBreakdown,
recentActivity,
@@ -180,17 +92,6 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
? pipelineRounds.find((r) => r.id === activeRoundId) ?? null
: null
// Find next draft round for summary panel
const lastActiveSortOrder = Math.max(
...pipelineRounds.filter((r) => r.status === 'ROUND_ACTIVE').map((r) => r.sortOrder),
-1
)
const nextDraftRound = pipelineRounds.find(
(r) => r.status === 'ROUND_DRAFT' && r.sortOrder > lastActiveSortOrder
) ?? null
const quickActions = getContextualActions(activeRound)
return (
<>
{/* Page Header */}
@@ -208,15 +109,25 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
Welcome back, {sessionName}
</p>
</div>
<div className="flex flex-wrap gap-2">
{quickActions.map((action) => (
<Link key={action.label} href={action.href as Route}>
<Button size="sm" variant="outline">
<action.icon className="mr-1.5 h-3.5 w-3.5" />
{action.label}
</Button>
</Link>
))}
<div className="flex gap-2">
<Link href="/admin/rounds">
<Button size="sm" variant="outline">
<CircleDot className="mr-1.5 h-3.5 w-3.5" />
Rounds
</Button>
</Link>
<Link href="/admin/projects/new">
<Button size="sm" variant="outline">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import
</Button>
</Link>
<Link href="/admin/members">
<Button size="sm" variant="outline">
<UserPlus className="mr-1.5 h-3.5 w-3.5" />
Invite
</Button>
</Link>
</div>
</motion.div>
@@ -236,7 +147,6 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
totalAssignments={totalAssignments}
evaluationStats={evaluationStats}
actionsCount={nextActions.length}
nextDraftRound={nextDraftRound ? { name: nextDraftRound.name, roundType: nextDraftRound.roundType } : null}
/>
</AnimatedCard>
@@ -251,11 +161,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
)}
<AnimatedCard index={3}>
<ProjectListCompact
projects={latestProjects}
activeProjects={recentlyActiveProjects}
mode={activeRound && activeRound.roundType !== 'INTAKE' ? 'active' : 'recent'}
/>
<ProjectListCompact projects={latestProjects} />
</AnimatedCard>
{recentEvals && recentEvals.length > 0 && (
@@ -272,7 +178,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
</AnimatedCard>
<AnimatedCard index={6}>
<ActivityFeed activity={liveActivity ?? recentActivity} />
<ActivityFeed activity={recentActivity} />
</AnimatedCard>
</div>
</div>

View File

@@ -34,8 +34,8 @@ import {
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { toast } from 'sonner'
import { cn, formatEnumLabel } from '@/lib/utils'
import { Plus, Scale, Users, Loader2, ArrowRight, CircleDot } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Plus, Scale, Users, Loader2 } from 'lucide-react'
const capModeLabels = {
HARD: 'Hard Cap',
@@ -267,82 +267,33 @@ function CompetitionJuriesSection({ competition }: CompetitionJuriesSectionProps
No jury groups configured for this competition.
</p>
) : (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{juryGroups.map((group) => (
<Link key={group.id} href={`/admin/juries/${group.id}` as Route}>
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer group">
<Card className="transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md cursor-pointer">
<CardContent className="p-4">
<div className="space-y-3">
{/* Header row */}
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-brand-blue/10 shrink-0">
<Scale className="h-4 w-4 text-brand-blue" />
</div>
<div className="min-w-0">
<h3 className="font-semibold text-sm line-clamp-1 group-hover:text-brand-blue transition-colors">
{group.name}
</h3>
<p className="text-xs text-muted-foreground">
{group._count.members} member{group._count.members !== 1 ? 's' : ''}
{' · '}
{group._count.assignments} assignment{group._count.assignments !== 1 ? 's' : ''}
</p>
</div>
<h3 className="font-semibold text-sm line-clamp-1">{group.name}</h3>
<Badge
variant="secondary"
className={cn('text-[10px] shrink-0', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
>
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Users className="h-3 w-3" />
<span>{group._count.members} members</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="secondary"
className={cn('text-[10px]', capModeColors[group.defaultCapMode as keyof typeof capModeColors])}
>
{capModeLabels[group.defaultCapMode as keyof typeof capModeLabels]}
</Badge>
<ArrowRight className="h-4 w-4 text-muted-foreground/40 group-hover:text-brand-blue transition-colors" />
<div>
{group._count.assignments} assignments
</div>
</div>
{/* Round assignments */}
{(group as any).rounds?.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{(group as any).rounds.map((r: any) => (
<Badge
key={r.id}
variant="outline"
className={cn(
'text-[10px] gap-1',
r.status === 'ROUND_ACTIVE' && 'border-blue-300 bg-blue-50 text-blue-700',
r.status === 'ROUND_CLOSED' && 'border-emerald-300 bg-emerald-50 text-emerald-700',
r.status === 'ROUND_DRAFT' && 'border-slate-200 text-slate-500',
)}
>
<CircleDot className="h-2.5 w-2.5" />
{r.name}
</Badge>
))}
</div>
)}
{/* Member preview */}
{(group as any).members?.length > 0 && (
<div className="flex items-center gap-1.5">
<div className="flex -space-x-1.5">
{(group as any).members.slice(0, 5).map((m: any) => (
<div
key={m.id}
className="h-6 w-6 rounded-full bg-brand-blue/10 border-2 border-white flex items-center justify-center text-[9px] font-semibold text-brand-blue"
title={m.user?.name || m.user?.email}
>
{(m.user?.name || m.user?.email || '?').charAt(0).toUpperCase()}
</div>
))}
</div>
{group._count.members > 5 && (
<span className="text-[10px] text-muted-foreground">
+{group._count.members - 5} more
</span>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
Default max: {group.defaultMaxAssignments}
</div>
</div>
</CardContent>
</Card>

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
@@ -8,11 +8,15 @@ import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
@@ -20,14 +24,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import {
AlertDialog,
AlertDialogAction,
@@ -39,62 +35,46 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
Settings,
Eye,
FileText,
Video,
Link as LinkIcon,
File,
Trash2,
Eye,
AlertCircle,
} from 'lucide-react'
// Dynamically import editors to avoid SSR issues
// Dynamically import BlockEditor to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const ROLE_OPTIONS = [
{ value: 'JURY_MEMBER', label: 'Jury Members' },
{ value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
]
type AccessRule =
| { type: 'everyone' }
| { type: 'roles'; roles: string[] }
| { type: 'jury_group'; juryGroupIds: string[] }
| { type: 'round'; roundIds: string[] }
function parseAccessJson(accessJson: unknown): { mode: 'everyone' | 'roles'; roles: string[] } {
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
return { mode: 'everyone', roles: [] }
}
const firstRule = accessJson[0] as AccessRule
if (firstRule.type === 'roles' && 'roles' in firstRule) {
return { mode: 'roles', roles: firstRule.roles }
}
return { mode: 'everyone', roles: [] }
}
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
export default function EditLearningResourcePage() {
const params = useParams()
@@ -109,14 +89,11 @@ export default function EditLearningResourcePage() {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
// Access rules state
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
@@ -138,13 +115,11 @@ export default function EditLearningResourcePage() {
setTitle(resource.title)
setDescription(resource.description || '')
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
setResourceType(resource.resourceType)
setCohortLevel(resource.cohortLevel)
setExternalUrl(resource.externalUrl || '')
setIsPublished(resource.isPublished)
setProgramId(resource.programId)
const { mode, roles } = parseAccessJson(resource.accessJson)
setAccessMode(mode)
setSelectedRoles(roles)
}
}, [resource])
@@ -159,89 +134,75 @@ export default function EditLearningResourcePage() {
await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
headers: {
'Content-Type': file.type,
},
})
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch {
} catch (error) {
toast.error('Failed to upload file')
throw new Error('Upload failed')
throw error
}
}
const buildAccessJson = (): AccessRule[] | null => {
if (accessMode === 'everyone') return null
if (accessMode === 'roles' && selectedRoles.length > 0) {
return [{ type: 'roles', roles: selectedRoles }]
}
return null
}
const handleSubmit = useCallback(async () => {
const handleSubmit = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await updateResource.mutateAsync({
id: resourceId,
programId,
title,
description: description || null,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
accessJson: buildAccessJson(),
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
externalUrl: externalUrl || null,
isPublished,
})
toast.success('Resource updated')
toast.success('Resource updated successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
}
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles, resourceId])
}
const handleDelete = async () => {
try {
await deleteResource.mutateAsync({ id: resourceId })
toast.success('Resource deleted')
toast.success('Resource deleted successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
}
}
// Ctrl+S save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSubmit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSubmit])
if (isLoading) {
return (
<div className="flex min-h-screen flex-col">
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2">
<Skeleton className="h-8 w-20" />
<div className="flex gap-2">
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-20" />
<Skeleton className="h-8 w-16" />
</div>
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-40" />
</div>
<div className="flex-1 px-4 py-8">
<div className="mx-auto max-w-3xl space-y-4">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
<div className="space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
</div>
)
@@ -249,7 +210,7 @@ export default function EditLearningResourcePage() {
if (error || !resource) {
return (
<div className="space-y-6 p-6">
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
@@ -268,250 +229,253 @@ export default function EditLearningResourcePage() {
}
return (
<div className="flex min-h-screen flex-col">
{/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" asChild>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
Back to Learning Hub
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant={previewing ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewing(!previewing)}
>
<Eye className="mr-2 h-4 w-4" />
{previewing ? 'Edit' : 'Preview'}
</Button>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
<p className="text-muted-foreground">
Update this learning resource
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{resource.title}&quot;? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
</SheetTrigger>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Resource Settings</SheetTitle>
<SheetDescription>
Configure publishing, access, and metadata
</SheetDescription>
</SheetHeader>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<div className="mt-6 space-y-6">
{/* Publish toggle */}
<div className="flex items-center justify-between">
<div>
<Label>Published</Label>
<p className="text-sm text-muted-foreground">
Make visible to users
</p>
</div>
<Switch
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<Separator />
{/* Program */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select program" />
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* Access Rules */}
<div className="space-y-3">
<Label>Access Rules</Label>
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
<SelectTrigger>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="everyone">Everyone</SelectItem>
<SelectItem value="roles">By Role</SelectItem>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{accessMode === 'roles' && (
<div className="space-y-2 rounded-lg border p-3">
{ROLE_OPTIONS.map((role) => (
<label key={role.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={selectedRoles.includes(role.value)}
onCheckedChange={(checked) => {
setSelectedRoles(
checked
? [...selectedRoles, role.value]
: selectedRoles.filter((r) => r !== role.value)
)
}}
/>
{role.label}
</label>
))}
</div>
)}
</div>
</div>
<Separator />
{/* External URL */}
{resourceType === 'LINK' && (
<div className="space-y-2">
<Label>External URL</Label>
<Label htmlFor="url">External URL *</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
<p className="text-xs text-muted-foreground">
Optional link to an external resource
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
key={resourceId}
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
</p>
</div>
<Separator />
{/* Statistics */}
{stats && (
<div className="space-y-2">
<Label>Statistics</Label>
<div className="grid grid-cols-2 gap-4 rounded-lg border p-3">
<div>
<p className="text-2xl font-semibold">{stats.totalViews}</p>
<p className="text-xs text-muted-foreground">Total views</p>
</div>
<div>
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
<p className="text-xs text-muted-foreground">Unique users</p>
</div>
</div>
</div>
)}
<Separator />
{/* Danger Zone */}
<div className="space-y-2">
<Label className="text-destructive">Danger Zone</Label>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full text-destructive hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Resource
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{resource.title}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
</SheetContent>
</Sheet>
<Button
size="sm"
onClick={handleSubmit}
disabled={updateResource.isPending || !title.trim()}
>
{updateResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Statistics */}
{stats && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Statistics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-semibold">{stats.totalViews}</p>
<p className="text-sm text-muted-foreground">Total views</p>
</div>
<div>
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
<p className="text-sm text-muted-foreground">Unique users</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
onClick={handleSubmit}
disabled={updateResource.isPending || !title.trim()}
className="w-full"
>
{updateResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Content area */}
<div className="flex-1 px-4 py-8">
{previewing ? (
<ResourceRenderer
title={title || 'Untitled'}
description={description || null}
contentJson={contentJson ? JSON.parse(contentJson) : null}
/>
) : (
<div className="mx-auto max-w-3xl space-y-4">
{/* Inline title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Untitled"
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
/>
{/* Inline description */}
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
/>
{/* Divider */}
<hr className="border-border" />
{/* Block editor */}
<BlockEditor
key={resourceId}
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[400px]"
/>
</div>
)}
</div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
@@ -8,9 +8,15 @@ import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
@@ -18,57 +24,33 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
Settings,
Eye,
} from 'lucide-react'
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
// Dynamically import editors to avoid SSR issues
// Dynamically import BlockEditor to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const ROLE_OPTIONS = [
{ value: 'JURY_MEMBER', label: 'Jury Members' },
{ value: 'MENTOR', label: 'Mentors' },
{ value: 'OBSERVER', label: 'Observers' },
{ value: 'APPLICANT', label: 'Applicants' },
{ value: 'AWARD_MASTER', label: 'Award Masters' },
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
]
type AccessRule =
| { type: 'everyone' }
| { type: 'roles'; roles: string[] }
| { type: 'jury_group'; juryGroupIds: string[] }
| { type: 'round'; roundIds: string[] }
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
export default function NewLearningResourcePage() {
const router = useRouter()
@@ -77,17 +59,14 @@ export default function NewLearningResourcePage() {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
const [previewing, setPreviewing] = useState(false)
// Access rules state
const [accessMode, setAccessMode] = useState<'everyone' | 'roles'>('everyone')
const [selectedRoles, setSelectedRoles] = useState<string[]>([])
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const [programId, setProgramId] = useState<string | null>(null)
const utils = trpc.useUtils()
const createResource = trpc.learningResource.create.useMutation({
@@ -103,41 +82,43 @@ export default function NewLearningResourcePage() {
mimeType: file.type,
})
// Upload to MinIO
await fetch(url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
headers: {
'Content-Type': file.type,
},
})
// Return the MinIO URL
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch {
} catch (error) {
toast.error('Failed to upload file')
throw new Error('Upload failed')
throw error
}
}
const buildAccessJson = (): AccessRule[] | null => {
if (accessMode === 'everyone') return null
if (accessMode === 'roles' && selectedRoles.length > 0) {
return [{ type: 'roles', roles: selectedRoles }]
}
return null
}
const handleSubmit = useCallback(async () => {
const handleSubmit = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await createResource.mutateAsync({
programId,
title,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
accessJson: buildAccessJson(),
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
externalUrl: externalUrl || undefined,
isPublished,
})
@@ -147,205 +128,200 @@ export default function NewLearningResourcePage() {
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
}
}, [title, description, contentJson, externalUrl, isPublished, programId, accessMode, selectedRoles])
// Ctrl+S save
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSubmit()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSubmit])
}
return (
<div className="flex min-h-screen flex-col">
{/* Sticky toolbar */}
<div className="sticky top-0 z-30 flex items-center justify-between border-b bg-background/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<Button variant="ghost" size="sm" asChild>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back
Back to Learning Hub
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant={previewing ? 'default' : 'outline'}
size="sm"
onClick={() => setPreviewing(!previewing)}
>
<Eye className="mr-2 h-4 w-4" />
{previewing ? 'Edit' : 'Preview'}
</Button>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
<p className="text-muted-foreground">
Create a new learning resource for jury members
</p>
</div>
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
</SheetTrigger>
<SheetContent className="overflow-y-auto">
<SheetHeader>
<SheetTitle>Resource Settings</SheetTitle>
<SheetDescription>
Configure publishing, access, and metadata
</SheetDescription>
</SheetHeader>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<div className="mt-6 space-y-6">
{/* Publish toggle */}
<div className="flex items-center justify-between">
<div>
<Label>Published</Label>
<p className="text-sm text-muted-foreground">
Make visible to users
</p>
</div>
<Switch
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<Separator />
{/* Program */}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger>
<SelectValue placeholder="Select program" />
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Separator />
{/* Access Rules */}
<div className="space-y-3">
<Label>Access Rules</Label>
<Select value={accessMode} onValueChange={(v) => setAccessMode(v as 'everyone' | 'roles')}>
<SelectTrigger>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="everyone">Everyone</SelectItem>
<SelectItem value="roles">By Role</SelectItem>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{accessMode === 'roles' && (
<div className="space-y-2 rounded-lg border p-3">
{ROLE_OPTIONS.map((role) => (
<label key={role.value} className="flex items-center gap-2 text-sm">
<Checkbox
checked={selectedRoles.includes(role.value)}
onCheckedChange={(checked) => {
setSelectedRoles(
checked
? [...selectedRoles, role.value]
: selectedRoles.filter((r) => r !== role.value)
)
}}
/>
{role.label}
</label>
))}
</div>
)}
</div>
</div>
<Separator />
{/* External URL */}
{resourceType === 'LINK' && (
<div className="space-y-2">
<Label>External URL</Label>
<Label htmlFor="url">External URL *</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
<p className="text-xs text-muted-foreground">
Optional link to an external resource
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
</SheetContent>
</Sheet>
<Button
size="sm"
onClick={handleSubmit}
disabled={createResource.isPending || !title.trim()}
>
{createResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save
</Button>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.year} Edition
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
onClick={handleSubmit}
disabled={createResource.isPending || !title.trim()}
className="w-full"
>
{createResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Create Resource
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{/* Content area */}
<div className="flex-1 px-4 py-8">
{previewing ? (
<ResourceRenderer
title={title || 'Untitled'}
description={description || null}
contentJson={contentJson ? JSON.parse(contentJson) : null}
/>
) : (
<div className="mx-auto max-w-3xl space-y-4">
{/* Inline title */}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Untitled"
autoFocus
className="w-full border-0 bg-transparent text-3xl font-bold tracking-tight text-foreground placeholder:text-muted-foreground/40 focus:outline-none sm:text-4xl"
/>
{/* Inline description */}
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add a description..."
className="w-full border-0 bg-transparent text-lg text-muted-foreground placeholder:text-muted-foreground/30 focus:outline-none"
/>
{/* Divider */}
<hr className="border-border" />
{/* Block editor */}
<BlockEditor
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[400px]"
/>
</div>
)}
</div>
</div>
)
}

View File

@@ -12,7 +12,6 @@ import {
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
@@ -23,212 +22,48 @@ import {
import {
Plus,
FileText,
Video,
Link as LinkIcon,
File,
Pencil,
ExternalLink,
Search,
GripVertical,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
type Resource = {
id: string
title: string
description: string | null
isPublished: boolean
sortOrder: number
externalUrl: string | null
objectKey: string | null
contentJson: unknown
accessJson: unknown
_count: { accessLogs: number }
program: { id: string; name: string; year: number } | null
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
function getAccessSummary(accessJson: unknown): string {
if (!accessJson || !Array.isArray(accessJson) || accessJson.length === 0) {
return 'Everyone'
}
const rule = accessJson[0] as { type: string; roles?: string[] }
if (rule.type === 'everyone') return 'Everyone'
if (rule.type === 'roles' && rule.roles) {
if (rule.roles.length === 1) return rule.roles[0].replace('_', ' ').toLowerCase()
return `${rule.roles.length} roles`
}
if (rule.type === 'jury_group') return 'Jury groups'
if (rule.type === 'round') return 'By round'
return 'Custom'
}
function SortableResourceCard({
resource,
onTogglePublished,
}: {
resource: Resource
onTogglePublished: (id: string, published: boolean) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: resource.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<Card
ref={setNodeRef}
style={style}
className={isDragging ? 'opacity-50 shadow-lg' : ''}
>
<CardContent className="flex items-center gap-3 py-3">
{/* Drag handle */}
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Icon */}
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-4 w-4" />
</div>
{/* Title & meta */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{resource.title}</h3>
{!resource.isPublished && (
<Badge variant="secondary" className="text-xs">Draft</Badge>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<span className="capitalize">{getAccessSummary(resource.accessJson)}</span>
<span>&middot;</span>
<span>{resource._count.accessLogs} views</span>
{resource.program && (
<>
<span>&middot;</span>
<span>{resource.program.year}</span>
</>
)}
</div>
</div>
{/* Quick publish toggle */}
<Switch
checked={resource.isPublished}
onCheckedChange={(checked) => onTogglePublished(resource.id, checked)}
aria-label={resource.isPublished ? 'Unpublish' : 'Publish'}
/>
{/* Actions */}
<div className="flex items-center gap-1">
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" className="h-8 w-8">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/learning/${resource.id}`}>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)
const cohortColors: Record<string, string> = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function LearningHubPage() {
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 100 })
const resources = (data?.data || []) as Resource[]
const { data, isLoading } = trpc.learningResource.list.useQuery({ perPage: 50 })
const resources = data?.data
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
const [publishedFilter, setPublishedFilter] = useState('all')
const utils = trpc.useUtils()
const reorderMutation = trpc.learningResource.reorder.useMutation({
onSuccess: () => utils.learningResource.list.invalidate(),
})
const updateMutation = trpc.learningResource.update.useMutation({
onSuccess: () => utils.learningResource.list.invalidate(),
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const [typeFilter, setTypeFilter] = useState('all')
const [cohortFilter, setCohortFilter] = useState('all')
const filteredResources = useMemo(() => {
if (!resources) return []
return resources.filter((resource) => {
const matchesSearch =
!debouncedSearch ||
resource.title.toLowerCase().includes(debouncedSearch.toLowerCase())
const matchesPublished =
publishedFilter === 'all' ||
(publishedFilter === 'published' && resource.isPublished) ||
(publishedFilter === 'draft' && !resource.isPublished)
return matchesSearch && matchesPublished
const matchesType = typeFilter === 'all' || resource.resourceType === typeFilter
const matchesCohort = cohortFilter === 'all' || resource.cohortLevel === cohortFilter
return matchesSearch && matchesType && matchesCohort
})
}, [resources, debouncedSearch, publishedFilter])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = filteredResources.findIndex((r) => r.id === active.id)
const newIndex = filteredResources.findIndex((r) => r.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
const reordered = arrayMove(filteredResources, oldIndex, newIndex)
const items = reordered.map((r, i) => ({ id: r.id, sortOrder: i }))
reorderMutation.mutate({ items }, {
onError: () => toast.error('Failed to reorder'),
})
}
const handleTogglePublished = (id: string, published: boolean) => {
updateMutation.mutate({ id, isPublished: published }, {
onSuccess: () => toast.success(published ? 'Published' : 'Unpublished'),
onError: () => toast.error('Failed to update'),
})
}
}, [resources, debouncedSearch, typeFilter, cohortFilter])
if (isLoading) {
return (
@@ -240,20 +75,25 @@ export default function LearningHubPage() {
</div>
<Skeleton className="h-9 w-32" />
</div>
{/* Toolbar skeleton */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Skeleton className="h-10 flex-1" />
<Skeleton className="h-10 w-[160px]" />
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-[160px]" />
<Skeleton className="h-10 w-[160px]" />
</div>
</div>
<div className="grid gap-3">
{/* Resource list skeleton */}
<div className="grid gap-4">
{[...Array(5)].map((_, i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-9 w-9 rounded-lg" />
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-8 w-8 rounded" />
</CardContent>
</Card>
))}
@@ -269,7 +109,7 @@ export default function LearningHubPage() {
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Manage educational resources for program participants
Manage educational resources for jury members
</p>
</div>
<Link href="/admin/learning/new">
@@ -291,49 +131,92 @@ export default function LearningHubPage() {
className="pl-9"
/>
</div>
<Select value={publishedFilter} onValueChange={setPublishedFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="published">Published</SelectItem>
<SelectItem value="draft">Drafts</SelectItem>
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="PDF">PDF</SelectItem>
<SelectItem value="VIDEO">Video</SelectItem>
<SelectItem value="DOCUMENT">Document</SelectItem>
<SelectItem value="LINK">Link</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
<Select value={cohortFilter} onValueChange={setCohortFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All cohorts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All cohorts</SelectItem>
<SelectItem value="ALL">All (cohort)</SelectItem>
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Results count */}
{resources.length > 0 && (
{resources && (
<p className="text-sm text-muted-foreground">
{filteredResources.length} of {resources.length} resources
{reorderMutation.isPending && ' · Saving order...'}
</p>
)}
{/* Resource List with DnD */}
{/* Resource List */}
{filteredResources.length > 0 ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredResources.map((r) => r.id)}
strategy={verticalListSortingStrategy}
>
<div className="grid gap-3">
{filteredResources.map((resource) => (
<SortableResourceCard
key={resource.id}
resource={resource}
onTogglePublished={handleTogglePublished}
/>
))}
</div>
</SortableContext>
</DndContext>
) : resources.length > 0 ? (
<div className="grid gap-4">
{filteredResources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{resource.title}</h3>
{!resource.isPublished && (
<Badge variant="secondary">Draft</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge className={cohortColors[resource.cohortLevel] || ''} variant="outline">
{resource.cohortLevel}
</Badge>
<span>{resource.resourceType}</span>
<span>-</span>
<span>{resource._count.accessLogs} views</span>
</div>
</div>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/learning/${resource.id}`}>
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
) : resources && resources.length > 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Search className="h-8 w-8 text-muted-foreground/40" />

View File

@@ -1,10 +0,0 @@
import { redirect } from 'next/navigation'
export default async function MentorDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
redirect(`/admin/members/${id}`)
}

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function MentorsPage() {
redirect('/admin/members')
}

View File

@@ -30,7 +30,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) {
const defaultEdition = await prisma.program.findFirst({
where: { status: 'ACTIVE' },
where: { status: 'ACTIVE', isTest: false },
orderBy: { year: 'desc' },
select: { id: true },
})
@@ -38,6 +38,7 @@ export default async function AdminDashboardPage({ searchParams }: PageProps) {
if (!editionId) {
const anyEdition = await prisma.program.findFirst({
where: { isTest: false },
orderBy: { year: 'desc' },
select: { id: true },
})

View File

@@ -19,9 +19,8 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowLeft, Pencil, Plus } from 'lucide-react'
import { ArrowLeft, GraduationCap, Pencil, Plus } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { CompetitionSettings } from '@/components/admin/program/competition-settings'
interface ProgramDetailPageProps {
params: Promise<{ id: string }>
@@ -66,12 +65,20 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</p>
</div>
</div>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/mentorship` as Route}>
<GraduationCap className="mr-2 h-4 w-4" />
Mentorship
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</div>
{program.description && (
@@ -85,24 +92,6 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</Card>
)}
{(() => {
const comp = (program as any).competitions?.[0]
if (!comp) return null
return (
<CompetitionSettings
competitionId={comp.id}
initialSettings={{
categoryMode: comp.categoryMode ?? 'SHARED',
startupFinalistCount: comp.startupFinalistCount ?? 3,
conceptFinalistCount: comp.conceptFinalistCount ?? 3,
notifyOnRoundAdvance: comp.notifyOnRoundAdvance ?? true,
notifyOnDeadlineApproach: comp.notifyOnDeadlineApproach ?? true,
deadlineReminderDays: comp.deadlineReminderDays ?? [7, 3, 1],
}}
/>
)
})()}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
@@ -112,9 +101,9 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</CardDescription>
</div>
<Button asChild>
<Link href={'/admin/rounds' as Route}>
<Link href={`/admin/competitions?programId=${id}` as Route}>
<Plus className="mr-2 h-4 w-4" />
Manage Rounds
Manage Competitions
</Link>
</Button>
</CardHeader>

View File

@@ -40,7 +40,7 @@ import { formatDateOnly } from '@/lib/utils'
async function ProgramsContent() {
const programs = await prisma.program.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
where: { isTest: false },
include: {
competitions: {
include: {

View File

@@ -927,7 +927,7 @@ function EvaluationDetailSheet({
if (type === 'section_header') return null
if (type === 'boolean' || type === 'advance') {
if (type === 'boolean') {
return (
<div key={key} className="flex items-center justify-between p-2.5 rounded-lg border">
<span className="text-sm">{label}</span>

View File

@@ -637,7 +637,7 @@ export default function ProjectsPage() {
)}
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects?hasAssign=false">
<Link href="/admin/projects/pool">
<Layers className="mr-2 h-4 w-4" />
Assign to Round
</Link>

View File

@@ -0,0 +1,558 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useSearchParams } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { useEdition } from '@/contexts/edition-context'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
import { toast } from 'sonner'
import { ArrowLeft, ChevronLeft, ChevronRight, Loader2, X, Layers, Info } from 'lucide-react'
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
export default function ProjectPoolPage() {
const searchParams = useSearchParams()
const { currentEdition, isLoading: editionLoading } = useEdition()
// URL params for deep-linking context
const urlRoundId = searchParams.get('roundId') || ''
const urlCompetitionId = searchParams.get('competitionId') || ''
// Auto-select programId from edition
const programId = currentEdition?.id || ''
const [selectedProjects, setSelectedProjects] = useState<string[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [assignAllDialogOpen, setAssignAllDialogOpen] = useState(false)
const [targetRoundId, setTargetRoundId] = useState<string>(urlRoundId)
const [searchQuery, setSearchQuery] = useState('')
const [categoryFilter, setCategoryFilter] = useState<'STARTUP' | 'BUSINESS_CONCEPT' | 'all'>('all')
const [showUnassignedOnly, setShowUnassignedOnly] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const perPage = 50
// Pre-select target round from URL param
useEffect(() => {
if (urlRoundId) setTargetRoundId(urlRoundId)
}, [urlRoundId])
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
unassignedOnly: showUnassignedOnly,
excludeRoundId: urlRoundId || undefined,
page: currentPage,
perPage,
},
{ enabled: !!programId }
)
// Load rounds from program (flattened from all competitions, now with competitionId)
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
{ id: programId },
{ enabled: !!programId }
)
// Get round name for context banner
const allRounds = useMemo(() => {
return (programData?.rounds || []) as Array<{
id: string
name: string
competitionId: string
status: string
_count: { projects: number; assignments: number }
}>
}, [programData])
// Filter rounds by competitionId if URL param is set
const filteredRounds = useMemo(() => {
if (urlCompetitionId) {
return allRounds.filter((r) => r.competitionId === urlCompetitionId)
}
return allRounds
}, [allRounds, urlCompetitionId])
const contextRound = urlRoundId ? allRounds.find((r) => r.id === urlRoundId) : null
const utils = trpc.useUtils()
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.projectPool.listUnassigned.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetRoundId(urlRoundId)
refetch()
},
onError: (error: unknown) => {
toast.error((error as { message?: string }).message || 'Failed to assign projects')
},
})
const assignAllMutation = trpc.projectPool.assignAllToRound.useMutation({
onSuccess: (result) => {
utils.project.list.invalidate()
utils.projectPool.listUnassigned.invalidate()
toast.success(`Assigned all ${result.assignedCount} projects to round`)
setSelectedProjects([])
setAssignAllDialogOpen(false)
setTargetRoundId(urlRoundId)
refetch()
},
onError: (error: unknown) => {
toast.error((error as { message?: string }).message || 'Failed to assign projects')
},
})
const isPending = assignMutation.isPending || assignAllMutation.isPending
const handleBulkAssign = () => {
if (selectedProjects.length === 0 || !targetRoundId) return
assignMutation.mutate({
projectIds: selectedProjects,
roundId: targetRoundId,
})
}
const handleAssignAll = () => {
if (!targetRoundId || !programId) return
assignAllMutation.mutate({
programId,
roundId: targetRoundId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
unassignedOnly: showUnassignedOnly,
})
}
const handleQuickAssign = (projectId: string, roundId: string) => {
assignMutation.mutate({
projectIds: [projectId],
roundId,
})
}
const toggleSelectAll = () => {
if (!poolData?.projects) return
if (selectedProjects.length === poolData.projects.length) {
setSelectedProjects([])
} else {
setSelectedProjects(poolData.projects.map((p) => p.id))
}
}
const toggleSelectProject = (projectId: string) => {
if (selectedProjects.includes(projectId)) {
setSelectedProjects(selectedProjects.filter((id) => id !== projectId))
} else {
setSelectedProjects([...selectedProjects, projectId])
}
}
if (editionLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-96 w-full" />
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start gap-3">
<Link href={"/admin/projects" as Route} className="mt-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1">
<h1 className="text-2xl font-semibold">Project Pool</h1>
<p className="text-muted-foreground">
{currentEdition
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
: 'No edition selected'}
</p>
</div>
</div>
{/* Context banner when coming from a round */}
{contextRound && (
<Card className="border-blue-200 bg-blue-50/50">
<CardContent className="py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-600 shrink-0" />
<p className="text-sm">
Assigning to <span className="font-semibold">{contextRound.name}</span>
{' \u2014 '}
<span className="text-muted-foreground">
projects already in this round are hidden
</span>
</p>
</div>
<Link
href={`/admin/rounds/${urlRoundId}` as Route}
>
<Button variant="outline" size="sm" className="shrink-0">
<ArrowLeft className="h-3.5 w-3.5 mr-1" />
Back to Round
</Button>
</Link>
</div>
</CardContent>
</Card>
)}
{/* Filters */}
<Card className="p-4">
<div className="flex flex-col gap-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: string) => {
setCategoryFilter(value as 'STARTUP' | 'BUSINESS_CONCEPT' | 'all')
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Search</label>
<Input
placeholder="Project or team name..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
/>
</div>
<div className="flex items-center gap-2 pb-0.5">
<Switch
id="unassigned-only"
checked={showUnassignedOnly}
onCheckedChange={(checked) => {
setShowUnassignedOnly(checked)
setCurrentPage(1)
}}
/>
<label htmlFor="unassigned-only" className="text-sm font-medium cursor-pointer whitespace-nowrap">
Unassigned only
</label>
</div>
</div>
</Card>
{/* Action bar */}
{programId && poolData && poolData.total > 0 && (
<div className="flex items-center justify-between flex-wrap gap-2">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{poolData.total}</span> project{poolData.total !== 1 ? 's' : ''}
{showUnassignedOnly && ' (unassigned only)'}
</p>
<div className="flex items-center gap-2">
{selectedProjects.length > 0 && (
<Button onClick={() => setAssignDialogOpen(true)} size="sm">
Assign {selectedProjects.length} Selected
</Button>
)}
<Button
variant="default"
size="sm"
onClick={() => setAssignAllDialogOpen(true)}
>
Assign All {poolData.total} to Round
</Button>
</div>
</div>
)}
{/* Projects Table */}
{programId ? (
<>
{isLoadingPool ? (
<Card className="p-4">
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</Card>
) : poolData && poolData.total > 0 ? (
<>
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left w-[40px]">
<Checkbox
checked={poolData.projects.length > 0 && selectedProjects.length === poolData.projects.length}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="p-3 text-left font-medium">Project</th>
<th className="p-3 text-left font-medium">Category</th>
<th className="p-3 text-left font-medium">Rounds</th>
<th className="p-3 text-left font-medium">Country</th>
<th className="p-3 text-left font-medium">Submitted</th>
<th className="p-3 text-left font-medium">Quick Assign</th>
</tr>
</thead>
<tbody>
{poolData.projects.map((project) => (
<tr key={project.id} className="border-b hover:bg-muted/50">
<td className="p-3">
<Checkbox
checked={selectedProjects.includes(project.id)}
onCheckedChange={() => toggleSelectProject(project.id)}
/>
</td>
<td className="p-3">
<Link
href={`/admin/projects/${project.id}` as Route}
className="hover:underline"
>
<div className="font-medium">{project.title}</div>
<div className="text-sm text-muted-foreground">{project.teamName}</div>
</Link>
</td>
<td className="p-3">
{project.competitionCategory && (
<Badge variant="outline" className="text-xs">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
</td>
<td className="p-3">
{(project as any).projectRoundStates?.length > 0 ? (
<div className="flex flex-wrap gap-1">
{(project as any).projectRoundStates.map((prs: any) => (
<Badge
key={prs.roundId}
variant="secondary"
className={`text-[10px] ${roundTypeColors[prs.round?.roundType] || 'bg-gray-100 text-gray-700'}`}
>
{prs.round?.name || 'Round'}
</Badge>
))}
</div>
) : (
<span className="text-xs text-muted-foreground">None</span>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.country ? (() => {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return <>{flag && <span>{flag} </span>}{name}</>
})() : '-'}
</td>
<td className="p-3 text-sm text-muted-foreground">
{project.submittedAt
? new Date(project.submittedAt).toLocaleDateString()
: '-'}
</td>
<td className="p-3">
{isLoadingRounds ? (
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
disabled={isPending}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Assign to round..." />
</SelectTrigger>
<SelectContent>
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
{/* Pagination */}
{poolData.totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === poolData.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Layers className="h-8 w-8 text-muted-foreground/50" />
<p>
{showUnassignedOnly
? 'No unassigned projects found'
: urlRoundId
? 'All projects are already assigned to this round'
: 'No projects found for this program'}
</p>
</div>
</Card>
)}
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No edition selected. Please select an edition from the sidebar.
</Card>
)}
{/* Bulk Assignment Dialog (selected projects) */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Selected Projects</DialogTitle>
<DialogDescription>
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to a round:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssignDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleBulkAssign}
disabled={!targetRoundId || isPending}
>
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign {selectedProjects.length} Projects
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Assign ALL Dialog */}
<Dialog open={assignAllDialogOpen} onOpenChange={setAssignAllDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign All Projects</DialogTitle>
<DialogDescription>
This will assign all {poolData?.total || 0}{categoryFilter !== 'all' ? ` ${categoryFilter === 'STARTUP' ? 'Startup' : 'Business Concept'}` : ''}{showUnassignedOnly ? ' unassigned' : ''} projects to a round in one operation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAssignAllDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleAssignAll}
disabled={!targetRoundId || isPending}
>
{assignAllMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign All {poolData?.total || 0} Projects
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -42,9 +42,6 @@ import {
UserCheck,
Globe,
Layers,
Trophy,
ArrowRight,
Hash,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
@@ -227,23 +224,25 @@ function ReportsOverview() {
</Card>
)}
{/* Project Reports Summary */}
{/* Project Reports (default: all projects, filterable by round) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-600" />
</div>
Project Reports
</CardTitle>
<CardDescription>
Summary dashboard optionally filter to a specific round
</CardDescription>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-emerald-500/10 p-1.5">
<ClipboardList className="h-4 w-4 text-emerald-600" />
</div>
Project Reports
</CardTitle>
<CardDescription>
Project-wide reporting across all projects optionally filter to a specific round
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Scope selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<span className="text-sm font-medium">Scope:</span>
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-[280px]">
<SelectTrigger className="w-full sm:w-[360px]">
<SelectValue placeholder="All projects" />
</SelectTrigger>
<SelectContent>
@@ -260,123 +259,44 @@ function ReportsOverview() {
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="space-y-5">
{projectsLoading ? (
<Skeleton className="h-[300px]" />
<Skeleton className="h-[400px]" />
) : projectRankings?.length ? (
<>
{/* Summary stats row */}
{(() => {
const evaluated = projectRankings.filter(p => p.averageScore !== null)
const scores = evaluated.map(p => p.averageScore as number)
const avgScore = scores.length ? scores.reduce((a, b) => a + b, 0) / scores.length : 0
const minScore = scores.length ? Math.min(...scores) : 0
const maxScore = scores.length ? Math.max(...scores) : 0
const evalPercent = projectRankings.length ? Math.round((evaluated.length / projectRankings.length) * 100) : 0
const statusCounts = projectRankings.reduce((acc, p) => {
acc[p.status] = (acc[p.status] || 0) + 1
return acc
}, {} as Record<string, number>)
return (
<>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Total Projects</p>
<p className="text-xl font-bold tabular-nums">{projectRankings.length}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Avg Score</p>
<p className="text-xl font-bold tabular-nums">{avgScore ? avgScore.toFixed(1) : '-'}</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Evaluated</p>
<p className="text-xl font-bold tabular-nums">{evalPercent}%</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Score Range</p>
<p className="text-xl font-bold tabular-nums">
{scores.length ? `${minScore.toFixed(1)}${maxScore.toFixed(1)}` : '-'}
</p>
</div>
</div>
{/* Status breakdown chips */}
{Object.keys(statusCounts).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(statusCounts)
.sort(([, a], [, b]) => b - a)
.map(([status, count]) => (
<Badge key={status} variant="outline" className="gap-1">
<Hash className="h-3 w-3" />
{formatStatusLabel(status)} <span className="font-bold tabular-nums">{count}</span>
</Badge>
))}
</div>
)}
{/* Top 10 ranked table */}
<div>
<p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1.5">
<Trophy className="h-3.5 w-3.5" /> Top 10 by Average Score
</p>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">#</TableHead>
<TableHead>Project</TableHead>
<TableHead className="hidden sm:table-cell">Team</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Evals</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectRankings.slice(0, 10).map((p, idx) => (
<TableRow key={p.id}>
<TableCell className="text-muted-foreground tabular-nums">{idx + 1}</TableCell>
<TableCell className="font-medium">
<Link href={`/admin/projects/${p.id}`} className="hover:underline">
{p.title}
</Link>
</TableCell>
<TableCell className="hidden sm:table-cell text-muted-foreground">
{p.teamName || '-'}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
<TableCell>
<Badge variant="outline">{formatStatusLabel(p.status)}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Link to full analytics */}
{projectRankings.length > 10 && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" className="gap-1 text-muted-foreground"
onClick={() => {
const tabs = document.querySelectorAll('[role="tab"]')
const analyticsTab = Array.from(tabs).find(t => t.textContent?.includes('Analytics')) as HTMLElement
analyticsTab?.click()
}}
>
View full rankings in Analytics <ArrowRight className="h-3.5 w-3.5" />
</Button>
</div>
)}
</>
)
})()}
</>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead className="hidden sm:table-cell">Team</TableHead>
<TableHead className="text-right">Avg</TableHead>
<TableHead className="text-right">Evals</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projectRankings.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<Link href={`/admin/projects/${p.id}`} className="hover:underline">
{p.title}
</Link>
</TableCell>
<TableCell className="hidden sm:table-cell text-muted-foreground">
{p.teamName || '-'}
</TableCell>
<TableCell className="text-right tabular-nums">
{p.averageScore === null ? '-' : p.averageScore.toFixed(2)}
</TableCell>
<TableCell className="text-right tabular-nums">{p.evaluationCount}</TableCell>
<TableCell>
<Badge variant="outline">{p.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center py-10 text-center">
<ClipboardList className="h-10 w-10 text-muted-foreground/50" />
@@ -488,30 +408,6 @@ function parseSelection(value: string | null): { roundId?: string; programId?: s
return { roundId: value }
}
// Map raw DB status to display-friendly labels
function formatStatusLabel(status: string): string {
const labels: Record<string, string> = {
ELIGIBLE: 'Special Award',
ASSIGNED: 'Assigned',
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
REJECTED: 'Rejected',
COMPLETED: 'Completed',
WITHDRAWN: 'Withdrawn',
}
return labels[status] ?? status
}
// Find the best default round: active > last closed > first
function findDefaultRound(rounds: Array<{ id: string; status?: string }>): string | undefined {
const active = rounds.find(r => r.status === 'ROUND_ACTIVE')
if (active) return active.id
const closed = [...rounds].reverse().find(r => r.status === 'ROUND_CLOSED')
if (closed) return closed.id
return rounds[0]?.id
}
function StageAnalytics() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
@@ -519,13 +415,13 @@ function StageAnalytics() {
// Flatten stages from all programs with program name
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ ...s, programId: p.id, programName: `${p.year} Edition` }))
) || []
// Set default selected stage — prefer active round
// Set default selected stage
useEffect(() => {
if (rounds.length && !selectedValue) {
setSelectedValue(findDefaultRound(rounds) ?? rounds[0].id)
setSelectedValue(rounds[0].id)
}
}, [rounds.length, selectedValue])
@@ -806,12 +702,12 @@ function JurorConsistencyTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
setSelectedValue(stages[0].id)
}
}, [stages.length, selectedValue])
@@ -880,12 +776,12 @@ function DiversityTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programId: p.id, programName: `${p.year} Edition` }))
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(findDefaultRound(stages) ?? stages[0].id)
setSelectedValue(stages[0].id)
}
}, [stages.length, selectedValue])
@@ -1000,8 +896,6 @@ function RoundPipelineTab() {
<div className="space-y-3">
{rounds.map((round, idx) => {
const stats = comparisonMap.get(round.id) as any
const isClosed = round.status === 'ROUND_CLOSED' || round.status === 'ROUND_ARCHIVED'
const progressValue = isClosed ? 100 : (stats?.completionRate ?? 0)
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">
@@ -1016,12 +910,14 @@ function RoundPipelineTab() {
<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' : isClosed ? 'secondary' : 'outline'}>
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : round.status === 'ROUND_CLOSED' ? 'secondary' : 'outline'}>
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
</Badge>
</div>
</div>
<Progress value={progressValue} className="mt-2 h-2" />
{stats?.completionRate != null && (
<Progress value={stats.completionRate} className="mt-2 h-2" />
)}
</div>
</div>
)
@@ -1038,12 +934,12 @@ export default function ReportsPage() {
const { data: pdfPrograms } = trpc.program.list.useQuery({ includeStages: true })
const pdfStages = pdfPrograms?.flatMap((p) =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string }>).map((s: { id: string; name: string; status: string }) => ({ id: s.id, name: s.name, status: s.status, programName: `${p.year} Edition` }))
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (pdfStages.length && !pdfStageId) {
setPdfStageId(findDefaultRound(pdfStages) ?? pdfStages[0].id)
setPdfStageId(pdfStages[0].id)
}
}, [pdfStages.length, pdfStageId])

File diff suppressed because it is too large Load Diff

View File

@@ -46,28 +46,43 @@ import {
ArrowRight,
} from 'lucide-react'
import { useEdition } from '@/contexts/edition-context'
import {
roundTypeConfig,
roundStatusConfig,
awardStatusConfig,
ROUND_TYPE_OPTIONS,
} from '@/lib/round-config'
// ─── Constants (derived from shared config) ──────────────────────────────────
// ─── Constants ───────────────────────────────────────────────────────────────
const ROUND_TYPES = ROUND_TYPE_OPTIONS
const ROUND_TYPES = [
{ value: 'INTAKE', label: 'Intake' },
{ value: 'FILTERING', label: 'Filtering' },
{ value: 'EVALUATION', label: 'Evaluation' },
{ value: 'SUBMISSION', label: 'Submission' },
{ value: 'MENTORING', label: 'Mentoring' },
{ value: 'LIVE_FINAL', label: 'Live Final' },
{ value: 'DELIBERATION', label: 'Deliberation' },
] as const
const ROUND_TYPE_COLORS: Record<string, { dot: string; bg: string; text: string; border: string }> = Object.fromEntries(
Object.entries(roundTypeConfig).map(([k, v]) => [k, { dot: v.dotColor, bg: v.cardBg, text: v.cardText, border: v.cardBorder }])
)
const ROUND_TYPE_COLORS: Record<string, { dot: string; bg: string; text: string; border: string }> = {
INTAKE: { dot: '#9ca3af', bg: 'bg-gray-50', text: 'text-gray-600', border: 'border-gray-300' },
FILTERING: { dot: '#f59e0b', bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-300' },
EVALUATION: { dot: '#3b82f6', bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-300' },
SUBMISSION: { dot: '#8b5cf6', bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-300' },
MENTORING: { dot: '#557f8c', bg: 'bg-teal-50', text: 'text-teal-700', border: 'border-teal-300' },
LIVE_FINAL: { dot: '#de0f1e', bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-300' },
DELIBERATION: { dot: '#6366f1', bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-300' },
}
const ROUND_STATUS_STYLES: Record<string, { color: string; label: string; pulse?: boolean }> = Object.fromEntries(
Object.entries(roundStatusConfig).map(([k, v]) => [k, { color: v.dotColor, label: v.label, pulse: v.pulse }])
)
const ROUND_STATUS_STYLES: Record<string, { color: string; label: string; pulse?: boolean }> = {
ROUND_DRAFT: { color: '#9ca3af', label: 'Draft' },
ROUND_ACTIVE: { color: '#10b981', label: 'Active', pulse: true },
ROUND_CLOSED: { color: '#3b82f6', label: 'Closed' },
ROUND_ARCHIVED: { color: '#6b7280', label: 'Archived' },
}
const AWARD_STATUS_COLORS: Record<string, string> = Object.fromEntries(
Object.entries(awardStatusConfig).map(([k, v]) => [k, v.color])
)
const AWARD_STATUS_COLORS: Record<string, string> = {
DRAFT: 'text-gray-500',
NOMINATIONS_OPEN: 'text-amber-600',
VOTING_OPEN: 'text-emerald-600',
CLOSED: 'text-blue-600',
ARCHIVED: 'text-gray-400',
}
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -190,14 +205,14 @@ export default function RoundsPage() {
}
const startEditSettings = () => {
if (!comp) return
if (!comp || !compDetail) return
setEditingCompId(comp.id)
setCompetitionEdits({
name: comp.name,
categoryMode: (comp as any).categoryMode,
startupFinalistCount: (comp as any).startupFinalistCount,
conceptFinalistCount: (comp as any).conceptFinalistCount,
notifyOnDeadlineApproach: (comp as any).notifyOnDeadlineApproach,
name: compDetail.name,
categoryMode: compDetail.categoryMode,
startupFinalistCount: compDetail.startupFinalistCount,
conceptFinalistCount: compDetail.conceptFinalistCount,
notifyOnDeadlineApproach: compDetail.notifyOnDeadlineApproach,
})
setSettingsOpen(true)
}
@@ -253,12 +268,12 @@ export default function RoundsPage() {
</div>
<h3 className="text-lg font-semibold text-[#053d57] mb-1">No Competition Configured</h3>
<p className="text-sm text-muted-foreground max-w-sm mb-5">
Create a program edition to start building the evaluation pipeline.
Create a competition to start building the evaluation pipeline.
</p>
<Link href={'/admin/programs' as Route}>
<Link href={`/admin/competitions/new?programId=${programId}` as Route}>
<Button className="bg-[#de0f1e] hover:bg-[#de0f1e]/90">
<Plus className="h-4 w-4 mr-2" />
Manage Editions
Create Competition
</Button>
</Link>
</div>

View File

@@ -12,6 +12,7 @@ export default async function AdminLayout({
// Fetch all editions (programs) for the edition selector
const editions = await prisma.program.findMany({
where: { isTest: false },
select: {
id: true,
name: true,

View File

@@ -0,0 +1,180 @@
'use client'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { FileUploadSlot } from '@/components/applicant/file-upload-slot'
import { ArrowLeft, Lock, Clock, Calendar, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import { useState } from 'react'
export default function ApplicantSubmissionWindowPage() {
const params = useParams()
const router = useRouter()
const windowId = params.windowId as string
const [uploadedFiles, setUploadedFiles] = useState<Record<string, File>>({})
const { data: window, isLoading } = trpc.round.getById.useQuery(
{ id: windowId },
{ enabled: !!windowId }
)
const { data: deadlineStatus } = trpc.round.checkDeadline.useQuery(
{ windowId },
{
enabled: !!windowId,
refetchInterval: 60000, // Refresh every minute
}
)
const handleUpload = (requirementId: string, file: File) => {
setUploadedFiles(prev => ({ ...prev, [requirementId]: file }))
toast.success(`File "${file.name}" selected for upload`)
// In a real implementation, this would trigger file upload
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-96" />
</div>
)
}
if (!window) {
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href={'/applicant/competitions' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-muted-foreground/50 mb-3" />
<p className="font-medium">Submission window not found</p>
</CardContent>
</Card>
</div>
)
}
const isLocked = deadlineStatus?.status === 'LOCKED' || deadlineStatus?.status === 'CLOSED'
const deadline = window.windowCloseAt
? new Date(window.windowCloseAt)
: null
const timeRemaining = deadline ? deadline.getTime() - Date.now() : null
const daysRemaining = timeRemaining ? Math.floor(timeRemaining / (1000 * 60 * 60 * 24)) : null
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={'/applicant/competitions' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight">{window.name}</h1>
<p className="text-muted-foreground mt-1">
Upload required documents for this submission window
</p>
</div>
</div>
{/* Deadline card */}
{deadline && (
<Card className={isLocked ? 'border-red-200 bg-red-50/50' : 'border-l-4 border-l-amber-500'}>
<CardContent className="flex items-start gap-3 p-4">
{isLocked ? (
<>
<Lock className="h-5 w-5 text-red-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm text-red-900">Submission Window Closed</p>
<p className="text-sm text-red-700 mt-1">
This submission window closed on {deadline.toLocaleDateString()}. No further
uploads are allowed.
</p>
</div>
</>
) : (
<>
<Clock className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-sm">Deadline Countdown</p>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold tabular-nums text-amber-600">
{daysRemaining !== null ? daysRemaining : '—'}
</span>
<span className="text-sm text-muted-foreground">
day{daysRemaining !== 1 ? 's' : ''} remaining
</span>
</div>
<p className="text-xs text-muted-foreground mt-1">
Due: {deadline.toLocaleString()}
</p>
</div>
</>
)}
</CardContent>
</Card>
)}
{/* File requirements */}
<Card>
<CardHeader>
<CardTitle>File Requirements</CardTitle>
<CardDescription>
Upload the required files below. {isLocked && 'Viewing only - window is closed.'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* File requirements would be fetched separately in a real implementation */}
{false ? (
[].map((req: any) => (
<FileUploadSlot
key={req.id}
requirement={{
id: req.id,
label: req.label,
description: req.description,
mimeTypes: req.mimeTypes || [],
maxSizeMb: req.maxSizeMb,
required: req.required || false,
}}
isLocked={isLocked}
onUpload={(file) => handleUpload(req.id, file)}
/>
))
) : (
<div className="text-center py-8">
<Calendar className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
<p className="text-sm text-muted-foreground">
No file requirements defined for this window
</p>
</div>
)}
</CardContent>
</Card>
{!isLocked && (
<div className="flex justify-end gap-3">
<Button variant="outline">Save Draft</Button>
<Button className="bg-brand-blue hover:bg-brand-blue-light">
Submit All Files
</Button>
</div>
)}
</div>
)
}

View File

@@ -4,13 +4,14 @@ import { useSession } from 'next-auth/react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ApplicantCompetitionTimeline } from '@/components/applicant/competition-timeline'
import { ArrowLeft, FileText, Calendar } from 'lucide-react'
import { toast } from 'sonner'
export default function ApplicantCompetitionPage() {
export default function ApplicantCompetitionsPage() {
const { data: session } = useSession()
const { data: myProject, isLoading } = trpc.applicant.getMyDashboard.useQuery(undefined, {
enabled: !!session,
@@ -25,7 +26,7 @@ export default function ApplicantCompetitionPage() {
)
}
const hasProject = !!myProject?.project
const competitionId = myProject?.project?.programId
return (
<div className="space-y-6">
@@ -44,7 +45,7 @@ export default function ApplicantCompetitionPage() {
</Button>
</div>
{!hasProject ? (
{!competitionId ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground/50 mb-4" />
@@ -58,7 +59,7 @@ export default function ApplicantCompetitionPage() {
) : (
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<ApplicantCompetitionTimeline />
<ApplicantCompetitionTimeline competitionId={competitionId} />
</div>
<div className="space-y-4">
<Card>

View File

@@ -1,147 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Star, MessageSquare } from 'lucide-react'
export default function ApplicantEvaluationsPage() {
const { data: rounds, isLoading } = trpc.applicant.getMyEvaluations.useQuery()
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground">Anonymous evaluations from jury members</p>
</div>
<div className="space-y-4">
{[1, 2].map((i) => (
<Card key={i}>
<CardContent className="py-6">
<Skeleton className="h-6 w-48 mb-4" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
const hasEvaluations = rounds && rounds.length > 0
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Jury Feedback</h1>
<p className="text-muted-foreground">
Anonymous evaluations from jury members
</p>
</div>
{!hasEvaluations ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Star className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium mb-2">No Evaluations Available</h3>
<p className="text-muted-foreground text-center max-w-md">
Evaluations will appear here once jury review is complete and results are published.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-6">
{rounds.map((round) => (
<Card key={round.roundId}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{round.roundName}</CardTitle>
<Badge variant="secondary">
{round.evaluationCount} evaluation{round.evaluationCount !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{round.evaluations.map((ev, idx) => (
<div
key={ev.id}
className="rounded-lg border p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">
Evaluator #{idx + 1}
</span>
{ev.submittedAt && (
<span className="text-xs text-muted-foreground">
{new Date(ev.submittedAt).toLocaleDateString()}
</span>
)}
</div>
{ev.globalScore !== null && (
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-500" />
<span className="text-lg font-semibold">{ev.globalScore}</span>
<span className="text-sm text-muted-foreground">/ 100</span>
</div>
)}
{ev.criterionScores && ev.criteria && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Criterion Scores</p>
<div className="grid gap-2">
{(() => {
const criteria = ev.criteria as Array<{ id?: string; label?: string; name?: string; maxScore?: number }>
const scores = ev.criterionScores as Record<string, number>
return criteria
.filter((c) => c.id || c.label || c.name)
.map((c, ci) => {
const key = c.id || String(ci)
const score = scores[key]
return (
<div key={ci} className="flex items-center justify-between text-sm">
<span>{c.label || c.name || `Criterion ${ci + 1}`}</span>
<span className="font-medium">
{score !== undefined ? score : '—'}
{c.maxScore ? ` / ${c.maxScore}` : ''}
</span>
</div>
)
})
})()}
</div>
</div>
)}
{ev.feedbackText && (
<div className="space-y-1.5">
<div className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
Written Feedback
</div>
<blockquote className="border-l-2 border-muted pl-4 text-sm italic text-muted-foreground">
{ev.feedbackText}
</blockquote>
</div>
)}
</div>
))}
</CardContent>
</Card>
))}
<p className="text-xs text-muted-foreground text-center">
Evaluator identities are kept confidential.
</p>
</div>
)}
</div>
)
}

View File

@@ -9,26 +9,25 @@ import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { StatusTracker } from '@/components/shared/status-tracker'
import { CompetitionTimelineSidebar } from '@/components/applicant/competition-timeline'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
FileText,
Calendar,
Clock,
AlertCircle,
CheckCircle,
Users,
Crown,
MessageSquare,
Upload,
ArrowRight,
Star,
AlertCircle,
} from 'lucide-react'
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
@@ -50,18 +49,6 @@ export default function ApplicantDashboardPage() {
enabled: isAuthenticated,
})
const { data: deadlines } = trpc.applicant.getUpcomingDeadlines.useQuery(undefined, {
enabled: isAuthenticated,
})
const { data: docCompleteness } = trpc.applicant.getDocumentCompleteness.useQuery(undefined, {
enabled: isAuthenticated,
})
const { data: evaluations } = trpc.applicant.getMyEvaluations.useQuery(undefined, {
enabled: isAuthenticated,
})
if (sessionStatus === 'loading' || isLoading) {
return (
<div className="space-y-6">
@@ -111,11 +98,10 @@ export default function ApplicantDashboardPage() {
)
}
const { project, timeline, currentStatus, openRounds, hasPassedIntake } = data
const { project, timeline, currentStatus, openRounds } = data
const isDraft = !project.submittedAt
const programYear = project.program?.year
const programName = project.program?.name
const totalEvaluations = evaluations?.reduce((sum, r) => sum + r.evaluationCount, 0) ?? 0
return (
<div className="space-y-6">
@@ -227,7 +213,7 @@ export default function ApplicantDashboardPage() {
{/* Quick actions */}
<AnimatedCard index={1}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-3">
<Link href={"/applicant/documents" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-blue-500/30 hover:bg-blue-500/5">
<div className="rounded-xl bg-blue-500/10 p-2.5 transition-colors group-hover:bg-blue-500/20">
<Upload className="h-5 w-5 text-blue-600 dark:text-blue-400" />
@@ -254,108 +240,41 @@ export default function ApplicantDashboardPage() {
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
{project.mentorAssignment && (
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Mentor</p>
<p className="text-xs text-muted-foreground">
{project.mentorAssignment.mentor?.name || 'Assigned'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
)}
<Link href={"/applicant/mentor" as Route} className="group flex items-center gap-3 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md hover:border-green-500/30 hover:bg-green-500/5">
<div className="rounded-xl bg-green-500/10 p-2.5 transition-colors group-hover:bg-green-500/20">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Mentor</p>
<p className="text-xs text-muted-foreground">
{project.mentorAssignment?.mentor?.name || 'Not assigned'}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
</Link>
</div>
</AnimatedCard>
{/* Document Completeness */}
{docCompleteness && docCompleteness.length > 0 && (
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Document Progress
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{docCompleteness.map((dc) => (
<div key={dc.roundId}>
<div className="flex items-center justify-between text-sm mb-1.5">
<span className="font-medium">{dc.roundName}</span>
<span className="text-muted-foreground">
{dc.uploaded}/{dc.required} files
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${dc.required > 0 ? Math.round((dc.uploaded / dc.required) * 100) : 0}%` }}
/>
</div>
</div>
))}
</CardContent>
</Card>
</AnimatedCard>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Competition timeline or status tracker */}
<AnimatedCard index={3}>
{/* Status timeline */}
<AnimatedCard index={2}>
<Card>
<CardHeader>
<CardTitle>
{hasPassedIntake ? 'Competition Progress' : 'Status Timeline'}
</CardTitle>
<CardTitle>Status Timeline</CardTitle>
</CardHeader>
<CardContent>
{hasPassedIntake ? (
<CompetitionTimelineSidebar />
) : (
<StatusTracker
timeline={timeline}
currentStatus={currentStatus || 'SUBMITTED'}
/>
)}
<StatusTracker
timeline={timeline}
currentStatus={currentStatus || 'SUBMITTED'}
/>
</CardContent>
</Card>
</AnimatedCard>
{/* Jury Feedback Card */}
{totalEvaluations > 0 && (
<AnimatedCard index={4}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Star className="h-5 w-5" />
Jury Feedback
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link href={"/applicant/evaluations" as Route}>
View All
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{totalEvaluations} evaluation{totalEvaluations !== 1 ? 's' : ''} available from{' '}
{evaluations?.length ?? 0} round{(evaluations?.length ?? 0) !== 1 ? 's' : ''}.
</p>
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Team overview */}
<AnimatedCard index={5}>
<AnimatedCard index={3}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
@@ -407,32 +326,8 @@ export default function ApplicantDashboardPage() {
</Card>
</AnimatedCard>
{/* Upcoming Deadlines */}
{deadlines && deadlines.length > 0 && (
<AnimatedCard index={6}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
Upcoming Deadlines
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{deadlines.map((dl, i) => (
<div key={i} className="flex items-center justify-between text-sm">
<span className="font-medium truncate mr-2">{dl.roundName}</span>
<span className="text-muted-foreground shrink-0">
{new Date(dl.windowCloseAt).toLocaleDateString()}
</span>
</div>
))}
</CardContent>
</Card>
</AnimatedCard>
)}
{/* Key dates */}
<AnimatedCard index={7}>
<AnimatedCard index={4}>
<Card>
<CardHeader>
<CardTitle>Key Dates</CardTitle>
@@ -452,6 +347,12 @@ export default function ApplicantDashboardPage() {
<span className="text-muted-foreground">Last Updated</span>
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
</div>
{openRounds.length > 0 && openRounds[0].windowCloseAt && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Deadline</span>
<span>{new Date(openRounds[0].windowCloseAt).toLocaleDateString()}</span>
</div>
)}
</CardContent>
</Card>
</AnimatedCard>

View File

@@ -1,122 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
ArrowLeft,
Download,
ExternalLink,
AlertCircle,
} from 'lucide-react'
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
export default function ApplicantResourceDetailPage() {
const params = useParams()
const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
const logAccess = trpc.learningResource.logAccess.useMutation()
const utils = trpc.useUtils()
// Log access on mount
useEffect(() => {
if (resourceId) {
logAccess.mutate({ id: resourceId })
}
}, [resourceId])
const handleDownload = async () => {
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch {
// silently fail
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-20" />
<div className="mx-auto max-w-3xl space-y-4">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
if (error || !resource) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
<AlertDescription>
This resource may have been removed or you don&apos;t have access.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/applicant/resources">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/applicant/resources">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
</Button>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open Link
</Button>
</a>
)}
{resource.objectKey && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
)}
</div>
</div>
{/* Content */}
<ResourceRenderer
title={resource.title}
description={resource.description}
contentJson={resource.contentJson}
/>
</div>
)
}

View File

@@ -1,146 +0,0 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
export default function ApplicantResourcesPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const { data, isLoading } = trpc.learningResource.myResources.useQuery({})
const utils = trpc.useUtils()
const handleDownload = async (resourceId: string) => {
setDownloadingId(resourceId)
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch (error) {
console.error('Download failed:', error)
} finally {
setDownloadingId(null)
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Resources</h1>
<p className="text-muted-foreground">
Resources and materials for applicants
</p>
</div>
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
const resources = data?.resources || []
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Resources</h1>
<p className="text-muted-foreground">
Resources and materials for applicants
</p>
</div>
{resources.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<BookOpen className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No resources available</h3>
<p className="text-muted-foreground">
Check back later for learning materials
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const isDownloading = downloadingId === resource.id
const hasContent = !!resource.contentJson
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
{resource.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{resource.description}
</p>
)}
</div>
<div className="flex items-center gap-2">
{hasContent && (
<Link href={`/applicant/resources/${resource.id}` as Route}>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Read
</Button>
</Link>
)}
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
)}
{resource.objectKey && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
)}
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}

View File

@@ -280,15 +280,6 @@ export default function ApplicantTeamPage() {
/>
</div>
</div>
<div className="rounded-lg bg-muted/50 border p-3 text-sm">
<p className="font-medium mb-1">What invited members can do:</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
<li>Upload documents for submission rounds</li>
<li>View project status and competition progress</li>
<li>Receive email notifications about round updates</li>
</ul>
<p className="mt-2 text-muted-foreground">Only the Team Lead can invite or remove members.</p>
</div>
<DialogFooter>
<Button
type="button"

View File

@@ -10,7 +10,6 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, CheckCircle2, Clock, Circle } from 'lucide-react'
import { toast } from 'sonner'
import { JurorProgressDashboard } from '@/components/jury/juror-progress-dashboard'
export default function JuryRoundDetailPage() {
const params = useParams()
@@ -54,14 +53,6 @@ export default function JuryRoundDetailPage() {
</div>
</div>
{(() => {
const config = (round?.configJson as Record<string, unknown>) ?? {}
if (config.showJurorProgressDashboard) {
return <JurorProgressDashboard roundId={roundId} />
}
return null
})()}
<Card>
<CardHeader>
<CardTitle>Assigned Projects</CardTitle>

View File

@@ -40,9 +40,6 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const isDirtyRef = useRef(false)
const evaluationIdRef = useRef<string | null>(null)
const isSubmittedRef = useRef(false)
const isSubmittingRef = useRef(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const startPromiseRef = useRef<Promise<{ id: string }> | null>(null)
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
@@ -164,7 +161,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
id: c.id,
label: c.label,
description: c.description,
type: type as 'numeric' | 'text' | 'boolean' | 'advance' | 'section_header',
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
weight: c.weight,
minScore,
maxScore,
@@ -176,24 +173,6 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
}
})
// Initialize numeric criteria with midpoint values so slider visual matches stored value.
const criteriaInitializedRef = useRef(false)
useEffect(() => {
if (criteriaInitializedRef.current || criteria.length === 0) return
if (existingEvaluation?.criterionScoresJson) return
criteriaInitializedRef.current = true
const defaults: Record<string, number | boolean | string> = {}
for (const c of criteria) {
if (c.type === 'numeric') {
defaults[c.id] = Math.ceil((c.minScore + c.maxScore) / 2)
}
}
if (Object.keys(defaults).length > 0) {
setCriteriaValues((prev) => ({ ...defaults, ...prev }))
}
}, [criteria, existingEvaluation?.criterionScoresJson])
// Build current form data for autosave
const buildSavePayload = useCallback(() => {
return {
@@ -206,23 +185,17 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
// Perform autosave
const performAutosave = useCallback(async () => {
if (!isDirtyRef.current || isSubmittedRef.current || isSubmittingRef.current) return
if (!isDirtyRef.current || isSubmittedRef.current) return
if (existingEvaluation?.status === 'SUBMITTED') return
let evalId = evaluationIdRef.current
if (!evalId && myAssignment) {
try {
// Reuse in-flight start promise to avoid duplicate creation
if (!startPromiseRef.current) {
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: myAssignment.id })
}
const newEval = await startPromiseRef.current
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evalId = newEval.id
evaluationIdRef.current = evalId
} catch {
return
} finally {
startPromiseRef.current = null
}
}
if (!evalId) return
@@ -304,11 +277,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
let evaluationId = evaluationIdRef.current
if (!evaluationId) {
if (!startPromiseRef.current) {
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: myAssignment.id })
}
const newEval = await startPromiseRef.current
startPromiseRef.current = null
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
evaluationIdRef.current = evaluationId
}
@@ -324,18 +293,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
}
const handleSubmit = async () => {
// Cancel any pending autosave to avoid race conditions
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
autosaveTimerRef.current = null
}
isSubmittingRef.current = true
setIsSubmitting(true)
if (!myAssignment) {
toast.error('Assignment not found')
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
@@ -348,20 +307,14 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const val = criteriaValues[c.id]
if (c.type === 'numeric' && (val === undefined || val === null)) {
toast.error(`Please score "${c.label}"`)
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
if ((c.type === 'boolean' || c.type === 'advance') && val === undefined) {
if (c.type === 'boolean' && val === undefined) {
toast.error(`Please answer "${c.label}"`)
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
toast.error(`Please fill in "${c.label}"`)
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
}
@@ -371,8 +324,6 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const score = parseInt(globalScore, 10)
if (isNaN(score) || score < 1 || score > 10) {
toast.error('Please enter a valid score between 1 and 10')
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
}
@@ -380,8 +331,6 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (scoringMode === 'binary') {
if (!binaryDecision) {
toast.error('Please select accept or reject')
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
}
@@ -389,20 +338,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (requireFeedback) {
if (!feedbackText.trim() || feedbackText.length < feedbackMinLength) {
toast.error(`Please provide feedback (minimum ${feedbackMinLength} characters)`)
isSubmittingRef.current = false
setIsSubmitting(false)
return
}
}
// Reuse in-flight start promise to avoid duplicate creation (e.g. if autosave is mid-flight)
let evaluationId = evaluationIdRef.current
if (!evaluationId) {
if (!startPromiseRef.current) {
startPromiseRef.current = startMutation.mutateAsync({ assignmentId: myAssignment.id })
}
const newEval = await startPromiseRef.current
startPromiseRef.current = null
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
evaluationIdRef.current = evaluationId
}
@@ -428,19 +370,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
}
}
try {
await submitMutation.mutateAsync({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
feedbackText: feedbackText || 'No feedback provided',
})
} catch {
// Error toast already handled by onError callback
isSubmittingRef.current = false
setIsSubmitting(false)
}
submitMutation.mutate({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
feedbackText: feedbackText || 'No feedback provided',
})
}
if (!round || !project) {
@@ -657,51 +593,6 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
}
if (criterion.type === 'advance') {
const currentValue = criteriaValues[criterion.id]
return (
<div key={criterion.id} className="space-y-3 p-5 border-2 border-brand-blue/30 rounded-xl bg-brand-blue/5">
<div className="space-y-1">
<Label className="text-base font-semibold text-brand-blue">
{criterion.label}
<span className="text-destructive ml-1">*</span>
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<div className="flex gap-4">
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 shadow-sm ring-2 ring-emerald-200'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
)}
>
<ThumbsUp className="mr-2 h-5 w-5" />
{criterion.trueLabel || 'Yes'}
</button>
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-14 rounded-xl border-2 flex items-center justify-center text-base font-semibold transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 shadow-sm ring-2 ring-red-200'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
)}
>
<ThumbsDown className="mr-2 h-5 w-5" />
{criterion.falseLabel || 'No'}
</button>
</div>
</div>
)
}
if (criterion.type === 'boolean') {
const currentValue = criteriaValues[criterion.id]
return (
@@ -949,7 +840,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
</Button>
<Button
onClick={handleSubmit}
disabled={submitMutation.isPending || isSubmitting}
disabled={submitMutation.isPending || autosaveMutation.isPending}
className="bg-brand-blue hover:bg-brand-blue-light"
>
<Send className="mr-2 h-4 w-4" />

View File

@@ -1,122 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
ArrowLeft,
Download,
ExternalLink,
AlertCircle,
} from 'lucide-react'
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
export default function JuryResourceDetailPage() {
const params = useParams()
const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
const logAccess = trpc.learningResource.logAccess.useMutation()
const utils = trpc.useUtils()
// Log access on mount
useEffect(() => {
if (resourceId) {
logAccess.mutate({ id: resourceId })
}
}, [resourceId])
const handleDownload = async () => {
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch {
// silently fail
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-20" />
<div className="mx-auto max-w-3xl space-y-4">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
if (error || !resource) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
<AlertDescription>
This resource may have been removed or you don&apos;t have access.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/jury/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/jury/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open Link
</Button>
</a>
)}
{resource.objectKey && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
)}
</div>
</div>
{/* Content */}
<ResourceRenderer
title={resource.title}
description={resource.description}
contentJson={resource.contentJson}
/>
</div>
)
}

View File

@@ -1,22 +1,41 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function JuryLearningPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
@@ -62,6 +81,7 @@ export default function JuryLearningPage() {
}
const resources = data?.resources || []
const userCohortLevel = data?.userCohortLevel || 'ALL'
return (
<div className="space-y-6">
@@ -70,6 +90,11 @@ export default function JuryLearningPage() {
<p className="text-muted-foreground">
Educational resources for jury members
</p>
{userCohortLevel !== 'ALL' && (
<Badge className={cohortColors[userCohortLevel]} variant="outline">
Your access level: {userCohortLevel}
</Badge>
)}
</div>
{resources.length === 0 ? (
@@ -85,14 +110,14 @@ export default function JuryLearningPage() {
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType]
const isDownloading = downloadingId === resource.id
const hasContent = !!resource.contentJson
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-5 w-5" />
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
@@ -101,39 +126,36 @@ export default function JuryLearningPage() {
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel]}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
{hasContent && (
<Link href={`/jury/learning/${resource.id}` as Route}>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Read
</Button>
</Link>
)}
{resource.externalUrl && (
<div>
{resource.externalUrl ? (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<Button>
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
)}
{resource.objectKey && (
) : resource.objectKey ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
)}
) : null}
</div>
</CardContent>
</Card>

View File

@@ -26,7 +26,6 @@ import {
Zap,
BarChart3,
Waves,
Send,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
@@ -391,11 +390,6 @@ async function JuryDashboardContent() {
<CheckCircle2 className="mr-1 h-3 w-3" />
Done
</Badge>
) : isDraft && isVotingOpen ? (
<Badge className="text-xs bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-700 animate-pulse">
<Send className="mr-1 h-3 w-3" />
Ready to submit
</Badge>
) : isDraft ? (
<Badge variant="warning" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
@@ -410,17 +404,10 @@ async function JuryDashboardContent() {
View
</Link>
</Button>
) : isVotingOpen && isDraft ? (
<Button size="sm" asChild className="h-7 px-3 bg-amber-600 hover:bg-amber-700 text-white shadow-sm">
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
<Send className="mr-1 h-3 w-3" />
Review & Submit
</Link>
</Button>
) : isVotingOpen ? (
<Button size="sm" asChild className="h-7 px-3 bg-brand-blue hover:bg-brand-blue-light shadow-sm">
<Link href={`/jury/competitions/${assignment.round.id}/projects/${assignment.project.id}/evaluate`}>
Evaluate
{isDraft ? 'Continue' : 'Evaluate'}
</Link>
</Button>
) : (

View File

@@ -13,8 +13,7 @@ export default async function MentorLayout({
const session = await requireRole('MENTOR', 'PROGRAM_ADMIN', 'SUPER_ADMIN')
// Check if user has completed onboarding (for mentors)
const userRoles = session.user.roles?.length ? session.user.roles : [session.user.role]
if (userRoles.includes('MENTOR') && !userRoles.some(r => r === 'SUPER_ADMIN' || r === 'PROGRAM_ADMIN')) {
if (session.user.role === 'MENTOR') {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { onboardingCompletedAt: true },

View File

@@ -1,122 +0,0 @@
'use client'
import { useEffect } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
ArrowLeft,
Download,
ExternalLink,
AlertCircle,
} from 'lucide-react'
const ResourceRenderer = dynamic(
() => import('@/components/shared/resource-renderer').then((mod) => mod.ResourceRenderer),
{
ssr: false,
loading: () => (
<div className="mx-auto max-w-3xl min-h-[200px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
export default function MentorResourceDetailPage() {
const params = useParams()
const resourceId = params.id as string
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
const logAccess = trpc.learningResource.logAccess.useMutation()
const utils = trpc.useUtils()
// Log access on mount
useEffect(() => {
if (resourceId) {
logAccess.mutate({ id: resourceId })
}
}, [resourceId])
const handleDownload = async () => {
try {
const { url } = await utils.learningResource.getDownloadUrl.fetch({ id: resourceId })
window.open(url, '_blank')
} catch {
// silently fail
}
}
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-20" />
<div className="mx-auto max-w-3xl space-y-4">
<Skeleton className="h-10 w-2/3" />
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-px w-full" />
<Skeleton className="h-64 w-full" />
</div>
</div>
)
}
if (error || !resource) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
<AlertDescription>
This resource may have been removed or you don&apos;t have access.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/mentor/resources">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/mentor/resources">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Resources
</Link>
</Button>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a href={resource.externalUrl} target="_blank" rel="noopener noreferrer">
<Button variant="outline" size="sm">
<ExternalLink className="mr-2 h-4 w-4" />
Open Link
</Button>
</a>
)}
{resource.objectKey && (
<Button variant="outline" size="sm" onClick={handleDownload}>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
)}
</div>
</div>
{/* Content */}
<ResourceRenderer
title={resource.title}
description={resource.description}
contentJson={resource.contentJson}
/>
</div>
)
}

View File

@@ -1,22 +1,38 @@
'use client'
import { useState } from 'react'
import type { Route } from 'next'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
FileText,
Video,
Link as LinkIcon,
File,
Download,
ExternalLink,
BookOpen,
} from 'lucide-react'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors: Record<string, string> = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
export default function MentorResourcesPage() {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
@@ -85,14 +101,14 @@ export default function MentorResourcesPage() {
) : (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType as keyof typeof resourceTypeIcons] || File
const isDownloading = downloadingId === resource.id
const hasContent = !!resource.contentJson
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted shrink-0">
<FileText className="h-5 w-5" />
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium">{resource.title}</h3>
@@ -101,39 +117,36 @@ export default function MentorResourcesPage() {
{resource.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className={cohortColors[resource.cohortLevel] || cohortColors.ALL}>
{resource.cohortLevel}
</Badge>
<Badge variant="secondary">
{resource.resourceType}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
{hasContent && (
<Link href={`/mentor/resources/${resource.id}` as Route}>
<Button variant="outline" size="sm">
<BookOpen className="mr-2 h-4 w-4" />
Read
</Button>
</Link>
)}
{resource.externalUrl && (
<div>
{resource.externalUrl ? (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<Button>
<ExternalLink className="mr-2 h-4 w-4" />
Open
</Button>
</a>
)}
{resource.objectKey && (
) : resource.objectKey ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(resource.id)}
disabled={isDownloading}
>
<Download className="mr-2 h-4 w-4" />
{isDownloading ? 'Loading...' : 'Download'}
</Button>
)}
) : null}
</div>
</CardContent>
</Card>

View File

@@ -13,8 +13,10 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const now = new Date()
// Delete projects where isDraft=true AND draftExpiresAt has passed
// Exclude test projects — they are managed separately
const result = await prisma.project.deleteMany({
where: {
isTest: false,
isDraft: true,
draftExpiresAt: {
lt: now,

View File

@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import './globals.css'
import { Providers } from './providers'
import { Toaster } from 'sonner'
import { ImpersonationBanner } from '@/components/shared/impersonation-banner'
export const metadata: Metadata = {
title: {
@@ -22,7 +23,10 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased">
<Providers>{children}</Providers>
<Providers>
<ImpersonationBanner />
{children}
</Providers>
<Toaster
position="top-right"
toastOptions={{

View File

@@ -1,170 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ShieldAlert, Eye, CheckCircle2, UserPlus, FileText } from 'lucide-react'
export type COIReviewSectionProps = {
roundId: string
}
export function COIReviewSection({ roundId }: COIReviewSectionProps) {
const utils = trpc.useUtils()
const { data: declarations, isLoading } = trpc.evaluation.listCOIByStage.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
)
const reviewMutation = trpc.evaluation.reviewCOI.useMutation({
onSuccess: (data) => {
utils.evaluation.listCOIByStage.invalidate({ roundId })
utils.assignment.listByStage.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
if (data.reassignment) {
toast.success(`Reassigned to ${data.reassignment.newJurorName}`)
} else {
toast.success('COI review updated')
}
},
onError: (err) => toast.error(err.message),
})
// Show placeholder when no declarations
if (!isLoading && (!declarations || declarations.length === 0)) {
return (
<div className="rounded-lg border border-dashed p-4 text-center">
<p className="text-sm text-muted-foreground">No conflict of interest declarations yet.</p>
</div>
)
}
const conflictCount = declarations?.filter((d) => d.hasConflict).length ?? 0
const unreviewedCount = declarations?.filter((d) => d.hasConflict && !d.reviewedAt).length ?? 0
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium flex items-center gap-2">
<ShieldAlert className="h-4 w-4" />
Conflict of Interest Declarations
</p>
<p className="text-xs text-muted-foreground">
{declarations?.length ?? 0} declaration{(declarations?.length ?? 0) !== 1 ? 's' : ''}
{conflictCount > 0 && (
<> &mdash; <span className="text-amber-600 font-medium">{conflictCount} conflict{conflictCount !== 1 ? 's' : ''}</span></>
)}
{unreviewedCount > 0 && (
<> ({unreviewedCount} pending review)</>
)}
</p>
</div>
</div>
<div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
) : (
<div className="space-y-1 max-h-[400px] overflow-y-auto">
<div className="grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
<span>Juror</span>
<span>Project</span>
<span>Conflict</span>
<span>Type</span>
<span>Action</span>
</div>
{declarations?.map((coi: any, idx: number) => (
<div
key={coi.id}
className={cn(
'grid grid-cols-[1fr_1fr_80px_100px_100px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
coi.hasConflict && !coi.reviewedAt && 'border-l-4 border-l-amber-500',
)}
>
<span className="truncate">{coi.user?.name || coi.user?.email || 'Unknown'}</span>
<span className="truncate text-muted-foreground">{coi.assignment?.project?.title || 'Unknown'}</span>
<Badge
variant="outline"
className={cn(
'text-[10px] justify-center',
coi.hasConflict
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-emerald-50 text-emerald-700 border-emerald-200',
)}
>
{coi.hasConflict ? 'Yes' : 'No'}
</Badge>
<span className="text-xs text-muted-foreground truncate">
{coi.hasConflict ? (coi.conflictType || 'Unspecified') : '\u2014'}
</span>
{coi.hasConflict ? (
coi.reviewedAt ? (
<Badge
variant="outline"
className={cn(
'text-[10px] justify-center',
coi.reviewAction === 'cleared'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: coi.reviewAction === 'reassigned'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-gray-50 text-gray-600 border-gray-200',
)}
>
{coi.reviewAction === 'cleared' ? 'Cleared' : coi.reviewAction === 'reassigned' ? 'Reassigned' : 'Noted'}
</Badge>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-7 text-xs">
<Eye className="h-3 w-3 mr-1" />
Review
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'cleared' })}
disabled={reviewMutation.isPending}
>
<CheckCircle2 className="h-3.5 w-3.5 mr-2 text-emerald-600" />
Clear no real conflict
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'reassigned' })}
disabled={reviewMutation.isPending}
>
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
Reassign to another juror
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => reviewMutation.mutate({ id: coi.id, reviewAction: 'noted' })}
disabled={reviewMutation.isPending}
>
<FileText className="h-3.5 w-3.5 mr-2 text-gray-600" />
Note keep as is
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
) : (
<span className="text-xs text-muted-foreground">&mdash;</span>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,869 +0,0 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Loader2,
Plus,
Trash2,
RotateCcw,
Check,
ChevronsUpDown,
Search,
MoreHorizontal,
UserPlus,
} from 'lucide-react'
export type IndividualAssignmentsTableProps = {
roundId: string
projectStates: any[] | undefined
}
export function IndividualAssignmentsTable({
roundId,
projectStates,
}: IndividualAssignmentsTableProps) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [confirmAction, setConfirmAction] = useState<{ type: 'reset' | 'delete'; assignment: any } | null>(null)
const [assignMode, setAssignMode] = useState<'byJuror' | 'byProject'>('byJuror')
// ── By Juror mode state ──
const [selectedJurorId, setSelectedJurorId] = useState('')
const [selectedProjectIds, setSelectedProjectIds] = useState<Set<string>>(new Set())
const [jurorPopoverOpen, setJurorPopoverOpen] = useState(false)
const [projectSearch, setProjectSearch] = useState('')
// ── By Project mode state ──
const [selectedProjectId, setSelectedProjectId] = useState('')
const [selectedJurorIds, setSelectedJurorIds] = useState<Set<string>>(new Set())
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false)
const [jurorSearch, setJurorSearch] = useState('')
const utils = trpc.useUtils()
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
)
const { data: juryMembers } = trpc.user.getJuryMembers.useQuery(
{ roundId },
{ enabled: addDialogOpen },
)
const deleteMutation = trpc.assignment.delete.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success('Assignment removed')
},
onError: (err) => toast.error(err.message),
})
const resetEvalMutation = trpc.evaluation.resetEvaluation.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
toast.success('Evaluation reset — juror can now start over')
},
onError: (err) => toast.error(err.message),
})
const reassignCOIMutation = trpc.assignment.reassignCOI.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.evaluation.listCOIByStage.invalidate({ roundId })
toast.success(`Reassigned to ${data.newJurorName}`)
},
onError: (err) => toast.error(err.message),
})
const createMutation = trpc.assignment.create.useMutation({
onSuccess: () => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success('Assignment created')
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const bulkCreateMutation = trpc.assignment.bulkCreate.useMutation({
onSuccess: (result) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.user.getJuryMembers.invalidate({ roundId })
toast.success(`${result.created} assignment(s) created`)
resetDialog()
},
onError: (err) => toast.error(err.message),
})
const resetDialog = useCallback(() => {
setAddDialogOpen(false)
setAssignMode('byJuror')
setSelectedJurorId('')
setSelectedProjectIds(new Set())
setProjectSearch('')
setSelectedProjectId('')
setSelectedJurorIds(new Set())
setJurorSearch('')
}, [])
const selectedJuror = useMemo(
() => juryMembers?.find((j: any) => j.id === selectedJurorId),
[juryMembers, selectedJurorId],
)
// Filter projects by search term
const filteredProjects = useMemo(() => {
const items = projectStates ?? []
if (!projectSearch) return items
const q = projectSearch.toLowerCase()
return items.filter((ps: any) =>
ps.project?.title?.toLowerCase().includes(q) ||
ps.project?.teamName?.toLowerCase().includes(q) ||
ps.project?.competitionCategory?.toLowerCase().includes(q)
)
}, [projectStates, projectSearch])
// Existing assignments for the selected juror (to grey out already-assigned projects)
const jurorExistingProjectIds = useMemo(() => {
if (!selectedJurorId || !assignments) return new Set<string>()
return new Set(
assignments
.filter((a: any) => a.userId === selectedJurorId)
.map((a: any) => a.projectId)
)
}, [selectedJurorId, assignments])
const toggleProject = useCallback((projectId: string) => {
setSelectedProjectIds(prev => {
const next = new Set(prev)
if (next.has(projectId)) {
next.delete(projectId)
} else {
next.add(projectId)
}
return next
})
}, [])
const selectAllUnassigned = useCallback(() => {
const unassigned = filteredProjects
.filter((ps: any) => !jurorExistingProjectIds.has(ps.project?.id))
.map((ps: any) => ps.project?.id)
.filter(Boolean)
setSelectedProjectIds(new Set(unassigned))
}, [filteredProjects, jurorExistingProjectIds])
const handleCreate = useCallback(() => {
if (!selectedJurorId || selectedProjectIds.size === 0) return
const projectIds = Array.from(selectedProjectIds)
if (projectIds.length === 1) {
createMutation.mutate({
userId: selectedJurorId,
projectId: projectIds[0],
roundId,
})
} else {
bulkCreateMutation.mutate({
roundId,
assignments: projectIds.map(projectId => ({
userId: selectedJurorId,
projectId,
})),
})
}
}, [selectedJurorId, selectedProjectIds, roundId, createMutation, bulkCreateMutation])
const isMutating = createMutation.isPending || bulkCreateMutation.isPending
// ── By Project mode helpers ──
// Existing assignments for the selected project (to grey out already-assigned jurors)
const projectExistingJurorIds = useMemo(() => {
if (!selectedProjectId || !assignments) return new Set<string>()
return new Set(
assignments
.filter((a: any) => a.projectId === selectedProjectId)
.map((a: any) => a.userId)
)
}, [selectedProjectId, assignments])
// Count assignments per juror in this round (for display)
const jurorAssignmentCounts = useMemo(() => {
if (!assignments) return new Map<string, number>()
const counts = new Map<string, number>()
for (const a of assignments) {
counts.set(a.userId, (counts.get(a.userId) || 0) + 1)
}
return counts
}, [assignments])
// Filter jurors by search term
const filteredJurors = useMemo(() => {
const items = juryMembers ?? []
if (!jurorSearch) return items
const q = jurorSearch.toLowerCase()
return items.filter((j: any) =>
j.name?.toLowerCase().includes(q) ||
j.email?.toLowerCase().includes(q)
)
}, [juryMembers, jurorSearch])
const toggleJuror = useCallback((jurorId: string) => {
setSelectedJurorIds(prev => {
const next = new Set(prev)
if (next.has(jurorId)) next.delete(jurorId)
else next.add(jurorId)
return next
})
}, [])
const handleCreateByProject = useCallback(() => {
if (!selectedProjectId || selectedJurorIds.size === 0) return
const jurorIds = Array.from(selectedJurorIds)
if (jurorIds.length === 1) {
createMutation.mutate({
userId: jurorIds[0],
projectId: selectedProjectId,
roundId,
})
} else {
bulkCreateMutation.mutate({
roundId,
assignments: jurorIds.map(userId => ({
userId,
projectId: selectedProjectId,
})),
})
}
}, [selectedProjectId, selectedJurorIds, roundId, createMutation, bulkCreateMutation])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{assignments?.length ?? 0} individual assignments</p>
</div>
<Button size="sm" variant="outline" onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1.5" />
Add
</Button>
</div>
<div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : !assignments || assignments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No assignments yet. Generate assignments or add one manually.
</p>
) : (
<div className="space-y-1 max-h-[500px] overflow-y-auto">
<div className="grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 text-xs text-muted-foreground font-medium px-3 py-2 sticky top-0 bg-background border-b">
<span>Juror</span>
<span>Project</span>
<span>Status</span>
<span>Advance</span>
<span>Actions</span>
</div>
{assignments.map((a: any, idx: number) => (
<div
key={a.id}
className={cn(
'grid grid-cols-[1fr_1fr_80px_80px_70px] gap-2 items-center px-3 py-2 rounded-md text-sm transition-colors',
idx % 2 === 1 ? 'bg-muted/20' : 'hover:bg-muted/20',
)}
>
<span className="truncate">{a.user?.name || a.user?.email || 'Unknown'}</span>
<span className="truncate text-muted-foreground">{a.project?.title || 'Unknown'}</span>
<div className="flex items-center gap-1">
{a.conflictOfInterest?.hasConflict ? (
<Badge variant="outline" className="text-[10px] justify-center bg-red-50 text-red-700 border-red-200">
COI
</Badge>
) : (
<Badge
variant="outline"
className={cn(
'text-[10px] justify-center',
a.evaluation?.status === 'SUBMITTED'
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
: a.evaluation?.status === 'DRAFT'
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'bg-gray-50 text-gray-600 border-gray-200',
)}
>
{a.evaluation?.status || 'PENDING'}
</Badge>
)}
</div>
<div className="flex items-center justify-center">
{(() => {
const ev = a.evaluation
if (!ev || ev.status !== 'SUBMITTED') return <span className="text-muted-foreground text-xs"></span>
const criteria = (ev.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
const advCrit = criteria.find((c: any) => c.type === 'advance')
if (!advCrit) return <span className="text-muted-foreground text-xs"></span>
const val = scores[advCrit.id]
if (val === true) return <Badge variant="outline" className="text-[10px] bg-emerald-50 text-emerald-700 border-emerald-200">YES</Badge>
if (val === false) return <Badge variant="outline" className="text-[10px] bg-red-50 text-red-700 border-red-200">NO</Badge>
return <span className="text-muted-foreground text-xs"></span>
})()}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{a.conflictOfInterest?.hasConflict && (
<>
<DropdownMenuItem
onClick={() => reassignCOIMutation.mutate({ assignmentId: a.id })}
disabled={reassignCOIMutation.isPending}
>
<UserPlus className="h-3.5 w-3.5 mr-2 text-blue-600" />
Reassign (COI)
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{a.evaluation && (
<>
<DropdownMenuItem
onClick={() => setConfirmAction({ type: 'reset', assignment: a })}
disabled={resetEvalMutation.isPending}
>
<RotateCcw className="h-3.5 w-3.5 mr-2" />
Reset Evaluation
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => setConfirmAction({ type: 'delete', assignment: a })}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Delete Assignment
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
))}
</div>
)}
</div>
{/* Add Assignment Dialog */}
<Dialog open={addDialogOpen} onOpenChange={(open) => {
if (!open) resetDialog()
else setAddDialogOpen(true)
}}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Add Assignment</DialogTitle>
<DialogDescription>
{assignMode === 'byJuror'
? 'Select a juror, then choose projects to assign'
: 'Select a project, then choose jurors to assign'
}
</DialogDescription>
</DialogHeader>
{/* Mode Toggle */}
<Tabs value={assignMode} onValueChange={(v) => {
setAssignMode(v as 'byJuror' | 'byProject')
// Reset selections when switching
setSelectedJurorId('')
setSelectedProjectIds(new Set())
setProjectSearch('')
setSelectedProjectId('')
setSelectedJurorIds(new Set())
setJurorSearch('')
}}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="byJuror">By Juror</TabsTrigger>
<TabsTrigger value="byProject">By Project</TabsTrigger>
</TabsList>
{/* ── By Juror Tab ── */}
<TabsContent value="byJuror" className="space-y-4 mt-4">
{/* Juror Selector */}
<div className="space-y-2">
<Label className="text-sm font-medium">Juror</Label>
<Popover open={jurorPopoverOpen} onOpenChange={setJurorPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={jurorPopoverOpen}
className="w-full justify-between font-normal"
>
{selectedJuror
? (
<span className="flex items-center gap-2 truncate">
<span className="truncate">{selectedJuror.name || selectedJuror.email}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">
{selectedJuror.currentAssignments}/{selectedJuror.maxAssignments ?? '\u221E'}
</Badge>
</span>
)
: <span className="text-muted-foreground">Select a jury member...</span>
}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search by name or email..." />
<CommandList>
<CommandEmpty>No jury members found.</CommandEmpty>
<CommandGroup>
{juryMembers?.map((juror: any) => {
const atCapacity = juror.maxAssignments !== null && juror.availableSlots === 0
return (
<CommandItem
key={juror.id}
value={`${juror.name ?? ''} ${juror.email}`}
disabled={atCapacity}
onSelect={() => {
setSelectedJurorId(juror.id === selectedJurorId ? '' : juror.id)
setSelectedProjectIds(new Set())
setJurorPopoverOpen(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedJurorId === juror.id ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{juror.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground truncate">
{juror.email}
</p>
</div>
<Badge
variant={atCapacity ? 'destructive' : 'secondary'}
className="text-[10px] ml-2 shrink-0"
>
{juror.currentAssignments}/{juror.maxAssignments ?? '\u221E'}
{atCapacity ? ' full' : ''}
</Badge>
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Project Multi-Select */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Projects
{selectedProjectIds.size > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">
({selectedProjectIds.size} selected)
</span>
)}
</Label>
{selectedJurorId && (
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={selectAllUnassigned}
>
Select all
</Button>
{selectedProjectIds.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedProjectIds(new Set())}
>
Clear
</Button>
)}
</div>
)}
</div>
{/* Search input */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter projects..."
value={projectSearch}
onChange={(e) => setProjectSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{/* Project checklist */}
<ScrollArea className="h-[320px] rounded-md border">
<div className="p-2 space-y-0.5">
{!selectedJurorId ? (
<p className="text-sm text-muted-foreground text-center py-8">
Select a juror first
</p>
) : filteredProjects.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No projects found
</p>
) : (
filteredProjects.map((ps: any) => {
const project = ps.project
if (!project) return null
const alreadyAssigned = jurorExistingProjectIds.has(project.id)
const isSelected = selectedProjectIds.has(project.id)
return (
<label
key={project.id}
className={cn(
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
alreadyAssigned
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-accent'
: 'hover:bg-muted/50',
)}
>
<Checkbox
checked={isSelected}
disabled={alreadyAssigned}
onCheckedChange={() => toggleProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<span className="truncate">{project.title}</span>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px]">
{project.competitionCategory === 'STARTUP'
? 'Startup'
: project.competitionCategory === 'BUSINESS_CONCEPT'
? 'Concept'
: project.competitionCategory}
</Badge>
)}
{alreadyAssigned && (
<Badge variant="secondary" className="text-[10px]">
Assigned
</Badge>
)}
</div>
</div>
</label>
)
})
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetDialog}>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={!selectedJurorId || selectedProjectIds.size === 0 || isMutating}
>
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedProjectIds.size <= 1
? 'Create Assignment'
: `Create ${selectedProjectIds.size} Assignments`
}
</Button>
</DialogFooter>
</TabsContent>
{/* ── By Project Tab ── */}
<TabsContent value="byProject" className="space-y-4 mt-4">
{/* Project Selector */}
<div className="space-y-2">
<Label className="text-sm font-medium">Project</Label>
<Popover open={projectPopoverOpen} onOpenChange={setProjectPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={projectPopoverOpen}
className="w-full justify-between font-normal"
>
{selectedProjectId
? (
<span className="truncate">
{(projectStates ?? []).find((ps: any) => ps.project?.id === selectedProjectId)?.project?.title || 'Unknown'}
</span>
)
: <span className="text-muted-foreground">Select a project...</span>
}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder="Search by project title..." />
<CommandList>
<CommandEmpty>No projects found.</CommandEmpty>
<CommandGroup>
{(projectStates ?? []).map((ps: any) => {
const project = ps.project
if (!project) return null
return (
<CommandItem
key={project.id}
value={`${project.title ?? ''} ${project.teamName ?? ''}`}
onSelect={() => {
setSelectedProjectId(project.id === selectedProjectId ? '' : project.id)
setSelectedJurorIds(new Set())
setProjectPopoverOpen(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedProjectId === project.id ? 'opacity-100' : 'opacity-0',
)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">{project.teamName}</p>
</div>
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
</Badge>
)}
</div>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* Juror Multi-Select */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Jurors
{selectedJurorIds.size > 0 && (
<span className="ml-1.5 text-muted-foreground font-normal">
({selectedJurorIds.size} selected)
</span>
)}
</Label>
{selectedProjectId && selectedJurorIds.size > 0 && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() => setSelectedJurorIds(new Set())}
>
Clear
</Button>
)}
</div>
{/* Search input */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter jurors..."
value={jurorSearch}
onChange={(e) => setJurorSearch(e.target.value)}
className="pl-9 h-9"
/>
</div>
{/* Juror checklist */}
<ScrollArea className="h-[320px] rounded-md border">
<div className="p-2 space-y-0.5">
{!selectedProjectId ? (
<p className="text-sm text-muted-foreground text-center py-8">
Select a project first
</p>
) : filteredJurors.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No jurors found
</p>
) : (
filteredJurors.map((juror: any) => {
const alreadyAssigned = projectExistingJurorIds.has(juror.id)
const isSelected = selectedJurorIds.has(juror.id)
const assignCount = jurorAssignmentCounts.get(juror.id) ?? 0
return (
<label
key={juror.id}
className={cn(
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors',
alreadyAssigned
? 'opacity-50 cursor-not-allowed'
: isSelected
? 'bg-accent'
: 'hover:bg-muted/50',
)}
>
<Checkbox
checked={isSelected}
disabled={alreadyAssigned}
onCheckedChange={() => toggleJuror(juror.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<span className="font-medium truncate block">{juror.name || 'Unnamed'}</span>
<span className="text-xs text-muted-foreground truncate block">{juror.email}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Badge variant="secondary" className="text-[10px]">
{assignCount} assigned
</Badge>
{alreadyAssigned && (
<Badge variant="outline" className="text-[10px] bg-amber-50 text-amber-700 border-amber-200">
Already on project
</Badge>
)}
</div>
</div>
</label>
)
})
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetDialog}>
Cancel
</Button>
<Button
onClick={handleCreateByProject}
disabled={!selectedProjectId || selectedJurorIds.size === 0 || isMutating}
>
{isMutating && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedJurorIds.size <= 1
? 'Create Assignment'
: `Create ${selectedJurorIds.size} Assignments`
}
</Button>
</DialogFooter>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
{/* Confirmation AlertDialog for reset/delete */}
<AlertDialog open={!!confirmAction} onOpenChange={(open) => { if (!open) setConfirmAction(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{confirmAction?.type === 'reset' ? 'Reset evaluation?' : 'Delete assignment?'}
</AlertDialogTitle>
<AlertDialogDescription>
{confirmAction?.type === 'reset'
? `Reset evaluation by ${confirmAction.assignment?.user?.name || confirmAction.assignment?.user?.email} for "${confirmAction.assignment?.project?.title}"? This will erase all scores and feedback so they can start over.`
: `Remove assignment for ${confirmAction?.assignment?.user?.name || confirmAction?.assignment?.user?.email} on "${confirmAction?.assignment?.project?.title}"?`
}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className={confirmAction?.type === 'delete' ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90' : ''}
onClick={() => {
if (confirmAction?.type === 'reset') {
resetEvalMutation.mutate({ assignmentId: confirmAction.assignment.id })
} else if (confirmAction?.type === 'delete') {
deleteMutation.mutate({ id: confirmAction.assignment.id })
}
setConfirmAction(null)
}}
>
{confirmAction?.type === 'reset' ? 'Reset' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@@ -1,381 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Loader2, Mail, ArrowRightLeft, UserPlus, Trash2 } from 'lucide-react'
import { TransferAssignmentsDialog } from './transfer-assignments-dialog'
import { InlineMemberCap } from '@/components/admin/jury/inline-member-cap'
export type JuryProgressTableMember = {
id: string
userId: string
name: string
email: string
maxAssignmentsOverride: number | null
}
export type JuryProgressTableProps = {
roundId: string
members?: JuryProgressTableMember[]
onSaveCap?: (memberId: string, val: number | null) => void
onRemoveMember?: (memberId: string, memberName: string) => void
onAddMember?: () => void
}
export function JuryProgressTable({
roundId,
members,
onSaveCap,
onRemoveMember,
onAddMember,
}: JuryProgressTableProps) {
const utils = trpc.useUtils()
const [transferJuror, setTransferJuror] = useState<{ id: string; name: string } | null>(null)
const { data: workload, isLoading } = trpc.analytics.getJurorWorkload.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
)
const notifyMutation = trpc.assignment.notifySingleJurorOfAssignments.useMutation({
onSuccess: (data) => {
toast.success(`Notified juror of ${data.projectCount} assignment(s)`)
},
onError: (err) => toast.error(err.message),
})
const reshuffleMutation = trpc.assignment.reassignDroppedJuror.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.roundEngine.getProjectStates.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
if (data.failedCount > 0) {
toast.warning(`Dropped juror and reassigned ${data.movedCount} project(s). ${data.failedCount} could not be reassigned (all remaining jurors at cap/blocked).`)
} else {
toast.success(`Dropped juror and reassigned ${data.movedCount} project(s) evenly across available jurors.`)
}
},
onError: (err) => toast.error(err.message),
})
const hasMembersData = members !== undefined
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{hasMembersData ? 'Jury Members & Progress' : 'Jury Progress'}
</CardTitle>
<CardDescription>
{hasMembersData
? 'Manage jury members, caps, and evaluation progress per juror.'
: 'Evaluation completion per juror. Click the mail icon to notify an individual juror.'}
</CardDescription>
</div>
{onAddMember && (
<Button size="sm" onClick={onAddMember}>
<UserPlus className="h-4 w-4 mr-1.5" />
Add Member
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : !workload || workload.length === 0 ? (
hasMembersData && members && members.length > 0 ? (
// Show members-only view when there are members but no assignments yet
<div className="space-y-1">
{members.map((member, idx) => (
<MemberOnlyRow
key={member.id}
member={member}
idx={idx}
roundId={roundId}
onSaveCap={onSaveCap}
onRemoveMember={onRemoveMember}
notifyMutation={notifyMutation}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
{hasMembersData ? 'No members yet. Add jury members to get started.' : 'No assignments yet'}
</p>
)
) : (
<div className="space-y-3 max-h-[500px] overflow-y-auto overflow-x-hidden">
{workload.map((juror) => {
const pct = juror.completionRate
const barGradient = pct === 100
? 'bg-gradient-to-r from-emerald-400 to-emerald-600'
: pct >= 50
? 'bg-gradient-to-r from-blue-400 to-blue-600'
: pct > 0
? 'bg-gradient-to-r from-amber-400 to-amber-600'
: 'bg-gray-300'
// Find the corresponding member entry for cap editing
const member = members?.find((m) => m.userId === juror.id)
return (
<div key={juror.id} className="space-y-1 hover:bg-muted/20 rounded px-1 py-0.5 -mx-1 transition-colors group">
<div className="flex justify-between items-center text-xs">
<span className="font-medium truncate max-w-[140px]">{juror.name}</span>
<div className="flex items-center gap-2 shrink-0">
{member && onSaveCap && (
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => onSaveCap(member.id, val)}
/>
)}
<span className="text-muted-foreground tabular-nums">
{juror.completed}/{juror.assigned} ({pct}%)
</span>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
disabled={notifyMutation.isPending}
onClick={() => notifyMutation.mutate({ roundId, userId: juror.id })}
>
{notifyMutation.isPending && notifyMutation.variables?.userId === juror.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Mail className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify this juror of their assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => setTransferJuror({ id: juror.id, name: juror.name })}
>
<ArrowRightLeft className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Transfer assignments to other jurors</p></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-destructive"
disabled={reshuffleMutation.isPending}
onClick={() => {
const ok = window.confirm(
`Remove ${juror.name} from this jury pool and reassign all their unsubmitted projects to other jurors within their caps? Submitted evaluations will be preserved. This cannot be undone.`
)
if (!ok) return
reshuffleMutation.mutate({ roundId, jurorId: juror.id })
}}
>
{reshuffleMutation.isPending && reshuffleMutation.variables?.jurorId === juror.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<UserPlus className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Drop juror + reshuffle pending projects</p></TooltipContent>
</Tooltip>
</TooltipProvider>
{member && onRemoveMember && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.name} from this jury group?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveMember(member.id, member.name)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn('h-full rounded-full transition-all duration-500', barGradient)}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{transferJuror && (
<TransferAssignmentsDialog
roundId={roundId}
sourceJuror={transferJuror}
open={!!transferJuror}
onClose={() => setTransferJuror(null)}
/>
)}
</>
)
}
// Sub-component for member-only rows (no workload data yet)
function MemberOnlyRow({
member,
idx,
roundId,
onSaveCap,
onRemoveMember,
notifyMutation,
}: {
member: JuryProgressTableMember
idx: number
roundId: string
onSaveCap?: (memberId: string, val: number | null) => void
onRemoveMember?: (memberId: string, memberName: string) => void
notifyMutation: ReturnType<typeof trpc.assignment.notifySingleJurorOfAssignments.useMutation>
}) {
return (
<div
className={cn(
'flex items-center justify-between py-2 px-2 rounded-md transition-colors text-xs',
idx % 2 === 1 && 'bg-muted/30',
)}
>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">{member.name}</p>
<p className="text-muted-foreground truncate">{member.email}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{onSaveCap && (
<InlineMemberCap
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
roundId={roundId}
jurorUserId={member.userId}
onSave={(val) => onSaveCap(member.id, val)}
/>
)}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
disabled={notifyMutation.isPending}
onClick={() => notifyMutation.mutate({ roundId, userId: member.userId })}
>
{notifyMutation.isPending && notifyMutation.variables?.userId === member.userId ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Mail className="h-3 w-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="left"><p>Notify juror of assignments</p></TooltipContent>
</Tooltip>
</TooltipProvider>
{onRemoveMember && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive shrink-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove member?</AlertDialogTitle>
<AlertDialogDescription>
Remove {member.name} from this jury group?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRemoveMember(member.id, member.name)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
)
}

View File

@@ -1,61 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Mail, Loader2 } from 'lucide-react'
export type NotifyJurorsButtonProps = {
roundId: string
}
export function NotifyJurorsButton({ roundId }: NotifyJurorsButtonProps) {
const [open, setOpen] = useState(false)
const mutation = trpc.assignment.notifyJurorsOfAssignments.useMutation({
onSuccess: (data) => {
toast.success(`Notified ${data.jurorCount} juror(s) of their assignments`)
setOpen(false)
},
onError: (err) => toast.error(err.message),
})
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Mail className="h-4 w-4 mr-1.5" />
Notify Jurors
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Notify jurors of their assignments?</AlertDialogTitle>
<AlertDialogDescription>
This will send an email to every juror assigned to this round, reminding them of how many projects they need to evaluate.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => mutation.mutate({ roundId })}
disabled={mutation.isPending}
>
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Notify Jurors
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -1,101 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { History } from 'lucide-react'
export type ReassignmentHistoryProps = {
roundId: string
}
export function ReassignmentHistory({ roundId }: ReassignmentHistoryProps) {
const { data: events, isLoading } = trpc.assignment.getReassignmentHistory.useQuery(
{ roundId },
)
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<History className="h-4 w-4" />
Reassignment History
</CardTitle>
<CardDescription>Juror dropout, COI, transfer, and cap redistribution audit trail</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[1, 2].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : !events || events.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No reassignment events for this round
</p>
) : (
<div className="space-y-4 max-h-[500px] overflow-y-auto">
{events.map((event) => (
<div key={event.id} className="border rounded-lg p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={event.type === 'DROPOUT' ? 'destructive' : 'secondary'}>
{event.type === 'DROPOUT' ? 'Juror Dropout' : event.type === 'COI' ? 'COI Reassignment' : event.type === 'TRANSFER' ? 'Assignment Transfer' : 'Cap Redistribution'}
</Badge>
<span className="text-sm font-medium">
{event.droppedJuror.name}
</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(event.timestamp).toLocaleString()}
</span>
</div>
<p className="text-xs text-muted-foreground">
By {event.performedBy.name || event.performedBy.email} {event.movedCount} project(s) reassigned
{event.failedCount > 0 && `, ${event.failedCount} failed`}
</p>
{event.moves.length > 0 && (
<div className="mt-2">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Project</th>
<th className="text-left py-1 font-medium">Reassigned To</th>
</tr>
</thead>
<tbody>
{event.moves.map((move, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1.5 pr-2 max-w-[250px] truncate">
{move.projectTitle}
</td>
<td className="py-1.5 font-medium">
{move.newJurorName}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{event.failedProjects.length > 0 && (
<div className="mt-1">
<p className="text-xs font-medium text-destructive">Could not reassign:</p>
<ul className="text-xs text-muted-foreground list-disc list-inside">
{event.failedProjects.map((p, i) => (
<li key={i}>{p}</li>
))}
</ul>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,66 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { cn } from '@/lib/utils'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
export type RoundUnassignedQueueProps = {
roundId: string
requiredReviews?: number
}
export function RoundUnassignedQueue({ roundId, requiredReviews = 3 }: RoundUnassignedQueueProps) {
const { data: unassigned, isLoading } = trpc.roundAssignment.unassignedQueue.useQuery(
{ roundId, requiredReviews },
{ refetchInterval: 15_000 },
)
return (
<div className="space-y-3">
<div>
<p className="text-sm font-medium">Unassigned Projects</p>
<p className="text-xs text-muted-foreground">Projects with fewer than {requiredReviews} jury assignments</p>
</div>
<div>
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
) : unassigned && unassigned.length > 0 ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{unassigned.map((project: any) => (
<div
key={project.id}
className={cn(
'flex justify-between items-center p-3 border rounded-md hover:bg-muted/30 transition-colors',
(project.assignmentCount || 0) === 0 && 'border-l-4 border-l-red-500',
)}
>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground">
{project.competitionCategory || 'No category'}
{project.teamName && ` \u00b7 ${project.teamName}`}
</p>
</div>
<Badge variant="outline" className={cn(
'text-xs shrink-0 ml-3',
(project.assignmentCount || 0) === 0
? 'bg-red-50 text-red-700 border-red-200'
: 'bg-amber-50 text-amber-700 border-amber-200',
)}>
{project.assignmentCount || 0} / {requiredReviews}
</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">
All projects have sufficient assignments
</p>
)}
</div>
</div>
)
}

View File

@@ -1,61 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Send, Loader2 } from 'lucide-react'
export type SendRemindersButtonProps = {
roundId: string
}
export function SendRemindersButton({ roundId }: SendRemindersButtonProps) {
const [open, setOpen] = useState(false)
const mutation = trpc.evaluation.triggerReminders.useMutation({
onSuccess: (data) => {
toast.success(`Sent ${data.sent} reminder(s)`)
setOpen(false)
},
onError: (err) => toast.error(err.message),
})
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Send className="h-4 w-4 mr-1.5" />
Send Reminders
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Send evaluation reminders?</AlertDialogTitle>
<AlertDialogDescription>
This will send reminder emails to all jurors who have incomplete evaluations for this round.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => mutation.mutate({ roundId })}
disabled={mutation.isPending}
>
{mutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Send Reminders
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -1,337 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Loader2, Sparkles } from 'lucide-react'
export type TransferAssignmentsDialogProps = {
roundId: string
sourceJuror: { id: string; name: string }
open: boolean
onClose: () => void
}
export function TransferAssignmentsDialog({
roundId,
sourceJuror,
open,
onClose,
}: TransferAssignmentsDialogProps) {
const utils = trpc.useUtils()
const [step, setStep] = useState<1 | 2>(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Fetch source juror's assignments
const { data: sourceAssignments, isLoading: loadingAssignments } = trpc.assignment.listByStage.useQuery(
{ roundId },
{ enabled: open },
)
const jurorAssignments = useMemo(() =>
(sourceAssignments ?? []).filter((a: any) => a.userId === sourceJuror.id),
[sourceAssignments, sourceJuror.id],
)
// Fetch transfer candidates when in step 2
const { data: candidateData, isLoading: loadingCandidates } = trpc.assignment.getTransferCandidates.useQuery(
{ roundId, sourceJurorId: sourceJuror.id, assignmentIds: [...selectedIds] },
{ enabled: step === 2 && selectedIds.size > 0 },
)
// Per-assignment destination overrides
const [destOverrides, setDestOverrides] = useState<Record<string, string>>({})
const [forceOverCap, setForceOverCap] = useState(false)
// Auto-assign: distribute assignments across eligible candidates balanced by load
const handleAutoAssign = () => {
if (!candidateData) return
const movable = candidateData.assignments.filter((a) => a.movable)
if (movable.length === 0) return
// Simulate load starting from each candidate's current load
const simLoad = new Map<string, number>()
for (const c of candidateData.candidates) {
simLoad.set(c.userId, c.currentLoad)
}
const overrides: Record<string, string> = {}
for (const assignment of movable) {
const eligible = candidateData.candidates
.filter((c) => c.eligibleProjectIds.includes(assignment.projectId))
if (eligible.length === 0) continue
// Sort: prefer not-all-completed, then under cap, then lowest simulated load
const sorted = [...eligible].sort((a, b) => {
// Prefer jurors who haven't completed all evaluations
if (a.allCompleted !== b.allCompleted) return a.allCompleted ? 1 : -1
const loadA = simLoad.get(a.userId) ?? 0
const loadB = simLoad.get(b.userId) ?? 0
// Prefer jurors under their cap
const overCapA = loadA >= a.cap ? 1 : 0
const overCapB = loadB >= b.cap ? 1 : 0
if (overCapA !== overCapB) return overCapA - overCapB
// Then pick the least loaded
return loadA - loadB
})
const best = sorted[0]
overrides[assignment.id] = best.userId
simLoad.set(best.userId, (simLoad.get(best.userId) ?? 0) + 1)
}
setDestOverrides(overrides)
}
const transferMutation = trpc.assignment.transferAssignments.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate({ roundId })
utils.analytics.getJurorWorkload.invalidate({ roundId })
utils.roundAssignment.unassignedQueue.invalidate({ roundId })
utils.assignment.getReassignmentHistory.invalidate({ roundId })
const successCount = data.succeeded.length
const failCount = data.failed.length
if (failCount > 0) {
toast.warning(`Transferred ${successCount} project(s). ${failCount} failed.`)
} else {
toast.success(`Transferred ${successCount} project(s) successfully.`)
}
onClose()
},
onError: (err) => toast.error(err.message),
})
// Build the transfer plan: for each selected assignment, determine destination
const transferPlan = useMemo(() => {
if (!candidateData) return []
const movable = candidateData.assignments.filter((a) => a.movable)
return movable.map((assignment) => {
const override = destOverrides[assignment.id]
// Default: first eligible candidate
const defaultDest = candidateData.candidates.find((c) =>
c.eligibleProjectIds.includes(assignment.projectId)
)
const destId = override || defaultDest?.userId || ''
const destName = candidateData.candidates.find((c) => c.userId === destId)?.name || ''
return { assignmentId: assignment.id, projectTitle: assignment.projectTitle, destinationJurorId: destId, destName }
}).filter((t) => t.destinationJurorId)
}, [candidateData, destOverrides])
// Check if any destination is at or over cap
const anyOverCap = useMemo(() => {
if (!candidateData) return false
const destCounts = new Map<string, number>()
for (const t of transferPlan) {
destCounts.set(t.destinationJurorId, (destCounts.get(t.destinationJurorId) ?? 0) + 1)
}
return candidateData.candidates.some((c) => {
const extraLoad = destCounts.get(c.userId) ?? 0
return c.currentLoad + extraLoad > c.cap
})
}, [candidateData, transferPlan])
const handleTransfer = () => {
transferMutation.mutate({
roundId,
sourceJurorId: sourceJuror.id,
transfers: transferPlan.map((t) => ({ assignmentId: t.assignmentId, destinationJurorId: t.destinationJurorId })),
forceOverCap,
})
}
const isMovable = (a: any) => {
const status = a.evaluation?.status
return !status || status === 'NOT_STARTED' || status === 'DRAFT'
}
const movableAssignments = jurorAssignments.filter(isMovable)
const allMovableSelected = movableAssignments.length > 0 && movableAssignments.every((a: any) => selectedIds.has(a.id))
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Transfer Assignments from {sourceJuror.name}</DialogTitle>
<DialogDescription>
{step === 1 ? 'Select projects to transfer to other jurors.' : 'Choose destination jurors for each project.'}
</DialogDescription>
</DialogHeader>
{step === 1 && (
<div className="space-y-3">
{loadingAssignments ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : jurorAssignments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No assignments found.</p>
) : (
<>
<div className="flex items-center gap-2 pb-2 border-b">
<Checkbox
checked={allMovableSelected}
onCheckedChange={(checked) => {
if (checked) {
setSelectedIds(new Set(movableAssignments.map((a: any) => a.id)))
} else {
setSelectedIds(new Set())
}
}}
/>
<span className="text-xs text-muted-foreground">Select all movable ({movableAssignments.length})</span>
</div>
<div className="space-y-1 max-h-[400px] overflow-y-auto">
{jurorAssignments.map((a: any) => {
const movable = isMovable(a)
const status = a.evaluation?.status || 'No evaluation'
return (
<div key={a.id} className={cn('flex items-center gap-3 py-2 px-2 rounded-md', !movable && 'opacity-50')}>
<Checkbox
checked={selectedIds.has(a.id)}
disabled={!movable}
onCheckedChange={(checked) => {
const next = new Set(selectedIds)
if (checked) next.add(a.id)
else next.delete(a.id)
setSelectedIds(next)
}}
/>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{a.project?.title || 'Unknown'}</p>
</div>
<Badge variant="outline" className="text-xs shrink-0">
{status}
</Badge>
</div>
)
})}
</div>
</>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
disabled={selectedIds.size === 0}
onClick={() => { setStep(2); setDestOverrides({}) }}
>
Next ({selectedIds.size} selected)
</Button>
</DialogFooter>
</div>
)}
{step === 2 && (
<div className="space-y-3">
{loadingCandidates ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
) : !candidateData || candidateData.candidates.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">No eligible candidates found.</p>
) : (
<>
<div className="flex items-center justify-end">
<Button variant="outline" size="sm" onClick={handleAutoAssign}>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
Auto-assign
</Button>
</div>
<div className="space-y-2 max-h-[350px] overflow-y-auto">
{candidateData.assignments.filter((a) => a.movable).map((assignment) => {
const currentDest = destOverrides[assignment.id] ||
candidateData.candidates.find((c) => c.eligibleProjectIds.includes(assignment.projectId))?.userId || ''
return (
<div key={assignment.id} className="flex items-center gap-3 py-2 px-2 border rounded-md">
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{assignment.projectTitle}</p>
<p className="text-xs text-muted-foreground">{assignment.evalStatus || 'No evaluation'}</p>
</div>
<Select
value={currentDest}
onValueChange={(v) => setDestOverrides((prev) => ({ ...prev, [assignment.id]: v }))}
>
<SelectTrigger className="w-[220px] h-8 text-xs">
<SelectValue placeholder="Select juror" />
</SelectTrigger>
<SelectContent>
{candidateData.candidates.map((c) => {
const isEligible = c.eligibleProjectIds.includes(assignment.projectId)
const alreadyHas = c.alreadyAssignedProjectIds?.includes(assignment.projectId)
return (
<SelectItem
key={c.userId}
value={c.userId}
disabled={!isEligible}
className={cn(!isEligible && 'opacity-50')}
>
<span>{c.name}</span>
<span className="text-muted-foreground ml-1">({c.currentLoad}/{c.cap})</span>
{c.allCompleted && <span className="text-emerald-600 ml-1">Done</span>}
{alreadyHas && <span className="text-amber-600 ml-1">Already assigned</span>}
{!isEligible && !alreadyHas && c.currentLoad >= c.cap && <span className="text-red-500 ml-1">At cap</span>}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
)
})}
</div>
{transferPlan.length > 0 && (
<p className="text-xs text-muted-foreground">
Transfer {transferPlan.length} project(s) from {sourceJuror.name}
</p>
)}
{anyOverCap && (
<div className="flex items-center gap-2 p-2 border border-amber-200 bg-amber-50 rounded-md">
<Checkbox
checked={forceOverCap}
onCheckedChange={(checked) => setForceOverCap(!!checked)}
/>
<span className="text-xs text-amber-800">Force over-cap: some destinations will exceed their assignment limit</span>
</div>
)}
</>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setStep(1)}>Back</Button>
<Button
disabled={transferPlan.length === 0 || transferMutation.isPending || (anyOverCap && !forceOverCap)}
onClick={handleTransfer}
>
{transferMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> : null}
Transfer {transferPlan.length} project(s)
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -6,18 +6,24 @@ import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
import {
roundTypeConfig as sharedRoundTypeConfig,
roundStatusConfig as sharedRoundStatusConfig,
} from '@/lib/round-config'
import { CheckCircle2, Circle, Clock } from 'lucide-react'
const roundTypeColors: Record<string, string> = Object.fromEntries(
Object.entries(sharedRoundTypeConfig).map(([k, v]) => [k, `${v.badgeClass} ${v.cardBorder}`])
)
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700 border-gray-300',
FILTERING: 'bg-amber-100 text-amber-700 border-amber-300',
EVALUATION: 'bg-blue-100 text-blue-700 border-blue-300',
SUBMISSION: 'bg-purple-100 text-purple-700 border-purple-300',
MENTORING: 'bg-teal-100 text-teal-700 border-teal-300',
LIVE_FINAL: 'bg-red-100 text-red-700 border-red-300',
DELIBERATION: 'bg-indigo-100 text-indigo-700 border-indigo-300',
}
const roundStatusConfig: Record<string, { icon: React.ElementType; color: string }> = Object.fromEntries(
Object.entries(sharedRoundStatusConfig).map(([k, v]) => [k, { icon: v.timelineIcon, color: v.timelineIconColor }])
)
const roundStatusConfig: Record<string, { icon: typeof Circle; color: string }> = {
ROUND_DRAFT: { icon: Circle, color: 'text-gray-400' },
ROUND_ACTIVE: { icon: Clock, color: 'text-emerald-500' },
ROUND_CLOSED: { icon: CheckCircle2, color: 'text-blue-500' },
ROUND_ARCHIVED: { icon: CheckCircle2, color: 'text-gray-400' },
}
type RoundSummary = {
id: string

View File

@@ -3,7 +3,6 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { AlertCircle, CheckCircle2 } from 'lucide-react'
import { roundTypeConfig } from '@/lib/round-config'
type WizardRound = {
tempId: string
@@ -41,9 +40,15 @@ type ReviewSectionProps = {
state: WizardState
}
const roundTypeColors: Record<string, string> = Object.fromEntries(
Object.entries(roundTypeConfig).map(([k, v]) => [k, v.badgeClass])
)
const roundTypeColors: Record<string, string> = {
INTAKE: 'bg-gray-100 text-gray-700',
FILTERING: 'bg-amber-100 text-amber-700',
EVALUATION: 'bg-blue-100 text-blue-700',
SUBMISSION: 'bg-purple-100 text-purple-700',
MENTORING: 'bg-teal-100 text-teal-700',
LIVE_FINAL: 'bg-red-100 text-red-700',
DELIBERATION: 'bg-indigo-100 text-indigo-700',
}
export function ReviewSection({ state }: ReviewSectionProps) {
const warnings: string[] = []

View File

@@ -1,164 +0,0 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Loader2, Pencil } from 'lucide-react'
export type InlineMemberCapProps = {
memberId: string
currentValue: number | null
onSave: (val: number | null) => void
roundId?: string
jurorUserId?: string
}
export function InlineMemberCap({
memberId,
currentValue,
onSave,
roundId,
jurorUserId,
}: InlineMemberCapProps) {
const utils = trpc.useUtils()
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(currentValue?.toString() ?? '')
const [overCapInfo, setOverCapInfo] = useState<{ total: number; overCapCount: number; movableOverCap: number; immovableOverCap: number } | null>(null)
const [showBanner, setShowBanner] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const redistributeMutation = trpc.assignment.redistributeOverCap.useMutation({
onSuccess: (data) => {
utils.assignment.listByStage.invalidate()
utils.analytics.getJurorWorkload.invalidate()
utils.roundAssignment.unassignedQueue.invalidate()
setShowBanner(false)
setOverCapInfo(null)
if (data.failed > 0) {
toast.warning(`Redistributed ${data.redistributed} project(s). ${data.failed} could not be reassigned.`)
} else {
toast.success(`Redistributed ${data.redistributed} project(s) to other jurors.`)
}
},
onError: (err) => toast.error(err.message),
})
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
const save = async () => {
const trimmed = value.trim()
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
toast.error('Enter a positive number or leave empty for no cap')
return
}
if (newVal === currentValue) {
setEditing(false)
return
}
// Check over-cap impact before saving
if (newVal !== null && roundId && jurorUserId) {
try {
const preview = await utils.client.assignment.getOverCapPreview.query({
roundId,
jurorId: jurorUserId,
newCap: newVal,
})
if (preview.overCapCount > 0) {
setOverCapInfo(preview)
setShowBanner(true)
setEditing(false)
return
}
} catch {
// If preview fails, just save the cap normally
}
}
onSave(newVal)
setEditing(false)
}
const handleRedistribute = () => {
const newVal = parseInt(value.trim(), 10)
onSave(newVal)
if (roundId && jurorUserId) {
redistributeMutation.mutate({ roundId, jurorId: jurorUserId, newCap: newVal })
}
}
const handleJustSave = () => {
const newVal = value.trim() === '' ? null : parseInt(value.trim(), 10)
onSave(newVal)
setShowBanner(false)
setOverCapInfo(null)
}
if (showBanner && overCapInfo) {
return (
<div className="space-y-1.5">
<div className="rounded-md border border-amber-200 bg-amber-50 p-2 text-xs text-amber-800">
<p>New cap of <strong>{value}</strong> is below current load (<strong>{overCapInfo.total}</strong> assignments). <strong>{overCapInfo.movableOverCap}</strong> can be redistributed.</p>
{overCapInfo.immovableOverCap > 0 && (
<p className="text-amber-600 mt-0.5">{overCapInfo.immovableOverCap} submitted evaluation(s) cannot be moved.</p>
)}
<div className="flex gap-1.5 mt-1.5">
<Button
size="sm"
variant="default"
className="h-6 text-xs px-2"
disabled={redistributeMutation.isPending || overCapInfo.movableOverCap === 0}
onClick={handleRedistribute}
>
{redistributeMutation.isPending ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Redistribute
</Button>
<Button size="sm" variant="outline" className="h-6 text-xs px-2" onClick={handleJustSave}>
Just save cap
</Button>
<Button size="sm" variant="ghost" className="h-6 text-xs px-2" onClick={() => { setShowBanner(false); setOverCapInfo(null) }}>
Cancel
</Button>
</div>
</div>
</div>
)
}
if (editing) {
return (
<Input
ref={inputRef}
type="number"
min={1}
className="h-6 w-16 text-xs"
value={value}
placeholder="\u221E"
onChange={(e) => setValue(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === 'Enter') save()
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
}}
/>
)
}
return (
<button
type="button"
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs hover:bg-muted transition-colors group"
title="Click to set max assignment cap"
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
>
<span className="text-muted-foreground">max:</span>
<span className="font-medium">{currentValue ?? '\u221E'}</span>
<Pencil className="h-2.5 w-2.5 text-muted-foreground" />
</button>
)
}

View File

@@ -364,16 +364,9 @@ export function MembersContent() {
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{((user as unknown as { roles?: string[] }).roles?.length
? (user as unknown as { roles: string[] }).roles
: [user.role]
).map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'}>
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace(/_/g, ' ')}
</Badge>
</TableCell>
<TableCell>
{user.expertiseTags && user.expertiseTags.length > 0 ? (
@@ -476,16 +469,9 @@ export function MembersContent() {
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span>
<div className="flex flex-wrap gap-1 justify-end">
{((user as unknown as { roles?: string[] }).roles?.length
? (user as unknown as { roles: string[] }).roles
: [user.role]
).map((r) => (
<Badge key={r} variant={roleColors[r] || 'secondary'}>
{r.replace(/_/g, ' ')}
</Badge>
))}
</div>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace(/_/g, ' ')}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>

View File

@@ -1,165 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Loader2, Save, Settings } from 'lucide-react'
type CompetitionSettingsProps = {
competitionId: string
initialSettings: {
categoryMode: string
startupFinalistCount: number
conceptFinalistCount: number
notifyOnRoundAdvance: boolean
notifyOnDeadlineApproach: boolean
deadlineReminderDays: number[]
}
}
export function CompetitionSettings({ competitionId, initialSettings }: CompetitionSettingsProps) {
const [settings, setSettings] = useState(initialSettings)
const [dirty, setDirty] = useState(false)
const updateMutation = trpc.competition.update.useMutation({
onSuccess: () => {
toast.success('Competition settings saved')
setDirty(false)
},
onError: (err) => toast.error(err.message),
})
function update<K extends keyof typeof settings>(key: K, value: (typeof settings)[K]) {
setSettings((prev) => ({ ...prev, [key]: value }))
setDirty(true)
}
function handleSave() {
updateMutation.mutate({ id: competitionId, ...settings })
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Settings className="h-4 w-4" />
Competition Settings
</CardTitle>
<CardDescription>
Category mode, finalist targets, and notification preferences
</CardDescription>
</div>
{dirty && (
<Button onClick={handleSave} disabled={updateMutation.isPending} size="sm">
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
)}
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label>Category Mode</Label>
<Select value={settings.categoryMode} onValueChange={(v) => update('categoryMode', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SHARED">Shared Pool</SelectItem>
<SelectItem value="SEPARATE">Separate Tracks</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Startup Finalist Count</Label>
<Input
type="number"
min={1}
value={settings.startupFinalistCount}
onChange={(e) => update('startupFinalistCount', parseInt(e.target.value) || 1)}
/>
</div>
<div className="space-y-2">
<Label>Concept Finalist Count</Label>
<Input
type="number"
min={1}
value={settings.conceptFinalistCount}
onChange={(e) => update('conceptFinalistCount', parseInt(e.target.value) || 1)}
/>
</div>
</div>
<div className="space-y-4">
<h4 className="text-sm font-medium">Notifications</h4>
<div className="flex items-center justify-between">
<div>
<Label>Notify on Round Advance</Label>
<p className="text-xs text-muted-foreground">Email applicants when their project advances</p>
</div>
<Switch
checked={settings.notifyOnRoundAdvance}
onCheckedChange={(v) => update('notifyOnRoundAdvance', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label>Notify on Deadline Approach</Label>
<p className="text-xs text-muted-foreground">Send reminders before deadlines</p>
</div>
<Switch
checked={settings.notifyOnDeadlineApproach}
onCheckedChange={(v) => update('notifyOnDeadlineApproach', v)}
/>
</div>
<div className="space-y-2">
<Label>Reminder Days Before Deadline</Label>
<div className="flex items-center gap-2">
{settings.deadlineReminderDays.map((day, idx) => (
<Badge key={idx} variant="secondary" className="gap-1">
{day}d
<button
className="ml-1 text-muted-foreground hover:text-foreground"
onClick={() => {
const next = settings.deadlineReminderDays.filter((_, i) => i !== idx)
update('deadlineReminderDays', next)
}}
>
×
</button>
</Badge>
))}
<Input
type="number"
min={1}
placeholder="Add..."
className="w-20 h-7 text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val = parseInt((e.target as HTMLInputElement).value)
if (val > 0 && !settings.deadlineReminderDays.includes(val)) {
update('deadlineReminderDays', [...settings.deadlineReminderDays, val].sort((a, b) => b - a))
;(e.target as HTMLInputElement).value = ''
}
}
}}
/>
</div>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,289 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Loader2 } from 'lucide-react'
export type AdvanceProjectsDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
roundType?: string
projectStates: any[] | undefined
config: Record<string, unknown>
advanceMutation: { mutate: (input: { roundId: string; projectIds?: string[]; targetRoundId?: string; autoPassPending?: boolean }) => void; isPending: boolean }
competitionRounds?: Array<{ id: string; name: string; sortOrder: number; roundType: string }>
currentSortOrder?: number
}
export function AdvanceProjectsDialog({
open,
onOpenChange,
roundId,
roundType,
projectStates,
config,
advanceMutation,
competitionRounds,
currentSortOrder,
}: AdvanceProjectsDialogProps) {
// For non-jury rounds (INTAKE, SUBMISSION, MENTORING), offer a simpler "advance all" flow
const isSimpleAdvance = ['INTAKE', 'SUBMISSION', 'MENTORING'].includes(roundType ?? '')
// Target round selector
const availableTargets = useMemo(() =>
(competitionRounds ?? [])
.filter((r) => r.sortOrder > (currentSortOrder ?? -1) && r.id !== roundId)
.sort((a, b) => a.sortOrder - b.sortOrder),
[competitionRounds, currentSortOrder, roundId])
const [targetRoundId, setTargetRoundId] = useState<string>('')
// Default to first available target when dialog opens
if (open && !targetRoundId && availableTargets.length > 0) {
setTargetRoundId(availableTargets[0].id)
}
const allProjects = projectStates ?? []
const pendingCount = allProjects.filter((ps: any) => ps.state === 'PENDING').length
const passedProjects = useMemo(() =>
allProjects.filter((ps: any) => ps.state === 'PASSED'),
[allProjects])
const startups = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'STARTUP'),
[passedProjects])
const concepts = useMemo(() =>
passedProjects.filter((ps: any) => ps.project?.competitionCategory === 'BUSINESS_CONCEPT'),
[passedProjects])
const other = useMemo(() =>
passedProjects.filter((ps: any) =>
ps.project?.competitionCategory !== 'STARTUP' && ps.project?.competitionCategory !== 'BUSINESS_CONCEPT',
),
[passedProjects])
const startupCap = (config.startupAdvanceCount as number) || 0
const conceptCap = (config.conceptAdvanceCount as number) || 0
const [selected, setSelected] = useState<Set<string>>(new Set())
// Reset selection when dialog opens
if (open && selected.size === 0 && passedProjects.length > 0) {
const initial = new Set<string>()
// Auto-select all (or up to cap if configured)
const startupSlice = startupCap > 0 ? startups.slice(0, startupCap) : startups
const conceptSlice = conceptCap > 0 ? concepts.slice(0, conceptCap) : concepts
for (const ps of startupSlice) initial.add(ps.project?.id)
for (const ps of conceptSlice) initial.add(ps.project?.id)
for (const ps of other) initial.add(ps.project?.id)
setSelected(initial)
}
const toggleProject = (projectId: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(projectId)) next.delete(projectId)
else next.add(projectId)
return next
})
}
const toggleAll = (projects: any[], on: boolean) => {
setSelected((prev) => {
const next = new Set(prev)
for (const ps of projects) {
if (on) next.add(ps.project?.id)
else next.delete(ps.project?.id)
}
return next
})
}
const handleAdvance = (autoPass?: boolean) => {
if (autoPass) {
// Auto-pass all pending then advance all
advanceMutation.mutate({
roundId,
autoPassPending: true,
...(targetRoundId ? { targetRoundId } : {}),
})
} else {
const ids = Array.from(selected)
if (ids.length === 0) return
advanceMutation.mutate({
roundId,
projectIds: ids,
...(targetRoundId ? { targetRoundId } : {}),
})
}
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const handleClose = () => {
onOpenChange(false)
setSelected(new Set())
setTargetRoundId('')
}
const renderCategorySection = (
label: string,
projects: any[],
cap: number,
badgeColor: string,
) => {
const selectedInCategory = projects.filter((ps: any) => selected.has(ps.project?.id)).length
const overCap = cap > 0 && selectedInCategory > cap
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={projects.length > 0 && projects.every((ps: any) => selected.has(ps.project?.id))}
onCheckedChange={(checked) => toggleAll(projects, !!checked)}
/>
<span className="text-sm font-medium">{label}</span>
<Badge variant="secondary" className={cn('text-[10px]', badgeColor)}>
{selectedInCategory}/{projects.length}
</Badge>
{cap > 0 && (
<span className={cn('text-[10px]', overCap ? 'text-red-500 font-medium' : 'text-muted-foreground')}>
(target: {cap})
</span>
)}
</div>
</div>
{projects.length === 0 ? (
<p className="text-xs text-muted-foreground pl-7">No passed projects in this category</p>
) : (
<div className="space-y-1 pl-7">
{projects.map((ps: any) => (
<label
key={ps.project?.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/30 cursor-pointer"
>
<Checkbox
checked={selected.has(ps.project?.id)}
onCheckedChange={() => toggleProject(ps.project?.id)}
/>
<span className="text-sm truncate flex-1">{ps.project?.title || 'Untitled'}</span>
{ps.project?.teamName && (
<span className="text-xs text-muted-foreground shrink-0">{ps.project.teamName}</span>
)}
</label>
))}
</div>
)}
</div>
)
}
const totalProjectCount = allProjects.length
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Advance Projects</DialogTitle>
<DialogDescription>
{isSimpleAdvance
? `Move all ${totalProjectCount} projects to the next round.`
: `Select which passed projects to advance. ${selected.size} of ${passedProjects.length} selected.`
}
</DialogDescription>
</DialogHeader>
{/* Target round selector */}
{availableTargets.length > 0 && (
<div className="space-y-2 pb-2 border-b">
<Label className="text-sm">Advance to</Label>
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select target round" />
</SelectTrigger>
<SelectContent>
{availableTargets.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.name} ({r.roundType.replace('_', ' ').toLowerCase()})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{availableTargets.length === 0 && (
<div className="text-sm text-amber-600 bg-amber-50 rounded-md p-3">
No subsequent rounds found. Projects will advance to the next round by sort order.
</div>
)}
{isSimpleAdvance ? (
/* Simple mode for INTAKE/SUBMISSION/MENTORING — no per-project selection needed */
<div className="py-4 space-y-3">
<div className="rounded-lg border bg-muted/30 p-4 text-center space-y-1">
<p className="text-3xl font-bold">{totalProjectCount}</p>
<p className="text-sm text-muted-foreground">projects will be advanced</p>
</div>
{pendingCount > 0 && (
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
{pendingCount} pending project{pendingCount !== 1 ? 's' : ''} will be automatically marked as passed and advanced.
{passedProjects.length > 0 && ` ${passedProjects.length} already passed.`}
</p>
</div>
)}
</div>
) : (
/* Detailed mode for jury/evaluation rounds — per-project selection */
<div className="flex-1 overflow-y-auto space-y-4 py-2">
{renderCategorySection('Startup', startups, startupCap, 'bg-blue-100 text-blue-700')}
{renderCategorySection('Business Concept', concepts, conceptCap, 'bg-purple-100 text-purple-700')}
{other.length > 0 && renderCategorySection('Other / Uncategorized', other, 0, 'bg-gray-100 text-gray-700')}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={handleClose}>Cancel</Button>
{isSimpleAdvance ? (
<Button
onClick={() => handleAdvance(true)}
disabled={totalProjectCount === 0 || advanceMutation.isPending || availableTargets.length === 0}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance All {totalProjectCount} Project{totalProjectCount !== 1 ? 's' : ''}
</Button>
) : (
<Button
onClick={() => handleAdvance()}
disabled={selected.size === 0 || advanceMutation.isPending || availableTargets.length === 0}
>
{advanceMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Advance {selected.size} Project{selected.size !== 1 ? 's' : ''}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,93 +0,0 @@
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { ThumbsUp, ThumbsDown, Clock } from 'lucide-react'
export function AdvancementSummaryCard({ roundId }: { roundId: string }) {
const { data: assignments, isLoading } = trpc.assignment.listByStage.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
)
if (isLoading) return <Skeleton className="h-40 w-full" />
if (!assignments || assignments.length === 0) return null
// Check if form has an advance criterion
const firstSubmitted = assignments.find(
(a: any) => a.evaluation?.status === 'SUBMITTED' && a.evaluation?.form?.criteriaJson
)
if (!firstSubmitted) return null
const criteria = ((firstSubmitted as any).evaluation?.form?.criteriaJson ?? []) as Array<{ id: string; type?: string }>
const advanceCriterion = criteria.find((c) => c.type === 'advance')
if (!advanceCriterion) return null
let yesCount = 0
let noCount = 0
let pendingCount = 0
for (const a of assignments as any[]) {
const ev = a.evaluation
if (!ev || ev.status !== 'SUBMITTED') {
pendingCount++
continue
}
const scores = (ev.criterionScoresJson ?? {}) as Record<string, unknown>
const val = scores[advanceCriterion.id]
if (val === true) yesCount++
else if (val === false) noCount++
else pendingCount++
}
const total = yesCount + noCount + pendingCount
const yesPct = total > 0 ? Math.round((yesCount / total) * 100) : 0
const noPct = total > 0 ? Math.round((noCount / total) * 100) : 0
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Advancement Votes</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="h-10 w-10 rounded-full bg-emerald-100 flex items-center justify-center">
<ThumbsUp className="h-5 w-5 text-emerald-700" />
</div>
<div>
<p className="text-2xl font-bold text-emerald-700">{yesCount}</p>
<p className="text-xs text-muted-foreground">Yes ({yesPct}%)</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="h-10 w-10 rounded-full bg-red-100 flex items-center justify-center">
<ThumbsDown className="h-5 w-5 text-red-700" />
</div>
<div>
<p className="text-2xl font-bold text-red-700">{noCount}</p>
<p className="text-xs text-muted-foreground">No ({noPct}%)</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center">
<Clock className="h-5 w-5 text-gray-500" />
</div>
<div>
<p className="text-2xl font-bold text-gray-600">{pendingCount}</p>
<p className="text-xs text-muted-foreground">Pending</p>
</div>
</div>
</div>
{/* Stacked bar */}
<div className="mt-4 h-3 rounded-full bg-gray-100 overflow-hidden flex">
{yesPct > 0 && <div className="bg-emerald-500 transition-all" style={{ width: `${yesPct}%` }} />}
{noPct > 0 && <div className="bg-red-500 transition-all" style={{ width: `${noPct}%` }} />}
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,231 +0,0 @@
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Loader2, ChevronDown, CheckCircle2, X } from 'lucide-react'
export type RecommendationItem = {
projectId: string
rank: number
score: number
category: string
strengths: string[]
concerns: string[]
recommendation: string
}
export type AIRecommendationsDisplayProps = {
recommendations: { STARTUP: RecommendationItem[]; BUSINESS_CONCEPT: RecommendationItem[] }
projectStates: any[] | undefined
roundId: string
onClear: () => void
onApplied: () => void
}
export function AIRecommendationsDisplay({
recommendations,
projectStates,
roundId,
onClear,
onApplied,
}: AIRecommendationsDisplayProps) {
const [expandedId, setExpandedId] = useState<string | null>(null)
const [applying, setApplying] = useState(false)
// Initialize selected with all recommended project IDs
const allRecommendedIds = useMemo(() => {
const ids = new Set<string>()
for (const item of recommendations.STARTUP) ids.add(item.projectId)
for (const item of recommendations.BUSINESS_CONCEPT) ids.add(item.projectId)
return ids
}, [recommendations])
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set(allRecommendedIds))
// Build projectId → title map from projectStates
const projectTitleMap = useMemo(() => {
const map = new Map<string, string>()
if (projectStates) {
for (const ps of projectStates) {
if (ps.project?.id && ps.project?.title) {
map.set(ps.project.id, ps.project.title)
}
}
}
return map
}, [projectStates])
const transitionMutation = trpc.roundEngine.transitionProject.useMutation()
const toggleProject = (projectId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(projectId)) next.delete(projectId)
else next.add(projectId)
return next
})
}
const selectedStartups = recommendations.STARTUP.filter((item) => selectedIds.has(item.projectId)).length
const selectedConcepts = recommendations.BUSINESS_CONCEPT.filter((item) => selectedIds.has(item.projectId)).length
const handleApply = async () => {
setApplying(true)
try {
// Transition all selected projects to PASSED
const promises = Array.from(selectedIds).map((projectId) =>
transitionMutation.mutateAsync({ projectId, roundId, newState: 'PASSED' }).catch(() => {
// Project might already be PASSED — that's OK
})
)
await Promise.all(promises)
toast.success(`Marked ${selectedIds.size} project(s) as passed`)
onApplied()
} catch (error) {
toast.error('Failed to apply recommendations')
} finally {
setApplying(false)
}
}
const renderCategory = (label: string, items: RecommendationItem[], colorClass: string) => {
if (items.length === 0) return (
<div className="text-center py-4 text-muted-foreground text-sm">
No {label.toLowerCase()} projects evaluated
</div>
)
return (
<div className="space-y-2">
{items.map((item) => {
const isExpanded = expandedId === `${item.category}-${item.projectId}`
const isSelected = selectedIds.has(item.projectId)
const projectTitle = projectTitleMap.get(item.projectId) || item.projectId
return (
<div
key={item.projectId}
className={cn(
'border rounded-lg overflow-hidden transition-colors',
!isSelected && 'opacity-50',
)}
>
<div className="flex items-center gap-2 p-3">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleProject(item.projectId)}
className="shrink-0"
/>
<button
onClick={() => setExpandedId(isExpanded ? null : `${item.category}-${item.projectId}`)}
className="flex-1 flex items-center gap-3 text-left hover:bg-muted/30 rounded transition-colors min-w-0"
>
<span className={cn(
'h-7 w-7 rounded-full flex items-center justify-center text-xs font-bold text-white shrink-0 shadow-sm',
colorClass === 'bg-blue-500' ? 'bg-gradient-to-br from-blue-400 to-blue-600' : 'bg-gradient-to-br from-purple-400 to-purple-600',
)}>
{item.rank}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{projectTitle}</p>
<p className="text-xs text-muted-foreground truncate">{item.recommendation}</p>
</div>
<Badge variant="outline" className="shrink-0 text-xs font-mono">
{item.score}/100
</Badge>
<ChevronDown className={cn(
'h-4 w-4 text-muted-foreground transition-transform shrink-0',
isExpanded && 'rotate-180',
)} />
</button>
</div>
{isExpanded && (
<div className="px-3 pb-3 pt-0 space-y-2 border-t bg-muted/10">
<div className="pt-2">
<p className="text-xs font-medium text-emerald-700 mb-1">Strengths</p>
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
{item.strengths.map((s, i) => <li key={i}>{s}</li>)}
</ul>
</div>
{item.concerns.length > 0 && (
<div>
<p className="text-xs font-medium text-amber-700 mb-1">Concerns</p>
<ul className="text-xs text-muted-foreground space-y-0.5 pl-4 list-disc">
{item.concerns.map((c, i) => <li key={i}>{c}</li>)}
</ul>
</div>
)}
<div>
<p className="text-xs font-medium text-blue-700 mb-1">Recommendation</p>
<p className="text-xs text-muted-foreground">{item.recommendation}</p>
</div>
</div>
)}
</div>
)
})}
</div>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">AI Shortlist Recommendations</CardTitle>
<CardDescription>
Ranked independently per category {selectedStartups} of {recommendations.STARTUP.length} startups, {selectedConcepts} of {recommendations.BUSINESS_CONCEPT.length} concepts selected
</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={onClear}>
<X className="h-4 w-4 mr-1" />
Dismiss
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-6 lg:grid-cols-2">
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
Startup ({recommendations.STARTUP.length})
</h4>
{renderCategory('Startup', recommendations.STARTUP, 'bg-blue-500')}
</div>
<div>
<h4 className="text-sm font-semibold mb-3 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-purple-500" />
Business Concept ({recommendations.BUSINESS_CONCEPT.length})
</h4>
{renderCategory('Business Concept', recommendations.BUSINESS_CONCEPT, 'bg-purple-500')}
</div>
</div>
{/* Apply button */}
<div className="flex items-center justify-between pt-4 border-t">
<p className="text-sm text-muted-foreground">
{selectedIds.size} project{selectedIds.size !== 1 ? 's' : ''} will be marked as <strong>Passed</strong>
</p>
<Button
onClick={handleApply}
disabled={selectedIds.size === 0 || applying}
className="bg-[#053d57] hover:bg-[#053d57]/90 text-white"
>
{applying ? (
<><Loader2 className="h-4 w-4 mr-1.5 animate-spin" />Applying...</>
) : (
<><CheckCircle2 className="h-4 w-4 mr-1.5" />Apply &amp; Mark as Passed</>
)}
</Button>
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,124 +0,0 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2 } from 'lucide-react'
import { EvaluationFormBuilder } from '@/components/forms/evaluation-form-builder'
import type { Criterion } from '@/components/forms/evaluation-form-builder'
export type EvaluationCriteriaEditorProps = {
roundId: string
}
export function EvaluationCriteriaEditor({ roundId }: EvaluationCriteriaEditorProps) {
const [pendingCriteria, setPendingCriteria] = useState<Criterion[] | null>(null)
const utils = trpc.useUtils()
const { data: form, isLoading } = trpc.evaluation.getForm.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const upsertMutation = trpc.evaluation.upsertForm.useMutation({
onSuccess: () => {
utils.evaluation.getForm.invalidate({ roundId })
toast.success('Evaluation criteria saved')
setPendingCriteria(null)
},
onError: (err) => toast.error(err.message),
})
// Convert server criteriaJson to Criterion[] format
const serverCriteria: Criterion[] = useMemo(() => {
if (!form?.criteriaJson) return []
return (form.criteriaJson as Criterion[]).map((c) => {
// Handle legacy numeric-only format: convert "scale" string like "1-10" back to minScore/maxScore
const type = c.type || 'numeric'
if (type === 'numeric' && typeof c.scale === 'string') {
const parts = (c.scale as string).split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
return { ...c, type: 'numeric' as const, scale: parts[1], minScore: parts[0], maxScore: parts[1] } as unknown as Criterion
}
}
return { ...c, type } as Criterion
})
}, [form?.criteriaJson])
const handleChange = useCallback((criteria: Criterion[]) => {
setPendingCriteria(criteria)
}, [])
const handleSave = () => {
const criteria = pendingCriteria ?? serverCriteria
const validCriteria = criteria.filter((c) => c.label.trim())
if (validCriteria.length === 0) {
toast.error('Add at least one criterion')
return
}
// Map to upsertForm format
upsertMutation.mutate({
roundId,
criteria: validCriteria.map((c) => ({
id: c.id,
label: c.label,
description: c.description,
type: c.type || 'numeric',
weight: c.weight,
scale: typeof c.scale === 'number' ? c.scale : undefined,
minScore: (c as any).minScore,
maxScore: (c as any).maxScore,
required: c.required,
maxLength: c.maxLength,
placeholder: c.placeholder,
trueLabel: c.trueLabel,
falseLabel: c.falseLabel,
condition: c.condition,
sectionId: c.sectionId,
})),
})
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Evaluation Criteria</CardTitle>
<CardDescription>
{form
? `Version ${form.version} \u2014 ${(form.criteriaJson as Criterion[]).filter((c) => (c.type || 'numeric') !== 'section_header').length} criteria`
: 'No criteria defined yet. Add numeric scores, yes/no questions, and text fields.'}
</CardDescription>
</div>
{pendingCriteria && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setPendingCriteria(null)}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={upsertMutation.isPending}>
{upsertMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Save Criteria
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full" />)}
</div>
) : (
<EvaluationFormBuilder
initialCriteria={serverCriteria}
onChange={handleChange}
/>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,43 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
export type ExportEvaluationsDialogProps = {
roundId: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function ExportEvaluationsDialog({
roundId,
open,
onOpenChange,
}: ExportEvaluationsDialogProps) {
const [exportData, setExportData] = useState<any>(undefined)
const [isLoadingExport, setIsLoadingExport] = useState(false)
const utils = trpc.useUtils()
const handleRequestData = async () => {
setIsLoadingExport(true)
try {
const data = await utils.export.evaluations.fetch({ roundId, includeDetails: true })
setExportData(data)
return data
} finally {
setIsLoadingExport(false)
}
}
return (
<CsvExportDialog
open={open}
onOpenChange={onOpenChange}
exportData={exportData}
isLoading={isLoadingExport}
filename={`evaluations-${roundId}`}
onRequestData={handleRequestData}
/>
)
}

View File

@@ -92,7 +92,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
const utils = trpc.useUtils()
const poolLink = `/admin/projects?hasAssign=false&round=${roundId}` as Route
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
@@ -328,7 +328,7 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
{/* Table */}
<div className="border rounded-lg overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[40px_1fr_140px_160px_120px_80px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
<div className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
<div>
<Checkbox
checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
@@ -339,7 +339,6 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
<div>Category</div>
<div>Country</div>
<div>State</div>
<div>Reviews</div>
<div>Entered</div>
<div />
</div>
@@ -348,13 +347,10 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
{filtered.map((ps: any) => {
const cfg = stateConfig[ps.state as ProjectState] || stateConfig.PENDING
const StateIcon = cfg.icon
const total = ps.totalAssignments ?? 0
const submitted = ps.submittedCount ?? 0
const allDone = total > 0 && submitted === total
return (
<div
key={ps.id}
className="grid grid-cols-[40px_1fr_140px_160px_120px_80px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm"
className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm"
>
<div>
<Checkbox
@@ -385,15 +381,6 @@ export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTabl
{cfg.label}
</Badge>
</div>
<div className="text-xs tabular-nums">
{total > 0 ? (
<span className={allDone ? 'text-green-600 font-medium' : 'text-muted-foreground'}>
{submitted}/{total}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</div>
<div className="text-xs text-muted-foreground">
{ps.enteredAt ? new Date(ps.enteredAt).toLocaleDateString() : '—'}
</div>

View File

@@ -1,713 +0,0 @@
'use client'
import { useState, useEffect, useRef, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { AnimatePresence, motion } from 'motion/react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
GripVertical,
BarChart3,
Loader2,
RefreshCw,
Trophy,
} from 'lucide-react'
import type { RankedProjectEntry } from '@/server/services/ai-ranking'
// ─── Types ────────────────────────────────────────────────────────────────────
type RankingDashboardProps = {
competitionId: string
roundId: string
}
type ProjectInfo = {
title: string
teamName: string | null
country: string | null
}
type SortableProjectRowProps = {
projectId: string
currentRank: number
entry: RankedProjectEntry | undefined
projectInfo: ProjectInfo | undefined
onSelect: () => void
isSelected: boolean
}
// ─── Sub-component: SortableProjectRow ────────────────────────────────────────
function SortableProjectRow({
projectId,
currentRank,
entry,
projectInfo,
onSelect,
isSelected,
}: SortableProjectRowProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: projectId })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
// isOverridden: current position differs from AI-assigned rank
const isOverridden = entry !== undefined && currentRank !== entry.rank
return (
<div
ref={setNodeRef}
style={style}
onClick={onSelect}
className={cn(
'flex items-center gap-3 rounded-lg border bg-card p-3 cursor-pointer transition-all hover:shadow-sm',
isDragging && 'opacity-50 shadow-lg ring-2 ring-[#de0f1e]/30',
isSelected && 'ring-2 ring-[#de0f1e]',
)}
>
{/* Drag handle */}
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground flex-shrink-0"
onClick={(e) => e.stopPropagation()}
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
{/* Rank badge */}
{isOverridden ? (
<Badge className="flex-shrink-0 bg-amber-100 text-amber-700 hover:bg-amber-100 border-amber-200 text-xs font-semibold">
#{currentRank} (override)
</Badge>
) : (
<Badge
className="flex-shrink-0 text-xs font-semibold"
style={{ backgroundColor: '#053d57', color: '#fefefe' }}
>
#{currentRank}
</Badge>
)}
{/* Project info */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{projectInfo?.title ?? `Project …${projectId.slice(-6)}`}
</p>
{projectInfo?.teamName && (
<p className="text-xs text-muted-foreground truncate">
{projectInfo.teamName}
{projectInfo.country ? ` · ${projectInfo.country}` : ''}
</p>
)}
</div>
{/* Stats */}
{entry && (
<div className="flex items-center gap-4 flex-shrink-0 text-xs text-muted-foreground">
<span title="Composite score">
<BarChart3 className="inline h-3 w-3 mr-0.5" />
{Math.round(entry.compositeScore * 100)}%
</span>
{entry.avgGlobalScore !== null && (
<span title="Average global score">
Avg {entry.avgGlobalScore.toFixed(1)}
</span>
)}
<span title="Yes/No vote rate">
Yes {Math.round(entry.passRate * 100)}%
</span>
<span title="Evaluator count">
{entry.evaluatorCount} juror{entry.evaluatorCount !== 1 ? 's' : ''}
</span>
</div>
)}
</div>
)
}
// ─── Main component ────────────────────────────────────────────────────────────
export function RankingDashboard({ competitionId: _competitionId, roundId }: RankingDashboardProps) {
// ─── State ────────────────────────────────────────────────────────────────
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [localOrder, setLocalOrder] = useState<Record<'STARTUP' | 'BUSINESS_CONCEPT', string[]>>({
STARTUP: [],
BUSINESS_CONCEPT: [],
})
const initialized = useRef(false)
const pendingReorderCount = useRef(0)
// ─── Advance dialog state ─────────────────────────────────────────────────
const [advanceDialogOpen, setAdvanceDialogOpen] = useState(false)
const [topNStartup, setTopNStartup] = useState(3)
const [topNConceptual, setTopNConceptual] = useState(3)
const [includeReject, setIncludeReject] = useState(false)
// ─── Sensors ──────────────────────────────────────────────────────────────
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
)
// ─── tRPC queries ─────────────────────────────────────────────────────────
const { data: snapshots, isLoading: snapshotsLoading } = trpc.ranking.listSnapshots.useQuery(
{ roundId },
{ refetchInterval: 30_000 },
)
const latestSnapshotId = snapshots?.[0]?.id ?? null
const latestSnapshot = snapshots?.[0] ?? null
const { data: snapshot, isLoading: snapshotLoading } = trpc.ranking.getSnapshot.useQuery(
{ snapshotId: latestSnapshotId! },
{ enabled: !!latestSnapshotId },
)
const { data: projectStates } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
)
const { data: projectDetail, isLoading: detailLoading } = trpc.project.getFullDetail.useQuery(
{ id: selectedProjectId! },
{ enabled: !!selectedProjectId },
)
// ─── tRPC mutations ───────────────────────────────────────────────────────
const utils = trpc.useUtils()
const saveReorderMutation = trpc.ranking.saveReorder.useMutation({
onMutate: () => { pendingReorderCount.current++ },
onSettled: () => { pendingReorderCount.current-- },
onError: (err) => toast.error(`Failed to save order: ${err.message}`),
// Do NOT invalidate getSnapshot — would reset localOrder
})
const triggerRankMutation = trpc.ranking.triggerAutoRank.useMutation({
onSuccess: () => {
toast.success('Ranking complete. Reload to see results.')
initialized.current = false // allow re-init on next snapshot load
void utils.ranking.listSnapshots.invalidate({ roundId })
},
onError: (err) => toast.error(err.message),
})
const advanceMutation = trpc.round.advanceProjects.useMutation({
onSuccess: (data) => {
toast.success(`Advanced ${data.advancedCount} project(s) to ${data.targetRoundName}`)
void utils.roundEngine.getProjectStates.invalidate({ roundId })
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
const batchRejectMutation = trpc.roundEngine.batchTransition.useMutation({
onSuccess: (data) => {
// MEMORY.md: use .length, not direct value comparison
toast.success(`Rejected ${data.succeeded.length} project(s)`)
if (data.failed.length > 0) {
toast.warning(`${data.failed.length} project(s) could not be rejected`)
}
void utils.roundEngine.getProjectStates.invalidate({ roundId })
setAdvanceDialogOpen(false)
},
onError: (err) => toast.error(err.message),
})
// ─── rankingMap (O(1) lookup) ──────────────────────────────────────────────
const rankingMap = useMemo(() => {
const map = new Map<string, RankedProjectEntry>()
if (!snapshot) return map
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
;[...startup, ...concept].forEach((entry) => map.set(entry.projectId, entry))
return map
}, [snapshot])
// ─── projectInfoMap (O(1) lookup by projectId) ────────────────────────────
const projectInfoMap = useMemo(() => {
const map = new Map<string, ProjectInfo>()
if (!projectStates) return map
for (const ps of projectStates) {
map.set(ps.project.id, {
title: ps.project.title,
teamName: ps.project.teamName,
country: ps.project.country,
})
}
return map
}, [projectStates])
// ─── localOrder init (once, with useRef guard) ────────────────────────────
useEffect(() => {
if (!initialized.current && snapshot) {
const startup = (snapshot.startupRankingJson ?? []) as unknown as RankedProjectEntry[]
const concept = (snapshot.conceptRankingJson ?? []) as unknown as RankedProjectEntry[]
setLocalOrder({
STARTUP: startup.map((r) => r.projectId),
BUSINESS_CONCEPT: concept.map((r) => r.projectId),
})
initialized.current = true
}
}, [snapshot])
// ─── handleDragEnd ────────────────────────────────────────────────────────
function handleDragEnd(category: 'STARTUP' | 'BUSINESS_CONCEPT', event: DragEndEvent) {
const { active, over } = event
if (!over || active.id === over.id) return
setLocalOrder((prev) => {
const ids = prev[category]
const newIds = arrayMove(
ids,
ids.indexOf(active.id as string),
ids.indexOf(over.id as string),
)
saveReorderMutation.mutate({
snapshotId: latestSnapshotId!,
category,
orderedProjectIds: newIds,
})
return { ...prev, [category]: newIds }
})
}
// ─── handleAdvance ────────────────────────────────────────────────────────
function handleAdvance() {
const advanceIds = [
...localOrder.STARTUP.slice(0, topNStartup),
...localOrder.BUSINESS_CONCEPT.slice(0, topNConceptual),
]
const advanceSet = new Set(advanceIds)
advanceMutation.mutate({ roundId, projectIds: advanceIds })
if (includeReject) {
const rejectIds = [...localOrder.STARTUP, ...localOrder.BUSINESS_CONCEPT].filter(
(id) => !advanceSet.has(id),
)
if (rejectIds.length > 0) {
batchRejectMutation.mutate({ projectIds: rejectIds, roundId, newState: 'REJECTED' })
}
}
}
// ─── Loading state ────────────────────────────────────────────────────────
if (snapshotsLoading || snapshotLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-24 w-full rounded-lg" />
<Skeleton className="h-48 w-full rounded-lg" />
<Skeleton className="h-48 w-full rounded-lg" />
</div>
)
}
// ─── Empty state ──────────────────────────────────────────────────────────
if (!latestSnapshotId) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-4 py-12 text-center">
<BarChart3 className="h-10 w-10 text-muted-foreground" />
<div>
<p className="font-medium">No ranking available yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Run ranking from the Config tab to generate results, or trigger it now.
</p>
</div>
<Button
onClick={() => triggerRankMutation.mutate({ roundId })}
disabled={triggerRankMutation.isPending}
>
{triggerRankMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Run Ranking Now
</Button>
</CardContent>
</Card>
)
}
// ─── Main content ─────────────────────────────────────────────────────────
const categoryLabels: Record<'STARTUP' | 'BUSINESS_CONCEPT', string> = {
STARTUP: 'Startups',
BUSINESS_CONCEPT: 'Business Concepts',
}
return (
<>
<div className="space-y-6">
{/* Header card */}
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<CardTitle className="text-base">Latest Ranking Snapshot</CardTitle>
{latestSnapshot && (
<CardDescription className="mt-1 space-y-0.5">
<span>
Created{' '}
{new Date(latestSnapshot.createdAt).toLocaleString(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
})}
{latestSnapshot.triggeredBy?.name && ` by ${latestSnapshot.triggeredBy.name}`}
{' · '}
{latestSnapshot.triggerType}
</span>
{latestSnapshot.criteriaText && (
<span className="block truncate text-xs">
Criteria: {latestSnapshot.criteriaText.slice(0, 120)}
{latestSnapshot.criteriaText.length > 120 ? '…' : ''}
</span>
)}
</CardDescription>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => triggerRankMutation.mutate({ roundId })}
disabled={triggerRankMutation.isPending}
>
{triggerRankMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
)}
Run Ranking
</Button>
<Button
size="sm"
disabled={saveReorderMutation.isPending || advanceMutation.isPending || !latestSnapshotId}
onClick={() => setAdvanceDialogOpen(true)}
className="bg-[#053d57] hover:bg-[#053d57]/90"
>
{advanceMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...</>
) : (
<><Trophy className="h-4 w-4 mr-2" /> Advance Top N</>
)}
</Button>
</div>
</CardHeader>
</Card>
{/* Per-category sections */}
{(['STARTUP', 'BUSINESS_CONCEPT'] as const).map((category) => (
<Card key={category}>
<CardHeader>
<CardTitle className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{categoryLabels[category]}
</CardTitle>
</CardHeader>
<CardContent>
{localOrder[category].length === 0 ? (
<p className="text-sm text-muted-foreground">
No {category === 'STARTUP' ? 'startup' : 'business concept'} projects ranked.
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => handleDragEnd(category, event)}
>
<SortableContext
items={localOrder[category]}
strategy={verticalListSortingStrategy}
>
<AnimatePresence initial={false}>
<div className="space-y-2">
{localOrder[category].map((projectId, index) => (
<motion.div
key={projectId}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<SortableProjectRow
projectId={projectId}
currentRank={index + 1}
entry={rankingMap.get(projectId)}
projectInfo={projectInfoMap.get(projectId)}
onSelect={() => setSelectedProjectId(projectId)}
isSelected={selectedProjectId === projectId}
/>
</motion.div>
))}
</div>
</AnimatePresence>
</SortableContext>
</DndContext>
)}
</CardContent>
</Card>
))}
</div>
{/* Advance Top N dialog */}
<Dialog open={advanceDialogOpen} onOpenChange={setAdvanceDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Advance Top Projects</DialogTitle>
<DialogDescription>
Select how many top-ranked projects to advance to the next round per category.
Projects are advanced in the order shown in the ranking list.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Top N for STARTUP */}
{localOrder.STARTUP.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Startups to advance</Label>
<Input
type="number"
min={0}
max={localOrder.STARTUP.length}
value={topNStartup}
onChange={(e) =>
setTopNStartup(
Math.max(0, Math.min(localOrder.STARTUP.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.STARTUP.length}</span>
</div>
)}
{/* Top N for BUSINESS_CONCEPT */}
{localOrder.BUSINESS_CONCEPT.length > 0 && (
<div className="flex items-center gap-3">
<Label className="w-40 text-sm">Business concepts to advance</Label>
<Input
type="number"
min={0}
max={localOrder.BUSINESS_CONCEPT.length}
value={topNConceptual}
onChange={(e) =>
setTopNConceptual(
Math.max(0, Math.min(localOrder.BUSINESS_CONCEPT.length, parseInt(e.target.value) || 0)),
)
}
className="w-24"
/>
<span className="text-xs text-muted-foreground">of {localOrder.BUSINESS_CONCEPT.length}</span>
</div>
)}
{/* Optional: also batch-reject non-advanced */}
<div className="flex items-center gap-2 pt-2 border-t">
<input
type="checkbox"
id="includeReject"
checked={includeReject}
onChange={(e) => setIncludeReject(e.target.checked)}
className="h-4 w-4 accent-[#de0f1e]"
/>
<Label htmlFor="includeReject" className="text-sm cursor-pointer">
Also batch-reject non-advanced projects
</Label>
</div>
{/* Preview */}
<div className="text-xs text-muted-foreground bg-muted/50 rounded-md p-3">
<p>Advancing: {topNStartup + topNConceptual} projects</p>
{includeReject && (
<p>
Rejecting:{' '}
{localOrder.STARTUP.length - topNStartup + (localOrder.BUSINESS_CONCEPT.length - topNConceptual)}{' '}
projects
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAdvanceDialogOpen(false)}
disabled={advanceMutation.isPending || batchRejectMutation.isPending}
>
Cancel
</Button>
<Button
onClick={handleAdvance}
disabled={
advanceMutation.isPending ||
batchRejectMutation.isPending ||
topNStartup + topNConceptual === 0
}
className="bg-[#053d57] hover:bg-[#053d57]/90"
>
{advanceMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Advancing...
</>
) : (
`Advance ${topNStartup + topNConceptual} Project${topNStartup + topNConceptual !== 1 ? 's' : ''}`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Side panel Sheet */}
<Sheet
open={!!selectedProjectId}
onOpenChange={(open) => {
if (!open) setSelectedProjectId(null)
}}
>
<SheetContent className="w-[480px] sm:max-w-[480px] overflow-y-auto">
<SheetHeader>
<SheetTitle>{projectDetail?.project.title ?? 'Project Details'}</SheetTitle>
<SheetDescription>
{selectedProjectId ? `ID: …${selectedProjectId.slice(-8)}` : ''}
</SheetDescription>
</SheetHeader>
{detailLoading ? (
<div className="mt-6 space-y-3">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : projectDetail ? (
<div className="mt-6 space-y-6">
{/* Stats summary */}
{projectDetail.stats && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Avg Score</p>
<p className="mt-1 text-lg font-semibold">
{projectDetail.stats.averageGlobalScore?.toFixed(1) ?? '—'}
</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Pass Rate</p>
<p className="mt-1 text-lg font-semibold">
{projectDetail.stats.totalEvaluations > 0
? `${Math.round((projectDetail.stats.yesVotes / projectDetail.stats.totalEvaluations) * 100)}%`
: '—'}
</p>
</div>
<div className="rounded-lg border p-3 text-center">
<p className="text-xs text-muted-foreground">Evaluators</p>
<p className="mt-1 text-lg font-semibold">
{projectDetail.stats.totalEvaluations}
</p>
</div>
</div>
)}
{/* Per-juror evaluations */}
<div>
<h4 className="mb-3 text-sm font-semibold">Juror Evaluations</h4>
{(() => {
const submitted = projectDetail.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED' && a.round.id === roundId,
)
if (submitted.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No submitted evaluations for this round.
</p>
)
}
return (
<div className="space-y-3">
{submitted.map((a) => (
<div key={a.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium truncate">{a.user.name ?? a.user.email}</p>
<div className="flex items-center gap-2 flex-shrink-0">
{a.evaluation?.globalScore !== null && a.evaluation?.globalScore !== undefined && (
<Badge variant="outline" className="text-xs">
Score: {a.evaluation.globalScore.toFixed(1)}
</Badge>
)}
{a.evaluation?.binaryDecision !== null && a.evaluation?.binaryDecision !== undefined && (
<Badge
className={cn(
'text-xs',
a.evaluation.binaryDecision
? 'bg-green-100 text-green-700 hover:bg-green-100'
: 'bg-red-100 text-red-700 hover:bg-red-100',
)}
>
{a.evaluation.binaryDecision ? 'Yes' : 'No'}
</Badge>
)}
</div>
</div>
{a.evaluation?.feedbackText && (
<p className="text-xs text-muted-foreground leading-relaxed">
{a.evaluation.feedbackText}
</p>
)}
</div>
))}
</div>
)
})()}
</div>
</div>
) : null}
</SheetContent>
</Sheet>
</>
)
}

View File

@@ -1,64 +0,0 @@
'use client'
import { useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
export type ScoreDistributionProps = {
roundId: string
}
export function ScoreDistribution({ roundId }: ScoreDistributionProps) {
const { data: dist, isLoading } = trpc.analytics.getRoundScoreDistribution.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
)
const maxCount = useMemo(() =>
dist ? Math.max(...dist.globalDistribution.map((b) => b.count), 1) : 1,
[dist])
return (
<Card className="flex flex-col">
<CardHeader>
<CardTitle className="text-base">Score Distribution</CardTitle>
<CardDescription>
{dist ? `${dist.totalEvaluations} evaluations \u2014 avg ${dist.averageGlobalScore.toFixed(1)}` : 'Loading...'}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col flex-1 pb-4">
{isLoading ? (
<div className="flex items-end gap-1 flex-1 min-h-[120px]">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="flex-1 h-full" />)}
</div>
) : !dist || dist.totalEvaluations === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No evaluations submitted yet
</p>
) : (
<div className="flex gap-1 h-[160px]">
{dist.globalDistribution.map((bucket) => {
const heightPct = (bucket.count / maxCount) * 100
return (
<div key={bucket.score} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[9px] text-muted-foreground">{bucket.count || ''}</span>
<div className="w-full flex-1 relative min-h-0">
<div className={cn(
'absolute inset-x-0 bottom-0 rounded-t transition-all',
bucket.score <= 3 ? 'bg-red-400' :
bucket.score <= 6 ? 'bg-amber-400' :
'bg-emerald-400',
)} style={{ height: `${Math.max(heightPct, 4)}%` }} />
</div>
<span className="text-[10px] text-muted-foreground">{bucket.score}</span>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,654 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Plus, Lock, Unlock, LockKeyhole, Loader2, Pencil, Trash2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { format } from 'date-fns'
type SubmissionWindowManagerProps = {
competitionId: string
roundId: string
}
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingWindow, setEditingWindow] = useState<string | null>(null)
const [deletingWindow, setDeletingWindow] = useState<string | null>(null)
// Create form state
const [createForm, setCreateForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
})
// Edit form state
const [editForm, setEditForm] = useState({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE' as 'HARD_DEADLINE' | 'FLAG' | 'GRACE',
graceHours: 0,
lockOnClose: true,
sortOrder: 1,
})
const utils = trpc.useUtils()
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window created')
setIsCreateOpen(false)
// Reset form
setCreateForm({
name: '',
slug: '',
roundNumber: 1,
windowOpenAt: '',
windowCloseAt: '',
deadlinePolicy: 'HARD_DEADLINE',
graceHours: 0,
lockOnClose: true,
})
},
onError: (err) => toast.error(err.message),
})
const updateWindowMutation = trpc.round.updateSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window updated')
setEditingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const deleteWindowMutation = trpc.round.deleteSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window deleted')
setDeletingWindow(null)
},
onError: (err) => toast.error(err.message),
})
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window opened')
},
onError: (err) => toast.error(err.message),
})
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window closed')
},
onError: (err) => toast.error(err.message),
})
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window locked')
},
onError: (err) => toast.error(err.message),
})
const handleCreateNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setCreateForm({ ...createForm, name: value, slug: autoSlug })
}
const handleEditNameChange = (value: string) => {
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setEditForm({ ...editForm, name: value, slug: autoSlug })
}
const handleCreate = () => {
if (!createForm.name || !createForm.slug) {
toast.error('Name and slug are required')
return
}
createWindowMutation.mutate({
competitionId,
name: createForm.name,
slug: createForm.slug,
roundNumber: createForm.roundNumber,
windowOpenAt: createForm.windowOpenAt ? new Date(createForm.windowOpenAt) : undefined,
windowCloseAt: createForm.windowCloseAt ? new Date(createForm.windowCloseAt) : undefined,
deadlinePolicy: createForm.deadlinePolicy,
graceHours: createForm.deadlinePolicy === 'GRACE' ? createForm.graceHours : undefined,
lockOnClose: createForm.lockOnClose,
})
}
const handleEdit = () => {
if (!editingWindow) return
if (!editForm.name || !editForm.slug) {
toast.error('Name and slug are required')
return
}
updateWindowMutation.mutate({
id: editingWindow,
name: editForm.name,
slug: editForm.slug,
roundNumber: editForm.roundNumber,
windowOpenAt: editForm.windowOpenAt ? new Date(editForm.windowOpenAt) : null,
windowCloseAt: editForm.windowCloseAt ? new Date(editForm.windowCloseAt) : null,
deadlinePolicy: editForm.deadlinePolicy,
graceHours: editForm.deadlinePolicy === 'GRACE' ? editForm.graceHours : null,
lockOnClose: editForm.lockOnClose,
sortOrder: editForm.sortOrder,
})
}
const handleDelete = () => {
if (!deletingWindow) return
deleteWindowMutation.mutate({ id: deletingWindow })
}
const openEditDialog = (window: any) => {
setEditForm({
name: window.name,
slug: window.slug,
roundNumber: window.roundNumber,
windowOpenAt: window.windowOpenAt ? new Date(window.windowOpenAt).toISOString().slice(0, 16) : '',
windowCloseAt: window.windowCloseAt ? new Date(window.windowCloseAt).toISOString().slice(0, 16) : '',
deadlinePolicy: window.deadlinePolicy ?? 'HARD_DEADLINE',
graceHours: window.graceHours ?? 0,
lockOnClose: window.lockOnClose ?? true,
sortOrder: window.sortOrder ?? 1,
})
setEditingWindow(window.id)
}
const formatDate = (date: Date | null | undefined) => {
if (!date) return 'Not set'
return format(new Date(date), 'MMM d, yyyy h:mm a')
}
const windows = competition?.submissionWindows ?? []
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base">Submission Windows</CardTitle>
<p className="text-sm text-muted-foreground">
File upload windows for this round
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
Create Window
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="create-name">Window Name</Label>
<Input
id="create-name"
placeholder="e.g., Round 1 Submissions"
value={createForm.name}
onChange={(e) => handleCreateNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-slug">Slug</Label>
<Input
id="create-slug"
placeholder="e.g., round-1-submissions"
value={createForm.slug}
onChange={(e) => setCreateForm({ ...createForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-roundNumber">Round Number</Label>
<Input
id="create-roundNumber"
type="number"
min={1}
value={createForm.roundNumber}
onChange={(e) => setCreateForm({ ...createForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowOpenAt">Window Open At</Label>
<Input
id="create-windowOpenAt"
type="datetime-local"
value={createForm.windowOpenAt}
onChange={(e) => setCreateForm({ ...createForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-windowCloseAt">Window Close At</Label>
<Input
id="create-windowCloseAt"
type="datetime-local"
value={createForm.windowCloseAt}
onChange={(e) => setCreateForm({ ...createForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-deadlinePolicy">Deadline Policy</Label>
<Select
value={createForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setCreateForm({ ...createForm, deadlinePolicy: value })
}
>
<SelectTrigger id="create-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{createForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="create-graceHours">Grace Hours</Label>
<Input
id="create-graceHours"
type="number"
min={0}
value={createForm.graceHours}
onChange={(e) => setCreateForm({ ...createForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="create-lockOnClose"
checked={createForm.lockOnClose}
onCheckedChange={(checked) => setCreateForm({ ...createForm, lockOnClose: checked })}
/>
<Label htmlFor="create-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleCreate}
disabled={createWindowMutation.isPending}
>
{createWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading windows...
</div>
) : windows.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No submission windows yet. Create one to enable file uploads.
</div>
) : (
<div className="space-y-2">
{windows.map((window) => {
const isPending = !window.windowOpenAt
const isOpen = window.windowOpenAt && !window.windowCloseAt
const isClosed = window.windowCloseAt && !window.isLocked
const isLocked = window.isLocked
return (
<div
key={window.id}
className="flex flex-col gap-3 border rounded-lg p-3"
>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{window.name}</p>
{isPending && (
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
Pending
</Badge>
)}
{isOpen && (
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
Open
</Badge>
)}
{isClosed && (
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
Closed
</Badge>
)}
{isLocked && (
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
Locked
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Round {window.roundNumber}</span>
<span></span>
<span>{window._count.fileRequirements} requirements</span>
<span></span>
<span>{window._count.projectFiles} files</span>
</div>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Open: {formatDate(window.windowOpenAt)}</span>
<span></span>
<span>Close: {formatDate(window.windowCloseAt)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 flex-wrap">
<Button
size="sm"
variant="ghost"
onClick={() => openEditDialog(window)}
className="h-8 px-2"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setDeletingWindow(window.id)}
className="h-8 px-2 text-destructive hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
{isPending && (
<Button
size="sm"
variant="outline"
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
disabled={openWindowMutation.isPending}
>
{openWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Unlock className="h-3 w-3 mr-1" />
)}
Open
</Button>
)}
{isOpen && (
<Button
size="sm"
variant="outline"
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
disabled={closeWindowMutation.isPending}
>
{closeWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Lock className="h-3 w-3 mr-1" />
)}
Close
</Button>
)}
{isClosed && (
<Button
size="sm"
variant="outline"
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
disabled={lockWindowMutation.isPending}
>
{lockWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<LockKeyhole className="h-3 w-3 mr-1" />
)}
Lock
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingWindow} onOpenChange={(open) => !open && setEditingWindow(null)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Window Name</Label>
<Input
id="edit-name"
placeholder="e.g., Round 1 Submissions"
value={editForm.name}
onChange={(e) => handleEditNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-slug">Slug</Label>
<Input
id="edit-slug"
placeholder="e.g., round-1-submissions"
value={editForm.slug}
onChange={(e) => setEditForm({ ...editForm, slug: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-roundNumber">Round Number</Label>
<Input
id="edit-roundNumber"
type="number"
min={1}
value={editForm.roundNumber}
onChange={(e) => setEditForm({ ...editForm, roundNumber: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowOpenAt">Window Open At</Label>
<Input
id="edit-windowOpenAt"
type="datetime-local"
value={editForm.windowOpenAt}
onChange={(e) => setEditForm({ ...editForm, windowOpenAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-windowCloseAt">Window Close At</Label>
<Input
id="edit-windowCloseAt"
type="datetime-local"
value={editForm.windowCloseAt}
onChange={(e) => setEditForm({ ...editForm, windowCloseAt: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-deadlinePolicy">Deadline Policy</Label>
<Select
value={editForm.deadlinePolicy}
onValueChange={(value: 'HARD_DEADLINE' | 'FLAG' | 'GRACE') =>
setEditForm({ ...editForm, deadlinePolicy: value })
}
>
<SelectTrigger id="edit-deadlinePolicy">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD_DEADLINE">Hard Deadline</SelectItem>
<SelectItem value="FLAG">Flag Late Submissions</SelectItem>
<SelectItem value="GRACE">Grace Period</SelectItem>
</SelectContent>
</Select>
</div>
{editForm.deadlinePolicy === 'GRACE' && (
<div className="space-y-2">
<Label htmlFor="edit-graceHours">Grace Hours</Label>
<Input
id="edit-graceHours"
type="number"
min={0}
value={editForm.graceHours}
onChange={(e) => setEditForm({ ...editForm, graceHours: parseInt(e.target.value, 10) || 0 })}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
id="edit-lockOnClose"
checked={editForm.lockOnClose}
onCheckedChange={(checked) => setEditForm({ ...editForm, lockOnClose: checked })}
/>
<Label htmlFor="edit-lockOnClose" className="cursor-pointer">
Lock window on close
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="edit-sortOrder">Sort Order</Label>
<Input
id="edit-sortOrder"
type="number"
min={1}
value={editForm.sortOrder}
onChange={(e) => setEditForm({ ...editForm, sortOrder: parseInt(e.target.value, 10) || 1 })}
/>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setEditingWindow(null)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleEdit}
disabled={updateWindowMutation.isPending}
>
{updateWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Save Changes
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deletingWindow} onOpenChange={(open) => !open && setDeletingWindow(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Submission Window</DialogTitle>
<DialogDescription>
Are you sure you want to delete this submission window? This action cannot be undone.
{(windows.find(w => w.id === deletingWindow)?._count?.projectFiles ?? 0) > 0 && (
<span className="block mt-2 text-destructive font-medium">
Warning: This window has uploaded files and cannot be deleted until they are removed.
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button
variant="outline"
onClick={() => setDeletingWindow(null)}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleteWindowMutation.isPending}
>
{deleteWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More