Compare commits
1 Commits
6b40fe7726
...
with-test
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e70de3a5a |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -58,7 +58,3 @@ build-output.txt
|
||||
# Misc
|
||||
*.log
|
||||
.vercel
|
||||
|
||||
# Private keys and secrets
|
||||
private/
|
||||
public/build-id.json
|
||||
|
||||
@@ -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*
|
||||
@@ -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*
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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*
|
||||
@@ -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 99–114) 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 361–389 and 421–438
|
||||
- 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 13–18, `src/app/api/auth/[...nextauth]/route.ts` lines 7–10, `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 `!==` (Non–Timing-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 28–29
|
||||
- Current mitigation: Production throws an error if credentials are missing (line 20–22). 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 355–390
|
||||
- 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 57–64
|
||||
- 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 184–194
|
||||
- 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 1637–1646 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*
|
||||
@@ -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*
|
||||
@@ -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 (0–10), 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*
|
||||
@@ -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*
|
||||
@@ -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*
|
||||
@@ -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*
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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*
|
||||
@@ -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*
|
||||
@@ -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*
|
||||
@@ -23,9 +23,9 @@ COPY . .
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build Next.js — mount .next/cache as a Docker build cache for faster rebuilds
|
||||
# Build Next.js
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN --mount=type=cache,target=/app/.next/cache npm run build
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
@@ -69,8 +69,5 @@ EXPOSE 7600
|
||||
ENV PORT=7600
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD wget -qO- http://localhost:7600/api/health || exit 1
|
||||
|
||||
# Run via entrypoint (migrate then start)
|
||||
CMD ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
image: postgres:16-alpine
|
||||
container_name: mopc-postgres-dev
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "5432:5432"
|
||||
environment:
|
||||
- POSTGRES_USER=${POSTGRES_USER:-mopc}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}?connection_limit=10&pool_timeout=30
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
|
||||
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}
|
||||
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc?connection_limit=10&pool_timeout=30
|
||||
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
|
||||
- NEXTAUTH_URL=${NEXTAUTH_URL}
|
||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
- AUTH_SECRET=${NEXTAUTH_SECRET}
|
||||
@@ -50,7 +50,6 @@ services:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- mopc-network
|
||||
- minio-external
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:7600/api/health').then(r=>{if(!r.ok)throw r;process.exit(0)}).catch(()=>process.exit(1))"]
|
||||
interval: 30s
|
||||
@@ -83,6 +82,3 @@ volumes:
|
||||
networks:
|
||||
mopc-network:
|
||||
driver: bridge
|
||||
minio-external:
|
||||
external: true
|
||||
name: minio_mopc-minio
|
||||
|
||||
@@ -33,44 +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."
|
||||
|
||||
# Sync team lead links only if there are unlinked submitters
|
||||
UNLINKED_COUNT=$(node -e "
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const p = new PrismaClient();
|
||||
p.\$queryRaw\`
|
||||
SELECT COUNT(*)::int AS c FROM \"Project\" p
|
||||
WHERE p.\"submittedByUserId\" IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM \"TeamMember\" tm
|
||||
WHERE tm.\"projectId\" = p.id AND tm.\"userId\" = p.\"submittedByUserId\"
|
||||
)
|
||||
\`.then(r => { console.log(r[0].c); p.\$disconnect(); }).catch(() => { console.log('0'); p.\$disconnect(); });
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "$UNLINKED_COUNT" != "0" ]; then
|
||||
echo "==> Syncing ${UNLINKED_COUNT} unlinked team lead links..."
|
||||
npx tsx prisma/seed-team-leads.ts || echo "WARNING: Team lead sync failed."
|
||||
else
|
||||
echo "==> Team lead links already synced, skipping."
|
||||
fi
|
||||
|
||||
echo "==> Starting application..."
|
||||
|
||||
# Graceful shutdown: forward SIGTERM/SIGINT to the Node process
|
||||
# so in-flight requests can complete before the container exits.
|
||||
shutdown() {
|
||||
echo "==> Received shutdown signal, stopping gracefully..."
|
||||
kill -TERM "$NODE_PID" 2>/dev/null
|
||||
wait "$NODE_PID"
|
||||
exit $?
|
||||
}
|
||||
|
||||
trap shutdown TERM INT
|
||||
|
||||
node server.js &
|
||||
NODE_PID=$!
|
||||
wait "$NODE_PID"
|
||||
exec node server.js
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -2,20 +2,10 @@ import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
typedRoutes: true,
|
||||
serverExternalPackages: ['@prisma/client', 'minio'],
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
'lucide-react',
|
||||
'sonner',
|
||||
'date-fns',
|
||||
'recharts',
|
||||
'motion/react',
|
||||
'zod',
|
||||
'@radix-ui/react-icons',
|
||||
],
|
||||
optimizePackageImports: ['lucide-react'],
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@@ -50,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,
|
||||
},
|
||||
]
|
||||
|
||||
2301
package-lock.json
generated
2301
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"prebuild": "node -e \"require('fs').writeFileSync('public/build-id.json', JSON.stringify({buildId: Date.now().toString()}))\"",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
@@ -25,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",
|
||||
@@ -96,7 +97,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-grab/mcp": "^0.1.25",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/node": "^25.0.10",
|
||||
@@ -111,7 +111,6 @@
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"prisma": "^6.19.2",
|
||||
"react-grab": "^0.1.25",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.2",
|
||||
|
||||
@@ -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");
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "nationality" TEXT;
|
||||
ALTER TABLE "User" ADD COLUMN "institution" TEXT;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "RankingMode" ADD VALUE 'FORMULA';
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "AwardEligibility" ADD COLUMN "notifiedAt" TIMESTAMP(3);
|
||||
@@ -1,79 +0,0 @@
|
||||
-- Round finalization fields
|
||||
ALTER TABLE "Round" ADD COLUMN "gracePeriodEndsAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Round" ADD COLUMN "finalizedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Round" ADD COLUMN "finalizedBy" TEXT;
|
||||
|
||||
-- ProjectRoundState proposed outcome for finalization pool
|
||||
ALTER TABLE "ProjectRoundState" ADD COLUMN "proposedOutcome" "ProjectRoundStateValue";
|
||||
|
||||
-- Mark already-closed rounds as pre-finalized IF their projects were already
|
||||
-- advanced to the IMMEDIATELY NEXT round (sortOrder = current + 1).
|
||||
-- We check the next sequential round only, not any subsequent round, because
|
||||
-- projects can appear in non-adjacent rounds (e.g. special award tracks) without
|
||||
-- implying the current round was finalized.
|
||||
UPDATE "Round" r
|
||||
SET "finalizedAt" = NOW(), "finalizedBy" = 'system-migration'
|
||||
WHERE r.status IN ('ROUND_CLOSED', 'ROUND_ARCHIVED')
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "Round" next_r
|
||||
JOIN "ProjectRoundState" next_prs ON next_prs."roundId" = next_r.id
|
||||
JOIN "ProjectRoundState" cur_prs ON cur_prs."roundId" = r.id
|
||||
AND cur_prs."projectId" = next_prs."projectId"
|
||||
WHERE next_r."competitionId" = r."competitionId"
|
||||
AND next_r."sortOrder" = r."sortOrder" + 1
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
-- ─── Backfill terminal states for already-finalized rounds ───────────────────
|
||||
-- These rounds were finalized manually before this system existed.
|
||||
-- Set ProjectRoundState to accurate terminal states so the data matches reality.
|
||||
-- All updates are guarded by current state + round type to avoid touching anything unexpected.
|
||||
|
||||
-- R0 (INTAKE, closed): All 214 projects completed intake successfully → PASSED
|
||||
-- Guard: only touch COMPLETED states in closed INTAKE rounds marked as finalized
|
||||
UPDATE "ProjectRoundState" prs
|
||||
SET state = 'PASSED', "proposedOutcome" = 'PASSED'
|
||||
FROM "Round" r
|
||||
WHERE prs."roundId" = r.id
|
||||
AND r."roundType" = 'INTAKE'
|
||||
AND r.status = 'ROUND_CLOSED'
|
||||
AND r."finalizedAt" IS NOT NULL
|
||||
AND prs.state = 'COMPLETED';
|
||||
|
||||
-- R1 (FILTERING, closed): Set states based on FilteringResult outcomes
|
||||
-- Projects that passed filtering → PASSED
|
||||
UPDATE "ProjectRoundState" prs
|
||||
SET state = 'PASSED', "proposedOutcome" = 'PASSED'
|
||||
FROM "Round" r
|
||||
WHERE prs."roundId" = r.id
|
||||
AND r."roundType" = 'FILTERING'
|
||||
AND r.status = 'ROUND_CLOSED'
|
||||
AND r."finalizedAt" IS NOT NULL
|
||||
AND prs.state = 'PENDING'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "FilteringResult" fr
|
||||
WHERE fr."projectId" = prs."projectId"
|
||||
AND (
|
||||
fr."finalOutcome" = 'PASSED'
|
||||
OR (fr."finalOutcome" IS NULL AND fr.outcome IN ('PASSED', 'FLAGGED'))
|
||||
)
|
||||
);
|
||||
|
||||
-- Projects that were filtered out → REJECTED
|
||||
UPDATE "ProjectRoundState" prs
|
||||
SET state = 'REJECTED', "proposedOutcome" = 'REJECTED'
|
||||
FROM "Round" r
|
||||
WHERE prs."roundId" = r.id
|
||||
AND r."roundType" = 'FILTERING'
|
||||
AND r.status = 'ROUND_CLOSED'
|
||||
AND r."finalizedAt" IS NOT NULL
|
||||
AND prs.state = 'PENDING'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "FilteringResult" fr
|
||||
WHERE fr."projectId" = prs."projectId"
|
||||
AND (
|
||||
fr."finalOutcome" = 'FILTERED_OUT'
|
||||
OR (fr."finalOutcome" IS NULL AND fr.outcome = 'FILTERED_OUT')
|
||||
)
|
||||
);
|
||||
@@ -1,31 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "NotificationLog" DROP CONSTRAINT "NotificationLog_userId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "NotificationLog" ADD COLUMN "batchId" TEXT,
|
||||
ADD COLUMN "email" TEXT,
|
||||
ADD COLUMN "projectId" TEXT,
|
||||
ADD COLUMN "roundId" TEXT,
|
||||
ALTER COLUMN "userId" DROP NOT NULL,
|
||||
ALTER COLUMN "channel" SET DEFAULT 'EMAIL';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationLog_roundId_type_idx" ON "NotificationLog"("roundId", "type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationLog_projectId_idx" ON "NotificationLog"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationLog_batchId_idx" ON "NotificationLog"("batchId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationLog_email_idx" ON "NotificationLog"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NotificationLog" ADD CONSTRAINT "NotificationLog_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -1,6 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "passwordResetToken" TEXT,
|
||||
ADD COLUMN "passwordResetExpiresAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_passwordResetToken_key" ON "User"("passwordResetToken");
|
||||
@@ -1,20 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "role" SET DEFAULT 'APPLICANT';
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Assignment_roundId_isCompleted_idx" ON "Assignment"("roundId", "isCompleted");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConflictOfInterest_projectId_idx" ON "ConflictOfInterest"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ConflictOfInterest_userId_hasConflict_idx" ON "ConflictOfInterest"("userId", "hasConflict");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NotificationLog_type_status_idx" ON "NotificationLog"("type", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ProjectRoundState_roundId_state_idx" ON "ProjectRoundState"("roundId", "state");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RankingSnapshot_roundId_createdAt_idx" ON "RankingSnapshot"("roundId", "createdAt");
|
||||
@@ -1,35 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AdvancementRule" DROP CONSTRAINT "AdvancementRule_roundId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_approvedById_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "AssignmentException" DROP CONSTRAINT "AssignmentException_assignmentId_fkey";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ConflictOfInterest" DROP COLUMN "roundId";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Evaluation" DROP COLUMN "version";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" DROP COLUMN "roundId";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "AdvancementRule";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "AssignmentException";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "NotificationPolicy";
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE "OverrideAction";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "AdvancementRuleType";
|
||||
|
||||
-- DropEnum
|
||||
DROP TYPE "OverrideReasonCode";
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -11,10 +11,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
// connection_limit and pool_timeout are set via query params in DATABASE_URL:
|
||||
// ?connection_limit=10&pool_timeout=30
|
||||
// Defaults: connection_limit = num_cpus * 2 + 1, pool_timeout = 10s.
|
||||
// Override in .env for production to prevent connection exhaustion.
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
@@ -119,6 +115,19 @@ enum NotificationChannel {
|
||||
NONE
|
||||
}
|
||||
|
||||
enum ResourceType {
|
||||
PDF
|
||||
VIDEO
|
||||
DOCUMENT
|
||||
LINK
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum CohortLevel {
|
||||
ALL
|
||||
SEMIFINALIST
|
||||
FINALIST
|
||||
}
|
||||
|
||||
enum PartnerVisibility {
|
||||
ADMIN_ONLY
|
||||
@@ -134,6 +143,13 @@ enum PartnerType {
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum OverrideReasonCode {
|
||||
DATA_CORRECTION
|
||||
POLICY_EXCEPTION
|
||||
JURY_CONFLICT
|
||||
SPONSOR_DECISION
|
||||
ADMIN_DISCRETION
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPETITION / ROUND ENGINE ENUMS
|
||||
@@ -172,6 +188,13 @@ enum ProjectRoundStateValue {
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
enum AdvancementRuleType {
|
||||
AUTO_ADVANCE
|
||||
SCORE_THRESHOLD
|
||||
TOP_N
|
||||
ADMIN_SELECTION
|
||||
AI_RECOMMENDED
|
||||
}
|
||||
|
||||
enum CapMode {
|
||||
HARD
|
||||
@@ -292,14 +315,11 @@ model User {
|
||||
email String @unique
|
||||
name String?
|
||||
emailVerified DateTime? // Required by NextAuth Prisma adapter
|
||||
role UserRole @default(APPLICANT)
|
||||
roles UserRole[] @default([])
|
||||
role UserRole @default(JURY_MEMBER)
|
||||
status UserStatus @default(INVITED)
|
||||
expertiseTags String[] @default([])
|
||||
maxAssignments Int? // Per-round limit
|
||||
country String? // User's home country (for mentor matching)
|
||||
nationality String? // User's nationality (for applicant profiles)
|
||||
institution String? // User's institution/organization
|
||||
metadataJson Json? @db.JsonB
|
||||
|
||||
// Profile
|
||||
@@ -325,15 +345,14 @@ model User {
|
||||
inviteToken String? @unique
|
||||
inviteTokenExpiresAt DateTime?
|
||||
|
||||
// Password reset token
|
||||
passwordResetToken String? @unique
|
||||
passwordResetExpiresAt DateTime?
|
||||
|
||||
// Digest & availability preferences
|
||||
digestFrequency String @default("none") // 'none' | 'daily' | 'weekly'
|
||||
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?
|
||||
@@ -417,12 +436,10 @@ model User {
|
||||
mentorFileComments MentorFileComment[] @relation("MentorFileCommentAuthor")
|
||||
resultLocksCreated ResultLock[] @relation("ResultLockCreator")
|
||||
resultUnlockEvents ResultUnlockEvent[] @relation("ResultUnlocker")
|
||||
assignmentExceptionsApproved AssignmentException[] @relation("AssignmentExceptionApprover")
|
||||
submissionPromotions SubmissionPromotionEvent[] @relation("SubmissionPromoter")
|
||||
deliberationReplacements DeliberationParticipant[] @relation("DeliberationReplacement")
|
||||
|
||||
// AI Ranking
|
||||
rankingSnapshots RankingSnapshot[] @relation("TriggeredRankingSnapshots")
|
||||
|
||||
@@index([role])
|
||||
@@index([status])
|
||||
}
|
||||
@@ -480,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
|
||||
|
||||
@@ -548,6 +568,7 @@ model EvaluationForm {
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
programId String
|
||||
roundId String?
|
||||
status ProjectStatus @default(SUBMITTED)
|
||||
|
||||
// Core fields
|
||||
@@ -603,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
|
||||
|
||||
@@ -630,7 +654,6 @@ model Project {
|
||||
deliberationVotes DeliberationVote[]
|
||||
deliberationResults DeliberationResult[]
|
||||
submissionPromotions SubmissionPromotionEvent[]
|
||||
notificationLogs NotificationLog[]
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@ -747,6 +770,7 @@ model Assignment {
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
evaluation Evaluation?
|
||||
conflictOfInterest ConflictOfInterest?
|
||||
exceptions AssignmentException[]
|
||||
|
||||
@@unique([userId, projectId, roundId])
|
||||
@@index([roundId])
|
||||
@@ -755,7 +779,6 @@ model Assignment {
|
||||
@@index([isCompleted])
|
||||
@@index([projectId, userId])
|
||||
@@index([juryGroupId])
|
||||
@@index([roundId, isCompleted])
|
||||
}
|
||||
|
||||
model Evaluation {
|
||||
@@ -773,6 +796,11 @@ model Evaluation {
|
||||
binaryDecision Boolean? // Yes/No for semi-finalist
|
||||
feedbackText String? @db.Text
|
||||
|
||||
// Versioning (currently unused - evaluations are updated in-place.
|
||||
// TODO: Implement proper versioning by creating new rows on re-submission
|
||||
// if version history is needed for audit purposes)
|
||||
version Int @default(1)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -919,35 +947,22 @@ model AIUsageLog {
|
||||
|
||||
model NotificationLog {
|
||||
id String @id @default(cuid())
|
||||
userId String?
|
||||
channel NotificationChannel @default(EMAIL)
|
||||
userId String
|
||||
channel NotificationChannel
|
||||
provider String? // META, TWILIO, SMTP
|
||||
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION, ADVANCEMENT_NOTIFICATION, etc.
|
||||
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION
|
||||
status String // PENDING, SENT, DELIVERED, FAILED
|
||||
externalId String? // Message ID from provider
|
||||
errorMsg String? @db.Text
|
||||
|
||||
// Bulk notification tracking
|
||||
email String? // Recipient email address
|
||||
roundId String?
|
||||
projectId String?
|
||||
batchId String? // Groups emails from same send operation
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
round Round? @relation(fields: [roundId], references: [id], onDelete: SetNull)
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([createdAt])
|
||||
@@index([roundId, type])
|
||||
@@index([projectId])
|
||||
@@index([batchId])
|
||||
@@index([email])
|
||||
@@index([type, status])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -1004,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?
|
||||
@@ -1018,9 +1034,6 @@ model LearningResource {
|
||||
bucket String?
|
||||
objectKey String?
|
||||
|
||||
// Cover image (stored in MinIO)
|
||||
coverImageKey String?
|
||||
|
||||
// External link
|
||||
externalUrl String?
|
||||
|
||||
@@ -1037,6 +1050,7 @@ model LearningResource {
|
||||
accessLogs ResourceAccess[]
|
||||
|
||||
@@index([programId])
|
||||
@@index([cohortLevel])
|
||||
@@index([isPublished])
|
||||
@@index([sortOrder])
|
||||
}
|
||||
@@ -1411,76 +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
|
||||
FORMULA // Formula-only: no LLM, pure math ranking
|
||||
}
|
||||
|
||||
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])
|
||||
@@index([roundId, createdAt])
|
||||
}
|
||||
|
||||
// Tracks progress of long-running AI tagging jobs
|
||||
model TaggingJob {
|
||||
id String @id @default(cuid())
|
||||
@@ -1624,9 +1568,6 @@ model AwardEligibility {
|
||||
confirmedAt DateTime?
|
||||
confirmedBy String?
|
||||
|
||||
// Pool notification tracking
|
||||
notifiedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1707,6 +1648,7 @@ model ConflictOfInterest {
|
||||
assignmentId String @unique
|
||||
userId String
|
||||
projectId String
|
||||
roundId String? // Legacy — kept for historical data
|
||||
hasConflict Boolean @default(false)
|
||||
conflictType String? // "financial", "personal", "organizational", "other"
|
||||
description String? @db.Text
|
||||
@@ -1724,8 +1666,6 @@ model ConflictOfInterest {
|
||||
|
||||
@@index([userId])
|
||||
@@index([hasConflict])
|
||||
@@index([projectId])
|
||||
@@index([userId, hasConflict])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -2088,6 +2028,24 @@ model LiveProgressCursor {
|
||||
@@index([sessionId])
|
||||
}
|
||||
|
||||
model OverrideAction {
|
||||
id String @id @default(cuid())
|
||||
entityType String // ProjectRoundState, FilteringResult, AwardEligibility, etc.
|
||||
entityId String
|
||||
previousValue Json? @db.JsonB
|
||||
newValueJson Json @db.JsonB
|
||||
reasonCode OverrideReasonCode
|
||||
reasonText String? @db.Text
|
||||
actorId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([entityType, entityId])
|
||||
@@index([actorId])
|
||||
@@index([reasonCode])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model DecisionAuditLog {
|
||||
id String @id @default(cuid())
|
||||
eventType String // stage.transitioned, routing.executed, filtering.completed, etc.
|
||||
@@ -2105,6 +2063,21 @@ model DecisionAuditLog {
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model NotificationPolicy {
|
||||
id String @id @default(cuid())
|
||||
eventType String @unique // stage.transitioned, filtering.completed, etc.
|
||||
channel String @default("EMAIL") // EMAIL, IN_APP, BOTH, NONE
|
||||
templateId String? // Optional reference to MessageTemplate
|
||||
isActive Boolean @default(true)
|
||||
configJson Json? @db.JsonB // Additional config (delay, batch, etc.)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([eventType])
|
||||
@@index([isActive])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPETITION / ROUND ENGINE MODELS (NEW — coexists with Pipeline/Track/Stage)
|
||||
// =============================================================================
|
||||
@@ -2126,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
|
||||
|
||||
@@ -2140,6 +2116,7 @@ model Competition {
|
||||
|
||||
@@index([programId])
|
||||
@@index([status])
|
||||
@@index([isTest])
|
||||
}
|
||||
|
||||
model Round {
|
||||
@@ -2166,11 +2143,6 @@ model Round {
|
||||
submissionWindowId String?
|
||||
specialAwardId String?
|
||||
|
||||
// Finalization
|
||||
gracePeriodEndsAt DateTime?
|
||||
finalizedAt DateTime?
|
||||
finalizedBy String?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -2180,6 +2152,7 @@ model Round {
|
||||
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
|
||||
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
|
||||
projectRoundStates ProjectRoundState[]
|
||||
advancementRules AdvancementRule[]
|
||||
visibleSubmissionWindows RoundSubmissionVisibility[]
|
||||
assignmentIntents AssignmentIntent[]
|
||||
deliberationSessions DeliberationSession[]
|
||||
@@ -2197,12 +2170,10 @@ model Round {
|
||||
filteringResults FilteringResult[]
|
||||
filteringJobs FilteringJob[]
|
||||
assignmentJobs AssignmentJob[]
|
||||
rankingSnapshots RankingSnapshot[] @relation("RoundRankingSnapshots")
|
||||
reminderLogs ReminderLog[]
|
||||
evaluationSummaries EvaluationSummary[]
|
||||
evaluationDiscussions EvaluationDiscussion[]
|
||||
messages Message[]
|
||||
notificationLogs NotificationLog[]
|
||||
cohorts Cohort[]
|
||||
liveCursor LiveProgressCursor?
|
||||
|
||||
@@ -2215,14 +2186,13 @@ model Round {
|
||||
}
|
||||
|
||||
model ProjectRoundState {
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
roundId String
|
||||
state ProjectRoundStateValue @default(PENDING)
|
||||
proposedOutcome ProjectRoundStateValue?
|
||||
enteredAt DateTime @default(now())
|
||||
exitedAt DateTime?
|
||||
metadataJson Json? @db.JsonB
|
||||
id String @id @default(cuid())
|
||||
projectId String
|
||||
roundId String
|
||||
state ProjectRoundStateValue @default(PENDING)
|
||||
enteredAt DateTime @default(now())
|
||||
exitedAt DateTime?
|
||||
metadataJson Json? @db.JsonB
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2235,7 +2205,24 @@ model ProjectRoundState {
|
||||
@@index([projectId])
|
||||
@@index([roundId])
|
||||
@@index([state])
|
||||
@@index([roundId, state])
|
||||
}
|
||||
|
||||
model AdvancementRule {
|
||||
id String @id @default(cuid())
|
||||
roundId String
|
||||
targetRoundId String?
|
||||
ruleType AdvancementRuleType
|
||||
configJson Json @db.JsonB
|
||||
isDefault Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([roundId, sortOrder])
|
||||
@@index([roundId])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -2414,6 +2401,22 @@ model AssignmentIntent {
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model AssignmentException {
|
||||
id String @id @default(cuid())
|
||||
assignmentId String
|
||||
reason String @db.Text
|
||||
overCapBy Int
|
||||
approvedById String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
|
||||
approvedBy User @relation("AssignmentExceptionApprover", fields: [approvedById], references: [id])
|
||||
|
||||
@@index([assignmentId])
|
||||
@@index([approvedById])
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MENTORING WORKSPACE MODELS (NEW)
|
||||
// =============================================================================
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Idempotent sync: ensure every project with a submittedByUserId has a
|
||||
* corresponding TeamMember(LEAD) record. Safe to run on every deploy.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { submittedByUserId: { not: null } },
|
||||
select: {
|
||||
id: true,
|
||||
submittedByUserId: true,
|
||||
teamMembers: { select: { userId: true } },
|
||||
},
|
||||
})
|
||||
|
||||
const toCreate: Array<{ projectId: string; userId: string; role: 'LEAD' }> = []
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.submittedByUserId) continue
|
||||
const alreadyLinked = project.teamMembers.some(
|
||||
(tm) => tm.userId === project.submittedByUserId
|
||||
)
|
||||
if (!alreadyLinked) {
|
||||
toCreate.push({
|
||||
projectId: project.id,
|
||||
userId: project.submittedByUserId,
|
||||
role: 'LEAD',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.teamMember.createMany({
|
||||
data: toCreate,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
console.log(`✓ Linked ${toCreate.length} project submitters as TeamMember(LEAD)`)
|
||||
} else {
|
||||
console.log('✓ All project submitters already linked — nothing to do')
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Team lead sync failed:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RoundStatus,
|
||||
CapMode,
|
||||
JuryGroupMemberRole,
|
||||
AdvancementRuleType,
|
||||
} from '@prisma/client'
|
||||
import bcrypt from 'bcryptjs'
|
||||
// Inline default configs so seed has ZERO dependency on src/ (not available in Docker prod image)
|
||||
@@ -315,7 +316,6 @@ async function main() {
|
||||
|
||||
const staffAccounts = [
|
||||
{ email: 'matt@monaco-opc.com', name: 'Matt', role: UserRole.SUPER_ADMIN, password: '195260Mp!' },
|
||||
{ email: 'matt@letsbe.solutions', name: 'Matt (Applicant)', role: UserRole.APPLICANT, password: '195260Mp!' },
|
||||
{ email: 'admin@monaco-opc.com', name: 'Admin', role: UserRole.PROGRAM_ADMIN, password: 'Admin123!' },
|
||||
{ email: 'awards@monaco-opc.com', name: 'Award Director', role: UserRole.AWARD_MASTER, password: 'Awards123!' },
|
||||
]
|
||||
@@ -323,10 +323,10 @@ async function main() {
|
||||
const staffUsers: Record<string, string> = {}
|
||||
for (const account of staffAccounts) {
|
||||
const passwordHash = await bcrypt.hash(account.password, 12)
|
||||
const needsPassword = account.role === UserRole.SUPER_ADMIN || account.role === UserRole.APPLICANT
|
||||
const isSuperAdmin = account.role === UserRole.SUPER_ADMIN
|
||||
const user = await prisma.user.upsert({
|
||||
where: { email: account.email },
|
||||
update: needsPassword
|
||||
update: isSuperAdmin
|
||||
? {
|
||||
status: UserStatus.ACTIVE,
|
||||
passwordHash,
|
||||
@@ -347,12 +347,11 @@ async function main() {
|
||||
email: account.email,
|
||||
name: account.name,
|
||||
role: account.role,
|
||||
roles: [account.role],
|
||||
status: needsPassword ? UserStatus.ACTIVE : UserStatus.NONE,
|
||||
passwordHash: needsPassword ? passwordHash : null,
|
||||
mustSetPassword: !needsPassword,
|
||||
passwordSetAt: needsPassword ? new Date() : null,
|
||||
onboardingCompletedAt: needsPassword ? new Date() : null,
|
||||
status: isSuperAdmin ? UserStatus.ACTIVE : UserStatus.NONE,
|
||||
passwordHash: isSuperAdmin ? passwordHash : null,
|
||||
mustSetPassword: !isSuperAdmin,
|
||||
passwordSetAt: isSuperAdmin ? new Date() : null,
|
||||
onboardingCompletedAt: isSuperAdmin ? new Date() : null,
|
||||
},
|
||||
})
|
||||
staffUsers[account.email] = user.id
|
||||
@@ -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,
|
||||
@@ -559,7 +554,7 @@ async function main() {
|
||||
})
|
||||
|
||||
// Create project
|
||||
const createdProject = await prisma.project.create({
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
programId: program.id,
|
||||
title: projectName || `Project by ${name}`,
|
||||
@@ -584,24 +579,13 @@ async function main() {
|
||||
},
|
||||
})
|
||||
|
||||
// Link submitter as team lead
|
||||
await prisma.teamMember.upsert({
|
||||
where: { projectId_userId: { projectId: createdProject.id, userId: user.id } },
|
||||
update: {},
|
||||
create: {
|
||||
projectId: createdProject.id,
|
||||
userId: user.id,
|
||||
role: 'LEAD',
|
||||
},
|
||||
})
|
||||
|
||||
projectCount++
|
||||
if (projectCount % 50 === 0) {
|
||||
console.log(` ... ${projectCount} projects created`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ✓ Created ${projectCount} projects (with team lead links)`)
|
||||
console.log(` ✓ Created ${projectCount} projects`)
|
||||
if (skippedNoEmail > 0) {
|
||||
console.log(` ⚠ Skipped ${skippedNoEmail} rows with no valid email`)
|
||||
}
|
||||
@@ -857,23 +841,23 @@ async function main() {
|
||||
}
|
||||
console.log(` ✓ ${rounds.length} rounds created (R1-R8)`)
|
||||
|
||||
// --- Assign all projects to intake round (COMPLETED, since intake is closed) ---
|
||||
const intakeRound = rounds[0]
|
||||
const allProjects = await prisma.project.findMany({
|
||||
where: { programId: program.id },
|
||||
select: { id: true },
|
||||
})
|
||||
if (allProjects.length > 0) {
|
||||
await prisma.projectRoundState.createMany({
|
||||
data: allProjects.map((p) => ({
|
||||
projectId: p.id,
|
||||
roundId: intakeRound.id,
|
||||
state: 'COMPLETED' as const,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
// --- Advancement Rules (auto-advance between rounds) ---
|
||||
for (let i = 0; i < rounds.length - 1; i++) {
|
||||
await prisma.advancementRule.upsert({
|
||||
where: {
|
||||
roundId_sortOrder: { roundId: rounds[i].id, sortOrder: 0 },
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
roundId: rounds[i].id,
|
||||
ruleType: AdvancementRuleType.AUTO_ADVANCE,
|
||||
sortOrder: 0,
|
||||
targetRoundId: rounds[i + 1].id,
|
||||
configJson: {},
|
||||
},
|
||||
})
|
||||
console.log(` ✓ ${allProjects.length} projects assigned to intake round (COMPLETED)`)
|
||||
}
|
||||
console.log(` ✓ ${rounds.length - 1} advancement rules created`)
|
||||
|
||||
// --- Round-Submission Visibility (which rounds can see which submission windows) ---
|
||||
// R2 and R3 can see R1 docs, R5 can see R4 docs
|
||||
@@ -898,28 +882,6 @@ async function main() {
|
||||
}
|
||||
console.log(` ✓ ${visibilityLinks.length} submission visibility links created`)
|
||||
|
||||
// --- Applicant/Observer visibility settings ---
|
||||
const visibilitySettings = [
|
||||
{ key: 'observer_show_team_tab', value: 'true', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show Team tab on observer project detail page' },
|
||||
{ key: 'applicant_show_evaluation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show anonymous jury evaluation feedback to applicants' },
|
||||
{ key: 'applicant_show_evaluation_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show global scores in evaluation feedback' },
|
||||
{ key: 'applicant_show_evaluation_criteria', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show per-criterion scores in evaluation feedback' },
|
||||
{ key: 'applicant_show_evaluation_text', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show written feedback text in evaluation feedback' },
|
||||
{ key: 'applicant_show_livefinal_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show live final scores to applicants' },
|
||||
{ key: 'applicant_show_livefinal_scores', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show individual jury scores from live finals' },
|
||||
{ key: 'applicant_show_deliberation_feedback', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Show deliberation results to applicants' },
|
||||
{ key: 'applicant_hide_feedback_from_rejected', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Hide feedback from rejected projects' },
|
||||
{ key: 'applicant_allow_description_edit', value: 'false', type: SettingType.BOOLEAN, category: SettingCategory.ANALYTICS, description: 'Allow applicants to edit their project description' },
|
||||
]
|
||||
for (const s of visibilitySettings) {
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { key: s.key },
|
||||
update: {},
|
||||
create: s,
|
||||
})
|
||||
}
|
||||
console.log(` ✓ Created ${visibilitySettings.length} applicant/observer visibility settings`)
|
||||
|
||||
// --- Feature flag: enable competition model ---
|
||||
await prisma.systemSettings.upsert({
|
||||
where: { key: 'feature.useCompetitionModel' },
|
||||
@@ -958,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 },
|
||||
|
||||
@@ -1,106 +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
|
||||
*
|
||||
* Safe to re-run: only updates evaluations where binaryDecision is still null.
|
||||
*/
|
||||
|
||||
import { PrismaClient, Prisma } 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
|
||||
let totalSkipped = 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
|
||||
// Use Prisma.JsonNull for proper null filtering
|
||||
const evaluations = await prisma.evaluation.findMany({
|
||||
where: {
|
||||
assignment: { roundId: round.id },
|
||||
binaryDecision: null,
|
||||
status: 'SUBMITTED',
|
||||
},
|
||||
select: { id: true, criterionScoresJson: true },
|
||||
})
|
||||
|
||||
let updated = 0
|
||||
let skipped = 0
|
||||
for (const ev of evaluations) {
|
||||
const scores = ev.criterionScoresJson as Record<string, unknown> | null
|
||||
if (!scores) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const value = scores[boolCriterion.id]
|
||||
let resolved: boolean | null = null
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
resolved = value
|
||||
} else if (value === 'true' || value === 1) {
|
||||
resolved = true
|
||||
} else if (value === 'false' || value === 0) {
|
||||
resolved = false
|
||||
}
|
||||
|
||||
if (resolved === null) {
|
||||
console.log(` Skipping eval ${ev.id}: criterion value is ${JSON.stringify(value)}`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
await prisma.evaluation.update({
|
||||
where: { id: ev.id },
|
||||
data: { binaryDecision: resolved },
|
||||
})
|
||||
updated++
|
||||
}
|
||||
|
||||
console.log(` Updated ${updated}/${evaluations.length} evaluations (skipped ${skipped})`)
|
||||
totalUpdated += updated
|
||||
totalSkipped += skipped
|
||||
}
|
||||
|
||||
console.log(`\nDone. Total updated: ${totalUpdated}, Total skipped: ${totalSkipped}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Backfill all projects into the intake round (and any intermediate rounds
|
||||
* between intake and their earliest assigned round) with COMPLETED state.
|
||||
*
|
||||
* Usage: npx tsx scripts/backfill-intake-round.ts
|
||||
* Add --dry-run to preview without making changes.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
|
||||
async function main() {
|
||||
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling intake round states...\n')
|
||||
|
||||
// Find the intake round
|
||||
const intakeRound = await prisma.round.findFirst({
|
||||
where: { roundType: 'INTAKE' },
|
||||
select: { id: true, name: true, sortOrder: true, competitionId: true },
|
||||
})
|
||||
|
||||
if (!intakeRound) {
|
||||
console.log('❌ No INTAKE round found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Intake round: "${intakeRound.name}" (sortOrder: ${intakeRound.sortOrder})`)
|
||||
|
||||
// Get all rounds in the competition ordered by sortOrder
|
||||
const allRounds = await prisma.round.findMany({
|
||||
where: { competitionId: intakeRound.competitionId },
|
||||
select: { id: true, name: true, sortOrder: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
})
|
||||
|
||||
// Find all projects NOT in the intake round
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
projectRoundStates: {
|
||||
none: { roundId: intakeRound.id },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
projectRoundStates: {
|
||||
select: { roundId: true, round: { select: { sortOrder: true } } },
|
||||
orderBy: { round: { sortOrder: 'asc' } },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`${projects.length} projects not in intake round\n`)
|
||||
|
||||
if (projects.length === 0) {
|
||||
console.log('✅ All projects already in intake round')
|
||||
return
|
||||
}
|
||||
|
||||
// For each project, create COMPLETED states for intake + any intermediate rounds
|
||||
const toCreate: Array<{ projectId: string; roundId: string; state: 'COMPLETED' }> = []
|
||||
|
||||
for (const project of projects) {
|
||||
// Find the earliest round this project is already in
|
||||
const earliestSortOrder = project.projectRoundStates.length > 0
|
||||
? Math.min(...project.projectRoundStates.map(ps => ps.round.sortOrder))
|
||||
: Infinity
|
||||
|
||||
const existingRoundIds = new Set(project.projectRoundStates.map(ps => ps.roundId))
|
||||
|
||||
// Add COMPLETED for intake + all intermediate rounds before the earliest assigned round
|
||||
for (const round of allRounds) {
|
||||
if (round.sortOrder >= earliestSortOrder) break
|
||||
if (existingRoundIds.has(round.id)) continue
|
||||
|
||||
toCreate.push({
|
||||
projectId: project.id,
|
||||
roundId: round.id,
|
||||
state: 'COMPLETED',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Creating ${toCreate.length} ProjectRoundState records...`)
|
||||
|
||||
if (!dryRun) {
|
||||
await prisma.projectRoundState.createMany({
|
||||
data: toCreate,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Summary by round
|
||||
const byRound = new Map<string, number>()
|
||||
for (const r of toCreate) {
|
||||
const name = allRounds.find(ar => ar.id === r.roundId)?.name ?? r.roundId
|
||||
byRound.set(name, (byRound.get(name) ?? 0) + 1)
|
||||
}
|
||||
for (const [name, count] of byRound) {
|
||||
console.log(` ${name}: ${count} projects`)
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done! ${toCreate.length} records ${dryRun ? 'would be' : ''} created`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Error:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Backfill TeamMember records for all projects that have a submittedByUserId
|
||||
* but no corresponding TeamMember link.
|
||||
*
|
||||
* Usage: npx tsx scripts/backfill-team-leads.ts
|
||||
* Add --dry-run to preview without making changes.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
|
||||
async function main() {
|
||||
console.log(dryRun ? '🔍 DRY RUN — no changes will be made\n' : '🚀 Backfilling team leads...\n')
|
||||
|
||||
// Find all projects with a submitter but no TeamMember link for that user
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {
|
||||
submittedByUserId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
submittedByUserId: true,
|
||||
teamMembers: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let created = 0
|
||||
let alreadyLinked = 0
|
||||
let noSubmitter = 0
|
||||
|
||||
for (const project of projects) {
|
||||
if (!project.submittedByUserId) {
|
||||
noSubmitter++
|
||||
continue
|
||||
}
|
||||
|
||||
const alreadyHasLink = project.teamMembers.some(
|
||||
(tm) => tm.userId === project.submittedByUserId
|
||||
)
|
||||
|
||||
if (alreadyHasLink) {
|
||||
alreadyLinked++
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(` + Linking "${project.title}" → user ${project.submittedByUserId}`)
|
||||
|
||||
if (!dryRun) {
|
||||
await prisma.teamMember.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
userId: project.submittedByUserId,
|
||||
role: 'LEAD',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
created++
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done!`)
|
||||
console.log(` ${created} TeamMember records ${dryRun ? 'would be' : ''} created`)
|
||||
console.log(` ${alreadyLinked} projects already had the submitter linked`)
|
||||
console.log(` ${noSubmitter} projects had no submitter`)
|
||||
console.log(` ${projects.length} total projects checked`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ Error:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -1,32 +0,0 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||
|
||||
(async () => {
|
||||
const members = await p.teamMember.findMany({
|
||||
orderBy: { joinedAt: 'desc' },
|
||||
take: 10,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, status: true, inviteToken: true } },
|
||||
project: { select: { title: true } }
|
||||
}
|
||||
});
|
||||
for (const m of members) {
|
||||
console.log(m.role, '|', m.user.name, '|', m.user.email, '|', m.user.status, '|', m.project.title, '|', m.joinedAt.toISOString().slice(0,16), '| token:', m.user.inviteToken ? 'yes' : 'no');
|
||||
}
|
||||
|
||||
const logs = await p.notificationLog.findMany({
|
||||
where: { type: 'TEAM_INVITATION' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
});
|
||||
if (logs.length) {
|
||||
console.log('\n--- Notification logs:');
|
||||
for (const l of logs) {
|
||||
console.log(l.status, '|', l.channel, '|', l.errorMsg, '|', l.createdAt.toISOString().slice(0,16));
|
||||
}
|
||||
} else {
|
||||
console.log('\n--- No TEAM_INVITATION notification logs found');
|
||||
}
|
||||
|
||||
await p.$disconnect();
|
||||
})();
|
||||
@@ -1,20 +0,0 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||
|
||||
(async () => {
|
||||
const rounds = await p.round.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { id: true, name: true, roundType: true, status: true, sortOrder: true, competitionId: true },
|
||||
});
|
||||
for (const r of rounds) console.log(r.sortOrder, '|', r.name, '|', r.roundType, '|', r.status, '|', r.id);
|
||||
|
||||
console.log('\n--- File Requirements:');
|
||||
const reqs = await p.fileRequirement.findMany({ include: { round: { select: { name: true } } } });
|
||||
for (const r of reqs) console.log(r.round.name, '|', r.name, '|', r.isRequired, '|', r.id);
|
||||
|
||||
console.log('\n--- Submission Windows:');
|
||||
const wins = await p.submissionWindow.findMany({ select: { id: true, name: true, roundNumber: true, windowOpenAt: true, windowCloseAt: true, competitionId: true } });
|
||||
for (const w of wins) console.log(w.name, '| round#', w.roundNumber, '| open:', w.windowOpenAt?.toISOString().slice(0,16), '| close:', w.windowCloseAt?.toISOString().slice(0,16));
|
||||
|
||||
await p.$disconnect();
|
||||
})();
|
||||
@@ -1,71 +0,0 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const p = new PrismaClient({ datasourceUrl: 'postgresql://mopc:devpassword@localhost:5433/mopc' });
|
||||
|
||||
(async () => {
|
||||
// R2 - AI Screening round ID
|
||||
const roundId = 'cmmafe7et00ldy53kxpdhhvf0';
|
||||
|
||||
// Check existing
|
||||
const existing = await p.fileRequirement.count({ where: { roundId } });
|
||||
if (existing > 0) {
|
||||
console.log(`Round already has ${existing} file requirements, skipping.`);
|
||||
await p.$disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const requirements = [
|
||||
{
|
||||
roundId,
|
||||
name: 'Executive Summary',
|
||||
description: 'A 2-page executive summary of your project (PDF format, max 10MB)',
|
||||
acceptedMimeTypes: ['application/pdf'],
|
||||
maxSizeMB: 10,
|
||||
isRequired: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
roundId,
|
||||
name: 'Business Plan',
|
||||
description: 'Full business plan or project proposal (PDF format, max 25MB)',
|
||||
acceptedMimeTypes: ['application/pdf'],
|
||||
maxSizeMB: 25,
|
||||
isRequired: true,
|
||||
sortOrder: 1,
|
||||
},
|
||||
{
|
||||
roundId,
|
||||
name: 'Pitch Presentation',
|
||||
description: 'Slide deck presenting your project (PDF or PowerPoint, max 50MB)',
|
||||
acceptedMimeTypes: ['application/pdf', 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||
maxSizeMB: 50,
|
||||
isRequired: true,
|
||||
sortOrder: 2,
|
||||
},
|
||||
{
|
||||
roundId,
|
||||
name: 'Video Pitch',
|
||||
description: 'A short video (max 3 minutes) explaining your project (MP4, max 200MB). Optional but recommended.',
|
||||
acceptedMimeTypes: ['video/mp4', 'video/quicktime', 'video/webm'],
|
||||
maxSizeMB: 200,
|
||||
isRequired: false,
|
||||
sortOrder: 3,
|
||||
},
|
||||
{
|
||||
roundId,
|
||||
name: 'Supporting Documents',
|
||||
description: 'Any additional supporting documents such as research papers, letters of support, etc. (PDF, max 20MB)',
|
||||
acceptedMimeTypes: ['application/pdf'],
|
||||
maxSizeMB: 20,
|
||||
isRequired: false,
|
||||
sortOrder: 4,
|
||||
},
|
||||
];
|
||||
|
||||
for (const req of requirements) {
|
||||
const created = await p.fileRequirement.create({ data: req });
|
||||
console.log('Created:', created.name, '| required:', created.isRequired, '| id:', created.id);
|
||||
}
|
||||
|
||||
console.log('\nDone! Created', requirements.length, 'file requirements for R2.');
|
||||
await p.$disconnect();
|
||||
})();
|
||||
@@ -1,68 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import crypto from 'crypto'
|
||||
import { sendInvitationEmail } from '../src/lib/email'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
// Find a program to attach the project to
|
||||
const program = await prisma.program.findFirst()
|
||||
if (!program) throw new Error('No program found - run seed first')
|
||||
|
||||
// Create applicant user
|
||||
const inviteToken = crypto.randomBytes(32).toString('hex')
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
id: 'test_applicant_matt_ciaccio',
|
||||
name: 'Matt Ciaccio',
|
||||
email: 'matt.ciaccio@gmail.com',
|
||||
role: 'APPLICANT',
|
||||
roles: ['APPLICANT'],
|
||||
status: 'INVITED',
|
||||
mustSetPassword: true,
|
||||
inviteToken,
|
||||
inviteTokenExpiresAt: new Date(Date.now() + 72 * 60 * 60 * 1000),
|
||||
},
|
||||
})
|
||||
console.log('Created user:', user.id)
|
||||
|
||||
// Create test project
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
id: 'test_project_qa',
|
||||
title: 'OceanWatch AI',
|
||||
description: 'AI-powered ocean monitoring platform for marine conservation',
|
||||
programId: program.id,
|
||||
submittedByUserId: user.id,
|
||||
},
|
||||
})
|
||||
console.log('Created project:', project.id)
|
||||
|
||||
// Create team member (LEAD)
|
||||
await prisma.teamMember.create({
|
||||
data: {
|
||||
id: 'test_tm_lead',
|
||||
projectId: project.id,
|
||||
userId: user.id,
|
||||
role: 'LEAD',
|
||||
},
|
||||
})
|
||||
console.log('Created team member (LEAD)')
|
||||
|
||||
// Send styled invitation email
|
||||
const url = `http://localhost:3000/accept-invite?token=${inviteToken}`
|
||||
console.log('Invite URL:', url)
|
||||
|
||||
await sendInvitationEmail(
|
||||
'matt.ciaccio@gmail.com',
|
||||
'Matt Ciaccio',
|
||||
url,
|
||||
'APPLICANT',
|
||||
72
|
||||
)
|
||||
console.log('Styled invitation email sent!')
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect().then(() => process.exit(0)))
|
||||
@@ -1,165 +0,0 @@
|
||||
/**
|
||||
* Seed NotificationLog with confirmed SMTP delivery data.
|
||||
*
|
||||
* Sources:
|
||||
* 1. 33 emails confirmed delivered in Poste.io SMTP logs (2026-03-04)
|
||||
* 2. Users with status ACTIVE who are LEADs on PASSED projects
|
||||
* (they clearly received and used their invite link)
|
||||
*
|
||||
* Usage: npx tsx scripts/seed-notification-log.ts
|
||||
* Add --dry-run to preview without making changes.
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
const dryRun = process.argv.includes('--dry-run')
|
||||
|
||||
// Emails confirmed delivered via SMTP logs on 2026-03-04
|
||||
const CONFIRMED_SMTP_EMAILS = new Set([
|
||||
'fbayong@balazstudio.com',
|
||||
'gnoel@kilimora.africa',
|
||||
'amal.chebbi@pigmentoco.com',
|
||||
'nairita@yarsi.net',
|
||||
'martin.itamalo@greenbrinetechnologies.com',
|
||||
'petervegan1223@gmail.com',
|
||||
'dmarinov@redget.io',
|
||||
'adrien@seavium.com',
|
||||
'l.buob@whisper-ef.com',
|
||||
'silvia@omnivorus.com',
|
||||
'marzettisebastian@gmail.com',
|
||||
'fiona.mcomish@algae-scope.com',
|
||||
'karimeguillen@rearvora.com',
|
||||
'info@skywatt.tech',
|
||||
'julia@nereia-coatings.com',
|
||||
'info@janmaisenbacher.com',
|
||||
'xbm_0201@qq.com',
|
||||
'irinakharitonova0201@gmail.com',
|
||||
'seablocksrecif@gmail.com',
|
||||
'oscar@seafuser.com',
|
||||
'charles.maher@blueshadow.dk',
|
||||
'sabirabokhari@gmail.com',
|
||||
'munayimbabura@gmail.com',
|
||||
'amritha.ramadevu@edu.escp.eu',
|
||||
'nele.jordan@myhsba.de',
|
||||
'karl.mihhels@aalto.fi',
|
||||
'christine.a.kurz@gmail.com',
|
||||
'aki@corall.eco',
|
||||
'topias.kilpinen@hotmail.fi',
|
||||
'nina.riutta.camilla@gmail.com',
|
||||
'sofie.boggiosella@my.jcu.edu.au',
|
||||
'giambattistafigari@gmail.com',
|
||||
'mussinig0@gmail.com',
|
||||
])
|
||||
|
||||
const SENT_AT = new Date('2026-03-04T01:00:00Z')
|
||||
|
||||
async function main() {
|
||||
console.log(dryRun ? '--- DRY RUN ---\n' : 'Seeding NotificationLog...\n')
|
||||
|
||||
// Find LEAD team members on PASSED projects
|
||||
const passedLeads = await prisma.teamMember.findMany({
|
||||
where: {
|
||||
role: 'LEAD',
|
||||
project: {
|
||||
projectRoundStates: {
|
||||
some: { state: 'PASSED' },
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {
|
||||
projectRoundStates: {
|
||||
where: { state: 'PASSED' },
|
||||
select: { roundId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
status: true,
|
||||
inviteToken: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Found ${passedLeads.length} LEAD team members on PASSED projects\n`)
|
||||
|
||||
let created = 0
|
||||
let skipped = 0
|
||||
|
||||
for (const lead of passedLeads) {
|
||||
const email = lead.user.email?.toLowerCase()
|
||||
if (!email) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if a NotificationLog already exists for this project+email
|
||||
const existing = await prisma.notificationLog.findFirst({
|
||||
where: {
|
||||
email,
|
||||
projectId: lead.projectId,
|
||||
type: 'ADVANCEMENT_NOTIFICATION',
|
||||
status: 'SENT',
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine confidence of delivery
|
||||
const isConfirmedSMTP = CONFIRMED_SMTP_EMAILS.has(email)
|
||||
const isActive = lead.user.status === 'ACTIVE'
|
||||
const isInvited = lead.user.status === 'INVITED' && !!lead.user.inviteToken
|
||||
|
||||
// Only seed for confirmed deliveries or active users
|
||||
if (!isConfirmedSMTP && !isActive && !isInvited) {
|
||||
console.log(` SKIP ${email} (status=${lead.user.status}, not in SMTP logs)`)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const roundId = lead.project.projectRoundStates[0]?.roundId ?? null
|
||||
const label = isConfirmedSMTP ? 'SMTP-confirmed' : isActive ? 'user-active' : 'invite-sent'
|
||||
|
||||
console.log(` ${dryRun ? 'WOULD CREATE' : 'CREATE'} ${email} [${label}] project=${lead.projectId}`)
|
||||
|
||||
if (!dryRun) {
|
||||
await prisma.notificationLog.create({
|
||||
data: {
|
||||
userId: lead.user.id,
|
||||
channel: 'EMAIL',
|
||||
type: 'ADVANCEMENT_NOTIFICATION',
|
||||
status: 'SENT',
|
||||
email,
|
||||
projectId: lead.projectId,
|
||||
roundId,
|
||||
batchId: 'seed-2026-03-04',
|
||||
createdAt: SENT_AT,
|
||||
},
|
||||
})
|
||||
created++
|
||||
} else {
|
||||
created++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. Created: ${created}, Skipped: ${skipped}`)
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('Error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
@@ -1,120 +0,0 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
// Import just the template helper without hitting DB
|
||||
// We'll construct the email manually since the DB connection fails
|
||||
|
||||
const BRAND = {
|
||||
red: '#de0f1e',
|
||||
darkBlue: '#053d57',
|
||||
white: '#fefefe',
|
||||
teal: '#557f8c',
|
||||
};
|
||||
|
||||
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||
|
||||
// Replicate the styled email template from email.ts
|
||||
function getStyledHtml(name: string, inviteUrl: string) {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>You're invited to join the MOPC Portal</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #f8fafc; font-family: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f8fafc;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 40px 20px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; width: 100%;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="background: linear-gradient(135deg, ${BRAND.darkBlue} 0%, ${BRAND.teal} 100%); border-radius: 16px 16px 0 0; padding: 32px 40px; text-align: center;">
|
||||
<h1 style="color: ${BRAND.white}; font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em;">
|
||||
Monaco Ocean Protection Challenge
|
||||
</h1>
|
||||
<p style="color: rgba(255,255,255,0.8); font-size: 13px; font-weight: 300; margin: 8px 0 0 0; letter-spacing: 0.05em; text-transform: uppercase;">
|
||||
Together for a healthier ocean
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="background-color: #ffffff; padding: 40px; border-radius: 0 0 16px 16px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);">
|
||||
<h2 style="color: ${BRAND.darkBlue}; font-size: 20px; font-weight: 600; margin: 0 0 24px 0;">
|
||||
Hello ${name},
|
||||
</h2>
|
||||
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 16px 0; font-weight: 400;">
|
||||
You've been invited to join the Monaco Ocean Protection Challenge platform as an <strong>applicant</strong>.
|
||||
</p>
|
||||
<p style="color: #475569; font-size: 15px; line-height: 1.7; margin: 0 0 24px 0; font-weight: 400;">
|
||||
Click the button below to set up your account and get started.
|
||||
</p>
|
||||
<!-- CTA Button -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 28px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="${inviteUrl}" style="display: inline-block; background: linear-gradient(135deg, ${BRAND.red} 0%, #c40d19 100%); color: #ffffff; text-decoration: none; padding: 14px 36px; border-radius: 10px; font-size: 15px; font-weight: 600; letter-spacing: 0.02em; box-shadow: 0 4px 14px rgba(222, 15, 30, 0.3);">
|
||||
Accept Invitation
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Info Box -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 20px 0;">
|
||||
<tr>
|
||||
<td style="background-color: #eff6ff; border-left: 4px solid ${BRAND.darkBlue}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
||||
<p style="color: #1e40af; margin: 0; font-size: 13px; line-height: 1.6;">
|
||||
This link will expire in 3 days.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 24px 40px; text-align: center;">
|
||||
<p style="color: #94a3b8; font-size: 12px; line-height: 1.6; margin: 0;">
|
||||
Monaco Ocean Protection Challenge<br>
|
||||
<span style="color: #cbd5e1;">Together for a healthier ocean.</span>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Creating transporter...');
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: 'mail.monaco-opc.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
auth: {
|
||||
user: 'noreply@monaco-opc.com',
|
||||
pass: '9EythPDcz1Fya4M88iigkB1wojNf8QEVPuRRnD9dJMBpT3pk2',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Sending styled invitation email...');
|
||||
const info = await transporter.sendMail({
|
||||
from: 'MOPC Portal <noreply@monaco-opc.com>',
|
||||
to: 'matt.ciaccio@gmail.com',
|
||||
subject: "You're invited to join the MOPC Portal",
|
||||
text: `Hello Matt Ciaccio,\n\nYou've been invited to join the Monaco Ocean Protection Challenge platform as an applicant.\n\nClick the link below to set up your account:\n\n${url}\n\nThis link will expire in 3 days.\n\n---\nMonaco Ocean Protection Challenge\nTogether for a healthier ocean.`,
|
||||
html: getStyledHtml('Matt Ciaccio', url),
|
||||
});
|
||||
|
||||
console.log('SUCCESS! Message ID:', info.messageId);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('FAILED:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { sendInvitationEmail } from '../src/lib/email';
|
||||
|
||||
const token = '6f974b1da9fae95f74bbcd2419df589730979ac945aeaa5413021c00311b5165';
|
||||
const url = 'http://localhost:3000/accept-invite?token=' + token;
|
||||
|
||||
async function main() {
|
||||
console.log('Sending styled invitation email...');
|
||||
console.log('To: matt.ciaccio@gmail.com');
|
||||
console.log('URL:', url);
|
||||
|
||||
try {
|
||||
await sendInvitationEmail(
|
||||
'matt.ciaccio@gmail.com',
|
||||
'Matt Ciaccio',
|
||||
url,
|
||||
'APPLICANT',
|
||||
72
|
||||
);
|
||||
console.log('SUCCESS: Styled invitation email sent!');
|
||||
} catch (err: any) {
|
||||
console.error('FAILED:', err.message || err);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,20 +0,0 @@
|
||||
require('dotenv').config();
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
async function main() {
|
||||
console.log('DATABASE_URL:', process.env.DATABASE_URL);
|
||||
const p = new PrismaClient({ log: ['query', 'info', 'warn', 'error'] });
|
||||
try {
|
||||
const result = await p.$queryRawUnsafe('SELECT 1 as ok');
|
||||
console.log('Connected!', result);
|
||||
} catch (e) {
|
||||
console.error('Error code:', e.code);
|
||||
console.error('Error meta:', JSON.stringify(e.meta, null, 2));
|
||||
console.error('Message:', e.message);
|
||||
} finally {
|
||||
await p.$disconnect();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -56,11 +56,9 @@ import { Switch } from '@/components/ui/switch'
|
||||
import { CsvExportDialog } from '@/components/shared/csv-export-dialog'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Action type options (manual audit actions + auto-generated mutation audit actions)
|
||||
// Action type options
|
||||
const ACTION_TYPES = [
|
||||
// Manual audit actions
|
||||
'CREATE',
|
||||
'UPDATE',
|
||||
'DELETE',
|
||||
@@ -78,8 +76,6 @@ const ACTION_TYPES = [
|
||||
'ROUND_ARCHIVED',
|
||||
'UPLOAD_FILE',
|
||||
'DELETE_FILE',
|
||||
'FILE_VIEWED',
|
||||
'FILE_OPENED',
|
||||
'FILE_DOWNLOADED',
|
||||
'BULK_CREATE',
|
||||
'BULK_UPDATE_STATUS',
|
||||
@@ -92,53 +88,12 @@ const ACTION_TYPES = [
|
||||
'APPLY_AI_SUGGESTIONS',
|
||||
'APPLY_SUGGESTIONS',
|
||||
'NOTIFY_JURORS_OF_ASSIGNMENTS',
|
||||
'IMPERSONATION_START',
|
||||
'IMPERSONATION_END',
|
||||
// Auto-generated mutation audit actions (non-super-admin)
|
||||
'EVALUATION_START',
|
||||
'EVALUATION_SUBMIT',
|
||||
'EVALUATION_AUTOSAVE',
|
||||
'EVALUATION_DECLARE_COI',
|
||||
'EVALUATION_ADD_COMMENT',
|
||||
'APPLICANT_SAVE_SUBMISSION',
|
||||
'APPLICANT_SAVE_FILE_METADATA',
|
||||
'APPLICANT_DELETE_FILE',
|
||||
'APPLICANT_REQUEST_MENTORING',
|
||||
'APPLICANT_WITHDRAW_FROM_COMPETITION',
|
||||
'APPLICANT_INVITE_TEAM_MEMBER',
|
||||
'APPLICANT_REMOVE_TEAM_MEMBER',
|
||||
'APPLICANT_SEND_MENTOR_MESSAGE',
|
||||
'APPLICATION_SUBMIT',
|
||||
'APPLICATION_SAVE_DRAFT',
|
||||
'APPLICATION_SUBMIT_DRAFT',
|
||||
'MENTOR_SEND_MESSAGE',
|
||||
'MENTOR_CREATE_NOTE',
|
||||
'MENTOR_DELETE_NOTE',
|
||||
'MENTOR_COMPLETE_MILESTONE',
|
||||
'LIVE_CAST_VOTE',
|
||||
'LIVE_CAST_STAGE_VOTE',
|
||||
'LIVE_VOTING_VOTE',
|
||||
'LIVE_VOTING_CAST_AUDIENCE_VOTE',
|
||||
'DELIBERATION_SUBMIT_VOTE',
|
||||
'NOTIFICATION_MARK_AS_READ',
|
||||
'NOTIFICATION_MARK_ALL_AS_READ',
|
||||
'USER_UPDATE_PROFILE',
|
||||
'USER_SET_PASSWORD',
|
||||
'USER_CHANGE_PASSWORD',
|
||||
'USER_COMPLETE_ONBOARDING',
|
||||
'SPECIAL_AWARD_SUBMIT_VOTE',
|
||||
// Security events
|
||||
'ACCOUNT_LOCKED',
|
||||
'ACCESS_DENIED_FORBIDDEN',
|
||||
'ACCESS_DENIED_UNAUTHORIZED',
|
||||
'ACCESS_DENIED_NOT_FOUND',
|
||||
]
|
||||
|
||||
// Entity type options
|
||||
const ENTITY_TYPES = [
|
||||
'User',
|
||||
'Program',
|
||||
'Competition',
|
||||
'Round',
|
||||
'Project',
|
||||
'Assignment',
|
||||
@@ -146,21 +101,6 @@ const ENTITY_TYPES = [
|
||||
'EvaluationForm',
|
||||
'ProjectFile',
|
||||
'GracePeriod',
|
||||
'Applicant',
|
||||
'Application',
|
||||
'Mentor',
|
||||
'Live',
|
||||
'LiveVoting',
|
||||
'Deliberation',
|
||||
'Notification',
|
||||
'SpecialAward',
|
||||
'File',
|
||||
'Tag',
|
||||
'Message',
|
||||
'Settings',
|
||||
'Ranking',
|
||||
'Filtering',
|
||||
'RoundEngine',
|
||||
]
|
||||
|
||||
// Color map for action types
|
||||
@@ -179,8 +119,6 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
||||
ROUND_ACTIVATED: 'default',
|
||||
ROUND_CLOSED: 'secondary',
|
||||
ROUND_ARCHIVED: 'secondary',
|
||||
FILE_VIEWED: 'outline',
|
||||
FILE_OPENED: 'outline',
|
||||
FILE_DOWNLOADED: 'outline',
|
||||
ROLE_CHANGED: 'secondary',
|
||||
PASSWORD_SET: 'outline',
|
||||
@@ -190,58 +128,6 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
|
||||
APPLY_AI_SUGGESTIONS: 'default',
|
||||
APPLY_SUGGESTIONS: 'default',
|
||||
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
|
||||
IMPERSONATION_START: 'destructive',
|
||||
IMPERSONATION_END: 'secondary',
|
||||
// Auto-generated mutation audit actions
|
||||
EVALUATION_START: 'default',
|
||||
EVALUATION_SUBMIT: 'default',
|
||||
EVALUATION_AUTOSAVE: 'outline',
|
||||
EVALUATION_DECLARE_COI: 'secondary',
|
||||
EVALUATION_ADD_COMMENT: 'outline',
|
||||
APPLICANT_SAVE_SUBMISSION: 'default',
|
||||
APPLICANT_DELETE_FILE: 'destructive',
|
||||
APPLICANT_WITHDRAW_FROM_COMPETITION: 'destructive',
|
||||
APPLICANT_INVITE_TEAM_MEMBER: 'default',
|
||||
APPLICANT_REMOVE_TEAM_MEMBER: 'destructive',
|
||||
APPLICATION_SUBMIT: 'default',
|
||||
MENTOR_SEND_MESSAGE: 'outline',
|
||||
MENTOR_CREATE_NOTE: 'default',
|
||||
MENTOR_DELETE_NOTE: 'destructive',
|
||||
LIVE_CAST_VOTE: 'default',
|
||||
LIVE_CAST_STAGE_VOTE: 'default',
|
||||
LIVE_VOTING_CAST_AUDIENCE_VOTE: 'default',
|
||||
DELIBERATION_SUBMIT_VOTE: 'default',
|
||||
SPECIAL_AWARD_SUBMIT_VOTE: 'default',
|
||||
USER_UPDATE_PROFILE: 'secondary',
|
||||
USER_SET_PASSWORD: 'outline',
|
||||
USER_CHANGE_PASSWORD: 'outline',
|
||||
USER_COMPLETE_ONBOARDING: 'default',
|
||||
// Security events
|
||||
ACCOUNT_LOCKED: 'destructive',
|
||||
ACCESS_DENIED_FORBIDDEN: 'destructive',
|
||||
ACCESS_DENIED_UNAUTHORIZED: 'destructive',
|
||||
ACCESS_DENIED_NOT_FOUND: 'secondary',
|
||||
}
|
||||
|
||||
|
||||
function getEntityLink(entityType: string, entityId: string): string | null {
|
||||
switch (entityType) {
|
||||
case 'User':
|
||||
return `/admin/members/${entityId}`
|
||||
case 'Project':
|
||||
return `/admin/projects/${entityId}`
|
||||
case 'Round':
|
||||
return `/admin/rounds/${entityId}`
|
||||
case 'Competition':
|
||||
return `/admin/competitions`
|
||||
case 'Evaluation':
|
||||
case 'EvaluationForm':
|
||||
return null // no dedicated page
|
||||
case 'SpecialAward':
|
||||
return `/admin/awards/${entityId}`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
@@ -576,24 +462,14 @@ export default function AuditLogPage() {
|
||||
{formatDate(log.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{log.userId ? (
|
||||
<Link
|
||||
href={`/admin/members/${log.userId}`}
|
||||
className="group block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="font-medium text-sm group-hover:text-primary group-hover:underline">
|
||||
{log.user?.name || 'System'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{log.user?.email}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div>
|
||||
<p className="font-medium text-sm">System</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{log.user?.name || 'System'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{log.user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
@@ -605,22 +481,11 @@ export default function AuditLogPage() {
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="text-sm">{log.entityType}</p>
|
||||
{log.entityId && (() => {
|
||||
const link = getEntityLink(log.entityType, log.entityId)
|
||||
return link ? (
|
||||
<Link
|
||||
href={link}
|
||||
className="text-xs text-primary font-mono hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{log.entityId.slice(0, 8)}...
|
||||
</Link>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{log.entityId.slice(0, 8)}...
|
||||
</p>
|
||||
)
|
||||
})()}
|
||||
{log.entityId && (
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
{log.entityId.slice(0, 8)}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
@@ -643,18 +508,9 @@ export default function AuditLogPage() {
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Entity ID
|
||||
</p>
|
||||
{log.entityId ? (() => {
|
||||
const link = getEntityLink(log.entityType, log.entityId)
|
||||
return link ? (
|
||||
<Link href={link} className="font-mono text-sm text-primary hover:underline" onClick={(e) => e.stopPropagation()}>
|
||||
{log.entityId}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-mono text-sm">{log.entityId}</p>
|
||||
)
|
||||
})() : (
|
||||
<p className="font-mono text-sm">N/A</p>
|
||||
)}
|
||||
<p className="font-mono text-sm">
|
||||
{log.entityId || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
@@ -751,23 +607,12 @@ export default function AuditLogPage() {
|
||||
{formatDate(log.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
{log.userId ? (
|
||||
<Link
|
||||
href={`/admin/members/${log.userId}`}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-primary"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<User className="h-3 w-3" />
|
||||
<span className="text-xs hover:underline">
|
||||
{log.user?.name || 'System'}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="text-xs">System</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<User className="h-3 w-3" />
|
||||
<span className="text-xs">
|
||||
{log.user?.name || 'System'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
|
||||
@@ -122,9 +122,11 @@ export default function EditAwardPage({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/awards/${awardId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Award
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { EmailPreviewDialog } from '@/components/admin/round/email-preview-dialog'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -92,28 +91,7 @@ import {
|
||||
AlertCircle,
|
||||
Layers,
|
||||
Info,
|
||||
Mail,
|
||||
GripVertical,
|
||||
ArrowRight,
|
||||
} 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 { CountryDisplay } from '@/components/shared/country-display'
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
DRAFT: 'secondary',
|
||||
@@ -136,199 +114,6 @@ function getStepIndex(status: string): number {
|
||||
return idx >= 0 ? idx : (status === 'ARCHIVED' ? 3 : 0)
|
||||
}
|
||||
|
||||
const ROUND_TYPE_COLORS: Record<string, string> = {
|
||||
EVALUATION: 'bg-violet-100 text-violet-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
SUBMISSION: 'bg-blue-100 text-blue-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
const ROUND_STATUS_COLORS: 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',
|
||||
}
|
||||
|
||||
function SortableRoundCard({
|
||||
round,
|
||||
index,
|
||||
isFirst,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: {
|
||||
round: any
|
||||
index: number
|
||||
isFirst: boolean
|
||||
onDelete: (roundId: string) => void
|
||||
isDeleting: boolean
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: round.id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
const projectCount = round._count?.projectRoundStates ?? 0
|
||||
const assignmentCount = round._count?.assignments ?? 0
|
||||
const statusLabel = round.status.replace('ROUND_', '')
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`hover:shadow-md transition-shadow ${isDragging ? 'opacity-50 shadow-lg z-50' : ''}`}
|
||||
>
|
||||
<CardContent className="pt-4 pb-3 space-y-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<button
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground mt-1 shrink-0"
|
||||
aria-label="Drag to reorder"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<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">
|
||||
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
||||
{round.name}
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
<Badge variant="secondary" className={`text-[10px] ${ROUND_TYPE_COLORS[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-[10px] ${ROUND_STATUS_COLORS[statusLabel]}`}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{isFirst && (
|
||||
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
||||
Entry point
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{assignmentCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{round.status === 'ROUND_DRAFT' && (
|
||||
<div className="flex justify-end pt-1">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{round.name}". This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(round.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RoundsDndGrid({
|
||||
rounds,
|
||||
awardId,
|
||||
onReorder,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: {
|
||||
rounds: any[]
|
||||
awardId: string
|
||||
onReorder: (roundIds: string[]) => void
|
||||
onDelete: (roundId: string) => void
|
||||
isDeleting: boolean
|
||||
}) {
|
||||
const [items, setItems] = useState(rounds.map((r: any) => r.id))
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
// Sync if server data changes
|
||||
useEffect(() => {
|
||||
setItems(rounds.map((r: any) => r.id))
|
||||
}, [rounds])
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = items.indexOf(active.id as string)
|
||||
const newIndex = items.indexOf(over.id as string)
|
||||
const newItems = arrayMove(items, oldIndex, newIndex)
|
||||
setItems(newItems)
|
||||
onReorder(newItems)
|
||||
}
|
||||
|
||||
const roundMap = new Map(rounds.map((r: any) => [r.id, r]))
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((id, index) => {
|
||||
const round = roundMap.get(id)
|
||||
if (!round) return null
|
||||
return (
|
||||
<SortableRoundCard
|
||||
key={id}
|
||||
round={round}
|
||||
index={index}
|
||||
isFirst={index === 0}
|
||||
onDelete={onDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfidenceBadge({ confidence }: { confidence: number }) {
|
||||
if (confidence > 0.8) {
|
||||
return (
|
||||
@@ -370,8 +155,6 @@ export default function AwardDetailPage({
|
||||
const [activeTab, setActiveTab] = useState('eligibility')
|
||||
const [addRoundOpen, setAddRoundOpen] = useState(false)
|
||||
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
|
||||
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false)
|
||||
const [notifyCustomMessage, setNotifyCustomMessage] = useState<string | undefined>()
|
||||
|
||||
// Pagination for eligibility list
|
||||
const [eligibilityPage, setEligibilityPage] = useState(1)
|
||||
@@ -499,31 +282,6 @@ export default function AwardDetailPage({
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
const reorderRounds = trpc.specialAward.reorderAwardRounds.useMutation({
|
||||
onSuccess: () => refetchRounds(),
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
const assignToFirstRound = trpc.specialAward.assignToFirstRound.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Assigned ${result.totalAssigned} projects to first round (${result.createdCount} new, ${result.movedCount} moved)`)
|
||||
refetchRounds()
|
||||
refetch()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const notifyPreview = trpc.specialAward.previewAwardSelectionEmail.useQuery(
|
||||
{ awardId, customMessage: notifyCustomMessage },
|
||||
{ enabled: notifyDialogOpen }
|
||||
)
|
||||
const notifyEligible = trpc.specialAward.notifyEligibleProjects.useMutation({
|
||||
onSuccess: (result) => {
|
||||
toast.success(`Notified ${result.notified} projects (${result.emailsSent} emails sent${result.emailsFailed ? `, ${result.emailsFailed} failed` : ''})`)
|
||||
setNotifyDialogOpen(false)
|
||||
setNotifyCustomMessage(undefined)
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const handleStatusChange = async (
|
||||
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
|
||||
@@ -664,9 +422,11 @@ export default function AwardDetailPage({
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -708,44 +468,13 @@ export default function AwardDetailPage({
|
||||
</Button>
|
||||
)}
|
||||
{award.status === 'NOMINATIONS_OPEN' && (
|
||||
<>
|
||||
<Button variant="outline" disabled={award.eligibleCount === 0} onClick={() => setNotifyDialogOpen(true)}>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Notify Pool ({award.eligibleCount})
|
||||
</Button>
|
||||
<EmailPreviewDialog
|
||||
open={notifyDialogOpen}
|
||||
onOpenChange={setNotifyDialogOpen}
|
||||
title="Notify Eligible Projects"
|
||||
description={`Send "Under consideration for ${award.name}" emails to all ${award.eligibleCount} eligible projects.`}
|
||||
recipientCount={notifyPreview.data?.recipientCount ?? 0}
|
||||
previewHtml={notifyPreview.data?.html}
|
||||
isPreviewLoading={notifyPreview.isLoading}
|
||||
onSend={(msg) => notifyEligible.mutate({ awardId, customMessage: msg })}
|
||||
isSending={notifyEligible.isPending}
|
||||
onRefreshPreview={(msg) => setNotifyCustomMessage(msg)}
|
||||
/>
|
||||
{award.eligibilityMode === 'SEPARATE_POOL' ? (
|
||||
<Button
|
||||
onClick={() => assignToFirstRound.mutate({ awardId })}
|
||||
disabled={assignToFirstRound.isPending || award.eligibleCount === 0}
|
||||
>
|
||||
{assignToFirstRound.isPending ? (
|
||||
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Assigning...</>
|
||||
) : (
|
||||
<><ArrowRight className="mr-2 h-4 w-4" />Assign to First Round</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Open Voting
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
<Button
|
||||
onClick={() => handleStatusChange('VOTING_OPEN')}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Open Voting
|
||||
</Button>
|
||||
)}
|
||||
{award.status === 'VOTING_OPEN' && (
|
||||
<Button
|
||||
@@ -1023,7 +752,7 @@ export default function AwardDetailPage({
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{project.country ? <CountryDisplay country={project.country} /> : '-'}</TableCell>
|
||||
<TableCell className="text-sm">{project.country || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -1189,7 +918,7 @@ export default function AwardDetailPage({
|
||||
'-'
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{e.project.country ? <CountryDisplay country={e.project.country} /> : '-'}</TableCell>
|
||||
<TableCell>{e.project.country || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={e.method === 'MANUAL' ? 'secondary' : 'outline'} className="text-xs gap-1">
|
||||
{e.method === 'MANUAL' ? 'Manual' : <><Bot className="h-3 w-3" />AI Assessed</>}
|
||||
@@ -1336,7 +1065,7 @@ export default function AwardDetailPage({
|
||||
<TableRow key={j.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<UserAvatar user={j.user} avatarUrl={j.user.avatarUrl} size="sm" />
|
||||
<UserAvatar user={j.user} size="sm" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{j.user.name || 'Unnamed'}
|
||||
@@ -1479,13 +1208,99 @@ export default function AwardDetailPage({
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<RoundsDndGrid
|
||||
rounds={awardRounds}
|
||||
awardId={awardId}
|
||||
onReorder={(roundIds) => reorderRounds.mutate({ awardId, roundIds })}
|
||||
onDelete={(roundId) => deleteRound.mutate({ roundId })}
|
||||
isDeleting={deleteRound.isPending}
|
||||
/>
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{awardRounds.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',
|
||||
}
|
||||
const roundTypeColors: Record<string, string> = {
|
||||
EVALUATION: 'bg-violet-100 text-violet-700',
|
||||
FILTERING: 'bg-amber-100 text-amber-700',
|
||||
SUBMISSION: 'bg-blue-100 text-blue-700',
|
||||
MENTORING: 'bg-teal-100 text-teal-700',
|
||||
LIVE_FINAL: 'bg-rose-100 text-rose-700',
|
||||
DELIBERATION: 'bg-indigo-100 text-indigo-700',
|
||||
}
|
||||
return (
|
||||
<Card key={round.id} className="hover:shadow-md transition-shadow h-full">
|
||||
<CardContent className="pt-4 pb-3 space-y-3">
|
||||
<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">
|
||||
<Link href={`/admin/rounds/${round.id}` as any} className="text-sm font-semibold truncate hover:underline">
|
||||
{round.name}
|
||||
</Link>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
<Badge variant="secondary" className={`text-[10px] ${roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}`}>
|
||||
{round.roundType.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={`text-[10px] ${statusColors[statusLabel]}`}>
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{index === 0 && (
|
||||
<Badge variant="outline" className="text-[10px] border-amber-300 bg-amber-50 text-amber-700">
|
||||
Entry point
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{assignmentCount > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
<span>{assignmentCount} assignment{assignmentCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{round.status === 'ROUND_DRAFT' && (
|
||||
<div className="flex justify-end pt-1">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Round</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete "{round.name}". This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteRound.mutate({ roundId: round.id })}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -69,9 +69,11 @@ export default function CreateAwardPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/awards">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Awards
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
599
src/app/(admin)/admin/competitions/[competitionId]/page.tsx
Normal file
599
src/app/(admin)/admin/competitions/[competitionId]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
307
src/app/(admin)/admin/competitions/new/page.tsx
Normal file
307
src/app/(admin)/admin/competitions/new/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
src/app/(admin)/admin/competitions/page.tsx
Normal file
201
src/app/(admin)/admin/competitions/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -35,98 +23,21 @@ import { ActivityFeed } from '@/components/dashboard/activity-feed'
|
||||
import { CategoryBreakdown } from '@/components/dashboard/category-breakdown'
|
||||
import { DashboardSkeleton } from '@/components/dashboard/dashboard-skeleton'
|
||||
import { RecentEvaluations } from '@/components/dashboard/recent-evaluations'
|
||||
import { RoundUserTracker } from '@/components/dashboard/round-user-tracker'
|
||||
|
||||
type DashboardContentProps = {
|
||||
editionId: string
|
||||
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 },
|
||||
{ enabled: !!editionId, refetchInterval: 60_000 }
|
||||
{ enabled: !!editionId, retry: 1, refetchInterval: 30_000 }
|
||||
)
|
||||
const { data: recentEvals } = trpc.dashboard.getRecentEvaluations.useQuery(
|
||||
{ editionId, limit: 8 },
|
||||
{ enabled: !!editionId, refetchInterval: 60_000 }
|
||||
)
|
||||
const { data: liveActivity } = trpc.dashboard.getRecentActivity.useQuery(
|
||||
{ limit: 8 },
|
||||
{ enabled: !!editionId, refetchInterval: 30_000 }
|
||||
)
|
||||
// Round User Tracker is self-contained — it fetches its own data
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton />
|
||||
@@ -172,7 +83,6 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
evaluationStats,
|
||||
totalAssignments,
|
||||
latestProjects,
|
||||
recentlyActiveProjects,
|
||||
categoryBreakdown,
|
||||
oceanIssueBreakdown,
|
||||
recentActivity,
|
||||
@@ -182,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 */}
|
||||
@@ -210,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>
|
||||
|
||||
@@ -238,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>
|
||||
|
||||
@@ -253,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 && (
|
||||
@@ -274,11 +178,7 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={6}>
|
||||
<RoundUserTracker editionId={editionId} />
|
||||
</AnimatedCard>
|
||||
|
||||
<AnimatedCard index={7}>
|
||||
<ActivityFeed activity={liveActivity ?? recentActivity} />
|
||||
<ActivityFeed activity={recentActivity} />
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,12 +186,12 @@ export function DashboardContent({ editionId, sessionName }: DashboardContentPro
|
||||
{/* Bottom Full Width */}
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<div className="lg:col-span-8">
|
||||
<AnimatedCard index={8}>
|
||||
<AnimatedCard index={7}>
|
||||
<GeographicSummaryCard programId={editionId} />
|
||||
</AnimatedCard>
|
||||
</div>
|
||||
<div className="lg:col-span-4">
|
||||
<AnimatedCard index={9}>
|
||||
<AnimatedCard index={8}>
|
||||
<CategoryBreakdown
|
||||
categories={categoryBreakdown}
|
||||
issues={oceanIssueBreakdown}
|
||||
|
||||
@@ -194,8 +194,10 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-muted-foreground">The requested jury group could not be found.</p>
|
||||
<Button className="mt-4" variant="outline" onClick={() => router.back()}>
|
||||
Back
|
||||
<Button asChild className="mt-4" variant="outline">
|
||||
<Link href={'/admin/juries' as Route}>
|
||||
Back to Juries
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -210,11 +212,13 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="mb-2"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back
|
||||
<Link href={'/admin/juries' as Route}>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Juries
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
@@ -285,9 +289,7 @@ export default function JuryGroupDetailPage({ params }: JuryGroupDetailPageProps
|
||||
{group.members.map((member) => (
|
||||
<TableRow key={member.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/admin/members/${member.user.id}` as Route} className="hover:underline text-primary">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</Link>
|
||||
{member.user.name || 'Unnamed'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{member.user.email}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
'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'
|
||||
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,
|
||||
@@ -19,14 +24,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -38,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()
|
||||
@@ -108,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' })
|
||||
@@ -137,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])
|
||||
|
||||
@@ -158,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>
|
||||
)
|
||||
@@ -248,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>
|
||||
@@ -256,257 +218,264 @@ export default function EditLearningResourcePage() {
|
||||
The resource you're looking for does not exist.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<Button onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button asChild>
|
||||
<Link href="/admin/learning">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Learning Hub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<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 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 "{resource.title}"? 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 "{resource.title}"?
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
'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'
|
||||
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,
|
||||
@@ -17,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()
|
||||
@@ -76,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({
|
||||
@@ -102,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,
|
||||
})
|
||||
@@ -146,203 +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" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>·</span>
|
||||
<span>{resource._count.accessLogs} views</span>
|
||||
{resource.program && (
|
||||
<>
|
||||
<span>·</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" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Papa from 'papaparse'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -258,7 +257,6 @@ function TagPicker({
|
||||
}
|
||||
|
||||
export default function MemberInvitePage() {
|
||||
const router = useRouter()
|
||||
const [step, setStep] = useState<Step>('input')
|
||||
const [inputMethod, setInputMethod] = useState<'manual' | 'csv'>('manual')
|
||||
const [rows, setRows] = useState<MemberRow[]>([createEmptyRow()])
|
||||
@@ -1046,9 +1044,11 @@ export default function MemberInvitePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/members">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Members
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function MentorsPage() {
|
||||
redirect('/admin/members')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -83,7 +83,6 @@ const defaultForm: TemplateFormData = {
|
||||
}
|
||||
|
||||
export default function MessageTemplatesPage() {
|
||||
const router = useRouter()
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
@@ -184,9 +183,11 @@ export default function MessageTemplatesPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/messages">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Messages
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -135,9 +135,11 @@ export default function EditPartnerPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -66,9 +66,11 @@ export default function NewPartnerPage() {
|
||||
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>
|
||||
<Link href="/admin/partners">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Add Partner</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -134,9 +134,11 @@ export default function EditProgramPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Link href={`/admin/programs/${id}`}>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Edit Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -67,7 +68,6 @@ const defaultMilestoneForm: MilestoneFormData = {
|
||||
|
||||
export default function MentorshipMilestonesPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const programId = params.id as string
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
@@ -184,9 +184,11 @@ export default function MentorshipMilestonesPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/programs">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Programs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -56,9 +56,11 @@ export default function NewProgramPage() {
|
||||
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>
|
||||
<Link href="/admin/programs">
|
||||
<Button variant="ghost" size="icon">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Create Program</h1>
|
||||
<p className="text-muted-foreground">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Suspense, use, useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useForm } from 'react-hook-form'
|
||||
@@ -19,8 +19,6 @@ import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { CountrySelect } from '@/components/ui/country-select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
@@ -57,7 +55,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
import { LogoUpload } from '@/components/shared/logo-upload'
|
||||
@@ -90,15 +87,8 @@ const updateProjectSchema = z.object({
|
||||
'SEMIFINALIST',
|
||||
'FINALIST',
|
||||
'REJECTED',
|
||||
]).optional(),
|
||||
]),
|
||||
tags: z.array(z.string()),
|
||||
competitionCategory: z.string().optional(),
|
||||
oceanIssue: z.string().optional(),
|
||||
institution: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
geographicZone: z.string().optional(),
|
||||
wantsMentorship: z.boolean().optional(),
|
||||
foundedAt: z.string().optional(),
|
||||
})
|
||||
|
||||
type UpdateProjectForm = z.infer<typeof updateProjectSchema>
|
||||
@@ -134,27 +124,6 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
// Fetch existing tags for suggestions
|
||||
const { data: existingTags } = trpc.project.getTags.useQuery({})
|
||||
|
||||
// Fetch submission round config to show required documents
|
||||
const programId = project?.programId
|
||||
const { data: submissionRound } = trpc.round.getSubmissionRoundForProgram.useQuery(
|
||||
{ programId: programId! },
|
||||
{ enabled: !!programId }
|
||||
)
|
||||
|
||||
const submissionRoundConfig = useMemo(() => {
|
||||
if (!submissionRound?.configJson) return null
|
||||
const config = submissionRound.configJson as Record<string, unknown>
|
||||
const docs = config.requiredDocuments as
|
||||
| Array<{ name: string; required?: boolean; description?: string }>
|
||||
| null
|
||||
| undefined
|
||||
if (!docs || docs.length === 0) return null
|
||||
return {
|
||||
roundName: submissionRound.name,
|
||||
requiredDocuments: docs,
|
||||
}
|
||||
}, [submissionRound])
|
||||
|
||||
// Mutations
|
||||
const utils = trpc.useUtils()
|
||||
const updateProject = trpc.project.update.useMutation({
|
||||
@@ -186,15 +155,8 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
title: '',
|
||||
teamName: '',
|
||||
description: '',
|
||||
status: 'SUBMITTED' as const,
|
||||
status: 'SUBMITTED',
|
||||
tags: [],
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
institution: '',
|
||||
country: '',
|
||||
geographicZone: '',
|
||||
wantsMentorship: false,
|
||||
foundedAt: '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -205,15 +167,8 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
title: project.title,
|
||||
teamName: project.teamName || '',
|
||||
description: project.description || '',
|
||||
status: (project.status || 'SUBMITTED') as UpdateProjectForm['status'],
|
||||
status: (project.status ?? 'SUBMITTED') as UpdateProjectForm['status'],
|
||||
tags: project.tags || [],
|
||||
competitionCategory: project.competitionCategory || '',
|
||||
oceanIssue: project.oceanIssue || '',
|
||||
institution: project.institution || '',
|
||||
country: project.country || '',
|
||||
geographicZone: project.geographicZone || '',
|
||||
wantsMentorship: project.wantsMentorship ?? false,
|
||||
foundedAt: project.foundedAt ? new Date(project.foundedAt).toISOString().split('T')[0] : '',
|
||||
})
|
||||
}
|
||||
}, [project, form])
|
||||
@@ -221,7 +176,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
const tags = form.watch('tags')
|
||||
const selectedStatus = form.watch('status')
|
||||
const previousStatus = (project?.status ?? 'SUBMITTED') as UpdateProjectForm['status']
|
||||
const statusTriggersNotifications = !!selectedStatus && ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
|
||||
const statusTriggersNotifications = ['SEMIFINALIST', 'FINALIST', 'REJECTED'].includes(selectedStatus)
|
||||
const requiresStatusNotificationConfirmation = Boolean(
|
||||
project && selectedStatus !== previousStatus && statusTriggersNotifications
|
||||
)
|
||||
@@ -267,21 +222,13 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
return
|
||||
}
|
||||
|
||||
const statusChanged = data.status !== previousStatus
|
||||
await updateProject.mutateAsync({
|
||||
id: projectId,
|
||||
title: data.title,
|
||||
teamName: data.teamName || null,
|
||||
description: data.description || null,
|
||||
...(statusChanged && { status: data.status }),
|
||||
status: data.status,
|
||||
tags: data.tags,
|
||||
competitionCategory: (data.competitionCategory || null) as 'STARTUP' | 'BUSINESS_CONCEPT' | null,
|
||||
oceanIssue: (data.oceanIssue || null) as 'POLLUTION_REDUCTION' | 'CLIMATE_MITIGATION' | 'TECHNOLOGY_INNOVATION' | 'SUSTAINABLE_SHIPPING' | 'BLUE_CARBON' | 'HABITAT_RESTORATION' | 'COMMUNITY_CAPACITY' | 'SUSTAINABLE_FISHING' | 'CONSUMER_AWARENESS' | 'OCEAN_ACIDIFICATION' | 'OTHER' | null,
|
||||
institution: data.institution || null,
|
||||
country: data.country || null,
|
||||
geographicZone: data.geographicZone || null,
|
||||
wantsMentorship: data.wantsMentorship,
|
||||
foundedAt: data.foundedAt ? new Date(data.foundedAt).toISOString() : null,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -300,17 +247,19 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">Back to Projects</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -328,9 +277,11 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -435,7 +386,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status..." />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
@@ -487,159 +438,6 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Project Details */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">Project Details</CardTitle>
|
||||
<CardDescription>
|
||||
Additional categorization and metadata for this project
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="competitionCategory"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Competition Category</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || ''}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category..." />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="STARTUP">Startup</SelectItem>
|
||||
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="oceanIssue"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ocean Issue</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || ''}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ocean issue..." />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="POLLUTION_REDUCTION">Pollution Reduction</SelectItem>
|
||||
<SelectItem value="CLIMATE_MITIGATION">Climate Mitigation</SelectItem>
|
||||
<SelectItem value="TECHNOLOGY_INNOVATION">Technology Innovation</SelectItem>
|
||||
<SelectItem value="SUSTAINABLE_SHIPPING">Sustainable Shipping</SelectItem>
|
||||
<SelectItem value="BLUE_CARBON">Blue Carbon</SelectItem>
|
||||
<SelectItem value="HABITAT_RESTORATION">Habitat Restoration</SelectItem>
|
||||
<SelectItem value="COMMUNITY_CAPACITY">Community Capacity</SelectItem>
|
||||
<SelectItem value="SUSTAINABLE_FISHING">Sustainable Fishing</SelectItem>
|
||||
<SelectItem value="CONSUMER_AWARENESS">Consumer Awareness</SelectItem>
|
||||
<SelectItem value="OCEAN_ACIDIFICATION">Ocean Acidification</SelectItem>
|
||||
<SelectItem value="OTHER">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="institution"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Institution</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Institution or organization" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="country"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Country</FormLabel>
|
||||
<FormControl>
|
||||
<CountrySelect
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
placeholder="Select country..."
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="geographicZone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Geographic Zone</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g. Europe, France" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="foundedAt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Founded Date</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="date" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="wantsMentorship"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Wants Mentorship</FormLabel>
|
||||
<FormDescription>
|
||||
Whether this project team is interested in mentorship
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tags */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -721,34 +519,7 @@ function EditProjectContent({ projectId }: { projectId: string }) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{submissionRoundConfig && (
|
||||
<div className="mb-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Required Documents</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
From {submissionRoundConfig.roundName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{submissionRoundConfig.requiredDocuments.map((doc, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-md border border-dashed p-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{doc.name}</p>
|
||||
{doc.description && (
|
||||
<p className="text-xs text-muted-foreground">{doc.description}</p>
|
||||
)}
|
||||
{doc.required && (
|
||||
<Badge variant="outline" className="mt-1 text-xs">Required</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{files && files.length > 0 ? (
|
||||
{files && files.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -46,7 +46,6 @@ interface MentorSuggestion {
|
||||
}
|
||||
|
||||
function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
@@ -129,9 +128,11 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href={`/admin/projects/${projectId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Project
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Suspense, use, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import type { Route } from 'next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import {
|
||||
Card,
|
||||
@@ -24,35 +23,18 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { FileViewer } from '@/components/shared/file-viewer'
|
||||
import { FileUpload } from '@/components/shared/file-upload'
|
||||
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
|
||||
import { UserAvatar } from '@/components/shared/user-avatar'
|
||||
import { EvaluationSummaryCard } from '@/components/admin/evaluation-summary-card'
|
||||
import { EvaluationEditSheet } from '@/components/admin/evaluation-edit-sheet'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { AnimatedCard } from '@/components/shared/animated-container'
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -61,6 +43,7 @@ import {
|
||||
Users,
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
BarChart3,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
@@ -73,13 +56,10 @@ import {
|
||||
Loader2,
|
||||
ScanSearch,
|
||||
Eye,
|
||||
Plus,
|
||||
X,
|
||||
MessageSquare,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { formatDateOnly } from '@/lib/utils'
|
||||
import { getCountryName, getCountryFlag } from '@/lib/countries'
|
||||
import { CountryDisplay } from '@/components/shared/country-display'
|
||||
import { formatDate, formatDateOnly } from '@/lib/utils'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
@@ -104,7 +84,6 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
|
||||
}
|
||||
|
||||
function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
const router = useRouter()
|
||||
// Fetch project + assignments + stats in a single combined query
|
||||
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
|
||||
{ id: projectId },
|
||||
@@ -149,52 +128,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
|
||||
|
||||
// State for add member dialog
|
||||
const [addMemberOpen, setAddMemberOpen] = useState(false)
|
||||
const [addMemberForm, setAddMemberForm] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
role: 'MEMBER' as 'LEAD' | 'MEMBER' | 'ADVISOR',
|
||||
title: '',
|
||||
sendInvite: true,
|
||||
})
|
||||
|
||||
// State for remove member confirmation
|
||||
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
|
||||
|
||||
const addTeamMember = trpc.project.addTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member added')
|
||||
setAddMemberOpen(false)
|
||||
setAddMemberForm({ email: '', name: '', role: 'MEMBER', title: '', sendInvite: true })
|
||||
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || 'Failed to add team member')
|
||||
},
|
||||
})
|
||||
|
||||
const updateTeamMemberRole = trpc.project.updateTeamMemberRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Role updated')
|
||||
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || 'Failed to update role')
|
||||
},
|
||||
})
|
||||
|
||||
const removeTeamMember = trpc.project.removeTeamMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('Team member removed')
|
||||
setRemovingMemberId(null)
|
||||
utils.project.getFullDetail.invalidate({ id: projectId })
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message || 'Failed to remove team member')
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <ProjectDetailSkeleton />
|
||||
}
|
||||
@@ -202,17 +135,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-destructive/50" />
|
||||
<p className="mt-2 font-medium">Project Not Found</p>
|
||||
<Button className="mt-4" onClick={() => router.back()}>
|
||||
Back
|
||||
<Button asChild className="mt-4">
|
||||
<Link href="/admin/projects">Back to Projects</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -224,9 +159,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -236,7 +173,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
project={project}
|
||||
size="lg"
|
||||
fallback="initials"
|
||||
clickToEnlarge
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
|
||||
@@ -255,13 +191,9 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{project.title}
|
||||
</h1>
|
||||
{(() => {
|
||||
const prs = (project as any).projectRoundStates ?? []
|
||||
if (!prs.length) return <Badge variant="secondary">Submitted</Badge>
|
||||
if (prs.some((p: any) => p.state === 'REJECTED')) return <Badge variant="destructive">Rejected</Badge>
|
||||
const latest = prs[0]
|
||||
return <Badge variant={latest.state === 'PASSED' ? 'default' : 'secondary'}>{latest.round.name}</Badge>
|
||||
})()}
|
||||
<Badge variant={statusColors[project.status ?? 'SUBMITTED'] || 'secondary'}>
|
||||
{(project.status ?? 'SUBMITTED').replace('_', ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
{project.teamName && (
|
||||
<p className="text-muted-foreground">{project.teamName}</p>
|
||||
@@ -374,7 +306,7 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Location</p>
|
||||
<p className="text-sm">{project.geographicZone}{project.geographicZone && project.country ? ', ' : ''}{project.country ? <CountryDisplay country={project.country} /> : null}</p>
|
||||
<p className="text-sm">{project.geographicZone || project.country}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -505,229 +437,53 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Team Members Section */}
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Team Members ({project.teamMembers?.length ?? 0})
|
||||
</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddMemberOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{project.teamMembers && project.teamMembers.length > 0 ? (
|
||||
{project.teamMembers && project.teamMembers.length > 0 && (
|
||||
<AnimatedCard index={2}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2.5 text-lg">
|
||||
<div className="rounded-lg bg-violet-500/10 p-1.5">
|
||||
<Users className="h-4 w-4 text-violet-500" />
|
||||
</div>
|
||||
Team Members ({project.teamMembers.length})
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null; nationality?: string | null; country?: string | null; institution?: string | null } }) => {
|
||||
const isLastLead =
|
||||
member.role === 'LEAD' &&
|
||||
project.teamMembers.filter((m: { role: string }) => m.role === 'LEAD').length <= 1
|
||||
const details = [
|
||||
member.user.nationality ? `${getCountryFlag(member.user.nationality)} ${getCountryName(member.user.nationality)}` : null,
|
||||
member.user.institution,
|
||||
member.user.country && member.user.country !== member.user.nationality ? `${getCountryFlag(member.user.country)} ${getCountryName(member.user.country)}` : null,
|
||||
].filter(Boolean)
|
||||
return (
|
||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||
{member.role === 'LEAD' ? (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
</div>
|
||||
) : (
|
||||
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/members/${member.user.id}`} className="font-medium text-sm truncate hover:underline text-primary">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</Link>
|
||||
<Select
|
||||
value={member.role}
|
||||
onValueChange={(value) =>
|
||||
updateTeamMemberRole.mutate({
|
||||
projectId: project.id,
|
||||
userId: member.user.id,
|
||||
role: value as 'LEAD' | 'MEMBER' | 'ADVISOR',
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-auto text-xs px-2 py-0 border-dashed gap-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LEAD">Lead</SelectItem>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{member.user.email}
|
||||
</p>
|
||||
{member.title && (
|
||||
<p className="text-xs text-muted-foreground">{member.title}</p>
|
||||
)}
|
||||
{details.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{details.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string; avatarUrl?: string | null } }) => (
|
||||
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
|
||||
{member.role === 'LEAD' ? (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<Crown className="h-5 w-5 text-yellow-500" />
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
disabled={isLastLead}
|
||||
onClick={() => setRemovingMemberId(member.user.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isLastLead && (
|
||||
<TooltipContent>
|
||||
Cannot remove the last team lead
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<UserAvatar user={member.user} avatarUrl={member.user.avatarUrl} size="md" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{member.user.name || 'Unnamed'}
|
||||
</p>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{member.user.email}
|
||||
</p>
|
||||
{member.title && (
|
||||
<p className="text-xs text-muted-foreground">{member.title}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No team members yet.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
|
||||
{/* Add Member Dialog */}
|
||||
<Dialog open={addMemberOpen} onOpenChange={setAddMemberOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Team Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-email">Email</Label>
|
||||
<Input
|
||||
id="member-email"
|
||||
type="email"
|
||||
placeholder="member@example.com"
|
||||
value={addMemberForm.email}
|
||||
onChange={(e) => setAddMemberForm((f) => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-name">Name</Label>
|
||||
<Input
|
||||
id="member-name"
|
||||
placeholder="Full name"
|
||||
value={addMemberForm.name}
|
||||
onChange={(e) => setAddMemberForm((f) => ({ ...f, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-role">Role</Label>
|
||||
<Select
|
||||
value={addMemberForm.role}
|
||||
onValueChange={(v) => setAddMemberForm((f) => ({ ...f, role: v as 'LEAD' | 'MEMBER' | 'ADVISOR' }))}
|
||||
>
|
||||
<SelectTrigger id="member-role">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="LEAD">Lead</SelectItem>
|
||||
<SelectItem value="MEMBER">Member</SelectItem>
|
||||
<SelectItem value="ADVISOR">Advisor</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="member-title">Title (optional)</Label>
|
||||
<Input
|
||||
id="member-title"
|
||||
placeholder="e.g. CEO, Co-founder"
|
||||
value={addMemberForm.title}
|
||||
onChange={(e) => setAddMemberForm((f) => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="member-invite"
|
||||
checked={addMemberForm.sendInvite}
|
||||
onCheckedChange={(checked) =>
|
||||
setAddMemberForm((f) => ({ ...f, sendInvite: checked === true }))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="member-invite" className="font-normal cursor-pointer">
|
||||
Send invite email
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddMemberOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
addTeamMember.mutate({
|
||||
projectId,
|
||||
email: addMemberForm.email,
|
||||
name: addMemberForm.name,
|
||||
role: addMemberForm.role,
|
||||
title: addMemberForm.title || undefined,
|
||||
sendInvite: addMemberForm.sendInvite,
|
||||
})
|
||||
}
|
||||
disabled={addTeamMember.isPending || !addMemberForm.email || !addMemberForm.name}
|
||||
>
|
||||
{addTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Add Member
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Remove Member Confirmation Dialog */}
|
||||
<Dialog open={!!removingMemberId} onOpenChange={(open) => { if (!open) setRemovingMemberId(null) }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Remove Team Member</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Are you sure you want to remove this team member? This action cannot be undone.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemovingMemberId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (removingMemberId) {
|
||||
removeTeamMember.mutate({ projectId, userId: removingMemberId })
|
||||
}
|
||||
}}
|
||||
disabled={removeTeamMember.isPending}
|
||||
>
|
||||
{removeTeamMember.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Remove
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</AnimatedCard>
|
||||
)}
|
||||
|
||||
{/* Mentor Assignment Section */}
|
||||
{project.wantsMentorship && (
|
||||
@@ -815,48 +571,33 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* All Files list — grouped by round */}
|
||||
{/* All Files list */}
|
||||
{files && files.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<FileViewer
|
||||
projectId={projectId}
|
||||
groupedFiles={(() => {
|
||||
const groups = new Map<string, { roundId: string | null; roundName: string; sortOrder: number; files: typeof mappedFiles }>()
|
||||
const mappedFiles = files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType as 'EXEC_SUMMARY' | 'PRESENTATION' | 'VIDEO' | 'OTHER' | 'BUSINESS_PLAN' | 'VIDEO_PITCH' | 'SUPPORTING_DOC',
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement ? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
description: f.requirement.description,
|
||||
isRequired: f.requirement.isRequired,
|
||||
} : null,
|
||||
}))
|
||||
for (const f of files) {
|
||||
const roundId = f.requirement?.roundId ?? null
|
||||
const roundName = f.requirement?.round?.name ?? 'General'
|
||||
const sortOrder = f.requirement?.round?.sortOrder ?? -1
|
||||
const key = roundId ?? '_general'
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { roundId, roundName, sortOrder, files: [] })
|
||||
}
|
||||
const mapped = mappedFiles.find((m) => m.id === f.id)!
|
||||
groups.get(key)!.files.push(mapped)
|
||||
}
|
||||
return Array.from(groups.values())
|
||||
})()}
|
||||
files={files.map((f) => ({
|
||||
id: f.id,
|
||||
fileName: f.fileName,
|
||||
fileType: f.fileType,
|
||||
mimeType: f.mimeType,
|
||||
size: f.size,
|
||||
bucket: f.bucket,
|
||||
objectKey: f.objectKey,
|
||||
pageCount: f.pageCount,
|
||||
textPreview: f.textPreview,
|
||||
detectedLang: f.detectedLang,
|
||||
langConfidence: f.langConfidence,
|
||||
analyzedAt: f.analyzedAt ? String(f.analyzedAt) : null,
|
||||
requirementId: f.requirementId,
|
||||
requirement: f.requirement ? {
|
||||
id: f.requirement.id,
|
||||
name: f.requirement.name,
|
||||
description: f.requirement.description,
|
||||
isRequired: f.requirement.isRequired,
|
||||
} : null,
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -1001,11 +742,10 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
|
||||
)}
|
||||
|
||||
{/* Evaluation Detail Sheet */}
|
||||
<EvaluationEditSheet
|
||||
<EvaluationDetailSheet
|
||||
assignment={selectedEvalAssignment}
|
||||
open={!!selectedEvalAssignment}
|
||||
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
|
||||
onSaved={() => utils.project.getFullDetail.invalidate({ id: projectId })}
|
||||
/>
|
||||
|
||||
{/* AI Evaluation Summary */}
|
||||
@@ -1090,6 +830,173 @@ function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string;
|
||||
)
|
||||
}
|
||||
|
||||
function EvaluationDetailSheet({
|
||||
assignment,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
assignment: any
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
if (!assignment?.evaluation) return null
|
||||
|
||||
const ev = assignment.evaluation
|
||||
const criterionScores = (ev.criterionScoresJson || {}) as Record<string, number | boolean | string>
|
||||
const hasScores = Object.keys(criterionScores).length > 0
|
||||
|
||||
// Try to get the evaluation form for labels
|
||||
const roundId = assignment.roundId as string | undefined
|
||||
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
|
||||
{ roundId: roundId ?? '' },
|
||||
{ enabled: !!roundId }
|
||||
)
|
||||
|
||||
// Build label lookup from form criteria
|
||||
const criteriaMap = new Map<string, { label: string; type: string; trueLabel?: string; falseLabel?: string }>()
|
||||
if (activeForm?.criteriaJson) {
|
||||
for (const c of activeForm.criteriaJson as Array<{ id: string; label: string; type?: string; trueLabel?: string; falseLabel?: string }>) {
|
||||
criteriaMap.set(c.id, {
|
||||
label: c.label,
|
||||
type: c.type || 'numeric',
|
||||
trueLabel: c.trueLabel,
|
||||
falseLabel: c.falseLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-lg overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<UserAvatar user={assignment.user} avatarUrl={assignment.user.avatarUrl} size="sm" />
|
||||
{assignment.user.name || assignment.user.email}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{ev.submittedAt
|
||||
? `Submitted ${formatDate(ev.submittedAt)}`
|
||||
: 'Evaluation details'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6 mt-6">
|
||||
{/* Global stats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<p className="text-xs text-muted-foreground">Score</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{ev.globalScore !== null ? `${ev.globalScore}/10` : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<p className="text-xs text-muted-foreground">Decision</p>
|
||||
<div className="mt-1">
|
||||
{ev.binaryDecision !== null ? (
|
||||
ev.binaryDecision ? (
|
||||
<div className="flex items-center gap-1.5 text-emerald-600">
|
||||
<ThumbsUp className="h-5 w-5" />
|
||||
<span className="font-semibold">Yes</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-red-600">
|
||||
<ThumbsDown className="h-5 w-5" />
|
||||
<span className="font-semibold">No</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<span className="text-2xl font-bold">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criterion Scores */}
|
||||
{hasScores && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-3 flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Criterion Scores
|
||||
</h4>
|
||||
<div className="space-y-2.5">
|
||||
{Object.entries(criterionScores).map(([key, value]) => {
|
||||
const meta = criteriaMap.get(key)
|
||||
const label = meta?.label || key
|
||||
const type = meta?.type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'string' ? 'text' : 'numeric')
|
||||
|
||||
if (type === 'section_header') return null
|
||||
|
||||
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>
|
||||
{value === true ? (
|
||||
<Badge className="bg-emerald-100 text-emerald-700 border-emerald-200" variant="outline">
|
||||
<ThumbsUp className="mr-1 h-3 w-3" />
|
||||
{meta?.trueLabel || 'Yes'}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-700 border-red-200" variant="outline">
|
||||
<ThumbsDown className="mr-1 h-3 w-3" />
|
||||
{meta?.falseLabel || 'No'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'text') {
|
||||
return (
|
||||
<div key={key} className="space-y-1">
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
<div className="text-sm text-muted-foreground p-2.5 rounded-lg border bg-muted/50 whitespace-pre-wrap">
|
||||
{typeof value === 'string' ? value : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Numeric
|
||||
return (
|
||||
<div key={key} className="flex items-center gap-3 p-2.5 rounded-lg border">
|
||||
<span className="text-sm flex-1 truncate">{label}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-20 h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary"
|
||||
style={{ width: `${(Number(value) / 10) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-bold tabular-nums w-8 text-right">
|
||||
{typeof value === 'number' ? value : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Text */}
|
||||
{ev.feedbackText && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Feedback
|
||||
</h4>
|
||||
<div className="text-sm text-muted-foreground p-3 rounded-lg border bg-muted/30 whitespace-pre-wrap leading-relaxed">
|
||||
{ev.feedbackText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProjectDetailPage({ params }: PageProps) {
|
||||
const { id } = use(params)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -63,7 +62,6 @@ type UploadState = {
|
||||
type UploadMap = Record<string, UploadState>
|
||||
|
||||
export default function BulkUploadPage() {
|
||||
const router = useRouter()
|
||||
const [roundId, setRoundId] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('')
|
||||
@@ -148,7 +146,7 @@ export default function BulkUploadPage() {
|
||||
const handleViewFile = useCallback(
|
||||
async (bucket: string, objectKey: string) => {
|
||||
try {
|
||||
const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey, purpose: 'open' as const })
|
||||
const { url } = await utils.file.getDownloadUrl.fetch({ bucket, objectKey })
|
||||
window.open(url, '_blank')
|
||||
} catch {
|
||||
toast.error('Failed to open file. It may have been deleted from storage.')
|
||||
@@ -298,8 +296,10 @@ export default function BulkUploadPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.back()}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Bulk Document Upload</h1>
|
||||
|
||||
@@ -59,9 +59,11 @@ function ImportPageContent() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -216,7 +216,6 @@ function NewProjectPageContent() {
|
||||
|
||||
createProject.mutate({
|
||||
programId: selectedProgramId,
|
||||
roundId: selectedRoundId || undefined,
|
||||
title: title.trim(),
|
||||
teamName: teamName.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
@@ -246,9 +245,11 @@ function NewProjectPageContent() {
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" className="-ml-4" onClick={() => router.back()}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
<Button variant="ghost" asChild className="-ml-4">
|
||||
<Link href="/admin/projects">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Projects
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from 'next/link'
|
||||
import { useSearchParams, usePathname } from 'next/navigation'
|
||||
import { trpc } from '@/lib/trpc/client'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -73,7 +72,6 @@ import {
|
||||
ArrowRightCircle,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Select,
|
||||
@@ -92,10 +90,8 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
import { truncate } from '@/lib/utils'
|
||||
import { ProjectLogo } from '@/components/shared/project-logo'
|
||||
import { BulkNotificationDialog } from '@/components/admin/projects/bulk-notification-dialog'
|
||||
|
||||
import { StatusBadge } from '@/components/shared/status-badge'
|
||||
import { Pagination } from '@/components/shared/pagination'
|
||||
import { SortableHeader } from '@/components/shared/sortable-header'
|
||||
import { getCountryName, getCountryFlag, normalizeCountryToCode } from '@/lib/countries'
|
||||
import { CountryFlagImg } from '@/components/ui/country-select'
|
||||
import {
|
||||
@@ -117,25 +113,6 @@ const statusColors: Record<
|
||||
WINNER: 'success',
|
||||
REJECTED: 'destructive',
|
||||
WITHDRAWN: 'secondary',
|
||||
// Round-state-based statuses
|
||||
PENDING: 'secondary',
|
||||
IN_PROGRESS: 'default',
|
||||
COMPLETED: 'default',
|
||||
PASSED: 'success',
|
||||
}
|
||||
|
||||
type ProjectRoundStateInfo = {
|
||||
state: string
|
||||
round: { name: string; sortOrder: number }
|
||||
}
|
||||
|
||||
function deriveProjectStatus(prs: ProjectRoundStateInfo[]): { label: string; variant: 'default' | 'success' | 'secondary' | 'destructive' | 'warning' } {
|
||||
if (!prs.length) return { label: 'Submitted', variant: 'secondary' }
|
||||
if (prs.some((p) => p.state === 'REJECTED')) return { label: 'Rejected', variant: 'destructive' }
|
||||
// prs is already sorted by sortOrder desc — first item is the latest round
|
||||
const latest = prs[0]
|
||||
if (latest.state === 'PASSED') return { label: latest.round.name, variant: 'success' }
|
||||
return { label: latest.round.name, variant: 'default' }
|
||||
}
|
||||
|
||||
function parseFiltersFromParams(
|
||||
@@ -146,9 +123,6 @@ function parseFiltersFromParams(
|
||||
statuses: searchParams.get('status')
|
||||
? searchParams.get('status')!.split(',')
|
||||
: [],
|
||||
roundStates: searchParams.get('roundState')
|
||||
? searchParams.get('roundState')!.split(',')
|
||||
: [],
|
||||
roundId: searchParams.get('round') || '',
|
||||
competitionCategory: searchParams.get('category') || '',
|
||||
oceanIssue: searchParams.get('issue') || '',
|
||||
@@ -183,8 +157,6 @@ function filtersToParams(
|
||||
if (filters.search) params.set('q', filters.search)
|
||||
if (filters.statuses.length > 0)
|
||||
params.set('status', filters.statuses.join(','))
|
||||
if (filters.roundStates.length > 0)
|
||||
params.set('roundState', filters.roundStates.join(','))
|
||||
if (filters.roundId) params.set('round', filters.roundId)
|
||||
if (filters.competitionCategory)
|
||||
params.set('category', filters.competitionCategory)
|
||||
@@ -210,7 +182,6 @@ export default function ProjectsPage() {
|
||||
const [filters, setFilters] = useState<ProjectFilters>({
|
||||
search: parsed.search,
|
||||
statuses: parsed.statuses,
|
||||
roundStates: parsed.roundStates,
|
||||
roundId: parsed.roundId,
|
||||
competitionCategory: parsed.competitionCategory,
|
||||
oceanIssue: parsed.oceanIssue,
|
||||
@@ -223,8 +194,6 @@ export default function ProjectsPage() {
|
||||
const [perPage, setPerPage] = useState(parsed.perPage || 20)
|
||||
const [searchInput, setSearchInput] = useState(parsed.search)
|
||||
const [viewMode, setViewMode] = useState<'table' | 'card'>('table')
|
||||
const [sortBy, setSortBy] = useState<string | undefined>(undefined)
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
// Fetch display settings
|
||||
const { data: displaySettings } = trpc.settings.getMultiple.useQuery({
|
||||
@@ -270,16 +239,6 @@ export default function ProjectsPage() {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
if (sortBy === column) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortBy(column)
|
||||
setSortDir('asc')
|
||||
}
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// Build tRPC query input
|
||||
const queryInput = {
|
||||
search: filters.search || undefined,
|
||||
@@ -316,16 +275,8 @@ export default function ProjectsPage() {
|
||||
wantsMentorship: filters.wantsMentorship,
|
||||
hasFiles: filters.hasFiles,
|
||||
hasAssignments: filters.hasAssignments,
|
||||
roundStates:
|
||||
filters.roundStates.length > 0
|
||||
? (filters.roundStates as Array<
|
||||
'PENDING' | 'IN_PROGRESS' | 'COMPLETED' | 'PASSED' | 'REJECTED' | 'WITHDRAWN'
|
||||
>)
|
||||
: undefined,
|
||||
page,
|
||||
perPage,
|
||||
sortBy: sortBy as 'title' | 'category' | 'program' | 'assignments' | 'status' | 'createdAt' | undefined,
|
||||
sortDir: sortBy ? sortDir : undefined,
|
||||
}
|
||||
|
||||
const utils = trpc.useUtils()
|
||||
@@ -339,15 +290,14 @@ export default function ProjectsPage() {
|
||||
const [projectToAssign, setProjectToAssign] = useState<{ id: string; title: string } | null>(null)
|
||||
const [assignRoundId, setAssignRoundId] = useState('')
|
||||
|
||||
const [bulkNotifyOpen, setBulkNotifyOpen] = useState(false)
|
||||
const [aiTagDialogOpen, setAiTagDialogOpen] = useState(false)
|
||||
const [taggingScope, setTaggingScope] = useState<'round' | 'program'>('round')
|
||||
const [selectedRoundForTagging, setSelectedRoundForTagging] = useState<string>('')
|
||||
const [selectedProgramForTagging, setSelectedProgramForTagging] = useState<string>('')
|
||||
const [activeTaggingJobId, setActiveTaggingJobId] = useState<string | null>(null)
|
||||
|
||||
// Fetch programs and rounds for the AI tagging dialog + assign-to-round
|
||||
const { data: programs } = trpc.program.list.useQuery({ includeStages: true })
|
||||
// Fetch programs and rounds for the AI tagging dialog
|
||||
const { data: programs } = trpc.program.list.useQuery()
|
||||
|
||||
// Start tagging job mutation
|
||||
const startTaggingJob = trpc.tag.startTaggingJob.useMutation({
|
||||
@@ -669,13 +619,6 @@ export default function ProjectsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBulkNotifyOpen(true)}
|
||||
>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Send Notifications
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setAiTagDialogOpen(true)}
|
||||
@@ -694,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>
|
||||
@@ -765,51 +708,23 @@ export default function ProjectsPage() {
|
||||
/>
|
||||
|
||||
{/* Stats Summary + View Toggle */}
|
||||
{data && (Object.keys(data.statusCounts ?? {}).length > 0 || data.projects.length > 0) && (
|
||||
{data && data.projects.length > 0 && (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{Object.entries(data.statusCounts ?? {})
|
||||
.sort(([a], [b]) => {
|
||||
const order = ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'PASSED', 'REJECTED', 'WITHDRAWN']
|
||||
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
|
||||
return order.indexOf(a) - order.indexOf(b)
|
||||
})
|
||||
.map(([status, count]) => {
|
||||
const isActive = filters.roundStates.includes(status)
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = isActive
|
||||
? filters.roundStates.filter((s) => s !== status)
|
||||
: [...filters.roundStates, status]
|
||||
handleFiltersChange({ ...filters, roundStates: next })
|
||||
}}
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<Badge
|
||||
variant={statusColors[status] || 'secondary'}
|
||||
className={cn(
|
||||
'text-xs font-normal cursor-pointer transition-all',
|
||||
isActive && 'ring-2 ring-offset-1 ring-primary',
|
||||
!isActive && filters.roundStates.length > 0 && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
|
||||
</Badge>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{filters.roundStates.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFiltersChange({ ...filters, roundStates: [] })}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors ml-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
.map(([status, count]) => (
|
||||
<Badge
|
||||
key={status}
|
||||
variant={statusColors[status] || 'secondary'}
|
||||
className="text-xs font-normal"
|
||||
>
|
||||
{count} {status.charAt(0) + status.slice(1).toLowerCase().replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
{data.total > data.projects.length && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
(page {data.page} of {data.totalPages})
|
||||
@@ -932,17 +847,6 @@ export default function ProjectsPage() {
|
||||
</Card>
|
||||
) : data ? (
|
||||
<>
|
||||
{/* Top Pagination */}
|
||||
{data.totalPages > 1 && (
|
||||
<Pagination
|
||||
page={data.page}
|
||||
totalPages={data.totalPages}
|
||||
total={data.total}
|
||||
perPage={perPage}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table View */}
|
||||
{viewMode === 'table' ? (
|
||||
<>
|
||||
@@ -958,18 +862,18 @@ export default function ProjectsPage() {
|
||||
aria-label="Select all projects"
|
||||
/>
|
||||
</TableHead>
|
||||
<SortableHeader label="Project" column="title" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} className="min-w-[280px]" />
|
||||
<SortableHeader label="Category" column="category" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||
<SortableHeader label="Program" column="program" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||
<TableHead className="min-w-[280px]">Project</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Program</TableHead>
|
||||
<TableHead>Tags</TableHead>
|
||||
<SortableHeader label="Assignments" column="assignments" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||
<SortableHeader label="Status" column="status" currentSort={sortBy} currentDir={sortDir} onSort={handleSort} />
|
||||
<TableHead>Assignments</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.projects.map((project) => {
|
||||
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
|
||||
const isEliminated = project.status === 'REJECTED'
|
||||
return (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
@@ -990,7 +894,6 @@ export default function ProjectsPage() {
|
||||
>
|
||||
<ProjectLogo
|
||||
project={project}
|
||||
logoUrl={project.logoUrl}
|
||||
size="sm"
|
||||
fallback="initials"
|
||||
/>
|
||||
@@ -1069,10 +972,7 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{(() => {
|
||||
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||
return <Badge variant={derived.variant}>{derived.label}</Badge>
|
||||
})()}
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||
</TableCell>
|
||||
<TableCell className="relative z-10 text-right">
|
||||
<DropdownMenu>
|
||||
@@ -1142,16 +1042,13 @@ export default function ProjectsPage() {
|
||||
<Card className="transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3 pl-8">
|
||||
<ProjectLogo project={project} logoUrl={project.logoUrl} size="md" fallback="initials" />
|
||||
<ProjectLogo project={project} size="md" fallback="initials" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||
{project.title}
|
||||
</CardTitle>
|
||||
{(() => {
|
||||
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||
return <Badge variant={derived.variant} className="shrink-0">{derived.label}</Badge>
|
||||
})()}
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} className="shrink-0" />
|
||||
</div>
|
||||
<CardDescription>{project.teamName}</CardDescription>
|
||||
</div>
|
||||
@@ -1199,7 +1096,7 @@ export default function ProjectsPage() {
|
||||
/* Card View */
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{data.projects.map((project) => {
|
||||
const isEliminated = (project.projectRoundStates ?? []).some((p: ProjectRoundStateInfo) => p.state === 'REJECTED')
|
||||
const isEliminated = project.status === 'REJECTED'
|
||||
return (
|
||||
<div key={project.id} className="relative">
|
||||
<div className="absolute left-3 top-3 z-10">
|
||||
@@ -1213,7 +1110,7 @@ export default function ProjectsPage() {
|
||||
<Card className={`transition-all duration-200 hover:bg-muted/50 hover:-translate-y-0.5 hover:shadow-md h-full ${isEliminated ? 'opacity-60 bg-destructive/5' : ''}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start gap-3 pl-7">
|
||||
<ProjectLogo project={project} logoUrl={project.logoUrl} size="lg" fallback="initials" />
|
||||
<ProjectLogo project={project} size="lg" fallback="initials" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className={`text-base line-clamp-2 ${uppercaseNames ? 'uppercase' : ''}`}>
|
||||
@@ -1280,10 +1177,7 @@ export default function ProjectsPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 pt-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{(() => {
|
||||
const derived = deriveProjectStatus(project.projectRoundStates ?? [])
|
||||
return <Badge variant={derived.variant}>{derived.label}</Badge>
|
||||
})()}
|
||||
<StatusBadge status={project.status ?? 'SUBMITTED'} />
|
||||
{project.competitionCategory && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
|
||||
@@ -1952,8 +1846,6 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<BulkNotificationDialog open={bulkNotifyOpen} onOpenChange={setBulkNotifyOpen} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
558
src/app/(admin)/admin/projects/pool/page.tsx
Normal file
558
src/app/(admin)/admin/projects/pool/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -63,7 +63,6 @@ const ISSUE_LABELS: Record<string, string> = {
|
||||
export interface ProjectFilters {
|
||||
search: string
|
||||
statuses: string[]
|
||||
roundStates: string[]
|
||||
roundId: string
|
||||
competitionCategory: string
|
||||
oceanIssue: string
|
||||
@@ -95,7 +94,6 @@ export function ProjectFiltersBar({
|
||||
|
||||
const activeFilterCount = [
|
||||
filters.statuses.length > 0,
|
||||
filters.roundStates.length > 0,
|
||||
filters.roundId !== '',
|
||||
filters.competitionCategory !== '',
|
||||
filters.oceanIssue !== '',
|
||||
@@ -116,7 +114,6 @@ export function ProjectFiltersBar({
|
||||
onChange({
|
||||
search: filters.search,
|
||||
statuses: [],
|
||||
roundStates: [],
|
||||
roundId: '',
|
||||
competitionCategory: '',
|
||||
oceanIssue: '',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user