Compare commits

...

124 Commits

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

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:28:07 +01:00
f42b452899 Add Anthropic API integration, remove locale settings UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 13m15s
Anthropic API:
- Add @anthropic-ai/sdk with adapter wrapping OpenAI-shaped interface
- Support Claude models (opus, sonnet, haiku) with extended thinking
- Auto-reset model on provider switch, JSON retry logic
- Add Claude model pricing to ai-usage tracker
- Update AI settings form with Anthropic provider option
- Add provider field to AIUsageLog for cross-provider cost tracking

Locale Settings Removal:
- Strip Localization tab from admin settings (mobile + desktop)
- Remove i18n settings from router and feature flags
- Remove LOCALIZATION from SettingCategory enum
- Keep franc document language detection intact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:26:59 +01:00
161cd1684a Fix observer reports: charts, filtering, project preview, dashboard stats
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
- Rewrite diversity metrics: horizontal bar charts for ocean issues and
  geographic distribution (replaces unreadable vertical/donut charts)
- Rewrite juror score heatmap: expandable table with score distribution
- Rewrite juror consistency: horizontal bar visual with juror names
- Merge filtering tabs into single screening view with per-project
  AI reasoning and expandable rows
- Add project preview dialog for juror performance table
- Fix status breakdown for evaluation rounds (Fully/Partially/Not Reviewed)
- Show active round name instead of count on observer dashboard
- Move Global tab to last position, default to first round-specific tab
- Add 4-card stats layout for evaluation with reviews/project ratio
- Fix oceanIssue field (singular) and remove non-existent aiSummary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 10:12:21 +01:00
2e4b95f29c Add round-type-specific observer reports with dynamic tabs
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
Refactor the observer reports page from a static 3-tab layout to a
dynamic tab system that adapts to each round type (INTAKE, FILTERING,
EVALUATION, SUBMISSION, MENTORING, LIVE_FINAL, DELIBERATION). Adds a
persistent Global tab for edition-wide analytics, juror score heatmap,
expandable juror assignment rows, filtering screening bar, and
deliberation results with tie detection.

- Add 5 observer proxy procedures to analytics router
- Create JurorScoreHeatmap, ExpandableJurorTable, FilteringScreeningBar
- Create 8 round-type tab components + GlobalAnalyticsTab
- Reduce reports page from 914 to ~190 lines (thin dispatcher)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:29:26 +01:00
ee3bfec8b0 Add Tremor design tokens for Tailwind v4 compatibility
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m27s
Tremor v3 expects its TW3 plugin to register tremor-* design tokens
(tremor-content, tremor-background, tremor-border, etc.). Since TW4
has no v3 plugin support, define these tokens manually in @theme and
safelist the utility classes via @source inline().

This fixes chart axis labels, grid lines, and tooltips rendering
without proper colors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:43:11 +01:00
8e607478d5 Fix Tremor chart colors: safelist dynamic utility classes for Tailwind v4
Tremor constructs class names via template literals (e.g. fill-${color}-${shade})
which Tailwind v4's scanner cannot detect statically. Added @source inline()
directives to explicitly safelist all color×shade×property combinations needed
by Tremor chart components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:39:24 +01:00
6d4ee93ab3 Fix round completion rate: use evaluations/assignments, closed rounds=100%
The round breakdown was showing 200% for active rounds (assignments/projects)
and 0% for closed rounds. Now correctly computes evaluations/assignments for
active rounds and shows 100% for closed/archived rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:34:42 +01:00
350e9b96e8 Fix Tremor chart colors: add @source for Tailwind v4 to scan Tremor classes
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
Tremor generates Tailwind utility classes dynamically (fill-blue-500,
bg-emerald-500, etc). Tailwind v4 auto-content detection doesn't scan
node_modules, so these classes were missing from CSS output, causing
all charts to render in black.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:14:59 +01:00
533d8cb8e5 Replace generic stat cards with clean horizontal stats strip
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m5s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:26:04 +01:00
4f73ba5a0e Fix reports: status breakdown uses round states, filter boolean criteria, replace insight tiles with country chart
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m47s
- getStatusBreakdown now uses ProjectRoundState when a specific round is selected
  (fixes donut showing all "Eligible")
- Filter out boolean/section_header criteria from getCriteriaScores
  (removes "Move to the Next Stage?" from bar chart)
- Replace 6 insight tiles with Top Countries horizontal bar chart
- Add round-level state labels/colors to chart-theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:00:55 +01:00
26e8830df2 Revamp chart colors: replace bland cyan/teal with vibrant blue/indigo/amber palette + fix tooltip indicators
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:54:14 +01:00
6e697cb5d8 Extend Recently Reviewed card to match sibling heights
Some checks are pending
Build and Push Docker Image / build (push) Waiting to run
Use flex-1 on the Recently Reviewed card so it stretches to fill the
remaining vertical space in the left column, aligning its bottom with
Juror Workload and Activity Feed. Add className prop to AnimatedCard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:49:15 +01:00
a714c56e81 Fix % recommended: derive from boolean criteria when binaryDecision is null
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
When scoringMode is not 'binary', binaryDecision is null even though
jurors answer boolean criteria (e.g. "Do you recommend?"). Now falls
back to checking boolean values in criterionScoresJson. Hides the
recommendation line entirely when no boolean data exists.

Fixed in both analytics.ts (observer) and project.ts (admin).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:47:48 +01:00
a6b6763fa4 Simplify project detail: back button, cleaner files, fix round inference
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Replace breadcrumb with "Back to Projects" button on observer detail
- Remove submission links from observer project info
- Simplify files tab: remove redundant requirements checklist, show only
  FileViewer (observer + admin)
- Fix round history: infer earlier rounds as PASSED when later round is
  active (e.g. R2 shows Passed when project is active in R3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:45:31 +01:00
d717040f03 Observer: fix round history, match admin project info, add AI rejection reason
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
- Round history infers rejection round when ProjectRoundState lacks explicit
  REJECTED state; shows red XCircle + badge, dims unreached rounds
- Project info section now matches admin: description, location, founded,
  submission links, expertise tags, internal notes, created/updated dates
- Fetch FilteringResult for rejected projects; display AI reasoning + confidence
- Remove cross-round comparison from reports, replace with scoring/criteria insights
- Remove unused AI synthesis placeholder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:30:14 +01:00
9f7b76b3cb Dashboard layout overhaul + fix Tremor chart colors and tooltips
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m55s
- Restructure dashboard: score distribution + recently reviewed stacked in left column,
  full-width map at bottom, activity feed in middle row
- Show all jurors in scrollable workload list (not just top 5)
- Filter recently reviewed to exclude rejected/not-reviewed projects
- Filter transition audit logs from activity feed
- Remove completion progress bar from stat tile for equal card heights
- Fix all Tremor charts: switch hex colors to named palette (cyan/teal/emerald/amber/rose)
  to fix black bar rendering
- Fix transparent chart tooltips with global CSS overrides
- Remove tilted text labels from cross-round comparison charts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:09:06 +01:00
213efdba87 Observer platform: mobile fixes, data/UX overhaul, animated nav
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m41s
- Fix dashboard default round selection to target active round instead of R1
- Move edition selector from dashboard header to hamburger menu via shared context
- Add observer-friendly status labels (Not Reviewed / Under Review / Reviewed)
- Fix pipeline completion: closed rounds show 100%, cap all rates at 100%
- Round badge on projects list shows furthest round reached
- Hide scores/evals for projects with zero evaluations
- Enhance project detail round history with pass/reject indicators from ProjectRoundState
- Remove irrelevant fields (Org Type, Budget, Duration) from project detail
- Clickable juror workload with expandable project assignments
- Humanize activity feed with icons and readable messages
- Fix jurors table: responsive card layout on mobile
- Fix criteria chart: horizontal bars for readable labels on mobile
- Animate hamburger menu open/close with CSS grid transition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:45:56 +01:00
5eea430ebd Fix Docker build: add .npmrc for Tremor peer dep conflict
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m44s
@tremor/react@3.18.7 requires react@^18 but project uses react@19.
Adding .npmrc with legacy-peer-deps=true and copying it in Dockerfiles
so npm ci resolves correctly. Also fix implicit any in seed file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:56:26 +01:00
8125ca6567 Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s
- Migrate 9 chart components from Nivo to @tremor/react (BarChart, AreaChart, DonutChart, ScatterChart)
- Remove @nivo/*, @react-spring/web dependencies (45 packages removed)
- Redesign dashboard: 6 stat tiles, competition pipeline, score distribution, juror workload, activity feed
- Add new /observer/projects page with search, filters, sorting, pagination, CSV export
- Restructure reports page from 5 tabs to 3 (Progress, Jurors, Scores & Analytics) with per-tab CSV export
- Redesign project detail: breadcrumb nav, score card header, 3-tab layout (Overview/Evaluations/Files)
- Update loading skeletons to match new layouts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:45:01 +01:00
77cbc64b33 Add missing deps: @radix-ui/react-toggle, @react-spring/web
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m59s
These packages are imported by new chart and toggle components but were
never added to package.json, causing the observer reports page to crash
client-side on load.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:02:40 +01:00
Matt
03c59c188e Add observer project detail page with files, evaluations & reviews
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
New page at /observer/projects/[projectId] showing project info,
documents grouped by round requirements, and jury evaluations with
click-through to full review details. Dashboard table rows now link
to project detail. Also cleans up redundant programName prefixes
and fixes chart edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:39:53 +01:00
Matt
f1062f4805 Fix admin getting juror assignment email on reshuffle/COI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m46s
notifyAdmins was using BATCH_ASSIGNED notification type, which triggers
the juror assignment email template ('X Projects Assigned'). Admins
received confusing emails that looked like they were assigned projects.

Changed to EVALUATION_MILESTONE type for admin-facing reshuffle/COI
notifications. Also included top receivers in admin notification message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:35:21 +01:00
Matt
34fdd0ba8e Add human-readable reshuffle details to audit log page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m50s
Audit log now renders JUROR_DROPOUT_RESHUFFLE and COI_REASSIGNMENT
entries as formatted tables with resolved juror names instead of raw
JSON with opaque IDs. Uses new user.resolveNames endpoint to batch-
lookup user IDs. Also adds missing action types to the filter dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:23:10 +01:00
Matt
0d0571ebf2 Fix reassignment scoping bug + add reassignment history
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Bug fix: reassignDroppedJuror, reassignAfterCOI, and getSuggestions all
fell back to querying ALL JURY_MEMBER users globally when the round had
no juryGroupId. This caused projects to be assigned to jurors who are no
longer active in the jury pool. Now scopes to jury group members when
available, otherwise to jurors already assigned to the round.

Also adds getSuggestions jury group scoping (matching runAIAssignmentJob).

New feature: Reassignment History panel on admin round page (collapsible)
shows per-project detail of where dropped/COI-reassigned projects went.
Reconstructs retroactive data from audit log timestamps + MANUAL
assignments for pre-fix entries. Future entries log full move details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:18:49 +01:00
Matt
0607d79484 Fix observer analytics crash: guard Nivo edge cases
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m41s
- Disable enableSlices on ResponsiveLine with single data point (causes
  null reference in Nivo internal slice computation)
- Add null check for slice.points[0] in timeline tooltip
- Guard ResponsivePie from empty data array in diversity metrics
- Add fallback for scoreDistribution.distribution on both
  observer and admin reports pages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 14:09:43 +01:00
Matt
57a16d089d Fix juror drop: remove from jury group + reassign projects
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
The reassignDroppedJuror flow was missing a key step — after
reshuffling unsubmitted projects to other jurors, the dropped juror
was not removed from the jury group. This meant they could be
re-assigned in future assignment runs. Now deletes the JuryGroupMember
record after reshuffle, logs removal in audit, and updates the
confirmation dialog to reflect the full action.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:57:15 +01:00
Matt
fbcbf895be Add defensive null guards to all chart components and analytics
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
All 9 chart components now have early-return null/empty checks before
calling .map() on data props. The diversity-metrics chart guards all
nested array fields (byCountry, byCategory, byOceanIssue, byTag).
Analytics backend guards p.tags in getDiversityMetrics. This prevents
any "Cannot read properties of null (reading 'map')" crashes even if
upstream data shapes are unexpected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:42:31 +01:00
Matt
4519bc6080 Fix criteria validation using wrong form + fix reports page null crash
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m38s
1. Evaluation submit: The requireAllCriteriaScored validation was
   querying findFirst({ roundId, isActive: true }) to get the form
   criteria, instead of using the evaluation's stored formId. If an
   admin ever re-saved the evaluation form (creating a new version
   with new criterion IDs), jurors who started evaluating before the
   re-save had scores keyed to old IDs that didn't match the new
   form. Now uses evaluation.form (the form assigned at start time).

2. Observer reports page: Two .map() calls on p.stages lacked null
   guards, causing "Cannot read properties of null (reading 'map')"
   crash. Added (p.stages || []) guards matching the pattern already
   used in CrossStageTab.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:16:09 +01:00
Matt
bf02684736 Fix COI audit log always saying conflict + fix boolean criteria submission
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m19s
1. COI audit log: The declareCOI mutation always logged action
   'COI_DECLARED' regardless of whether the user clicked "No Conflict"
   or "Yes, I Have a Conflict". Now uses 'COI_NO_CONFLICT' when
   hasConflict is false, showing "confirmed no conflict of interest"
   in the audit trail.

2. Evaluation submission: The requireAllCriteriaScored validation
   only accepted numeric values (typeof === 'number'), but boolean
   criteria (yes/no questions) store true/false. This caused jurors
   to get "Missing scores for criteria: criterion-xxx" errors even
   after completing all fields. Now correctly validates boolean
   criteria with typeof === 'boolean'. Also improved the error
   message to show criterion labels instead of cryptic IDs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:53:54 +01:00
Matt
d9d6a63e4a fix(assignments): make reshuffle concurrency-safe; preserve juryGroupId
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m16s
2026-02-20 03:48:17 +01:00
Claw
c7f20e2f32 fix(assignments): complete dropped juror reshuffle with type-safe logic
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 00:07:30 +01:00
Claw
d3a63b0354 feat(assignments): reshuffle dropped juror projects within caps
Some checks failed
Build and Push Docker Image / build (push) Failing after 3m38s
2026-02-19 23:12:55 +01:00
Matt
9d945c33f9 Observer platform overhaul: Nivo charts, round-type stats, UX improvements
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s
Phase 1: Fix 6 backend data bugs in analytics.ts (roundName filtering,
unscored projects, criteria scores, activeRoundCount scoping, email
privacy leaks in juror consistency + workload)

Phase 2-3: Migrate all 9 chart components from Recharts to Nivo
(@nivo/bar, @nivo/line, @nivo/pie, @nivo/scatterplot) with shared brand
theme, scoreGradient colors, and STATUS_COLORS map. Fixes scatter plot
outlier coloring and pie chart label visibility bugs.

Phase 4: Add round-type-aware stats (getRoundTypeStats backend +
RoundTypeStatsCards component) showing appropriate metrics per round
type (intake/filtering/evaluation/submission/mentoring/live/deliberation).

Phase 5: UX improvements — Stage→Round terminology, clickable dashboard
round links, URL-based round selection (?round=), round type indicators
in selectors, accessible Toggle-based cross-round comparison, sortable
project table columns (title/score/evaluations), brand score colors on
dashboard bar chart with aria labels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:44:38 +01:00
Matt
8ae8145d86 Default observer reports to active round instead of first round
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:52:07 +01:00
Matt
0ff84686f0 Auto-reassign projects when juror declares conflict of interest
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
When a juror declares COI, the system now automatically:
- Finds an eligible replacement juror (not at capacity, no COI, not already assigned)
- Deletes the conflicted assignment and creates a new one
- Notifies the replacement juror and admins
- Load-balances by picking the juror with fewest current assignments

Also adds:
- "Reassign (COI)" action in assignment table dropdown with COI badge indicator
- Admin "Reassign to another juror" in COI review now triggers actual reassignment
- Per-juror notify button is now always visible (not just on hover)
- reassignCOI admin procedure for retroactive manual reassignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:30:01 +01:00
Matt
1dcc7a5990 Add per-juror notify button in Jury Progress section
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m45s
Adds a mail icon on hover for each juror row in the Jury Progress
table, allowing admins to send assignment notifications to individual
jurors instead of only bulk-notifying all at once.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 17:18:07 +01:00
Matt
725d88fec2 Show full country names instead of ISO codes on projects pages
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m38s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:55:04 +01:00
Matt
c62a335424 Fix email links using relative paths — prepend baseUrl for absolute URLs
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m49s
Relative linkUrl paths (e.g. /jury/competitions) were passed as-is to
email templates, causing email clients to interpret them as local file
protocols (x-webdoc:// on macOS). Now prepends NEXTAUTH_URL to any
relative path before sending.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:45:20 +01:00
Matt
baca483fcb Comprehensive round system audit: fix 27 logic bugs, add manual project/assignment features, improve UI/UX
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
## Critical Logic Fixes (Tier 1)
- Fix requiredReviews config key mismatch (always defaulted to 3)
- Fix double-email + stageName/roundName metadata mismatch in notifications
- Fix snake_case config reads in peer review (peerReviewEnabled was always blocked)
- Add server-side COI check to evaluation submit (was client-only)
- Fix hard-coded feedbackText.min(10) — now uses config values
- Fix binaryDecision corruption in non-binary scoring modes
- Fix advanceProjects: add competition/sort-order/status validations, move autoPass into tx
- Fix removeFromRound: now cleans up orphaned Assignment records
- Fix 3-day reminder sending wrong email template (was using 24h template)

## High-Priority Logic Fixes (Tier 2)
- Add project state transition whitelist (prevent invalid transitions like REJECTED→PASSED)
- Scope AI assignment job to jury group members (was querying all JURY_MEMBERs)
- Add COI awareness to AI assignment generation
- Enforce requireAllCriteriaScored server-side
- Fix expireIntentsForRound nested transaction (now uses caller's tx)
- Implement notifyOnEntry for advancement path
- Implement notifyOnAdvance (was dead config)
- Fix checkRequirementsAndTransition for SubmissionFileRequirement model

## New Features (Tier 3)
- Add Project to Round: dialog with "Create New" and "From Pool" tabs
- Assignment "By Project" mode: select project → assign multiple jurors
- Backend: project.createAndAssignToRound procedure

## UI/UX Improvements (Tier 4+5)
- Add AlertDialog confirmation to header status dropdown
- Replace native confirm() with AlertDialog in assignments table
- Jury stats card now display-only with "Change" link
- Assignments tab restructured into logical card groups
- Inline-editable round name in header
- Back button shows destination label
- Readiness checklist: green check instead of strikethrough
- Gate assignments tab when no jury group assigned
- Relative time on window stats card
- Toast feedback on date saves
- Disable advance button when no target round
- COI section shows placeholder when empty
- Round position shown as "Round X of Y"
- InlineMemberCap edit icon always visible
- Status badge tooltip with description
- Add REMINDER_3_DAYS email template
- Fix maybeSendEmail to respect notification preferences
- Optimize bulk notification email loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:59:35 +01:00
Matt
ee8b12e59c Fix jury reminders, add notify jurors button, fix checkbox borders, widen assignment modal
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m32s
- Send Reminders button now works: added sendManualReminders() that bypasses
  cron-specific window/deadline/dedup guards so admin can send immediately
- Added Notify Jurors button that sends direct BATCH_ASSIGNED emails to all
  jurors with assignments (not dependent on NotificationEmailSetting config)
- Fixed checkbox component: default border is now neutral grey (border-input),
  red border (border-primary) only applied when checked
- Widened Add Assignment dialog from max-w-2xl to max-w-3xl to prevent overflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 12:15:51 +01:00
Matt
51e18870b6 Admin UI audit round 2: fix 28 display bugs across 23 files
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m51s
HIGH fixes (broken features / wrong data):
- H1: Fix roundAssignments → projectRoundStates in project router (7 occurrences)
- H2: Fix deliberation results panel blank table (wrong field names)
- H3: Fix deliberation participant names blank (wrong data path)
- H4: Fix awards "Evaluated" stat duplicating "Eligible" count
- H5: Fix cross-round comparison enabled at 1 round (backend requires 2)
- H6: Fix setState during render anti-pattern (6 occurrences)
- H7: Fix round detail jury member count always showing 0
- H8: Remove 4 invalid status values from observer dashboard filter
- H9: Fix filtering progress bar always showing 100%

MEDIUM fixes (misleading display):
- M1: Filter special-award rounds from competition timeline
- M2: Exclude special-award rounds from distinct project count
- M3: Fix MENTORING pipeline node hardcoded "0 mentored"
- M4: Fix DELIB_LOCKED badge using red for success state
- M5: Add status label maps to deliberation session detail
- M6: Humanize deliberation category + tie-break method displays
- M8: Rename setStageId → setRoundId, "Select Stage" → "Select Round"
- M9: Add missing INVITED/ACTIVE/SUSPENDED to members status labels
- M10: Add ROUND_DRAFT/ACTIVE/CLOSED/ARCHIVED to StatusBadge
- M11: Fix unsent messages showing "Scheduled" instead of "Draft"
- M12: Rename misleading totalEvaluations → totalAssignments
- M13: Rename "Stage" column to "Program" in projects page

LOW fixes (cosmetic / edge-case):
- L1: Use unfiltered rounds array for active round detection
- L2: Use all rounds length for new round sort order
- L3: Filter special-award rounds from header count
- L4: Fix single-underscore replace in award status badges
- L5: Fix score bucket boundary gaps (4.99 dropped between buckets)
- L6: Title-case LIVE_FINAL pipeline metric status
- L7: Fix roundType.replace only replacing first underscore
- L8: Remove duplicate severity sort in smart-actions component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 11:11:00 +01:00
Matt
ae1685179c Comprehensive admin UI stats audit: fix 16 display bugs
HIGH fixes:
- H1: Competition detail project count no longer double-counts across rounds
- H2: Rounds page header stats use unfiltered round set
- H3: Rounds page "eval" label corrected to "asgn" (assignment count)
- H4: Observer reports project count uses distinct analytics count
- H5: Awards eligibility count filters to only eligible=true (backend)
- H6: Round detail projectCount derived from projectStates for consistency
- H7: Deliberation hasVoted derived from votes array (was always undefined)

MEDIUM fixes:
- M1: Reports page round status badges use correct ROUND_ACTIVE/ROUND_CLOSED enums
- M2: Observer reports badges use ROUND_ prefix instead of stale STAGE_ prefix
- M3: Deliberation list status badges use correct VOTING/TALLYING/RUNOFF enums
- M4: Competition list/detail round count excludes special-award rounds (backend)
- M5: Messages page shows actual recipient count instead of hardcoded "1 user"

LOW fixes:
- L2: Observer analytics jurorCount scoped to round when roundId provided
- L3: Analytics round-scoped project count uses ProjectRoundState not assignments
- L4: JuryGroup delete audit log reports member count (not assignment count)
- L5: Project rankings include unevaluated projects at bottom instead of hiding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:56:09 +01:00
Matt
d117090fca Fix rounds page showing inflated project count
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m29s
The "537 projects" count was summing projectRoundStates across all
rounds, so a project in 3 rounds was counted 3 times. Now queries
distinct projectIds across all competition rounds to show the actual
unique project count (214).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:35:58 +01:00
Matt
099157bf74 Fix project status badges to show counts across all pages
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
The status summary badges (Eligible, Rejected, Assigned, etc.) were
computed from only the current page's projects. Now uses a groupBy
query on the same filters to return statusCounts for all matching
projects across all pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:33:14 +01:00
1308c3ba87 Admin platform audit: fix bugs, harden backend, add auto-refresh, clean dead code
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
Phase 1 — Critical bugs:
- Fix deliberation participant selection (wire jury group query)
- Fix reports "By Round" tab (inline content instead of 404 route)
- Fix messages "Sent History" (add message.sent procedure, wire tab)
- Add missing fields to competition award form (criteriaText, maxRankedPicks)
- Wire LiveControlPanel buttons (cursor, voting, scores)
- Fix ResultLockControls empty snapshot (fetch actual data before lock)
- Fix SubmissionWindowManager losing fields on edit

Phase 2 — Backend fixes:
- Remove write-in-query from specialAward.get
- Fix award eligibility job overwriting manual shortlist overrides
- Fix filtering startJob deleting all prior results (defer cleanup to post-success)
- Tighten access control: protectedProcedure → adminProcedure on 8 procedures
- Add audit logging to deliberation mutations
- Add FINALIST/SEMIFINALIST delete guard on project.delete/bulkDelete

Phase 3 — Auto-refresh:
- Add refetchInterval to 15+ admin pages/components (10s–30s)
- Fix AI job polling: derive speed from job status for all viewers

Phase 4 — Dead code cleanup:
- Delete unused command-palette, pdf-report, admin-page-transition
- Remove dead subItems sidebar code, unused GripVertical import
- Replace redundant isGenerating state with mutation.isPending
- Add Role column to jury members table
- Remove misleading manual mentor assignment stub

Phase 5 — UX improvements:
- Fix rounds page single-competition assumption (add selector)
- Remove raw UUID fallback in deliberation config
- Fix programs page "Stage" → "Round" terminology

Phase 6 — Backend hardening:
- Complete logAudit calls (add prisma, ipAddress, userAgent)
- Batch analytics queries (fix N+1 in getCrossRoundComparison, getYearOverYear)
- Batch user.bulkCreate writes (assignments, jury memberships, intents)
- Remove any casts from deliberation service (typed PrismaClient + TransactionClient)
- Fix stale DeliberationStatus enum values blocking build

40 files changed, 1010 insertions(+), 612 deletions(-)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 08:20:13 +01:00
aa1bf564ee Fix award eligibility FK constraint + add country column to round projects
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m13s
- specialAward.setEligibility: add ensureUserExists() guard and use Prisma
  connect syntax to prevent FK violation on stale session user IDs
- specialAward.confirmShortlist: same ensureUserExists() guard for confirmedBy
- Round projects table: add Country column showing project origin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:47:20 +01:00
Matt
6838b01724 Fix per-juror assignment caps: read correct field + inline edit UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m50s
- Fix bug: AI assignment router read non-existent `(m as any).maxAssignments`
  instead of the actual schema field `m.maxAssignmentsOverride`
- Wire `jurorLimits` record into AI assignment constraints so per-juror
  caps are respected during both AI scoring and algorithmic assignment
- Add inline editable cap in jury members table (click to edit, blur/enter
  to save, empty = no cap / use group default)
- Add inline editable cap badges on round page member list so admins can
  set caps right from the assignment workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:23:54 +01:00
Matt
735b841f4a Rewrite AI assignment to hybrid approach: single AI call + algorithm
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m42s
Instead of 10 sequential GPT calls (which timeout with GPT-5.1 on 99
projects), use a two-phase approach:

Phase 1 - AI Scoring: ONE API call asks GPT to score each juror's
affinity for all projects, returning a compact preference matrix with
expertise match scores and reasoning.

Phase 2 - Algorithm: Uses AI scores as the preference input to a
balanced assignment algorithm that assigns N reviewers per project,
enforcing even workload distribution, respecting per-juror caps, and
filling coverage gaps.

Benefits:
- Single API call eliminates timeout issues
- AI provides expertise-aware scoring, algorithm ensures balance
- Truncated response handling (JSON repair) for resilience
- Falls back to tag-based algorithm if AI fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:49:41 +01:00
Matt
7c3f041892 Fix AI assignment returning nothing: cap tokens, optimize prompt, show errors
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m32s
- Cap maxTokens at 12000 (was unlimited dynamic calc that could exceed model limits)
- Replace massive EXISTING array with compact CURRENT_JUROR_LOAD counts and
  ALREADY_ASSIGNED per-project map (keeps prompt small across batches)
- Add coverage gap-filler: algorithmically fills projects below required reviews
- Show error state inline on page when AI fails (red banner with message)
- Add server-side logging for debugging assignment flow
- Reduce batch size to 10 projects

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:24:16 +01:00
Matt
998ffe3af8 Fix AI assignment: generate multiple reviewers per project
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m32s
Problems:
- GPT only generated 1 reviewer per project despite N being required
- maxTokens (4000) too small for N×projects assignment objects
- No fallback when GPT under-assigned

Fixes:
- System prompt now explicitly explains multiple reviewers per project
  with concrete example showing 3 different juror_ids per project
- User prompt includes REVIEWS_PER_PROJECT, EXPECTED_OUTPUT_SIZE
- maxTokens dynamically calculated: expectedAssignments × 200 + 500
- Reduced batch size from 15 to 10 (fewer projects per GPT call)
- Added fillCoverageGaps() post-processor: algorithmically assigns
  least-loaded jurors to any project below required coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:48:06 +01:00
Matt
6abf962fa0 Fix AI assignment workload imbalance: enforce caps and rebalance
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
Root cause: batches of 15 projects were processed independently -
GPT didn't see assignments from previous batches, so expert jurors
got assigned 18-22 projects while others got 4-5.

Fixes:
- Track cumulative assignments across batches (feed to each batch)
- Calculate ideal target per juror and communicate to GPT
- Add post-processing rebalancer that enforces hard caps and
  redistributes excess assignments to least-loaded jurors
- Calculate sensible default max cap when not configured
- Reweight prompt: workload balance 50%, expertise 35%, diversity 15%

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 16:16:55 +01:00
Matt
8bbdc31d17 Remove download button on mobile, keep only Open in New Tab
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m25s
iOS Safari doesn't support programmatic downloads reliably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:53:12 +01:00
Matt
a212bde51b Warn when jurors lack profile data in AI assignment preview
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
Shows warning with juror names when they have no expertise tags or bio,
so admin can ask them to onboard before committing assignments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:16:22 +01:00
Matt
7e85348a6d AI shortlist with approve/reject, assignment reasoning, fix review count badge
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Rewrite AIRecommendationsDisplay: show project titles, per-project
  checkboxes, Apply and Mark as Passed button with batch transition
- Show AI jury assignment reasoning directly in rows (not tooltip)
- Fix unassigned projects badge using requiredReviews instead of hardcoded 3
- Add aiParseFiles to EvaluationConfigSchema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 15:11:20 +01:00
Matt
cab311fbbb Fix advancement targets stripped by Zod, remove redundant save bar
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
- Add general settings fields (startupAdvanceCount, conceptAdvanceCount,
  notifyOnEntry, notifyOnAdvance) to ALL round config schemas, not just
  FilteringConfig. Zod was stripping them on save for other round types.
- Replace floating save bar with error-only bar since autosave handles
  all config persistence (800ms debounce)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:59:23 +01:00
Matt
9c19661400 Fix iOS download via Content-Disposition header, fix COI gate null check
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Server: presigned GET URLs now include Content-Disposition: attachment header
  when forDownload=true, triggering native browser downloads on all platforms
- Download button uses window.location.href with attachment URL (works on iOS Safari)
- Bulk download uses hidden iframes instead of fetch+blob
- Fix COI gate: getCOIStatus returns null (not undefined) when undeclared,
  so `!== undefined` was always true — changed to `!= null`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:56:09 +01:00
Matt
8d28104d51 COI gate + admin review, mobile file viewer fixes for iOS
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
- Integrate COI declaration dialog into jury evaluate page (blocks evaluation until declared)
- Add COI review section to admin round page with clear/reassign/note actions
- Fix mobile: remove inline preview (viewport too small), add labeled buttons
- Fix iOS: open-in-new-tab uses synchronous window.open to avoid popup blocker
- Fix iOS: download falls back to direct link if fetch+blob fails (CORS/Safari)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:34:27 +01:00
Matt
0f6473c999 Jury inline doc preview, download fix, category tags, admin eval reset
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m55s
- Replace MultiWindowDocViewer with FileViewer for inline previews (PDF/image/video/Office)
- Fix cross-origin download using fetch+blob instead of <a download>
- Show Startup/Business Concept badge on jury project detail + evaluate pages
- Add admin resetEvaluation procedure with audit logging
- Add dropdown menu on admin assignment rows with Reset Evaluation + Delete
- Make file action buttons responsive on mobile (separate row below file info)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 14:03:38 +01:00
Matt
9ce56f13fd Jury evaluation UX overhaul + admin review features
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m53s
- Fix project documents not displaying on jury project page (rewrote MultiWindowDocViewer to use file.listByProject)
- Add working download/preview for project files via presigned URLs
- Display project tags on jury project detail page
- Add autosave for evaluation drafts (debounced 3s + save on unmount/beforeunload)
- Support mixed criterion types: numeric scores, yes/no booleans, text responses, section headers
- Replace inline criteria editor with rich EvaluationFormBuilder on admin round page
- Remove COI dialog from evaluation page
- Update AI summary service to handle boolean/text criteria (yes/no counts, text synthesis)
- Update EvaluationSummaryCard to show boolean criteria bars and text responses
- Add evaluation detail sheet on admin project page (click juror row to view full scores + feedback)
- Add Recent Evaluations dashboard widget showing latest jury reviews

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 12:43:28 +01:00
Matt
73759eaddd Trigger rebuild
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m8s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:52:28 +01:00
Matt
f814cf6dc4 Move round scheduler in-app via instrumentation.ts, remove cron endpoint
All checks were successful
Build and Push Docker Image / build (push) Successful in 18s
Round open/close scheduling now runs as a 60s setInterval inside the
app process (via instrumentation.ts register hook) instead of needing
an external crontab. Removed the /api/cron/round-scheduler endpoint.

- DRAFT rounds auto-activate when windowOpenAt arrives
- ACTIVE rounds auto-close when windowCloseAt passes
- Uses existing activateRound/closeRound from round-engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:35:28 +01:00
Matt
9b1b319362 Add cron endpoint for automatic round open/close scheduling
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
New /api/cron/round-scheduler endpoint that:
- Activates DRAFT rounds whose windowOpenAt has arrived
- Closes ACTIVE rounds whose windowCloseAt has passed
- Uses existing activateRound/closeRound from round-engine
- Protected by CRON_SECRET header like other cron endpoints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:33:25 +01:00
Matt
7b16873b9c Fix finalize to actually advance passed projects to next round
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
finalizeResults was finding nextRound but never creating
ProjectRoundState entries — projects got set to ELIGIBLE but
were not placed into the next round. Now creates entries with
skipDuplicates so it's safe to re-run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:31:13 +01:00
Matt
fc7a37094b Exclude SEPARATE_POOL award projects from main pool finalization
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m47s
- finalizeResults now queries confirmed SEPARATE_POOL shortlist and
  excludes those projects from the main pool ELIGIBLE set
- Finalize confirmation dialog shows breakdown: main pool vs award-routed
- Finalize toast includes award-routed count
- Audit log records routedToAwards count

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:28:45 +01:00
Matt
35f30af7ce Show award-routed projects in filtering stats and results table
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Stats cards show new 'Award Track' card with count of confirmed
  SEPARATE_POOL shortlisted projects
- Passed card shows breakdown (main + award) when awards are routed
- Results table shows award badge on projects routed to award tracks
- getResults query includes confirmed award eligibility data per project

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:25:47 +01:00
Matt
6e9fcda45a Fix stale session redirect loop, filtering stats to reflect overrides
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m56s
- Auth layout verifies user exists in DB before redirecting to dashboard,
  breaking infinite loop for deleted accounts with stale sessions
- Jury/Mentor layouts handle null user (deleted) by redirecting to login
- Filtering stats cards and result list now use effective outcome
  (finalOutcome ?? outcome) instead of raw AI outcome
- Award eligibility evaluation includes admin-overridden PASSED projects
- Award shortlist reasoning column shows full text instead of truncating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:01:31 +01:00
1ec2247295 Make selected expertise tags compact in onboarding
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m17s
Reduced badge size (text-[11px], h-5, tighter padding/gaps) and capped
the selected tags container to max-h-20 with overflow scroll so they
no longer push the rest of the form off-screen on mobile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:34:51 +01:00
1c68512598 Add built-in hard reject for projects with zero uploaded files
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Projects with no files are now automatically rejected before AI
screening runs, regardless of whether a DOCUMENT_CHECK rule is
configured. This prevents the AI from incorrectly passing projects
that have no supporting documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:31:46 +01:00
04c54b6794 Fix FK constraint error on filtering override — verify user exists
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
The overriddenBy FK to User was failing when the session contained a
stale user ID (e.g. after database reseed). Added ensureUserExists()
guard to all override/reinstate mutations and switched single-record
updates to use Prisma connect syntax for safer FK resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:23:16 +01:00
d02b0b91b9 Award shortlist UX improvements + configurable invite link expiry
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m30s
Award shortlist:
- Expandable reasoning text (click to toggle, hover hint)
- Bulk select/deselect all checkbox in header
- Top N projects highlighted with amber background
- New bulkToggleShortlisted backend mutation

Invite link expiry:
- New "Invitation Link Expiry (hours)" field in Security Settings
- Reads from systemSettings `invite_link_expiry_hours` (default 72h / 3 days)
- Email template dynamically shows "X hours" or "X days" based on setting
- All 3 invite paths (bulk create, single invite, bulk resend) use setting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 22:05:58 +01:00
8a7da0fd93 Fix standalone award eligibility to send rich project data matching filtering pass
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m39s
The Re-evaluate button was producing fewer eligible results because
the standalone job sent minimal project data (title, description, tags)
while the integrated filtering pass sent full data (files, team, institution).

- Fetch rich project data in award-eligibility-job (files, team, institution, etc.)
- Relax AI prompt to be inclusive like the integrated pass — strong primary
  criterion fit is sufficient, don't require all dimensions above 0.5

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:34:30 +01:00
70d24036f9 Fix award source round dropdown — auto-resolve competitionId from program
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s
Awards created from /admin/awards/new only sent programId, leaving
competitionId null. The edit page's source round dropdown was empty
because it depended on competitionId to fetch competition rounds.

- create mutation: auto-resolve competitionId from program's latest competition
- update mutation: backfill competitionId on save if missing
- get query: backfill competitionId on read for legacy awards
- edit page: use award.competition.rounds directly instead of separate query

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:00:20 +01:00
619206c03f Integrate special award eligibility into AI filtering pass
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
Single AI call now evaluates both screening criteria AND award eligibility.
Awards with useAiEligibility + criteriaText are appended to the system prompt,
AI returns award_matches per project, results auto-populate AwardEligibility
and auto-shortlist top-N. Re-running filtering clears and re-evaluates awards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 20:32:38 +01:00
1fe6667400 Special awards: Rounds tab UI, auto-filter threshold, remove auto-tag rules
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m23s
- Add Rounds tab to award detail page with create/list/delete functionality
- Add "Entry point" badge on first award round (confirmShortlist routes here)
- Fix round detail back-link to navigate to parent award when specialAwardId set
- Filter award rounds out of competition round list
- Add specialAwardId to competition getById round select
- Warn on confirmShortlist when no award rounds exist (SEPARATE_POOL mode)
- Remove auto-tag rules from award config, edit page, router, and AI service
- Fix competitionId not passed when creating awards from competition context
- Add AUTO_FILTER quality threshold to AI filtering dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 19:53:20 +01:00
Matt
4fa3ca0bb6 Fix config save state sync — local config now re-syncs after save
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m46s
The save bar persisted forever because Zod.parse() adds defaults for
new fields, making the server config differ from local state. After
save, the sync effect now picks up the server value. Uses savingRef
to prevent overwriting local edits during the save roundtrip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 18:53:51 +01:00
Matt
cf1508f856 Fix filtering config save, auto-save, streamed results, improved AI prompt
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m7s
- Add missing fields to FilteringConfigSchema (aiParseFiles, startupAdvanceCount,
  conceptAdvanceCount, notifyOnEntry, notifyOnAdvance) — Zod was silently
  stripping them on save
- Restore auto-save with 800ms debounce on config changes
- Add staggered animations for filtering results (stream in one-by-one)
- Improve AI screening prompt: file type label mappings, soft cap handling,
  missing documents = fail, better user prompt structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:18:04 +01:00
Matt
bed444e5f4 Move AI document parsing toggle from Config to Filtering tab
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m9s
Removes duplicate: the setting was in Config tab (General Settings) AND
inside Advanced Settings in the Filtering tab. Now it lives only in the
Filtering tab as a prominent standalone toggle, since it's directly
related to AI filtering behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:51:23 +01:00
Matt
a4ff278db2 Manual save button, file requirement labels, fix config revert bug
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Replace auto-save with manual floating save bar that appears when config
  has unsaved changes (Discard / Save Changes buttons). Fixes race condition
  where server sync overwrote local state after toggling switches.
- Show file requirement name (e.g. "Pitch Deck", "Presentation") above each
  document in the All Uploaded Files section on project detail page
- Pass requirement relation data through to FileViewer component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:43:47 +01:00
Matt
1c6961355b Filtering UX: overview results, auto-clear on re-run, config save fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m25s
- Add filtering results summary card on round Overview tab with pass/fail/flag
  counts and color-coded progress bar (polls every 5s)
- Auto-delete previous filtering results when re-running so new ones stream in
- Rename BUSINESS_CONCEPT to "Concept" in filtering results to prevent overflow
- Fix config save race condition where toggling switches (aiParseFiles, advance
  counts) would revert: pendingSaveRef cleared before refetch completed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:25:59 +01:00
Matt
a02ed59158 Fix AI filtering bugs, add special award shortlist integration
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m20s
Part 1 - Bug Fixes:
- Fix toProjectWithRelations() stripping file fields needed by AI (detectedLang, textContent, etc.)
- Fix parseAIData() reading flat when aiScreeningJson is nested under rule ID
- Fix getAIConfidenceScore() with same nesting issue (always returned 0)

Part 2 - Special Award Track Integration:
- Add shortlistSize to SpecialAward, qualityScore/shortlisted/confirmed fields to AwardEligibility
- Add specialAwardId to Round for award-owned rounds
- Update AI eligibility service to return qualityScore (0-100) for ranking
- Update eligibility job with filteringRoundId scoping and auto-shortlist top N
- Add 8 new specialAward router procedures (listForRound, runEligibilityForRound,
  listShortlist, toggleShortlisted, confirmShortlist, listRounds, createRound, deleteRound)
- Create award-shortlist.tsx component with ranked table, shortlist checkboxes, confirm dialog
- Add "Special Award Tracks" section to filtering dashboard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:38:31 +01:00
Matt
6743119c4d AI-powered assignment generation with enriched data and streaming UI
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s
- Add aiPreview mutation with full project/juror data (bios, descriptions,
  documents, categories, ocean issues, countries, team sizes)
- Increase AI description limit from 300 to 2000 chars for richer context
- Update GPT system prompt to use all available data fields
- Add mode toggle (AI default / Algorithm fallback) in assignment preview
- Lift AI mutation to parent page for background generation persistence
- Show visual indicator on page while AI generates (spinner + progress card)
- Toast notification with "Review" action when AI completes
- Staggered reveal animation for assignment results (streaming feel)
- Fix assignment balance with dynamic penalty (25pts per existing assignment)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:45:57 +01:00
Matt
a7b6031f4d Redesign assignment preview with detailed editable juror-project view
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m40s
Replace bare summary stats with full interactive assignment preview:
- Assignments grouped by juror with collapsible cards
- Per-assignment detail: match score, tags, reasoning, policy warnings
- Remove individual assignments with hover X button
- Inline add projects per juror + global juror/project picker
- No cap enforcement on manual adds (admin override)
- Track manual additions and removals with badge indicators
- Include user details in round.getById jury group members query

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:27:01 +01:00
Matt
a62f511d7f Fix voting gate to use round status, make eval doc uploads toggleable
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m26s
Voting check now uses round.status === ROUND_ACTIVE instead of requiring
windowOpenAt/windowCloseAt date range, fixing manual open/reopen scenarios.
Added requireDocumentUpload toggle (default off) to evaluation round config
so rounds reusing prior-round documents don't need file requirements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 14:13:25 +01:00
Matt
cef4709444 Auto-refresh jury dashboard every 30s for live round updates
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m40s
Add AutoRefresh client component that calls router.refresh() on an
interval. Pauses when tab is hidden and refreshes immediately when
tab becomes visible again. Jury dashboard now reflects round
activations within seconds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:57:40 +01:00
Matt
cf3c7631cb Auto-close preceding active rounds when closing a later round
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
When closing round N, any active rounds with lower sortOrder in the
same competition are automatically closed. Each cascade closure is
recorded in DecisionAuditLog with closedBy: 'cascade' reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:55:44 +01:00
Matt
b3b3bbb8b3 Fix mobile overflow, logo nav, round activation, compare projects setting
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Fix assignments page header overflow on mobile (flex-wrap)
- Hide 'Back to Dashboard' button on mobile (logo tap navigates home)
- Make logo/brand text clickable to navigate to role dashboard
- Snap windowOpenAt to now when manually activating a round early
- Gate Compare Projects cards behind jury_compare_enabled setting (defaults off)
- Expose jury_compare_enabled in getFeatureFlags tRPC procedure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:48:12 +01:00
Matt
bfdbd0fc6a Jury UX: fix COI modal, add sliders, redesign stats, gate evaluations
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
- Switch COI dialog from Dialog to AlertDialog (non-dismissible)
- Replace number inputs with sliders + rating buttons for criteria/global scores
- Redesign jury dashboard stat cards: compact strip on mobile, editorial grid on desktop
- Remove ROUND_ACTIVE filter from myAssignments so all assignments show
- Block evaluate page when round is inactive or voting window is closed
- Gate evaluate button on project detail page based on voting window status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:07:40 +01:00
Matt
ef1bf24388 Fix evaluation criteria, jury preferences, assignment config, and dashboard stats
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s
- Fix criteria not showing for jurors: fetch active form independently via
  getStageForm query instead of relying on existing evaluation record
- Fix scoringMode default from 'global' to 'criteria' (matching schema)
- Parse scale string format ("1-10") into minScore/maxScore for criteria display
- Fix COI dialog dismissal: prevent outside click on evaluate page Dialog
- Fix requiredReviews hardcoded to 3: read from round configJson in 4 locations
- Add jury preferences banner for unconfirmed caps on jury dashboard
- Add updateJuryPreferences tRPC procedure for self-service cap/ratio
- Simplify onboarding: always show jury step, allow cap up to 50
- Add role/ratio/availability fields to jury member invite dialog
- Simplify jury group settings (keep only defaultMaxAssignments)
- Enforce deliberation showCollectiveRankings flag for non-admin users
- Redesign dashboard stat cards: editorial data strip on mobile,
  clean grid layout on desktop (no more generic card pattern)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:33:20 +01:00
Matt
f9016168e7 UI fixes: onboarding scroll, expertise tags, jury assignments view
- Fix onboarding card overflow (overflow-hidden → overflow-x-hidden) so
  expertise step can scroll to submit button
- Reduce expertise category list height (max-h-64 → max-h-48)
- Add color dots to expertise tag options matching admin display
- Single-column layout for expertise tags (no truncation)
- Ocean background on onboarding (matches email template)
- Rewrite jury competitions page as assignment-centric grouped by round
- Conditionally show Awards nav item only when juror has award assignments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:48:14 +01:00
Matt
a006c6505c Onboarding: use ocean background image, show full expertise tag names
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m32s
- Replace blue gradient with ocean.png background (matches email templates)
- Display expertise tags one per line with full names (no truncation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:38:26 +01:00
Matt
d80043c4aa Strip null bytes from extracted text to fix PostgreSQL UTF-8 errors
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Some PDFs contain \x00 null bytes in their text which PostgreSQL rejects
with "invalid byte sequence for encoding UTF8: 0x00". Sanitize extracted
text in both document-analyzer and file-content-extractor services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:34:05 +01:00
Matt
1a0525c108 Redesign admin dashboard: pipeline view, round-specific stats, smart actions
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
Complete rewrite of the admin dashboard replacing the 1056-line monolith with
modular components. Key improvements:

- Competition pipeline: horizontal visualization of all rounds in pipeline order
  (by sortOrder, not creation date) with type-specific icons and metrics
- Round-specific stats: stat cards dynamically change based on active round type
  (INTAKE shows submissions/docs, FILTERING shows pass/fail/flagged, EVALUATION
  shows assignments/completion, fallback shows generic project/jury stats)
- Smart actions: context-aware "Action Required" panel that only flags the next
  draft round (not all), only flags unassigned projects for EVALUATION rounds,
  includes deadline warnings with severity levels (critical/warning/info)
- Active round panel: detailed view with project state bar, type-specific content,
  and deadline countdown
- Extracted components: project list, activity feed, category breakdown, skeleton

Backend enriched with 17 parallel queries building rich PipelineRound data
including per-round project states, eval stats, filtering stats, live session
status, and deliberation counts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:12:28 +01:00
Matt
842e79e319 Simplify doc-analysis upload hooks (always fire-and-forget)
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:02:05 +01:00
Matt
ed5e782f61 Fix document analysis: switch to unpdf + mammoth for PDF/Word parsing
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m26s
pdf-parse v2 requires DOMMatrix (browser API) which fails in Node.js.
Replaced with unpdf (serverless PDF.js build) for PDFs and mammoth for
Word .docx files. Also fixed the same broken pdf-parse usage in
file-content-extractor.ts used by AI filtering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:27:36 +01:00
Matt
c9640c6086 Add document analysis: page count, text extraction & language detection
All checks were successful
Build and Push Docker Image / build (push) Successful in 11m7s
Introduces a document analyzer service that extracts page count (via pdf-parse),
text preview, and detected language (via franc) from uploaded files. Analysis runs
automatically on upload (configurable via SystemSettings) and can be triggered
retroactively for existing files. Results are displayed as badges in the FileViewer
and fed to AI screening for language-based filtering criteria.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:09:04 +01:00
Matt
771f35c695 Retroactive auto-PASS for projects with complete documents
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Wire batchCheckRequirementsAndTransition into round activation and reopen
so pre-existing projects that already have all required docs get auto-
passed. Also adds checkDocumentCompletion endpoint for manual sweeps on
already-active rounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:29:57 +01:00
Matt
fbeec846a3 Pass tag confidence scores to AI assignment for weighted matching
The AI assignment path was receiving project tags as flat strings, losing
the confidence scores from AI tagging. Now both the GPT path and the
fallback algorithm weight tag matches by confidence — a 0.9 tag matters
more than a 0.5 one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:29:46 +01:00
Matt
cfeef9a601 Add auto-pass & advance for intake rounds (no manual marking needed)
For INTAKE, SUBMISSION, and MENTORING rounds, the Advance Projects dialog
now shows a simplified "Advance All" flow that auto-passes all pending
projects and advances them in one click. Backend accepts autoPassPending
flag to bulk-set PENDING→PASSED before advancing. Jury/evaluation rounds
keep the existing per-project selection workflow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:17:16 +01:00
Matt
fcee8761b9 Hide jury stat card in header for non-jury rounds (INTAKE, FILTERING, etc.)
The jury selector card in the stats bar was still visible on round types
where juries don't apply. Now conditionally rendered based on hasJury,
with the grid adjusting from 4 to 3 columns accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:17:15 +01:00
7b98b64c1c Auto-transition projects to PASSED when all required documents uploaded
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m55s
Add checkRequirementsAndTransition() to round-engine that checks if all
required FileRequirements for a round are satisfied by uploaded files.
When all are met and the project is PENDING/IN_PROGRESS, it auto-
transitions to PASSED. Also adds batchCheckRequirementsAndTransition()
for bulk operations.

Wired into:
- file.adminUploadForRoundRequirement (admin bulk upload)
- applicant.saveFileMetadata (applicant self-upload)

Non-fatal: failures in the check never break the upload itself.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 01:43:28 +01:00
Matt
09049d2911 Round management: tab cleanup, date pickers, advancement workflow
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m15s
- Remove Document Windows tab (round dates + file requirements in
  Config are sufficient, separate SubmissionWindow was redundant)
- Restrict Jury and Awards tabs to round types that use them
  (EVALUATION, LIVE_FINAL, DELIBERATION only)
- Add Round Dates card in Config tab with DateTimePicker for
  start/end dates (supports past and future dates)
- Make Advance Projects button always visible when projects exist
  (dimmed with guidance when no projects are PASSED yet)
- Add Close & Advance combined quick action to streamline round
  progression workflow
- Add target round selector to Advance Projects dialog so admin
  can pick which round to advance projects into

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:43:23 +01:00
Matt
3fb0d128a1 Fix missing query invalidations across member management
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m19s
Add utils.user.list.invalidate() after mutations that change user
status to ensure member lists refresh without manual page reload:
- Member detail page: after update and send invitation
- User mobile actions: after send invitation
- Add member dialog: after send invitation in jury group flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:16:23 +01:00
Matt
5965f7889d Platform-wide UX fixes: assignment dialog, invalidation, settings, dashboard
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m4s
1. Assignment dialog overhaul: replace raw UUID inputs with searchable
   juror Combobox (shows name, email, capacity) and multi-select project
   checklist with bulk assignment support

2. Query invalidation sweep: fix missing invalidations in
   assignment-preview-sheet (roundAssignment.execute) and
   filtering-dashboard (filtering.finalizeResults) so data refreshes
   without page reload

3. Rename Submissions tab to Document Windows with descriptive
   header explaining upload window configuration

4. Connect 6 disconnected settings: storage_provider, local_storage_path,
   avatar_max_size_mb, allowed_image_types, whatsapp_enabled,
   whatsapp_provider - all now accessible in Settings UI

5. Admin dashboard redesign: branded Editorial Command Center with
   Dark Blue gradient header, colored border-l-4 stat cards, staggered
   animations, 2-column layout, action-required panel, activity timeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:05:25 +01:00
Matt
b2279067e2 Add LiteLLM proxy support for ChatGPT subscription AI access
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m22s
- Add ai_provider setting: 'openai' (API key) or 'litellm' (ChatGPT subscription proxy)
- Auto-strip max_tokens/max_completion_tokens for chatgpt/ prefix models
  (ChatGPT subscription backend rejects token limit fields)
- LiteLLM mode: dummy API key when none configured, base URL required
- isOpenAIConfigured() checks base URL instead of API key for LiteLLM
- listAvailableModels() returns manualEntry flag for LiteLLM (no models.list)
- Settings UI: conditional fields, info banner, manual model input with
  chatgpt/ prefix examples when LiteLLM selected
- All 7 AI services work transparently via buildCompletionParams()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:48:34 +01:00
Matt
014bb15890 Reduce AI costs: switch tagging to gpt-4o-mini, add custom base URL support
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Change AI tagging to use AI_MODELS.QUICK (gpt-4o-mini) instead of gpt-4o for
  10-15x cost reduction on classification tasks
- Add openai_base_url system setting for OpenAI-compatible providers
  (OpenRouter, Groq, Together AI, local models)
- Reset OpenAI client singleton when API key, base URL, or model changes
- Add base URL field to AI settings form with provider examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:34:59 +01:00
Matt
f12c29103c Fix project detail crash: replace dynamic hooks with single query
The project detail page called useQuery inside .map() to fetch file
requirements per round, violating React's rules of hooks. When
competitionRounds changed from [] to [round1, round2], the hook count
changed, causing React to crash with "Cannot read properties of
undefined (reading 'length')".

Fix: Add listRequirementsByRounds endpoint that accepts multiple
roundIds in one query, replacing the dynamic hook pattern with a
single stable useQuery call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:30:44 +01:00
Matt
65a22e6f19 Optimize all AI functions for efficiency and speed
- AI Tagging: batch 10 projects per API call with 3 concurrent batches (~10x faster)
  - New `tagProjectsBatch()` with `getAISuggestionsBatch()` for multi-project prompts
  - Single DB query for all projects, single anonymization pass
  - Compact JSON in prompts (no pretty-print) saves tokens
- AI Shortlist: run STARTUP and BUSINESS_CONCEPT categories in parallel (2x faster)
- AI Filtering: increase default parallel batches from 1 to 3 (3x faster)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 14:02:38 +01:00
Matt
989db4dc14 Allow AI tagging dialog to close during processing, show background progress
- Remove blocking guard on dialog close when tagging is in progress
- Change Cancel button to "Run in Background" during processing
- Add amber border + spinner + progress % on AI Tags button when job runs in background
- Job already runs server-side and sends in-app notification on completion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:58:03 +01:00
Matt
5e0c8b2dfe Add schema reconciliation migration and file removal in bulk upload
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m38s
Migration:
- Add standalone hasConflict index on ConflictOfInterest
- Ensure roundId is nullable on ConflictOfInterest
- Drop stale composite roundId_hasConflict index

Bulk upload:
- Add trash icon button to remove uploaded files
- Uses existing file.delete endpoint with audit logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:46:12 +01:00
Matt
85a0fa5016 Make bulk upload documents clickable with storage verification
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m2s
- Add bucket/objectKey to file select in listProjectsByRoundRequirements
- Add verifyFilesExist endpoint to bulk-check file existence in MinIO
- Make uploaded filenames clickable links that open presigned download URLs
- Verify files exist in storage on page load, show re-upload button if missing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:32:23 +01:00
Matt
c707899179 Add missing migration for ProjectFile.pageCount column
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m48s
Column was in Prisma schema but had no migration file, causing
'column does not exist' errors on file uploads.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:23:18 +01:00
Matt
4d40afec6e Improve Project Pool button contrast in dark header
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Give button a subtle bg-white/15 default background so it's visible
without hovering, and stronger hover state (bg-white/30).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:21:35 +01:00
Matt
effc078918 Make all migration SQL files idempotent for clean prod deploys
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m14s
Added IF NOT EXISTS, IF EXISTS, and DO $$ EXCEPTION guards to all
migration files from 20260205 onwards so they survive partial application
and work correctly on both fresh databases and existing deployments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:09:41 +01:00
Matt
763b2ef0f5 Jury management: create, delete, add/remove members from round detail page
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m0s
- Added Jury tab to round detail page with full jury management inline
- Create new jury groups (auto-assigns to current round)
- Delete jury groups with confirmation dialog
- Add/remove members with inline member list
- Assign/switch jury groups via dropdown selector
- Added delete endpoint to juryGroup router (unlinks rounds, deletes members)
- Removed CHAIR/OBSERVER role selectors from add-member dialog (all members)
- Removed role column from jury-members-table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:46:01 +01:00
Matt
86fa542371 Fix round reopen bug + redesign round detail page UI
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Round engine: moved logAudit() calls outside $transaction blocks to prevent
FK violations from poisoning PostgreSQL transactions and rolling back status changes.

Round detail page: redesigned with Editorial Command Center aesthetic -
dark blue gradient header, colored accent stat cards, underline tab bar,
SVG readiness ring, grouped quick actions, branded progress bars and animations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:38:28 +01:00
Matt
079468d2ca Reopen rounds, file type buttons, checklist live-update
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m1s
- Add reopenRound() to round engine (CLOSED → ACTIVE) with auto-pause of subsequent active rounds
- Add reopen endpoint to roundEngine router and UI button on round detail page
- Replace free-text MIME type input with toggle-only badge buttons in file requirements editor
- Enable refetchOnWindowFocus and shorter polling intervals for readiness checklist queries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:06:07 +01:00
de73a6f080 Rounds page: flat pipeline view with awards branching visualization
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m44s
Redesign the rounds page from a card-per-competition layout to a flat
pipeline/timeline view. Rounds display as compact rows connected by a
vertical track with status dots (pulsing green for active). Special
awards branch off to the right from their linked evaluation round with
connector lines and tooltip details. Competition settings moved to a
dialog behind a gear icon. Filter pills replace the dropdown selector.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:19:50 +01:00
80c9e35971 AI category-aware evaluation: per-round config, file parsing, shortlist, advance flow
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
- Per-juror cap mode (HARD/SOFT/NONE) in add-member dialog and members table
- Jury invite flow: create user + add to group + send invitation from dialog
- Per-round config: notifyOnAdvance, aiParseFiles, startupAdvanceCount, conceptAdvanceCount
- Moved notify-on-advance from competition-level to per-round setting
- AI filtering: round-tagged files with newest-first sorting, optional file content extraction
- File content extractor service (pdf-parse for PDF, utf-8 for text files)
- AI shortlist runs independently per category (STARTUP / BUSINESS_CONCEPT)
- generateAIRecommendations tRPC endpoint with per-round config integration
- AI recommendations UI: trigger button, confirmation dialog, per-category results display
- Category-aware advance dialog: select/deselect projects by category with target caps
- STAGE_ACTIVE bug fix in assignment router

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:09:52 +01:00
93f4ad4b31 Add auto-refresh polling across all admin and jury pages
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m35s
- Round detail page: 15s for live data (projects, assignments, scores, workload), 30s for config, 60s for static data
- Filtering dashboard: 15s for results/stats, 30s for rules (job status already 2s)
- Project states table: 15s polling
- Coverage report: 15s polling
- Jury round page: 30s for assignments and round data
- Deliberation session: 10s polling for live vote updates
- Admin dashboard: 30s for stats

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:30:19 +01:00
8e5fc18da6 Consolidated round management, AI filtering enhancements, MinIO storage restructure
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m45s
- Fix STAGE_ACTIVE bug in assignment router (now ROUND_ACTIVE)
- Add evaluation form CRUD (getForm + upsertForm endpoints)
- Add advanceProjects mutation for manual project advancement
- Rewrite round detail page: 7-tab consolidated interface
- Add filtering rules UI with full CRUD (field-based, document check, AI screening)
- Add pageCount field to ProjectFile for document page limit filtering
- Enhance AI filtering: per-file page limits, category/region-aware guidelines
- Restructure MinIO paths: {ProjectName}/{RoundName}/{timestamp}-{file}
- Update dashboard and pool page links from /admin/competitions to /admin/rounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:20:02 +01:00
845554fdb8 Pool, competition & round pages overhaul: deep-link context, inline project management, AI filtering UX, email toggle
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m30s
- Pool page: auto-select program from edition context, URL params for roundId/competitionId deep-linking, unassigned toggle, round badges column
- Competition detail: rich round cards with project counts, dates, jury info, status badges replacing flat list
- Round detail: readiness checklist, embedded assignment dashboard, file requirements in config tab, notifyOnEntry toggle
- ProjectStatesTable: search input, project links, quick-add dialog, pool links with context params
- FilteringDashboard: expandable rows with AI reasoning inline, quick override buttons, search, clickable stats
- Backend: notifyOnEntry in round configJson triggers announcement emails on project assignment via existing email infra

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 08:23:40 +01:00
7f334ed095 Round detail overhaul, file requirements, project management, audit log fix
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m32s
- Redesign round detail page with 6 tabs (overview, projects, filtering, assignments, config, documents)
- Add jury group assignment selector in round stats bar
- Add FileRequirementsEditor component replacing SubmissionWindowManager
- Add FilteringDashboard component for AI-powered project screening
- Add project removal from rounds (single + bulk) with cascading to subsequent rounds
- Add project add/remove UI in ProjectStatesTable with confirmation dialogs
- Fix logAudit inside $transaction pattern across all 12 router files
  (PostgreSQL aborted-transaction state caused silent operation failures)
- Fix special awards creation, deletion, status update, and winner assignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:49:39 +01:00
f572336781 Rounds overhaul: full CRUD submission windows, scheduling UI, analytics, design refresh
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m40s
- Fix special award FK crash: replace 4x raw auditLog.create with logAudit() helper
- Add updateSubmissionWindow + deleteSubmissionWindow mutations to round router
- Add per-round analytics (_count, juryGroup) to competition.getById
- Remove redundant acceptedCategories from intake config
- Rewrite submission window manager with full CRUD, all fields, date pickers
- Add round scheduling card (open/close dates) to round detail page
- Add project count, assignment count, jury group to round list cards
- Visual redesign: pipeline view, brand colors, progress bars, enhanced cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:07:09 +01:00
2fb26d4734 Pool page: add bulk assign-to-round, enhance project pool UI
- Add assignAllToRound mutation to project-pool router
- Rewrite pool page with round selector, bulk assignment, and better layout
- Add pool navigation link to admin projects page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 07:06:59 +01:00
221 changed files with 35035 additions and 9279 deletions

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -11,7 +11,7 @@ RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
COPY package.json package-lock.json* .npmrc* ./
RUN npm ci
# Rebuild the source code only when needed

View File

@@ -10,7 +10,7 @@ WORKDIR /app
RUN apk add --no-cache libc6-compat openssl
# Copy package files
COPY package.json package-lock.json* ./
COPY package.json package-lock.json* .npmrc* ./
# Install dependencies
RUN npm install && npm install tailwindcss-animate

View File

@@ -1,6 +1,6 @@
Jury 1 attribués,Full name,Téléphone,Lien Whatsapp,E-mail,Project's name,Team members,Country,Tri par zone,University,Category,Date of creation,Issue,"Comment ",Mentorship,How did you hear about MOPC?,Application status,PHASE 1 - Submission,PHASE 2 - Submission
,Mutave Nelly,+254704458380,Envoyer le message,mutavenelly.mn@gmail.com,Revamp Flips,Nthatisi Lesala,"Nairobi, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,1997-01-24,Technology & innovations,Hrkb,true,Friend shared link,,,
,Omoding Olinga Simon,+256773351242,Envoyer le message,simonomoding.ace@gmail.com,Dagim Fisheries (U) Ltd,"Omoding Simon, Ilukat Musa, Omiel Peter, Omongole Richard, Fellista Nakatabirwa","Kampala, Ouganda","Africa, Ouganda",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-01-05,Sustainable fishing and aquaculture & blue food,"Dagim Fisheries directly advances equitable access to safe, nutritious, affordable food while improving planetary health through zero-waste processing and sustainable fishing. Our multidisciplinary approach integrates nutrition science, food engineering, supply chain management, environmental conservation, and economics. We address malnutrition, reduce waste, empower fishing communities, and protect Lake Victoria's and Kyoga's ecosystem creating regenerative food systems scalable across East Africa toward the billion-lives impact goal.",true,Through Linkedinn social media,,,
,Omoding Olinga Simon,+256773351242,Envoyer le message,simonomoding.ace@gmail.com,Dagim Fisheries (U) Ltd,"Omoding Simon, Ilukat Musa, Omiel Peter, Omongole Richard, Fellista Nakatabirwa","Kampala, Ouganda","Africa, Ouganda",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-01-05,Sustainable fishing and aquaculture & blue food,"Dagim Fisheries directly advances equitable access to safe, nutritious, affordable food while improving planetary health through zero-waste processing and sustainable fishing. Our multidisciplinary approach integrates nutrition science, food engineering, supply chain management, environmental conservation, and economics. We address malnutrition, reduce waste, empower fishing communities, and protect Lake Victoria's and Kyoga's ecosystem creating regenerative food systems scalable across East Africa toward the billion-lives impact goal.",true,Through Linkedinn social media,Received,https://drive.google.com/drive/folders/1uwb8O2na8OvbEp-3-QiUSQoPCVUbCYgE?usp=drive_link,
,SENI Abd-Ramane,+2290161149564,Envoyer le message,seniramane@gmail.com,OceanClean Tech,"SENI Abd-Ramane, DJIBRIL Samir, SOULÉ SEIDOU Mansoura","Banikoara, Bénin","Africa, Bénin",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-12-29,Reduction of pollution (plastics chemicals noise light...),"_Project Title_: OceanClean Tech
_Objective_: The OceanClean Tech project aims to reduce plastic pollution in the oceans by developing a marine plastic waste collection system. The main goal is to clean up polluted marine areas and prevent new plastic waste from entering marine ecosystems.
@@ -84,12 +84,12 @@ Pelagos aims to transform oceans into self-healing climate engines while creatin
,Yahuza Sani Hudu,+2348146036089,Envoyer le message,ysanihudu@gmail.com,CleanUp MDC,"Abner Ayuba Atuga, Ameer Saeed","Zaria, Kaduna, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-26,Reduction of pollution (plastics chemicals noise light...),"CleanUp Multi Dyna mic Concept (CleanUp MDC) is a Nigeria-based social enterprise advancing inclusive climate-tech solutions within the circular economy. Our flagship innovation, JoliTrash, is a toll-free, AI-powered, voice-based recycling platform that allows households and informal waste actors to sort and sell recyclable waste using a simple AI phone call in their local language without the need for smartphones, internet access, or digital literacy. Nigeria generates about 2.5 million tons of plastic waste annually, yet less than 10% is recycled (World Bank). At the same time, over 70% of Nigerians lack easy access to recycling facilities, locations, or clear recycling processes (NESREA, 2022), and 48% of the population has poor or no internet connectivity (NCC, 2023), making most app-based recycling platforms inaccessible to low-income and marginalized communities. CleanUp MDC was created to bridge this gap by enabling users to dial a toll-free number on any basic phone (cell-phone) and interact with our AI in Hausa, Yoruba, Igbo, Pidgin, or English with no language barrier, our AI identify users location, connect with nearby verified waste collectors, and user earn income from recyclables. Our target market includes low-income households, women, youth, informal waste pickers, and underserved urban and peri-urban communities across Nigeria, as well as recycling agents and aggregators seeking reliable recyclable feedstock. To date, we have onboarded over 30,163 active users from underserved communities, 17,907 of them women, and facilitated the recovery of more than 10,000 tons of plastic waste, positioning our operations to contribute to an estimated 25,000 tons of CO₂ emissions reduction annually, equivalent to removing about 4,000 fuel-powered cars from the road each year. We partner with the Waste Pickers Association of Nigeria (WAPAN), we are scaling nationwide with the long-term goal of expanding across Africa. Our main goals are to expand access to recycli",true,"The Commissioner for Environment and Natural Resources of Kaduna State Government, Nigeria Share's the link with my startup",Received,https://drive.google.com/drive/folders/1yuSmCXSrORvgjYkHSpb5fFz9qFNe7FJD?usp=drive_link,
,Nesphory Mwambai,+254714520023,Envoyer le message,mwambai@seamo.earth,seamo.earth,"Nesphory Mwambai, Lewis Kimaru","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-22,Restoration of marine habitats & ecosystems,"seamo.earth initiative focused on utilizing artificial intelligence (AI) to explore, document, monitor, and preserve the mariculture and seascapes of the Pwani regions. This project aims to enhance our understanding and protection of marine environments through the development of eco-friendly and climate adaptive technologies.",true,email news letter,Received,https://drive.google.com/drive/folders/1eOyDGZwwlNNAzbwwC-CUVmJi4gM3kDLI?usp=drive_link,
,Fiona McOmish,+447722083419,Envoyer le message,fiona.mcomish@algae-scope.com,Algae Scope,Natasha Yamamura; Alejandra Noren; Farshid Pahlevani,"Venise, Vénétie, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-12-16,Technology & innovations,We replace toxic PFAS chemicals in textiles with a water- and fire-resistant coating made 100% from seaweed. We sell our high-performing solution to textile manufacturers and formulators in a 'drop-in' format.,true,LinkedIn,Received,https://drive.google.com/drive/folders/1KA4GZXWQhMCodASq_15Ksx5yzak5AwpT?usp=drive_link,
,Veronica Nzuu,+254748488312,Envoyer le message,veramichael2000@gmail.com,Furies,Angelo Mulu,"Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-05-29,Consumer awareness and education,"My project focuses on empowering children and youth in my community to take action on plastic pollution through simple, community led learning and action. The objective is to build awareness, responsibility, and leadership by combining environmental education with practical activities such as waste segregation, plastic collection, creative upcycling, and community dialogue. By using participatory and inclusive approaches, especially for girls and marginalized youth, the project aims to strengthen community ownership of sustainability solutions and inspire long term behavior change at the local level.",true,Social Media linked in,,,
,Veronica Nzuu,+254748488312,Envoyer le message,veramichael2000@gmail.com,Furies,Angelo Mulu,"Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-05-29,Consumer awareness and education,"My project focuses on empowering children and youth in my community to take action on plastic pollution through simple, community led learning and action. The objective is to build awareness, responsibility, and leadership by combining environmental education with practical activities such as waste segregation, plastic collection, creative upcycling, and community dialogue. By using participatory and inclusive approaches, especially for girls and marginalized youth, the project aims to strengthen community ownership of sustainability solutions and inspire long term behavior change at the local level.",true,Social Media linked in,Received,https://drive.google.com/drive/folders/1rRGp6lWNe0jbNVMdZKEawVitpEEv63rM?usp=drive_link,
,Neville Agesa,+254796438122,Envoyer le message,agesanevil@gmail.com,Sustainable Blue Food & Livelihoods Innovation,"Robert Meya,Hannah Mathenge,JohnChaka","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-02-01,Sustainable fishing and aquaculture & blue food,"The Tsunza Community, located on Kenyas South Coast in Kwale County, is a vital ecological hub linking mangrove forests, wetlands, and the Mwache River estuary. These interconnected ecosystems support fisheries, biodiversity, and local livelihoods but face increasing pressure from degradation, pollution, and declining fish stocks.
This project aims to protect and restore mangrove and wetland ecosystems while strengthening sustainable blue livelihoods. Through community-led mangrove restoration, marine pollution awareness, and youth and women engagement in sustainable fisheries and aquaculture practices, the project promotes ocean protection alongside economic resilience.
By integrating nature-based solutions, environmental education, and livelihood innovation, the initiative positions Tsunza as a scalable model for community-driven ocean conservation and sustainable development.",true,Gensea opportunities,,,
By integrating nature-based solutions, environmental education, and livelihood innovation, the initiative positions Tsunza as a scalable model for community-driven ocean conservation and sustainable development.",true,Gensea opportunities,Received,https://drive.google.com/drive/folders/1K87ubq9S801uVUVmSj79cmygKmrmd6ig?usp=drive_link,
,Anshika Sarraf,+917897130506,Envoyer le message,anshika.sarraf_ug2024@ashoka.edu.in,Auralis Blue,Anshika Sarraf,"Sonipat, Haryana, Inde",Asia,Ashoka University + Sonipat,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Auralis Blue is tackling a problem few people see but that is harming our oceans: underwater noise pollution. Ships, ports, and offshore construction create constant sound that travels far underwater, interfering with how whales, dolphins, and fish communicate, migrate, and reproduce. Auralis Blue measures this invisible threat and turns it into clear, actionable data, helping maritime stakeholders protect marine life while continuing sustainable operations.
Underwater noise is an invisible threat, but its effects are very real: studies show that marine mammals rely on sound to survive, and high noise levels can cause stress, confusion, and even death in fish populations. Despite this, there are almost no tools that measure or manage noise systematically. Auralis Blue fills this gap, providing a science-based, scalable solution that can protect marine ecosystems worldwide.
@@ -119,7 +119,7 @@ The project delivers a flood-resilient, decentralized sanitation s",true,BFA Glo
,Christian Mwijage,+255711457346,Envoyer le message,chrissmwijage@gmail.com,ECOACT Tanzania,"• Mr. Bernard Ernest, Technical Director overseeing all production activities, holds a Master of Engineering in Biochemical Engineering. Mr. Christian Mwijage, Managing Director responsible for overall operations, holds a Bachelors degree in Business Administration and Marketing. Ms. Elineca Ndowo, Chief Finance Officer, holds a Masters degree in Project Management and Financing from the University of Dar es Salaam.","Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-12-21,Reduction of pollution (plastics chemicals noise light...),"Every year, 9 million tonnes of plastic waste enter our oceans, polluting marine ecosystems and threatening ocean life. At this rate, by 2050 the ocean could contain more plastic than fish. At the same time, the world loses over 2 billion trees annually to meet the demand for timber in the furniture and construction industries—making deforestation the second leading driver of climate change.
We address both crises through a chemical-free, energy-efficient, AI-powered technology that transforms ocean-bound plastics and post-consumer packaging waste into high-quality, sustainable materials for furniture, building, and construction applications. By converting low-value, hard-to-recycle multi-layer plastic (MLP) waste into durable products, we are advancing the circular economy and giving new life to materials that would otherwise damage the environment.
We address one of the most persistent challenges in the plastics value chain: waste streams that lack viable conventional recycling pathways. We focus specifically on two difficult-to-recycle categories - multi-layer plastics (MLP), which combine multiple plastic layers and/or aluminum foil, and mixed plastic waste that cannot be economically or efficiently segregated. Globally, an estimated 6 billion tons of plastic waste have been generated, approximately 14% of which consists of MLP. Due to technical and economic limitations, these materials are typically landfilled, incinerated, or left uncollected, contributing significantly to environmental pollution and ecosystem degradation.",true,Social Media,Received,https://drive.google.com/drive/folders/1xCJ_8EpTEdBORiJHYwIRZO22z8e54fbx?usp=drive_link,
,Olaleye Rofiat Olayinka,+2348038877293,Envoyer le message,olaleyerofiatyinka@gmail.com,Eco Heroes Nigeria limited,Olaleye Rofiat Olayinka Salaam Lateef Oladimeji Akinsanya Dorcas Olaleye Hassan Ogundairo Ganiyat,"Lagos, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-11-08,Reduction of pollution (plastics chemicals noise light...),"Eco Heroes is an incentive-based, tech-enabled recycling solution that prevents ocean-bound plastic waste from entering rivers and marine ecosystems. The project mobilizes communities to collect and exchange post-consumer plastic for rewards such as cash and essential services, creating a reliable supply of recovered plastic while improving livelihoods. Recovered materials are recycled and transformed into value-added products, including sewing threads, ensuring financial sustainability and scalable impact. The objective is to measurably reduce plastic pollution, create local economic value, and build a replicable model for coastal and river-connected communities.",true,I learned about the Monaco Ocean Protection Challenge through my involvement in an entrepreneurship and innovation programs focused on the blue economy and plastic pollution solutions.,,,
,Olaleye Rofiat Olayinka,+2348038877293,Envoyer le message,olaleyerofiatyinka@gmail.com,Eco Heroes Nigeria limited,Olaleye Rofiat Olayinka Salaam Lateef Oladimeji Akinsanya Dorcas Olaleye Hassan Ogundairo Ganiyat,"Lagos, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-11-08,Reduction of pollution (plastics chemicals noise light...),"Eco Heroes is an incentive-based, tech-enabled recycling solution that prevents ocean-bound plastic waste from entering rivers and marine ecosystems. The project mobilizes communities to collect and exchange post-consumer plastic for rewards such as cash and essential services, creating a reliable supply of recovered plastic while improving livelihoods. Recovered materials are recycled and transformed into value-added products, including sewing threads, ensuring financial sustainability and scalable impact. The objective is to measurably reduce plastic pollution, create local economic value, and build a replicable model for coastal and river-connected communities.",true,I learned about the Monaco Ocean Protection Challenge through my involvement in an entrepreneurship and innovation programs focused on the blue economy and plastic pollution solutions.,Received,https://drive.google.com/drive/folders/1RSk7W3_ay-xnsBy5UE1xKaT2uyxlOmfH?usp=drive_link,
,Shamim Wasii Nyanda,+255764190074,Envoyer le message,shamim@sunwaveltd.com,SUNWAVE,Ridhiwan Mseya,"Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-03-01,Sustainable fishing and aquaculture & blue food,"SUNWAVE provides small-scale fishers in Tanzania with solar-powered ice-making units to reduce fish spoilage. These machines, powered by solar energy, offer a sustainable and cost-effective solution to fish preservation, especially in remote areas where access to the power grid is limited. By keeping fish fresh for longer, these units help fishers reduce spoilage, maintain higher-quality products, and increase income. The ice-making machines are operated by trained personnel to ensure proper use and efficiency.",true,It was shared by SUNWAVE's Advisory Board member.,Received,https://drive.google.com/drive/folders/1qoWF1b-GIhjylYcGloZy1xnMZRjB0XEO?usp=drive_link,
,Lorna Mudegu,+254718059337,Envoyer le message,lornaafwandi@gmail.com,WAVU,Don Okoth | Vincent Oduor | Chris Munialo | Loise Mudegu,"Kisumu, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-30,Sustainable fishing and aquaculture & blue food,"WAVU is a market and aggregation platform that connects verified aquaculture producers to buyers through organised, predictable supply chains.
@@ -135,7 +135,7 @@ Objectives: Reduce waste; add value for fishers; create local jobs; supply resta
Key details: Source = local landings; partners = fishers + certified processor + food-safety lab; compliance = HACCP/food regs; go-to-market = horeca, gourmet stores, e-commerce; pilot → scale path.",false,Through Fondation Prince Albert II de Monaco.,Received,https://drive.google.com/drive/folders/1Pbf4FwTfAfqklel_a94CYA7dZsmvPfGH?usp=drive_link,
,Jonas Wüst,+41766307924,Envoyer le message,jonas@tethys-robotics.ch,Tethys Robotics,Pragash Sivananthaguru,"Zurich, Suisse","Europe, Switzerland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-15,Technology & innovations,"Tethys Robotics builds compact autonomous underwater robots that replace emission-intensive vessel operations with remote, low-impact subsea inspection. Our goal is to make offshore maintenance safer and more sustainable by reducing CO₂ emissions, preventing environmental damage through early detection, and improving the reliability of renewable marine infrastructure.",false,BRIDGE by Innosuisse forward us.,,,
,James Kalo Malau,+6787774965,Envoyer le message,malau_jk@hotmail.com,Coral Reforestation,"John Maliu, Josue Jimmy, Nalo Samuel, Manu Roy, James Sulu","Port Vila, Shefa, Vanuatu",Oceania,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-01,Sustainable fishing and aquaculture & blue food,,true,Funds for NGOs Premium,,,
,James Kalo Malau,+6787774965,Envoyer le message,malau_jk@hotmail.com,Coral Reforestation,"John Maliu, Josue Jimmy, Nalo Samuel, Manu Roy, James Sulu","Port Vila, Shefa, Vanuatu",Oceania,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-01,Sustainable fishing and aquaculture & blue food,,true,Funds for NGOs Premium,Ignore,,
,Amelia Martin,+18606824426,Envoyer le message,amelia@mudratsurf.com,Mud Rat,"Jack Tarka, Patricio Acevedo, Brian Lassy","Mansfield, CT, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-06-13,Reduction of pollution (plastics chemicals noise light...),We manufacture an eco-friendly alternative to marine foam (marine grade styrofoam).,true,Google!,Ignore,,
,Rasmus Borgstrøm,+4527117113,Envoyer le message,rasmus@blueplanetinnovators.com,FlowMinerals,"Rasmus Borgstrøm, Esben Jessen","Copenhague, Région de la Capitale, Danemark","Denmark, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-09-24,Mitigation of ocean acidification,"FlowMinerals captures CO₂ from seawater and converts it into fossil-free calcium carbonate, contributing to the mitigation of ocean acidification while reducing reliance on land-based limestone mining. The solution enables industrial decarbonization using ocean-compatible materials, with a strong focus on environmental safety and minimal marine impact.
www.FlowMinerals.com",true,LinkedIn,,,
@@ -160,13 +160,13 @@ The database is designed for policymakers and academic institutions, offering pr
Key features include the development of a Fisheries Atlas and a Blue Carbon Biodiversity initiative focused on Africas landing beaches, providing strategic recommendations for the establishment of Marine Protected Areas (MPAs). The project also promotes data sharing among local indigenous fishermen and enhances understanding aligned with the UN Ocean Decade objectives. It will create a comprehensive data repository covering various marine species, including fish, mangroves, algae, and seaweeds.
Links:
https://oceandecade.org/action",true,MOPC Linkedin.,,,
https://oceandecade.org/action",true,MOPC Linkedin.,Received,https://drive.google.com/drive/folders/1nLGw1as7LT440vYA-_Bs__lyPD-Uo3rr?usp=drive_link,
,Carol Nkawaga Moonga,+260979164462,Envoyer le message,moongacaroln@gmail.com,Kacachi General Dealers,Cathrine Kapesha,Zambie,"Africa, Zambia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-11,Sustainable fishing and aquaculture & blue food,,true,I saw an advertisement on LinkedIn,Received,https://drive.google.com/drive/folders/1wEWiGREhq-dWPkFqGqmSK89PcuOjhsXX?usp=drive_link,
,Coral Bisson,+377643915342,Envoyer le message,coralbisson@icloud.com,Corali,Coral Bisson,"St Helier, Jersey","Europe, Jersey",International University of Monaco,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),- Reduction of ocean plastics through development of swimwear using recycled ocean plastics,true,University,,,
,Pavel Kartashov,+38975588771,Envoyer le message,pavel.k@wavespark.co,WaveSpark Green Marine Energies,"Pavel Kartashov, Rodrigo Caba, Francisco Perez, Glib Ivanov","Skopje, Macédoine du Nord","Europe, Macedonia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-05,Technology & innovations,"Scalable and capital-light hybrid ocean energy platforms harvesting wave, sun and wind energy in near-shore areas for shore and offshore energy end-users",false,Social media post,Received,https://drive.google.com/drive/folders/1vdcWHlPUURdN69T-Ek7wsqOTrLNaODq0?usp=drive_link,
,Raphaëlle Guénard,+33663688277,Envoyer le message,contact@filae.eu,Filae,Raphaëlle Guénard & Killian Bossé,"Marseille, Provence-Alpes-Côte d'Azur, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-21,Reduction of pollution (plastics chemicals noise light...),"Filae transforms end-of-life fishing nets into ultra-light, modular supports for plant-based shading and greening (façades and canopies), helping cool down dense urban areas without heavy structures.
Our goal is to scale a Mediterranean circular model, from local net collection to on-site deployment, reducing waste and embodied carbon while boosting thermal comfort and biodiversity through real-world pilots.",true,"from Marine Jacq-Pietri, Coordinatrice du Monaco Ocean Protection Challenge",Received,https://drive.google.com/drive/folders/1QriiO8XKqx97efs-dAPqHDMhcSy9HjHH?usp=drive_link,
,Anastasiia,+380680650309,Envoyer le message,grozdova.anastasiia@gmail.com,Innovations in ocean environment,Darina Mitina,Ukraine,"Europe, Ukraine",,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,Take technology onto another level,true,Social media marketing,,,
,Anastasiia,+380680650309,Envoyer le message,grozdova.anastasiia@gmail.com,Innovations in ocean environment,Darina Mitina,Ukraine,"Europe, Ukraine",,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,Take technology onto another level,true,Social media marketing,Ignore,,
,Raismin Kotta,+6281342018565,Envoyer le message,raisminkotta88@gmail.com,The Pearls cultuvation & Pearls jewelry,"Raismin Kotta, aya sophia, Lalu harianza,asril junaidy","Lombok, Indonesie",Asia,"45 University, Mataram Indonesia",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,Sustainability fisheries and Aquaculture,true,I hear and read MOPC in website and interested to apply,,,
,Vincent Kneefel,+31622514465,Envoyer le message,vincent@vitalocean.io,Vital Ocean,Joi Danielson,"Amsterdam, Hollande Septentrionale, Pays-Bas","Europe, Netherland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-04-16,Technology & innovations,,true,Linkedin,,,
,Maaire Gyengne Francis,+233208397960,Envoyer le message,gyengnefrancis90@gmail.com,WasteTrack,"Frank Faarkuu, Prosper Dorfiah","Accra, Ghana","Africa, Ghana",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-01-01,Reduction of pollution (plastics chemicals noise light...),"Problem and Solution
@@ -176,7 +176,7 @@ Urban cities across Africa face a severe plastic waste crisis driven by rapid po
Our solution is to develop an AI-powered platform that helps urban households dispose of plastic waste by connecting them with local collectors through image, video, or weight-based pricing and cashless payments. It tackles severe plastic pollution in African cities caused by limited collection capacity and unsafe disposal practices. With millions of households generating increasing waste, the market potential is vast across Ghana and other rapidly urbanizing regions. Once consistent collection volumes are reached, WasteTrack will expand into a global plastic trading marketplace, enabling recyclers worldwide to buy verified, traceable plastic waste - positioning the startup as a major player in the circular plastics economy.
Our AI-driven waste management and digital payment solution is designed to make plastic disposal easy, convenient, and traceable for urban households. Key features will include photo, video, or weight-based AI analysis to estimate disposal fees; secure digital payments; GPS-linked pickup requests; and unique tracking codes for every waste package. The platform also supports community micro-dumpsites for flexible drop-off and pr",true,Google search,Received,https://drive.google.com/drive/folders/1Rv9W6h5zQESX7A68bQio5JWy5TML86rH?usp=drive_link,
,Shelby Thomas,+13866897675,Envoyer le message,admin@oceanrescuealliance.org,Coastal Resilience Solutions: WeRestore,"Dr. Shelby Thomas, Dr. David Weinstein, Lindsay Humbles,","FL, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2019-12-01,Restoration of marine habitats & ecosystems,"Ocean Rescue Alliance International, through its Coastal Resilience Solutions for-profit arm and the We Restore initiative, deploys scalable living shoreline and hybrid reef technologies to restore degraded coastal and marine ecosystems while enhancing climate resilience for vulnerable communities. The projects objective is to deliver measurable ocean biodiversity recovery, erosion reduction, and carbon co-benefits through science-based, nature-positive infrastructure that can be replicated regionally and globally.",true,via Email Newsletter,,,
,Shelby Thomas,+13866897675,Envoyer le message,admin@oceanrescuealliance.org,Coastal Resilience Solutions: WeRestore,"Dr. Shelby Thomas, Dr. David Weinstein, Lindsay Humbles,","FL, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2019-12-01,Restoration of marine habitats & ecosystems,"Ocean Rescue Alliance International, through its Coastal Resilience Solutions for-profit arm and the We Restore initiative, deploys scalable living shoreline and hybrid reef technologies to restore degraded coastal and marine ecosystems while enhancing climate resilience for vulnerable communities. The projects objective is to deliver measurable ocean biodiversity recovery, erosion reduction, and carbon co-benefits through science-based, nature-positive infrastructure that can be replicated regionally and globally.",true,via Email Newsletter,Received,https://drive.google.com/drive/folders/1wO7CWNMl75fa9A-sm1ei9fEFGWqWv-vM?usp=drive_link,
,Danail Marinov,+359895497694,Envoyer le message,dmarinov@redget.io,RedGet.io,"Danail Marinov, Dobromir Balabanov, Alexander Valchev","Sofia, Bulgarie","Bulgaria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-12-01,Technology & innovations,"What it is (TRL 45): Pilot-ready, AI collaborative platform for GHG emissions Scope 13 monitoring, compliance reporting and forecasting for ports/terminals/shipping companies. RedGet.io was among the selected companies and participated in ADT4Blue, EY Startup Academy Germany, Blue Readiness Assistance and Green Marine Med (by Port of Barcelona) programs.
Value: Up to 60% reduction in reporting efforts and costs, emission forecasting for EU-ETS regulations, AI maritime assistant and decision-ready visibility to plan and verify decarbonization.
Status & partners: Confirmed pilot with Port of Gdynia (Jan 2026) and Port of Talling (Jan 2026); negotiations with Port of Valencia, Port of Huelva, and EY Bulgaria.",false,A friend of mine shared this opportunity to me,Received,https://drive.google.com/drive/folders/1xElP9xEg6r2R0lDElZD9n6v4FOW9CCEp?usp=drive_link,
@@ -215,7 +215,7 @@ Objectives:
2. Guide boaters away from seagrass using a digital map and reservation system that clearly marks no-anchor zones and available eco-moorings.
3. Measure and monetise impact by estimating hectares of seagrass protected and associated blue-carbon storage and ecosystem-service value, creating reporting for marinas, municipalities and impact investors.",true,Through my University.,,,
3. Measure and monetise impact by estimating hectares of seagrass protected and associated blue-carbon storage and ecosystem-service value, creating reporting for marinas, municipalities and impact investors.",true,Through my University.,Received,https://drive.google.com/drive/folders/1mDEkYkvinYZ4eJzMqYSCMcDxpbCr8Rs-?usp=drive_link,
,ssentubiro billy,+256708630034,Envoyer le message,lemanfoundation16@gmail.com,schoolarships,Nakayulu Grace and ssentubiro billy,"Kampala, Ouganda","Africa, Ouganda",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2016-08-13,Capacity building for coastal communities,allow needy children access quality education,true,via social media,,,
,Hasan Noor Ahmed,+491737752964,Envoyer le message,biland.awdal.org@gmail.com,BlueGuard Africa Community-Driven Ocean & Coastal Protection Innovation Hub,"Hasan Noor Ahmed Chairman & Founder Amina Abdillahi Ibrahim Program Director (Health & Nutrition) Mohamed Abdi Warsame Finance & Administration Officer Hodan Ismail Ali Climate & Environment Program Lead Abdirahman Yusuf Farah Monitoring, Evaluation & Learning Officer Fardowsa Ahmed Jama Community Outreach & Protection Coordinator","Boorama, Awdal, Somalie","Africa, Somalia",Bilan Awdal Organization Training & Capacity Development Unit,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"The Blue Coast Guardians Initiative is a youth-led, community-centered program designed by Bilan Awdal Organization to combat coastal pollution, restore marine ecosystems, and create sustainable blue-economy opportunities along the Somaliland/Somalia coastline.
Our approach combines innovative low-cost technologies, community livelihoods, and education, enabling coastal communities to protect the ocean while improving their economic resilience.
@@ -341,7 +341,7 @@ Objectives:
• Increase resilient incomes by training and supporting community micro-enterprises (waste-to-value, eco-services, sustainable seafood handling) and link them to off takers.
• Restore natural coastal defenses through mangrove/coastal habitat restoration tied to local stewardship incentives and verified survival rates.
• Create a repeatable “Hub-in-a-box” model that can scale across coastal regions quickly with clear KPIs and partner networks delivering positive, measurable ocean impact in the short to medium term, consistent with MOPCs focus on ocean-positive business concepts",true,United Nations SDGs Newsletter.,Received,https://drive.google.com/drive/folders/1ph6DBmqeSGvSSqxQkymPx9rlU-ZmGFnr?usp=drive_link,
,Veronica Nzuu,+254748488312,Envoyer le message,veramichael2000@gmail.com,Furies,Angelo Mulu,"Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-05-23,Consumer awareness and education,"My project focuses on community-based climate and ocean education for children and youth, using storytelling, play, and interactive learning to build awareness around plastic pollution, waste segregation, and environmental responsibility. The objective is to transform how young people and families understand and relate to plastic consumption moving from awareness to everyday action. Through games, facilitated sessions, and community learning spaces, the project empowers children to become informed advocates within their households and neighborhoods, strengthening long-term behavior change and community ownership of sustainability solutions.",true,Social media linked in,,,
,Veronica Nzuu,+254748488312,Envoyer le message,veramichael2000@gmail.com,Furies,Angelo Mulu,"Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-05-23,Consumer awareness and education,"My project focuses on community-based climate and ocean education for children and youth, using storytelling, play, and interactive learning to build awareness around plastic pollution, waste segregation, and environmental responsibility. The objective is to transform how young people and families understand and relate to plastic consumption moving from awareness to everyday action. Through games, facilitated sessions, and community learning spaces, the project empowers children to become informed advocates within their households and neighborhoods, strengthening long-term behavior change and community ownership of sustainability solutions.",true,Social media linked in,Doublon,,
,Cristiano da Silva Palma,+5511978020540,Envoyer le message,cristianospalma@yahoo.com.br,Tabernacle Space Islands,Cristiano da Silva Palma,"São José dos Campos, SP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-08-09,Technology & innovations,"Project Idea & Scientific Context
The project develops a next-generation modular OTEC (Ocean Thermal Energy Conversion) system, combining innovative deep-ocean structures, ultra-optimized thermodynamic cycles, and AI-based monitoring to deliver continuous (24/7) clean energy, with initial pilot operation targeted from 2027. The system is designed for scalable deployment in tropical and island regions, validated through a pilot-scale OTEC unit operating with deep-water intake (~1000 m or more) and real-time intelligent control.
@@ -365,7 +365,7 @@ Tech stack: Next.js frontend, Supabase backend (PostgreSQL + Auth + Storage), Op
Timeline: 90 days to launch following MVP development, beta testing with 100 users, then public launch.
Initial budget required: 15-30K EUR covering development, educational content creation, and marketing expenses.",true,Linkedin,,,
Initial budget required: 15-30K EUR covering development, educational content creation, and marketing expenses.",true,Linkedin,Received,https://drive.google.com/drive/folders/1P0Js2H_6QStp7z-GaXLw6ODuTz0XOhqY?usp=drive_link,
,Christopher Enriquez Urban,+4915679760251,Envoyer le message,christopher@algrid.tech,Algrid,Valentina Iunosheva,"Francfort-sur-le-Main, Hesse, Allemagne","Europe, Germany","University of Leeds, Leeds, UK",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,"Project: AI-powered offshore infrastructure for Sargassum monitoring, harvesting, and conversion into industrial biomass.
Problem: Massive Sargassum blooms devastate Caribbean coasts but remain unused due to unpredictable availability and high logistics costs.
@@ -380,7 +380,7 @@ Our advanced material development and manufacturing ensure durability, aesthetic
,Francesca Rose Turner Prichard,+34671298357,Envoyer le message,francescaroseturner@gmail.com,Residensea,"Francesca Turner, Aoife Martin, Alberto Rangel","Palma, Iles Baléares, Espagne","Europe, Spain",Southampton Solent University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Restore societys relationship with the ocean by building connections between women and the ocean through sport, art and conservation activities.",true,Linkedin,,,
,Brian Ochieng Aliech,+254757008417,Envoyer le message,ochiengaliech@gmail.com,NOLA AFRICA,"Brian Aliech, Charles Okutah, Hussein Hezekiah, Kevin Onsongo, Lidah Makena","Nairobi, Kenya","Africa, Kenya","University of Nairobi, Nairobi",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Our project seeks to address the pervasive challenge of plastic pollution that has afflicted urban centers and aquatic ecosystems across the globe.
By converting discarded plastic into durable construction materials, we aim to alleviate the strain on finite natural resources traditionally employed in the building industry, thereby reducing both costs and inefficiencies.
In addition, this initiative seeks to generate meaningful employment opportunities for young people in underserved communities, empowering them to achieve economic stability and dignity. Ultimately, our vision is nothing less than to contribute to the preservation and renewal of our planet.",true,Through a friend.,,,
In addition, this initiative seeks to generate meaningful employment opportunities for young people in underserved communities, empowering them to achieve economic stability and dignity. Ultimately, our vision is nothing less than to contribute to the preservation and renewal of our planet.",true,Through a friend.,Received,https://drive.google.com/drive/folders/1IqXSPxuTPOJaQomXwDT9oKzkthgSpyOw?usp=drive_link,
,Adhithi Mugundha Kumar,+447512296331,Envoyer le message,Adhithimukhundh@gmail.com,Blue crabs,Xenia Anagnostou,"Plymouth, Angleterre, Royaume-Uni",UK,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-06,Sustainable fishing and aquaculture & blue food,We aim to provide a solution to invasive blue crabs in the Mediterranean by developing a bait-induced fishing method.,true,,Received,https://drive.google.com/drive/folders/1wAQ7xulHAa-nRAOzZrCgCWO96CW3IWuy?usp=drive_link,
,THIERRY BOUSSION,+33621220023,Envoyer le message,t.boussion@yuniboat.com,Yuniboat,Thierry Boussion,"Le Pouliguen, Pays de la Loire, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-06-01,Reduction of pollution (plastics chemicals noise light...),"Yuniboat develops an industrial model dedicated to the eco-reconditioning of leisure and professional boats, designed to significantly reduce the environmental impact of boating activities.
@@ -397,9 +397,9 @@ Make boating more compatible with ocean preservation.
Support professionals (fishing, rental fleets) in meeting decarbonation goals by 2030.
Offer a sustainable, economically viable alternative to new boat construction.
Deploy a scalable industrial model capable of transforming the nautical and maritime sectors.
Yuniboats ambition is to position eco-reconditioning as a key lever for ocean protection, combining circular economy, innovation, and long-term impact on marine biodiversity.",true,we follow your activities on Linkedin and Instagram,,,
,Daniele Tassara,+393466376215,Envoyer le message,daniele.tassara@outlook.com,MareNetto,"Giambattista Figari, Giorgio Mussini","Santa Margherita Ligure, Ligurie, Italie","Europe, Italia","Universita di Genova, Genova",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"MareNetto is a yacht-focused climate platform that automatically calculates and offsets superyacht CO2 emissions from AIS/MMSI data, then issues verifiable certificates that owners and charter managers use for marketing and ESG compliance",true,I lived in Monaco and i knew about this project,,,
,Gaia Minopoli,+393393499607,Envoyer le message,gaia.minopoli@ogyre.com,Ogyre,Agnese Antoci Alessandro Serra Alice Casella Andrea Faldella Andrea Scatolero Antonio Augeri Chiara Maggiolini Davide Brugola Filippo Ferraris Gaia Minopoli Gian Piero Seregni Lorenzo Gastaldo Matteo Quaglio Mattia De Serio Michele Migliau Alessandro Sciarpelletti Francesco Carletto francesco notari Irene Eustazio Jurgen Ametaj Lorenzo Varas Marta Berardini Lucrezia Napoletano Gabriele Cusimano Enrica Sandigliano,"Gênes, Ligurie, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-01-21,Reduction of pollution (plastics chemicals noise light...),"Ogyre is a startup tackling marine plastic pollution through a Fishing for Litter model, working directly with fishing communities worldwide. Its mission is to clean the Ocean while turning plastic waste into a resource. By financially supporting fishers to recover marine litter during their daily activities, and by involving local partners for sorting and recycling, Ogyre delivers measurable environmental and social impact. The entire process is fully traceable through a blockchain-enabled platform, allowing companies to monitor progress and impact in real time. Active across Europe, South America, Africa, and Asia, Ogyre has already recovered over 800 tons of marine waste and proven a financially sustainable model—now scaling its impact globally to reach 30M kg of cumulated collection by 2030!",true,Scientific attaché of Italian Embassy in Paris,,,
Yuniboats ambition is to position eco-reconditioning as a key lever for ocean protection, combining circular economy, innovation, and long-term impact on marine biodiversity.",true,we follow your activities on Linkedin and Instagram,Received,https://drive.google.com/drive/folders/1CxO_JDBexB0DVCo8SZPek_ggZXtn279f?usp=drive_link,
,Daniele Tassara,+393466376215,Envoyer le message,daniele.tassara@outlook.com,MareNetto,"Giambattista Figari, Giorgio Mussini","Santa Margherita Ligure, Ligurie, Italie","Europe, Italia","Universita di Genova, Genova",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"MareNetto is a yacht-focused climate platform that automatically calculates and offsets superyacht CO2 emissions from AIS/MMSI data, then issues verifiable certificates that owners and charter managers use for marketing and ESG compliance",true,I lived in Monaco and i knew about this project,Received,https://drive.google.com/drive/folders/1TKm7VEt5kt2JrSJjIePDXtDILQmLqY-t?usp=drive_link,
,Gaia Minopoli,+393393499607,Envoyer le message,gaia.minopoli@ogyre.com,Ogyre,Agnese Antoci Alessandro Serra Alice Casella Andrea Faldella Andrea Scatolero Antonio Augeri Chiara Maggiolini Davide Brugola Filippo Ferraris Gaia Minopoli Gian Piero Seregni Lorenzo Gastaldo Matteo Quaglio Mattia De Serio Michele Migliau Alessandro Sciarpelletti Francesco Carletto francesco notari Irene Eustazio Jurgen Ametaj Lorenzo Varas Marta Berardini Lucrezia Napoletano Gabriele Cusimano Enrica Sandigliano,"Gênes, Ligurie, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-01-21,Reduction of pollution (plastics chemicals noise light...),"Ogyre is a startup tackling marine plastic pollution through a Fishing for Litter model, working directly with fishing communities worldwide. Its mission is to clean the Ocean while turning plastic waste into a resource. By financially supporting fishers to recover marine litter during their daily activities, and by involving local partners for sorting and recycling, Ogyre delivers measurable environmental and social impact. The entire process is fully traceable through a blockchain-enabled platform, allowing companies to monitor progress and impact in real time. Active across Europe, South America, Africa, and Asia, Ogyre has already recovered over 800 tons of marine waste and proven a financially sustainable model—now scaling its impact globally to reach 30M kg of cumulated collection by 2030!",true,Scientific attaché of Italian Embassy in Paris,Received,https://drive.google.com/drive/folders/1oRYYsGxF-urjoO74GFaMMQ9sg8ZCVdw9?usp=drive_link,
,Yajaira Cristina Alquinga Salazar,+541136132787,Envoyer le message,cristinalquinga@gmail.com,Dynamics of Coastal Dune Fields in the Southwest of Buenos Aires Province,"Bsc. Yajaira Cristina Alquinga Salazar, Dr. Gerardo M. E. Perillo and Dr Sibila A. Genchi","Bahía Blanca, Buenos Aires, Argentine",South America,Universidad Nacional del Sur,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Mitigation of climate change and sea-level rise,"The general objective of this research plan is to study the dynamics of coastal dunes in the southwest of Buenos Aires Province, with special emphasis on the foredune, and its relationship with climatic, oceanographic, and anthropogenic factors. In particular, the study aims to determine the degree of influence of each of these factors, especially in areas where urban settlements have been established over the last 80 years, in comparison with adjacent sectors subjected to similar environmental conditions but without anthropogenic influence.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1y8Z1QYa6a7MeN1qs7ILCELvWHY1mRfr2?usp=drive_link,
,Jovana,+381645655226,Envoyer le message,jovanaperisic059@gmail.com,EcoMath,Jovana Perišić,Kraljevo,"Europe, Serbia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-07,Technology & innovations,"Project name: Symphony of the Blue
@@ -441,12 +441,12 @@ Objectives:
- Scalable nature-inclusive designs and eco-structure integration for offshore oil & gas and offshore wind infrastructure to enhance ecological performance and biodiversity protection.
Other relevant details:
We are currently testing prototypes in Australia and developing an autonomous monitoring system, with early results showing very positive outcomes and remarkable improvements in biodiversity.",true,Linkedin,,,
We are currently testing prototypes in Australia and developing an autonomous monitoring system, with early results showing very positive outcomes and remarkable improvements in biodiversity.",true,Linkedin,Received,https://drive.google.com/drive/folders/1lktP9O-QU69NHylfTXEhJzbrRxHZ04JE?usp=drive_link,
,Lee patrick EKOUAGUET,+33778199372,Envoyer le message,ogoouecorpstechnologies@gmail.com,OGOOUE CORPS TECHNOLOGIES,"ANDRE BIAYOUMOU, NGABOU PASCAL XAVIER, LYNDA NGARBAHAM, DUPUIS NOUILE NICOLAS","Bordeaux, Nouvelle-Aquitaine, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-10-23,Technology & innovations,"OCEAN-PATCH is an intelligent, autonomous maritime safety patch designed to protect human lives at sea.
It detects critical situations (man overboard, distress, abnormal conditions) in real time and transmits alerts and data without batteries, using body or environmental energy.
The project aims to improve maritime safety while generating valuable ocean data to support prevention, monitoring, and smarter decision-making through AI.",true,Through online research and innovation platforms focused on ocean protection and blue tech.,Received,https://drive.google.com/drive/folders/1BEc9s5h5H41vf2bRxpqvz4AHBWMZS1Xm?usp=drive_link,
,Tshephiso Kola,+27671509841,Envoyer le message,kolatshepisho@gmail.com,Luminet,Tshephiso Kola,"Johannesburg, Gauteng, Afrique du Sud",Africa,"University of the Witwatersrand, Johannesburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"LumiNet is our solution to the fishing industrys two biggest headaches: catching the wrong fish and losing expensive gear that pollutes the ocean forever. We are replacing standard nylon nets with a smart, dual-action material that actually works with nature. First, our nets glow with a specific light underwater that sharks and turtles instinctively avoid, which keeps them out of the net while the target fish swim right in. Second, weve solved the ghost gear problem with a built-in fail-safe: as long as the net is used in the sun, it stays strong, but if it gets lost and sinks into the dark ocean, it rapidly breaks down and turns into fish food. Our goal is simple: to stop plastic pollution at the source and make fishing more efficient, saving marine life and money at the same time.",true,Social Media,,,
,Tshephiso Kola,+27671509841,Envoyer le message,kolatshepisho@gmail.com,Luminet,Tshephiso Kola,"Johannesburg, Gauteng, Afrique du Sud",Africa,"University of the Witwatersrand, Johannesburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"LumiNet is our solution to the fishing industrys two biggest headaches: catching the wrong fish and losing expensive gear that pollutes the ocean forever. We are replacing standard nylon nets with a smart, dual-action material that actually works with nature. First, our nets glow with a specific light underwater that sharks and turtles instinctively avoid, which keeps them out of the net while the target fish swim right in. Second, weve solved the ghost gear problem with a built-in fail-safe: as long as the net is used in the sun, it stays strong, but if it gets lost and sinks into the dark ocean, it rapidly breaks down and turns into fish food. Our goal is simple: to stop plastic pollution at the source and make fishing more efficient, saving marine life and money at the same time.",true,Social Media,Received,https://drive.google.com/drive/folders/1TD0eL9lEAMapQk7z65du-7l8VVC46aRm?usp=drive_link,
,Eric & Aurélie Viard,+33695360436,Envoyer le message,eric@biovie.fr,Algues au quotidien,"Eric Viard, Aurélie Viard","Nîmes, Occitanie, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2007-03-11,Consumer awareness and education,Use of organic edible seaweeds in daily food and gastronomy,true,We have been invited directly by Marine Jacq-Pietri to submit our project,Received,https://drive.google.com/drive/folders/1KUwDOwvZxoiXJB0cHtwVeIPjdYAYbOV0?usp=drive_link,
,BARHOUMI Nawress,+21621898617,Envoyer le message,nawressbarhoumigf@gmail.com,El Makina,"Mustapha Zoghlami, Nawress Barhoumi",Tunisie,"Africa, Tunisia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-05-05,Technology & innovations,"The project aims to develop an autonomous intelligent robot for cleaning marine environments, specifically targeting oil spills, human hair, and other pollutants. It focuses on sustainable technology, environmental protection, and smart control systems. The robot is built using recovered and recycled plastic materials, reinforcing the projects commitment to circular economy principles and eco-friendly engineering.",false,Newsletters,,,
,Yao Yinan,+8615221826163,Envoyer le message,yyn982715367@outlook.com,"BluePulse Design, Protect, Inspire",Yinan Yao,Xinjiang,Asia,"Communication University of China, Nanjing(Location: Nanjing, China)",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"BluePulse is an innovative project that combines art, design, and technology to raise awareness about ocean pollution and marine conservation. Its main objective is to educate and inspire the public through creative visual campaigns, interactive installations, and sustainable product concepts that highlight the importance of protecting our oceans. The project also explores solutions to reduce plastic and chemical pollution, fostering a culture of environmental responsibility.",true,I found out about the Monaco Ocean Protection Challenge through the organisers listed on the UArctic Congress 2026 website: https://www.uarcticcongress.fo/about,,,
@@ -455,7 +455,7 @@ The project aims to improve maritime safety while generating valuable ocean data
Face aux pressions climatiques et anthropiques, il combine des technologies innovantes (capteurs environnementaux, drones, caméras sous-marines et intelligence artificielle) et une approche participative impliquant les communautés de pêcheurs.
Le projet permettra de suivre la santé des récifs, de restaurer les zones dégradées et daméliorer la gestion des ressources halieutiques, contribuant ainsi à la conservation des écosystèmes marins, à la sécurité alimentaire et au développement durable des communautés côtières de Belle-Anse.",true,Through my university and professional networks and partnerships,,,
Le projet permettra de suivre la santé des récifs, de restaurer les zones dégradées et daméliorer la gestion des ressources halieutiques, contribuant ainsi à la conservation des écosystèmes marins, à la sécurité alimentaire et au développement durable des communautés côtières de Belle-Anse.",true,Through my university and professional networks and partnerships,Received,https://drive.google.com/drive/folders/1Tg4_Z3MOzYbpuBOAJ1EO3bKpn6Aw7kTW?usp=drive_link,
,Samuel Nnaji,+2348161502448,Envoyer le message,realstard247@gmail.com,Zero Ocean,Benjamin Odusanya,"Enugu, Nigéria","Africa, Nigeria",University of Nigeria,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable shipping & yachting,"Project: Zero Ocean
Idea: Digital platform for transparent, efficient, and sustainable clean fuel supply chain in maritime
@@ -464,7 +464,7 @@ Objectives:
- Optimize clean fuel procurement and reduce emissions
- Ensure compliance with global regulations
- Enhance bunkering efficiency and audit trails
Key Features: eBDN, AI-driven analytics, real-time tracking, supplier integration",true,WhatsApp,,,
Key Features: eBDN, AI-driven analytics, real-time tracking, supplier integration",true,WhatsApp,Doublon,,
,Hannah Gillespie,+447887479247,Envoyer le message,hggillespie12@gmail.com,SeaBrew Coffee,"Anne Moullier, Joseph Flynn, Hannah Gillespie, Laura Coombs, Ronan Cooney","Cambridge, Angleterre, Royaume-Uni",UK,"University of Cambridge, Cambridge, UK",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"SeaBrew is an early-stage food and drink start-up developing a seaweed-reinforced coffee designed to improve micronutrient intake through an existing daily habit. Our product combines sustainably sourced seaweed with coffee to deliver nutrients such as magnesium, while maintaining taste and consumer acceptability. We have already conducted a blind taste test with positive consumer feedback and recently pitched SeaBrew to EIT Food, where we were awarded second place, which has encouraged us to progress towards more rigorous technical validation and compliance ahead of scaling.",true,The Ocean Opportunity Lab (TOOL),,,
,Rhea Thoppil,+33745764372,Envoyer le message,rmthoppil@gmail.com,phytoflight,Rhea Thoppil,"Kerala, Inde",Asia,"Sorbonne University, France",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"PhytOFlight
Plant-based mitigation of plastic pollution in Keralas backwaters
@@ -479,7 +479,7 @@ Reduce macroplastic and microplastic pollution in targeted backwater zones, impr
PhytOFlight integrates ecological restoration with pollution control, offering a cost-effective, climate-resilient solution tailored to Keralas backwaters in India.",true,Through my university,,,
,Ethan Jezek,+18178996766,Envoyer le message,ejezek12@gmail.com,OceanID,Ethan Jezek,"Dallas, TX, États-Unis",US,"I have started this concept myself in Dallas, Texas but I am also a PhD candidate at the University of Waikato in New Zealand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"I have been developing an AI integrated app called OceanID that helps users identify marine species (vegetation, algae, and animals) by uploading photographs. By doing so, and by providing key and exciting information to users, I have ambitions of improving and better establishing community education and outreach, as well as marine networking in communities around the globe. Upon identifying an organism, users are presented with key ecological and economical information about the organism they captured on camera, recent publications, distribution, and if the species is currently a foodstuffs, will be presented with recipes, information on how to safely and sustainably harvest, and sustainable producers where a user could buy ingredients for said recipe . For higher level users, e.g. ocean users such as fishers, farmers, and researchers, information on permitting, local processors, producers, and developers is also provided (this information is provided for all users but intended to be helpful and beneficial for higher-level users).
Other functions on the app include; a database of all species the app has identified, a community tab that displays the discoveries of nearby and followed users, a map function where users can see community discoveries and the location of permit zones, and key economic players (see above) in relation to their location, and a cookbook that saves all of the recipes that a user has collected.",true,I heard of the MOPC through colleagues I have on LinkedIN,,,
Other functions on the app include; a database of all species the app has identified, a community tab that displays the discoveries of nearby and followed users, a map function where users can see community discoveries and the location of permit zones, and key economic players (see above) in relation to their location, and a cookbook that saves all of the recipes that a user has collected.",true,I heard of the MOPC through colleagues I have on LinkedIN,Received,https://drive.google.com/drive/folders/17Wu23H236j-mA4a9UDH80Roh93YY2p5M?usp=drive_link,
,Nnaji Samuel Ebube,+2348161502448,Envoyer le message,nnajisamuel2448@gmail.com,OceanFin,"Ifeoma Odusanya, Benjamin Odusanya","Enugu, Nigéria","Africa, Nigeria",University of Nigeria Nsukka,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"🌟 *Project: OceanFin - Boosting Nigeria's Blue Economy 🌊*
- *Idea*: Empower coastal communities with digital financial services for sustainable ocean-based livelihoods 🐟
- *Objectives*:
@@ -487,14 +487,18 @@ Other functions on the app include; a database of all species the app has identi
- Improve financial inclusion for fishermen, traders 💸
- Promote sustainable ocean practices 🌿
- *Key features*: Digital payments, loans, insurance, FX services, international partnerships 🌍",true,Online,Received,https://drive.google.com/drive/folders/10-Xa_exXqDL83JlnFq4-TnAjcL_jPgT-?usp=drive_link,
,Sofie Boggio Sella,+61448568796,Envoyer le message,boggiosellasofie@gmail.com,PMRF: Probabilistic Multi Reef Fusion pipeline,"Sofie Boggio Sella, Lily Lewis, Mohammad Jahanbakht","Turin, Piémont, Italie","Europe, Italia",James Cook University Australia,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"The project develops an AI-driven system to predict where coral reefs are most likely to survive under future climate conditions. By fusing seafloor structure, reef imagery, environmental data, and biodiversity indicators into a single probabilistic model, it moves beyond mapping what exists today to forecasting where restoration and protection will be most effective tomorrow. Its objective is to identify climate-resilient “safe havens” and restoration hotspots, providing actionable, uncertainty-aware maps for scientists and conservation practitioners. This enables smarter allocation of limited resources, transforming coral conservation from reactive damage control into a proactive strategy for long-term reef resilience.",true,Linkedln,,,
,Christine Kurz,+4917622904612,Envoyer le message,christine.a.kurz@gmail.com,Xy,Xy,,,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,,,,,,,
,Sofie Boggio Sella,+61448568796,Envoyer le message,boggiosellasofie@gmail.com,PMRF: Probabilistic Multi Reef Fusion pipeline,"Sofie Boggio Sella, Lily Lewis, Mohammad Jahanbakht","Turin, Piémont, Italie","Europe, Italia",James Cook University Australia,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"The project develops an AI-driven system to predict where coral reefs are most likely to survive under future climate conditions. By fusing seafloor structure, reef imagery, environmental data, and biodiversity indicators into a single probabilistic model, it moves beyond mapping what exists today to forecasting where restoration and protection will be most effective tomorrow. Its objective is to identify climate-resilient “safe havens” and restoration hotspots, providing actionable, uncertainty-aware maps for scientists and conservation practitioners. This enables smarter allocation of limited resources, transforming coral conservation from reactive damage control into a proactive strategy for long-term reef resilience.",true,Linkedln,Doublon,,
,Christine Kurz,+4917622904612,Envoyer le message,christine.a.kurz@gmail.com,METHAPOD,"- Harjasdeep: New Delhi, India
- Himanshu: Delhi, India
- Rainy: Jakarta, Indonesia
- Pranav: Pune, India
- Christine: Hamburg, Germany ","Hambourg, Allemagne","Europe, Germany",RWTH Aachen Business School,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable shipping & yachting,"we are still working on the specifics of the solution, which you can see by the Open Questions we identified and are currently investigating. However, the identified problem is quite well researched and we believe we have the right angle to support in the decarbonization of commercial shipping. ",true,"I found the MOPC when I was researching innovation and sustainability in the maritime space, as this is exactly the niche I want to be active in. And with such a strong team, it was obvious we had to join! ",Received,https://drive.google.com/drive/folders/1U1bdexvWnQQUQ0C3lPXW1QFuDYgHzaKE?usp=drive_link,
,Antonella Bongiovanni,+393286093034,Envoyer le message,info@evebiofactory.com,EVE Biofactory,Antonella Bongiovanni - Natasa Zarovni - Mauro Manno - Paolo de Stefanis - Lorenzo Sbizzera - Gabriella Pocsfalvi - Paola Gargano,"Palerme, Sicile, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-09-29,Technology & innovations,"EVE Biofactory is a deep-biotech company leveraging microalgae to build the most scalable nano drug-delivery platform on the market.
Inefficient drug delivery causes treatment failure, patient harm, and up to $40B in annual losses from underperforming bioactives.
Inspired by the smallest ocean organisms, EVE develops Nanoalgosomes: naturally occurring exosomes produced from microalgae, the only delivery system that is scalable, circular, and fully biological.
Nanoalgosomes are cost-competitive, biologically active, and more efficient than synthetic nanoparticles, enabling lower drug doses and reducing the release of medicines and persistent nanomaterials into wastewater that today impact river and ocean ecosystems.",true,Our mentor Alessandro ROmano pointed out the challenge and recommended our project would be a good fit.,Received,https://drive.google.com/drive/folders/1imjoZvbTn-c-xjqgr4C9J_BP5JZz1gyX?usp=drive_link,
,Justyna Grosjean,+33685638357,Envoyer le message,justyna@cleanoceancoatings.com,Clean Ocean Coatings GmbH,"Christina Linke, Jens Deppe, Friederike Bartels, Johana Chen, Sandra Lötsch, Patricia Greim","Hambourg, Allemagne","Europe, Germany",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-05-11,Sustainable shipping & yachting,"The Antifouling Coating of Tomorrow.
Lower Costs. Cleaner Oceans. Decarbonating Shipping.",true,Through the Fondation Prince Albert II de Monaco,,,
Lower Costs. Cleaner Oceans. Decarbonating Shipping.",true,Through the Fondation Prince Albert II de Monaco,Received,https://drive.google.com/drive/folders/1CKPi1GhRjyF1dMhQcx0Da8g-nPetoIx6?usp=drive_link,
,Erick Patrick dos Anjos Vilhena,+5596981337237,Envoyer le message,e.vilhena@hotmail.com,sustainable fish leather,Andria Carrilho,"Amapá, AP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-01-01,Sustainable fishing and aquaculture & blue food,"Sustainable fish leather production is more than just an exotic alternative; it addresses critical issues within the fashion and food industries, as well as the environment.
Here are the main problems this solution solves:
@@ -523,7 +527,7 @@ The Difference: Sustainable fish leather solutions focus on vegetable tanning (u
4. Durability vs. Aesthetics
Many leather alternatives (such as ""synthetic leather"" made of plastic/PU) have low durability and pollute the environment with microplastics.
The Solution: Fish leather has a cross-fiber structure (unlike the parallel fibers in bovine leather), making it extremely strong and tear-resistant despite being thin. It solves the dilemma for those seeking a material that is delicate, durable, and eco-",true,Linkedln,,,
The Solution: Fish leather has a cross-fiber structure (unlike the parallel fibers in bovine leather), making it extremely strong and tear-resistant despite being thin. It solves the dilemma for those seeking a material that is delicate, durable, and eco-",true,Linkedln,Received,https://drive.google.com/drive/folders/1NWL0QlFg3RiyvIsm_hfs7d-nvlpf06f2?usp=drive_link,
,Amaia Rodriguez,+34606655862,Envoyer le message,amaia@thegravitywave.com,GRAVITY WAVE,"Amaia Rodriguez, Julen Rodriguez, Naiara Lopez, Alvaro Garcia, Camila Lago, Norberto De Rodrigo, Irene Hurtado","Madrid, Communauté de Madrid, Espagne","Europe, Spain",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-05-18,Reduction of pollution (plastics chemicals noise light...),Clean plastic from the sea with fishermen and transform the waste into materials for construction and architecture.,true,A friend sent it to me,Received,https://drive.google.com/drive/folders/11McKvPyKzbUgYiFd2gfeWvLrlGGPhyP2?usp=sharing,
,Dr Mumthas Yahiya,+917012789400,Envoyer le message,mumthasy@gmail.com,Migratory birds,Thamanna K,Nil Yucel DDS,US,Kerala,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Mitigation of ocean acidification,"“Nature-Based Solutions for Mitigating Ocean Acidification through Coastal Blue Carbon Ecosystems - Project Idea
@@ -550,10 +554,10 @@ Reducing coral reef destruction",true,thanks to the oceanography museum,Received
,Lily Atussa Payton,+13015297789,Envoyer le message,lily.a.payton@gmail.com,Oyster Club NYC,"Lily Payton, Kelsey Burkin, Savannah Harker","New York, NY, États-Unis",US,N/A,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Oyster Club NYC is a fledgling organization aimed at giving New Yorkers a hands-on connection to their maritime past, present, and future through the lens of ocean sustainability. Rending New Yorkers that NYC is truly their oyster, and that some of the strongest communities are built around the smallest of creatures. Specifically, we create bespoke events aimed at bringing together people to create community, discuss how making small choices can benefit our oceans, such as eating oysters, all while having fun in the process. This has manifested in a Learn-to-Shuck Holiday Party in December and a monthly oyster happy hour at various locations across the city. Our specific objectives are threefold:
- use oysters as a catalyst to expose New Yorkers to sustainable and regenerative food in a social environment,
- embed an oceans-focused mindset into an island city that often forgets its connection to the water, and
- build a climate-minded community across the five boroughs.",true,Online research,,,
- build a climate-minded community across the five boroughs.",true,Online research,Received,https://drive.google.com/drive/folders/1JBMaMkVyzQXRqSyJiw6QOdCqiroFyHVF?usp=drive_link,
,Gary Molano,+12135198233,Envoyer le message,gary@macrobreed.com,MacroBreed,"Scott Lindell, Charles Yarish, Filipe Alberto","New York, NY, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-07-19,Sustainable fishing and aquaculture & blue food,"We breed better seaweed using genomic breeding techniques. We started with kelp, and have achieved 4fold harvestable yield gains in 5 years of breeding, a 10x speed advancement compared to breeding efforts in Asia. We are currently targeting traits that increase the value of seaweed, such as lower iodine and higher bioactive composition (fucoidan, alginate, laminarin, etc), to help make farmed kelp more competitive with wild harvests. We also have a breeding scheme that produces ""sterile"" kelp to protect local ecosystems from farmed kelp. This sterile kelp is produced using non-GMO techniques.",true,Through the ocean exchange newsletter,,,
,Qendresa Krasniqi,+4798474602,Envoyer le message,qendresa04@gmail.com,Aegir by Navier USN,"Qendresa Krasniqi, Hedda Collin, Markus Marstad","Horten, Vestfold, Norvège","Europe, Norway",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-10-06,Restoration of marine habitats & ecosystems,"We are developing a specialized ROV designed to restore marine ecosystems. Coastal ecosystems, such as the Oslofjord, are currently threatened by invasive species and marine debris. Specifically, the Pacific oyster is spreading rapidly, requiring efficient and noninvasive methods for removal to protect local biodiversity. Our current prototype is part of a joint venture with 'Matfat Oslofjorden,' where it will harvest invasive oysters from the Oslo Fjord to be repurposed as a sustainable food source. Our solution is efficient, non-invasive, and fully programmable for diverse oceanic habitats and tasks. Navier USN is not starting from scratch with a proven track record in developing autonomous surface vehicles (ASVs), our startup concept expands this expertise into the underwater domain. We are a seasoned technical and commercial team with a proven track record in autonomous maritime technology, including multiple world championship titles. Supported by prominent industry partners, we have the proven competence and scale to transform maritime environmental management",true,1000 Ocean StartUps,,,
,Dorra Fadhloun,+21629508048,Envoyer le message,dorra.fadhloun@msb.tn,Oceani,Samar,"Tunis, Tunisie","Africa, Tunisia",Mediterranean School of Business,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,PlastiTrack's goal is to turn citizen smartphone photos into citywide microplastic pollution heatmaps that municipalities use to prioritize cleanup investments.,true,LinkedIn,,,
,Qendresa Krasniqi,+4798474602,Envoyer le message,qendresa04@gmail.com,Aegir by Navier USN,"Qendresa Krasniqi, Hedda Collin, Markus Marstad","Horten, Vestfold, Norvège","Europe, Norway",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-10-06,Restoration of marine habitats & ecosystems,"We are developing a specialized ROV designed to restore marine ecosystems. Coastal ecosystems, such as the Oslofjord, are currently threatened by invasive species and marine debris. Specifically, the Pacific oyster is spreading rapidly, requiring efficient and noninvasive methods for removal to protect local biodiversity. Our current prototype is part of a joint venture with 'Matfat Oslofjorden,' where it will harvest invasive oysters from the Oslo Fjord to be repurposed as a sustainable food source. Our solution is efficient, non-invasive, and fully programmable for diverse oceanic habitats and tasks. Navier USN is not starting from scratch with a proven track record in developing autonomous surface vehicles (ASVs), our startup concept expands this expertise into the underwater domain. We are a seasoned technical and commercial team with a proven track record in autonomous maritime technology, including multiple world championship titles. Supported by prominent industry partners, we have the proven competence and scale to transform maritime environmental management",true,1000 Ocean StartUps,Received,https://drive.google.com/drive/folders/1-upTx0iRxpXg-LE476_mtG92i6TCv2pM?usp=drive_link,
,Dorra Fadhloun,+21629508048,Envoyer le message,dorra.fadhloun@msb.tn,Oceani,Samar,"Tunis, Tunisie","Africa, Tunisia",Mediterranean School of Business,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,PlastiTrack's goal is to turn citizen smartphone photos into citywide microplastic pollution heatmaps that municipalities use to prioritize cleanup investments.,true,LinkedIn,Received,https://drive.google.com/drive/folders/12aiatT4nDGJJFMI9Z91gXjeeaSePL7A4?usp=drive_link,
,Ahamed Adhnaf,+94760270097,Envoyer le message,anaadhnaf413@gmail.com,OCEAN RENEW,"Ahamed Adhnaf , Kaveesha Gunarathna, Mohammed Rifath","Colombo, Western, Sri Lanka","Africa, Sri Lanka",National Institute of Social Development - Sri Lanka,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"AquaHorizon is a holistic ocean innovation hub that transforms ocean challenges into solutions while empowering coastal communities. Our objectives are to develop sustainable practices for marine conservation, reduce pollution, provide education and capacity building for coastal populations, and create a collaborative space where innovators can design and implement solutions for a healthier ocean and thriving communities.",true,I learned about the Monaco Ocean Protection Challenge through a friend.,,,
,Robert Kunzmann,+441751026862,Envoyer le message,robert.kunzmann@acbiode.com,AC Biode,Robert Kunzmann,Luxembourg,"Europe, Luxembourg",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2019-04-01,Reduction of pollution (plastics chemicals noise light...),"According to the UN, only 9% of 8.3 billion tons of plastic waste have been recycled over the past 65 years.
Most of the plastic waste ends up burned, burried or in the oceans. This profound impact on our environment is not fully understood yet, but micro plastics have been found in all the way from glaciers, to human placentas.
@@ -569,11 +573,11 @@ This scalable solution contributes directly to ocean conservation, blue economy
,Julia Denkmayr,+393203476632,Envoyer le message,julia@nereia-coatings.com,NEREIA,Rimah Darawish,"Salzbourg, Autriche","Austria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-04-30,Sustainable shipping & yachting,"NEREIA develops non-toxic antifouling technology using functional surfaces to prevent biofouling on ship hulls, reducing drag and improving fuel efficiency. Eliminating hull fouling could save the shipping industry up to $30bn in fuel costs and 200 Mt of CO2 annually.",false,"Through a Carbon 13 domain expert, Thibaut Monfort Micheo, as well as LinkedIn and other social media.",Received,https://drive.google.com/drive/folders/1vdRP4PIQBCZ3xuRqXk2NHyW-1o0JAbZ9?usp=drive_link,
,Tara Lepine,+64273401929,Envoyer le message,tlep171@aucklanduni.ac.nz,OceanSeed,Tara Lepine,"Ottawa, ON, Canada",Canada,"University of Auckland, Auckland, New Zealand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"OceanSeed deploys mobile hatchery units as emergency response infrastructure to restore marine ecosystems and fisheries after collapse, often caused by climate change. Its objectives are to rapidly produce native juvenile shellfish, rebuild ecosystems and keystone populations, protect biodiversity, and support local livelihoods. OceanSeed can be scaled globally through a network of mobile hatcheries, using aquaculture as a tool for conservation and climate adaptation.",false,Communication from our university department head.,Received,https://drive.google.com/drive/folders/1zNrkuAtAAcqyLPkiyLH8ihV675jWbF1Y?usp=drive_link,
,Reid Barnett,+19198010336,Envoyer le message,reidbarnett@ceretunellc.com,Ceretune LLC,Reid Barnett and Blake Parrish,"Mt Olive, NC, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-05-01,Reduction of pollution (plastics chemicals noise light...),"Our project is using our proprietary technology to grow plants directly on the surface of natural and manmade surface waters. This allows us to sequester carbon, nutirents, and other chemical pollutants at scale in both ocean systems and the freshwater systems upstream. After the plants are fully grown the entire system, material and biomass, can be pyrolyzed to generate carbon negative energy and lock carbon away in a stable form. This process is highly efficient and extremely inexpensive. When we pyrolyze the system we generate over three times more revenue than the total cost of the system and its operation, while opening up opportunites for blue carbon credits and creating bio-oil which can be refined for various uses.
This project is about creating an entirely new pathway for pollutants in the environment to redirect them from where they cause harm and towards where they can generate value and do good.",true,We were connected through the team at Ocean Exchange.,,,
This project is about creating an entirely new pathway for pollutants in the environment to redirect them from where they cause harm and towards where they can generate value and do good.",true,We were connected through the team at Ocean Exchange.,Received,https://drive.google.com/drive/folders/1xIC5oLItJoIqtP4Eua3P7hk5tQ_6Hudy?usp=drive_link,
,Ryan Borotra,+16479659526,Envoyer le message,ryan@sentrylabs.cc,Sentry Labs,"Ryan Borotra, Martin Chaperot, Andrei Bogza","Montréal, QC, Canada",Canada,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-10-20,Sustainable fishing and aquaculture & blue food,"Sentry Labs is developing graphene field-effect transistor (GFET)based molecular sensors for sustainable fishing, aquaculture, and blue food systems. The project focuses on real-time, in-situ detection of biologically and chemically relevant signals in seawater to enable earlier identification of environmental and biological risks affecting farmed and wild stocks. Our objective is to provide robust, reproducible sensing systems that support healthier stocks, reduced losses, and more sustainable management of marine food production.",true,LinkedIn,,,
,Nadine Hakim,+573205421979,Envoyer le message,nadinehakimm@gmail.com,SEAMOSS COLOMBIA,Sandra Bessudo and Irene Arroyave,"Bogota, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-10-01,Sustainable fishing and aquaculture & blue food,"SEAMOSS is a sustainable biodesign and coastal livelihood project focused on the cultivation and transformation of sea moss (marine macroalgae) as a nature-based solution to environmental and social challenges in coastal communities. The project combines regenerative aquaculture, biomaterial development, and community-led value chains to reduce pressure on marine ecosystems while creating local economic opportunities.
The core idea is to cultivate native sea moss species using low-impact, regenerative methods and transform the biomass into biodegradable materials and functional products that can replace plastic-based alternatives, particularly in packaging, design, and everyday consumer goods.",true,,,,
The core idea is to cultivate native sea moss species using low-impact, regenerative methods and transform the biomass into biodegradable materials and functional products that can replace plastic-based alternatives, particularly in packaging, design, and everyday consumer goods.",true,,Received,https://drive.google.com/drive/folders/1hLqybevHIM2yCrOslFLhh1QSv0--zBKz?usp=drive_link,
,Maria Ester Faiella,+393311538952,Envoyer le message,maria.ester.faiella@gmail.com,ThermoShield,Maria Ester Faiella,"Rome, Latium, Italie","Europe, Italia",The American University of Rome,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"ThermoShield is a modular underwater panel system that passively reduces local heat from coastal infrastructure. Its objective is to prevent thermal stress on sensitive marine ecosystems, protecting coral reefs and seagrass worldwide. The panels are easy to install, require no electricity and provide measurable local temperature reductions of 0.30.5°C, making the solution scalable and globally applicable.",true,LinkedIn,Received,https://drive.google.com/drive/folders/11mSSY2USGnxyypDwJa5DYiVLUlo6iyvF?usp=drive_link,
,Kumari Anushka,+919798061093,Envoyer le message,nasabutbetter@gmail.com,accore,Kumari Anushka,"Jamshedpur, Jharkhand, Inde",Asia,Ashoka University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"Coral reefs are collapsing - rising ocean temperatures have triggered mass bleaching, 84.6% of corals in Lakshadweep bleached recently. India has 1,439 km² of mapped coral reefs, coasts have 80 ± 33 microplastic particles per cubic meter, and ~30% of sampled market fish have microplastics. Odishas Bay of Bengal estuaries have elevated metal concentrations.
@@ -591,7 +595,7 @@ The MoEFCC National Coastal Mission Scheme funds coral/mangrove conservation act
Also, the World Bank-supported Integrated Coastal Zone Management (ICZM) gives importance to science-based coastal planning; pods sensor data could be used for threat mapping and adaptive management in the deployed zones.
For global scaling: Australias Reef 2050 Plan, Indonesias COREMAP, and the US NOAA Coral Reef Conservation Program exist, so the project could plug into existing national funding priorities across eligible countries.",true,My university's professor,,,
For global scaling: Australias Reef 2050 Plan, Indonesias COREMAP, and the US NOAA Coral Reef Conservation Program exist, so the project could plug into existing national funding priorities across eligible countries.",true,My university's professor,Received,https://drive.google.com/drive/folders/1_T-uCTNFwzkeyKx70TZhM8OUEe7KZVYB?usp=drive_link,
,Daniela Nairita,+254717162468,Envoyer le message,nairita@yarsi.net,Yarsi Aquacycle,"Christabell Adhambo, Snyder Phoebe, Ken Lenguro, Walter Mwaluma, Zuhura Ahmed","Marsabit, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-11-22,Reduction of pollution (plastics chemicals noise light...),"YARSI Aquacycle is a circular blue economy enterprise focused on transforming aquatic waste into high-value, sustainable products. The core idea is to close resource loops in the fisheries and aquaculture sectors by recovering fish by-products and underutilized biomass and converting them into commercially viable outputs, reducing environmental pollution while creating new income streams.
Objectives:
1.Valorize aquatic waste into high-value sustainable products (fish oil, fish skin, bio-compost).
@@ -608,8 +612,8 @@ By harvesting algae that would otherwise be eliminated, the project contributes
In the short term, the project focuses on collecting and supplying this algae to identified outlets such as organic fertilisers, animal feed and food processing. In parallel, it aims to strengthen local food systems by making a high-quality marine resource available for nearby transformation, reducing transport distances and supporting coastal economies.
In the longer term, the project contributes to the development of integrated oysteralgae co-cultivation systems, increasing the resilience of coastal farming activities to climate and sanitary pressures, while actively participating in the sustainable growth of the seaweed sector in line with national and European marine strategies.",true,From my friends from IZALGUE,,,
,Elizabeth Okullow,+254724328171,Envoyer le message,elizabethokullow@gmail.com,Bionala,1. Sydney Badiola 2. Heloisa Bredemann 3. Elizabeth Okullow,"Kisumu, Kenya","Africa, Kenya","University of Liège (HEC Liège), Belgium",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Bionala is a circular bioeconomy venture that transforms unavoidable seafood by-products into high-value marine bio-ingredients, including protein powders, fish oil, and collagen. The project addresses pollution and environmental externalities in fisheries and aquaculture by intercepting seafood by-products before they are dumped or left to decompose, reducing harm to aquatic ecosystems. Bionala works with existing seafood value-chain actors to create a traceable, scalable sourcing model and supplies sustainable marine bio-ingredients to global food, nutrition, and cosmetic markets, contributing to more resilient and resource-efficient blue food systems.",true,LinkedIn,,,
In the longer term, the project contributes to the development of integrated oysteralgae co-cultivation systems, increasing the resilience of coastal farming activities to climate and sanitary pressures, while actively participating in the sustainable growth of the seaweed sector in line with national and European marine strategies.",true,From my friends from IZALGUE,Received,https://drive.google.com/drive/folders/1bH0Dpgnob5lCk8neLaoV4ppDkrykt-9I?usp=drive_link,
,Elizabeth Okullow,+254724328171,Envoyer le message,elizabethokullow@gmail.com,Bionala,1. Sydney Badiola 2. Heloisa Bredemann 3. Elizabeth Okullow,"Kisumu, Kenya","Africa, Kenya","University of Liège (HEC Liège), Belgium",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Bionala is a circular bioeconomy venture that transforms unavoidable seafood by-products into high-value marine bio-ingredients, including protein powders, fish oil, and collagen. The project addresses pollution and environmental externalities in fisheries and aquaculture by intercepting seafood by-products before they are dumped or left to decompose, reducing harm to aquatic ecosystems. Bionala works with existing seafood value-chain actors to create a traceable, scalable sourcing model and supplies sustainable marine bio-ingredients to global food, nutrition, and cosmetic markets, contributing to more resilient and resource-efficient blue food systems.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1i8Fka0z7owVsJW74L32UFS1FVK88_Tzc?usp=drive_link,
,Maria,+51968969569,Envoyer le message,maria.moralesleguia@gmail.com,metalmas,juan morales,Peru,South America,PUCP,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,ETENOLMAL,true,NOTHING,,,
,Indira Angela Luza Eyzaguirre,+5591985374539,Envoyer le message,ieyzaguirre@reinnova.org,Guardianes de la Costa: Monitoreo costero participativo para la protección de playas urbanas,"Indira Angela Luza Eyzaguirre, Leonardo Jasiel Luza Eyzaguirre, Marco Ivart Mateo Eyzaguirre, Josefina Eyzaguirre Flores","Lima, Peru",South America,"Universidad Tecnológica del Perú - UTP, Lima, Perú",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"Guardianes de la Costa is a participatory coastal monitoring initiative designed to protect urban beaches by generating reliable local data and empowering coastal communities. The project trains citizens to monitor key indicators such as erosion, pollution, and coastal biodiversity using simple, replicable protocols. The collected data supports evidence-based decision-making, early detection of coastal degradation, and the implementation of nature-based and community-led solutions. Piloted on urban beaches in Lima, the model is scalable and adaptable to other coastal cities facing similar environmental pressures.",true,In instagram,Doublon,,
,Leonardo Jasiel Luza Eyzaguirre,+51942673128,Envoyer le message,resilienciainnovadora@gmail.com,Mangrove Watchers: Data-driven conservation with shellfish and crab harvesters,"Leonardo Jasiel Luza Eyzaguirre, Indira Angela Luza Eyzaguirre, Marco Ivart Mateo Eyzaguirre, Josefina Caridad Eyzaguirre Flores","Zarumilla, Tumbes, Peru",South America,Universidad Nacional Federico Villareal,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Mangrove Watchers is a data-driven conservation initiative working with shellfish and crab harvesters to support sustainable mangrove fisheries. The project combines local ecological knowledge with simple monitoring tools to track mangrove health, shellfish and crab stocks, and human pressures. Community-generated data is used to inform sustainable harvesting practices, improve resource management, and strengthen coastal livelihoods. Piloted with artisanal harvesters in northern Peru, the model is low-cost, scalable, and adaptable to mangrove-dependent fisheries across coastal regions.",true,In website and instagram,Received,https://drive.google.com/drive/folders/1dwsY_WDvzJ6tjnDdsYVPA88TRH7gIYo7?usp=drive_link,
@@ -639,15 +643,15 @@ Over time, these programs were consolidated into Charlas El Océano as the proje
Implemented continuously for seven years, Proyecto Acuática has reached over 1,900 students through in-person school-based sessions and international youth activities. A distinctive feature of the project is the direct interaction between children, youth and marine scientists, allowing educational spaces to inform conservation thinking and, in some cases, inspire new scientific inquiry.
The current phase focuses on consolidating impact measurement and scaling the model through institutional partnerships, ensuring that ocean education translates into measurable and lasting conservation outcomes.",true,A friend and social media,,,
,Nina Lantinga,+14388630647,Envoyer le message,nina@netsfornetzero.com,Nets for Net Zero,"Purnank Shah, Shadi Khamani","Montréal, QC, Canada",Canada,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-03-21,Reduction of pollution (plastics chemicals noise light...),"By 2030, Nets for Net Zero will be a global leader in circular marine plastic systems, leveraging partnerships with coastal communities, fisheries, and manufacturers to advance processing technologies that create resilient, circular, net-positive economies worldwide.",true,Student On Ice Foundation,,,
The current phase focuses on consolidating impact measurement and scaling the model through institutional partnerships, ensuring that ocean education translates into measurable and lasting conservation outcomes.",true,A friend and social media,Received,https://drive.google.com/drive/folders/1WT7pf7I6W0JR0YWHbzQTIzRSVL6btS7G?usp=drive_link,
,Nina Lantinga,+14388630647,Envoyer le message,nina@netsfornetzero.com,Nets for Net Zero,"Purnank Shah, Shadi Khamani","Montréal, QC, Canada",Canada,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-03-21,Reduction of pollution (plastics chemicals noise light...),"By 2030, Nets for Net Zero will be a global leader in circular marine plastic systems, leveraging partnerships with coastal communities, fisheries, and manufacturers to advance processing technologies that create resilient, circular, net-positive economies worldwide.",true,Student On Ice Foundation,Received,https://drive.google.com/drive/folders/1cMVVwL369wMyEcEYcVlfzoxCLPXilad8?usp=drive_link,
,Davide Balbi,+393318344974,Envoyer le message,db@mondomigliore.eu,C.A.S.A. Marine Loop,"Santino Filippeddu, Davide Balbi, Gianluca Del Vecchio","Olbia, Sardaigne, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-09-19,Sustainable fishing and aquaculture & blue food,"C.A.S.A. Marine Loop is a modular retrofit solution for offshore fish cages, designed to operate around, inside and below cages.
It aspirates and treats water to intercept solid waste and organic loads that negatively affect fish health and surrounding marine areas.
The core technology is a stainless-steel biofiltration system with organic BSA media, already validated in land-based RAS, which does not generate microplastics, unlike many plastic-based alternatives.
The system is designed to be powered by offshore renewable energy and to enable circular economy pathways for recovered materials.
The objective is to transform offshore aquaculture into a cleaner, lower-impact and more resilient activity, contributing to ocean protection while improving farm performance.",true,"I heard about the MOPC through the SUBMARINER Networks communications and website, where the call was featured.",Received,https://drive.google.com/drive/folders/1jSB8rvtoNf5w8cRMbd4vV_7u2N-RKr6Z?usp=drive_link,
,Emily Hannah Purnell,+4917644482080,Envoyer le message,emily.purnell@web.de,Seads,"Simona Töpfer, Matz Hüffner, Jonas Pfitzner, Leonie Schwarte, Malena Meyer","Münster, Rhénanie-du-Nord-Westphalie, Allemagne","Europe, Germany",University of Münster in Münster,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"We restore seagrass meadows at scale.
We invented an autonomous, AI-powered seagrass seed harvester to overcome the main bottleneck in seagrass restoration: the lack of scalable and affordable seed supply. By enabling efficient seed harvesting, cultivation and replanting, we unlock large-scale seagrass restoration that was previously limited to small pilot projects. As a restoration service provider, we work with governments and companies to restore degraded marine habitats within regulated ecosystem compensation schemes. Our restored seagrass meadows store CO₂, protect coastlines from erosion, and bring biodiveristy back to our oceans.",false,We met Magnus Frohmann at the Enactus World Cup last year.,,,
We invented an autonomous, AI-powered seagrass seed harvester to overcome the main bottleneck in seagrass restoration: the lack of scalable and affordable seed supply. By enabling efficient seed harvesting, cultivation and replanting, we unlock large-scale seagrass restoration that was previously limited to small pilot projects. As a restoration service provider, we work with governments and companies to restore degraded marine habitats within regulated ecosystem compensation schemes. Our restored seagrass meadows store CO₂, protect coastlines from erosion, and bring biodiveristy back to our oceans.",false,We met Magnus Frohmann at the Enactus World Cup last year.,Received,https://drive.google.com/drive/folders/1fSLC7MjhdXkI3lHpl8Tf2WjCJCDKU09N?usp=drive_link,
,Maria Jose Castaño González,+573245654738,Envoyer le message,mjose.castano2020@gmail.com,SEMOCEA - Numerical Modeling Seedbed of the Ocean and the Atmosphere,María José Castaño González,"Turbo, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-10-03,Mitigation of climate change and sea-level rise,"This project is based on the Ocean and Atmospheric Numerical Modeling Research Group and focuses on the use of numerical modeling to understand coastal and oceanic hydrodynamics influenced by river discharges. The core idea is to analyze the spatio-temporal variability of ocean circulation in coastal and estuarine systems, using the Gulf of Urabá (Colombia) as a case study.
The project aims to model and analyze five years of oceanographic conditions to evaluate how river inflows affect currents, circulation patterns, and coastal dynamics. By integrating numerical models, observational data, and hydrodynamic analysis, the project seeks to generate scientific knowledge that supports coastal management, environmental protection, and climate resilience in vulnerable coastal regions.
@@ -655,11 +659,11 @@ The project aims to model and analyze five years of oceanographic conditions to
Additionally, the project has a strong educational and capacity-building component, promoting the training of young researchers in numerical modeling of the ocean and atmosphere, and fostering the use of science-based tools for sustainable ocean protection and decision-making.",true,I heard about the MOPC through a community leaders group in my local area.,,,
,Athavan RASENTHIRAM,+33758298244,Envoyer le message,athavan.rasenthiram@builders-ingenieurs.fr,Investigation on modular artificial reefs to attenuate waves,Dominique Mouaze,"Caen, Normandie, France","Europe, France","University of Caen Normandy, Caen",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"enhancement, yet the influence of reef geometry on hydrodynamic performance remains insufficiently quantified. This study presents an experimental comparison of the hydrodynamic
performance of two modular artificial reef geometries, focusing on their ability to modify wave transmission, reflection, and energy dissipation. The investigation aims to support reef-inspired design strategies that balance coastal engineering efficiency with ecological functionality.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1u9uWfPyoLZgqJRUwqoxfNLjgNtqCtCTg?usp=drive_link,
,JHONATAN DANIEL PILLA ORTIZ,+593991978337,Envoyer le message,agroindustria_pilor@outlook.com,DEL CAMPO ECUADOR,JHONATAN DANIEL PILLA ORTIZ,"Galápagos, Équateur",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-08,Reduction of pollution (plastics chemicals noise light...),"Mi empresa se dedica a la producción y comercialización de PULPAS DE FRUTAS congeladas, pero en la industria de alimentos todo es con plásticos de un solo uso. Nuestro objetivo es empezar a usar plásticos biodegradables en toda nuestras presentaciones, ser mas amigables con el medio ambiente y reducir la basura que siempre llegan a nuestras costas, mas a nuestra frágil biodiversidad en Galápagos.",true,"Encuentro Empresarial Galápagos, donde nuestras autoridades nos comparten temas y organizan eventos para promocionar los emprendimientos de las Islas Galápagos",,,
,JHONATAN DANIEL PILLA ORTIZ,+593991978337,Envoyer le message,agroindustria_pilor@outlook.com,DEL CAMPO ECUADOR,JHONATAN DANIEL PILLA ORTIZ,"Galápagos, Équateur",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-08,Reduction of pollution (plastics chemicals noise light...),"Mi empresa se dedica a la producción y comercialización de PULPAS DE FRUTAS congeladas, pero en la industria de alimentos todo es con plásticos de un solo uso. Nuestro objetivo es empezar a usar plásticos biodegradables en toda nuestras presentaciones, ser mas amigables con el medio ambiente y reducir la basura que siempre llegan a nuestras costas, mas a nuestra frágil biodiversidad en Galápagos.",true,"Encuentro Empresarial Galápagos, donde nuestras autoridades nos comparten temas y organizan eventos para promocionar los emprendimientos de las Islas Galápagos",Received,https://drive.google.com/drive/folders/1XFF3LHXaTyjrwJYL9rNqcEDtwHxHLXjT?usp=drive_link,
,Rocío Paola Urtubia Oyarzún,+56954072346,Envoyer le message,rocio.urtubia@gmail.com,VIOBACT,"Pamela Torres, Bárbara Morales","Coquimbo, Chili",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-08-28,Sustainable fishing and aquaculture & blue food,"VioBact is a science-based marine biotechnology project developing natural probiotic solutions to improve the sustainability of marine fish larviculture.
The project aims to increase larval survival during first feeding, one of the most critical bottlenecks in marine aquaculture, by reducing harmful Vibrio bacteria in live feed systems (rotifers).
VioBacts probiotic formulations are based on beneficial marine bacteria that naturally outcompete pathogens, lowering the need for antibiotics and chemical treatments.
By improving early survival and health of marine fish larvae, VioBact contributes to more efficient, resilient, and environmentally responsible aquaculture, supporting the production of sustainable blue food.",true,"I learned about the Monaco Ocean Protection Challenge through social media and international collaboration networks focused on ocean conservation, sustainability, and blue innovation.",,,
By improving early survival and health of marine fish larvae, VioBact contributes to more efficient, resilient, and environmentally responsible aquaculture, supporting the production of sustainable blue food.",true,"I learned about the Monaco Ocean Protection Challenge through social media and international collaboration networks focused on ocean conservation, sustainability, and blue innovation.",Received,https://drive.google.com/drive/folders/1FxSW9wBYdozAwvEX9gtSrKjLNf1Y4F4W?usp=drive_link,
,Christian Yowel Masagati,+255621379594,Envoyer le message,chrstnyowel@gmail.com,Youth in Marine Science Tanzania,"(Christian Masagati), (Rahma Sadiki), (Revival Herman), (Prosper Mahenge), (Zahra Mazoya)","Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-23,Capacity building for coastal communities,"Tanzanias future is intrinsically linked to the Western Indian Ocean, yet a profound ""ocean blindness"" persists within the educational system and local communities. While the government
pushes for a Blue Economy, there is a critical shortage of ocean-literate youth and educators to drive this vision. Current efforts are often limited by the small capacity of existing marine
organizations to reach the vast number of primary and secondary students in need of this education.
@@ -678,11 +682,11 @@ Statement of Need:
Despite Tanzania's 1,424-kilometer coastline, two critical gaps hinder sustainable blue economy development:
1. The",true,From the MOPC linkedin page,,,
,Jan Maisenbacher,+41793951989,Envoyer le message,info@janmaisenbacher.com,Podcast Ocean Collaborations,"Jan Maisenbacher, Louise Cooke","Lucerne, Suisse","Europe, Switzerland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-02-16,Other,"Ocean Collaborations is an English podcast (online since Feb 2025; ~2 episodes/month) sharing global ocean conservation insights through conversations with personalities leading complex ocean regeneration projects - spotlighting practical collaboration across sectors to help ocean changemakers (and newcomers) act beyond filter bubbles and misinformation. Available on Substack (https://janmaisenbacher.substack.com/), Apple Podcast (https://podcasts.apple.com/us/podcast/ocean-collaborations/id1797113400), and Spotify (https://open.spotify.com/show/4a3iqQ3Grj2rrcUNpaaEbi).",false,Linkedin In like,Received,https://drive.google.com/drive/folders/1WsnVle3wnXJsK-fpO0aaNOHvQMO7lBBJ?usp=drive_link,
,Edwin Breganza,+639061214632,Envoyer le message,eobreganza@up.edu.ph,Conservation of Pemphis acidula,"Edmar Angeles, Victorino Sandoval and Mary Grace Breganza","Los Baños, Calabarzon, Philippines",Asia,"University of the Philippines at Los Banos, Philippines",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Blue Carbon,"This conservation project addresses the urgent need to prevent extinction of Camptostemon philippinensis by generating fundamental scientific knowledge on its reproductive biology, developing germination and propagation protocols, conducting GIS-based distribution mapping, and engaging coastal communities in conservation activities. The project will establish technical foundations for ex situ conservation and restoration programs while building local capacity through training programs and information campaigns, ultimately aiming to secure legal protection for Gapas-gapas habitat as a Protected Area. Beyond species conservation, the initiative contributes to maintaining critical ecosystem services including coastal protection, carbon sequestration, and nursery habitat for commercially important species, while fulfilling the Philippines' commitments under international biodiversity agreements and providing a replicable model for community-based endangered species recovery throughout Southeast Asia.",false,from Funds for NGOs website,,,
,Edwin Breganza,+639061214632,Envoyer le message,eobreganza@up.edu.ph,Conservation of Pemphis acidula,"Edmar Angeles, Victorino Sandoval and Mary Grace Breganza","Los Baños, Calabarzon, Philippines",Asia,"University of the Philippines at Los Banos, Philippines",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Blue Carbon,"This conservation project addresses the urgent need to prevent extinction of Camptostemon philippinensis by generating fundamental scientific knowledge on its reproductive biology, developing germination and propagation protocols, conducting GIS-based distribution mapping, and engaging coastal communities in conservation activities. The project will establish technical foundations for ex situ conservation and restoration programs while building local capacity through training programs and information campaigns, ultimately aiming to secure legal protection for Gapas-gapas habitat as a Protected Area. Beyond species conservation, the initiative contributes to maintaining critical ecosystem services including coastal protection, carbon sequestration, and nursery habitat for commercially important species, while fulfilling the Philippines' commitments under international biodiversity agreements and providing a replicable model for community-based endangered species recovery throughout Southeast Asia.",false,from Funds for NGOs website,Received,https://drive.google.com/drive/folders/1Pzom13qFnYslQVPoKRptL1lWSRykfwSL?usp=drive_link,
,Nasibu Mtambo,+254742051141,Envoyer le message,mtamboduke@gmail.com,Blue EcoponicX,"1. Sharon Shali, 2. Mohammed Athman 3. Terry Okwanyo 4. Mohamed Salim","Mombasa, Kenya","Africa, Kenya",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-05-20,Reduction of pollution (plastics chemicals noise light...),"Blue EcoponicX is a climate-tech initiative that transforms marine plastic waste into smart hydroponic towers for urban food production. The project tackles two pressing challenges; ocean plastic pollution and urban food insecurity by converting waste into productive, sustainable farming systems. Using plastic recycling, 3D printing and sensor-based monitoring, the modular towers allow households, schools and small-scale urban farmers to grow fresh food in limited spaces while using far less water and energy than traditional agriculture.
The project aims to reduce marine plastic waste, improve access to affordable fresh produce in cities and lower carbon emissions from food transportation. Blue EcoponicX also creates green jobs in recycling, manufacturing and urban farming while contributing to more climate-resilient, self-sufficient urban communities.",true,Through LinkedIn,Ignore,,
,Maarten Berkhout,+31637655680,Envoyer le message,m.berkhout@hollandmarineprojects.com,IceSalvage,Ewoud Visser,"Emmeloord, Flevoland, Pays-Bas","Europe, Netherland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-11-01,Reduction of pollution (plastics chemicals noise light...),Salvage of complex and potentially dangerous and harmful objects,true,via LinkedIn,,,
,Maarten Berkhout,+31637655680,Envoyer le message,m.berkhout@hollandmarineprojects.com,IceSalvage,Ewoud Visser,"Emmeloord, Flevoland, Pays-Bas","Europe, Netherland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-11-01,Reduction of pollution (plastics chemicals noise light...),Salvage of complex and potentially dangerous and harmful objects,true,via LinkedIn,Received,https://drive.google.com/drive/folders/1NZA0B66dYkmpEXo7m0DQpFLlWEWWe7Ys?usp=drive_link,
,Mathias Mondo,+237693514085,Envoyer le message,mathiasmondo35@gmail.com,Test of the website,"Team member 1, team pepbrrx","Yaoundé, Cameroun","Africa, Cameroun",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-07-07,,Ok,false,,Ignore,,
,Juan Esteban Perdomo Ortiz,+573024197126,Envoyer le message,estebanjperdomo@gmail.com,REDJODS,Maria Fernanda Montañez - Natalia Alexandra Barrera,"Bogota, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2020-07-20,Mitigation of climate change and sea-level rise,"REDJODS (Red de Jóvenes para el Desarrollo Social) is a youth-led network focused on strengthening civic engagement, leadership, and social innovation among young people in Latin America.
@@ -720,7 +724,7 @@ The Role of Mangroves: Why the mangroves of Laguna de las Ninfas and the area su
Seabird Watching: The importance of boobies and frigatebirds as indicators of ocean health.
4. Climate Change and Local Resilience: The El Niño Phenomenon: Education on why",true,REDES SOCIALES,,,
4. Climate Change and Local Resilience: The El Niño Phenomenon: Education on why",true,REDES SOCIALES,Received,https://drive.google.com/drive/folders/10Y3qbqomEJgeSFNtLv3VWECVyLmTjM3_?usp=drive_link,
,Achraf Bleili,+21651834344,Envoyer le message,achrefbleili@gmail.com,EcoRecy,Achraf Bleili,"Bizerte, Tunisie","Africa, Tunisia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-01,Reduction of pollution (plastics chemicals noise light...),"EcoRecy is a greentech startup tackling plastic waste through automated recycling machines combined with a digital rewards platform. The idea is to make recycling simple, accessible, and incentivized for citizens.",true,Google,Received,https://drive.google.com/drive/folders/1Qm6ZkKcHHKnL0lDM_iCyLa1Bsii1Lks5?usp=drive_link,
,Douglas Bertin,+33601185122,Envoyer le message,dbertin@calx-sea.com,CalX,Quentin GERME and Tematuanui a Tehei HANTZ,"Gujan-Mestras, Nouvelle-Aquitaine, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-11-19,Restoration of marine habitats & ecosystems,"CalX is developing Oysteria X, a 100% natural, low-carbon marine construction material designed to replace conventional marine concrete.
Developed in partnership with IFREMER and CNRS, Oysteria X is a structurally engineered material that complies with marine infrastructure standards. It is based on recycled oyster shells used as a direct substitute for sand and traditional mineral aggregates, combined with natural mineral binders without clinker.
@@ -732,7 +736,7 @@ The material has undergone in situ marine testing to validate its durability, ec
We are giving back to the sea what it has given us.",true,On LinkedIn and through our personal research,Received,https://drive.google.com/drive/folders/1T5ONe70hze061-C7AOnaPLBlN6AYpPfH?usp=drive_link,
,Mayra Alejandra Cuero Gonzalez,+573186629665,Envoyer le message,macuerogonzalez@gmail.com,Roots of the Sea: Ancestral Tourism for Ocean Protection in the Colombian Pacific,Waldis Natalia Conrado Gamboa,"Buenaventura, Colombie",South America,"Community-based Project Fundación Cultura Ancestral de Juanchaco, Colombia",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"Roots of the Sea is a community-led ancestral tourism project developed with the Fundación Cultura Ancestral de Juanchaco on Colombias Pacific coast.
The project empowers coastal communities to protect marine and coastal ecosystems by transforming ancestral knowledge, cultural practices, and responsible tourism into a sustainable source of income.
Its main objectives are to strengthen local capacities, raise ocean awareness among visitors, reduce pollution on beaches and coastal areas, and promote long-term ocean conservation through community leadership and cultural transmission.",true,Through LinkedIn and international professional networks focused on ocean protection and sustainable innovation.,,,
Its main objectives are to strengthen local capacities, raise ocean awareness among visitors, reduce pollution on beaches and coastal areas, and promote long-term ocean conservation through community leadership and cultural transmission.",true,Through LinkedIn and international professional networks focused on ocean protection and sustainable innovation.,Received,https://drive.google.com/drive/folders/1KrDCfgbjDtifUR9iOkDFz20Is8djoqZu?usp=drive_link,
,Anthony Duxell Malle,+237675306895,Envoyer le message,anthonymalle3@gmail.com,Coastal Communities for Grassroots Mangrove Restoration and Livelihoods,"Tabang David , Chiamoh Blandine, Ebune Jacques",Cameroun,"Africa, Cameroun","University of Buea , Cameroon.",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"This project aims to scale restoration by rehabilitating 3 hectares of mangroves through planting 5,000 seedlings with active participation from three local communities ( Ekange 3, Motombolombo , Ijaw-Mabeta, supported by site surveys, GIS web maps and geographic data monitoring. It also integrates sustainable agriculture by empowering 20 local farmers to propagate 2,000 plantain and 1,000 cocoyam seedlings using natural compost, enhancing food security and reducing mangrove deforestation for firewood.
The initiative targets multiple social and environmental benefits including increased farmer income, improved nutrition, climate-resilient agricultural systems, and strengthened local institutions within Tiko and Limbe 3 municipalities. A long-term vision includes creating a mangrove ecopark (Marine Protected Area) to protect biodiversity and support community livelihoods.",true,,,,
,Tarwita Phetchaiyo,+66947484964,Envoyer le message,tarwita550@gmail.com,Mute Matter,Tarwita Phetchaiyo,"Phetchabun, 67000 Thailande",Asia,"Phetchabun Rajabhat University, Thailand",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Mute Matter: Turning Ocean's #1 Pollution into Profitable Acoustic Solution - Cigarette butts are the ocean's #1 microplastic polluter. Mute Matter stops this toxic waste at the source by upcycling it into premium acoustic panels. We transform hazardous litter into high-value products for the green construction market, cleaning the ocean while building a profitable circular economy",true,Internet Search,,,
@@ -745,7 +749,7 @@ Piloted in the Caribbean region of Quintana Roo, Mexico, SARGASAFE ENERGY combin
By enabling traceability, impact measurement, and ESG-aligned reporting, Bluezone allows corporate buyers to demonstrably reduce Scope 3 emissions while embedding sustainability into their procurement decisions.
Bluezones long-term vision is to become the go-to global platform for ocean-positive procurement, aligning economic incentives with marine regeneration. By redirecting capital toward low-carbon ocean-based solutions, Bluezone helps restore ecosystems, reduce emissions, and build resilient coastal economies, turning the ocean from a victim of climate change into a driver of climate solutions.",true,Word of mouth,,,
Bluezones long-term vision is to become the go-to global platform for ocean-positive procurement, aligning economic incentives with marine regeneration. By redirecting capital toward low-carbon ocean-based solutions, Bluezone helps restore ecosystems, reduce emissions, and build resilient coastal economies, turning the ocean from a victim of climate change into a driver of climate solutions.",true,Word of mouth,Received,https://drive.google.com/drive/folders/19xR5_x16lNiaOjBi4U5uhZm2WuiCdoom?usp=drive_link,
,Ayman El Arroubi,+212619179409,Envoyer le message,aymanelarroubi@gmail.com,Ecoplast,"Ayman El Arroubi ( co-founder , CEO)","Rabat, Rabat - Salé - Kénitra, Maroc","Africa, Morocco",École Nationale supérieure dArts et Métiers de Rabat,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"The Core Idea A biodegradable, algae-based alternative to single-use marine plastics (like fishing gear, poly-bags, and coastal packaging) that dissolves harmlessly if lost at sea, or serves as ""fish food"" rather than microplastic pollution.",true,Alumni recommandation,Received,https://drive.google.com/drive/folders/1ZUabWuOlOO_xdQIwpE0GQ3AdLsg0ngek?usp=drive_link,
,Tabakova,+33749812907,Envoyer le message,maria.tabakova.06@gmail.com,Tideroom.ai,"Olivier Guillo, Jeanne Guillo, Olivier Bettati",Ville de Valbonne Sophia Antipolis,"Europe, France",International University of Monaco,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Capacity building for coastal communities,"TideRoom est une plateforme numérique (""GPS du financement climat"") qui met en relation les villes côtières africaines avec les bailleurs de fonds climat internationaux. Elle guide les collectivités de l'identification de leurs besoins jusqu'à l'obtention des financements et la mise en œuvre de solutions de résilience côtière. Le pilote cible deux villes : Dakhla (Maroc) et Grand-Bassam (Côte d'Ivoire), toutes deux vulnérables au changement climatique (érosion, montée des eaux) et dont les économies locales (pêche, tourisme) dépendent directement de la santé des écosystèmes côtiers.
Objectifs à 36 mois :
@@ -764,13 +768,13 @@ The projects objective is to enable the decarbonisation of fast maritime tran
Beyond the technological development, Whisper EF aims to contribute to the emergence of sustainable, high-performance maritime mobility solutions in Europe and internationally.",false,"We learned about the MOPC through our previous participation, having applied to the programme last year.",Received,https://drive.google.com/drive/folders/1L1Gaac4QLxxXYfcUD3wsqM_mTo5vrpss?usp=drive_link,
,Amy Dzikowski,+33652328060,Envoyer le message,amy.dzikowski@monaco.edu,PEARL (Protective Ecosystem AUV for Reef Longevity),"Amy Dzikowski, Nathalie Falck, Anne Marie Medema, Rebecca Sidalova, Melissa Chera","Pereybere, Grand Baie, Maurice","Africa, Mauritius","International University of Monaco, Monaco",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"Our project is the development of the Protective Ecosystem AUV for Reef Longevity (PEARL), an autonomous underwater vehicle (AUV) designed to locate and capture invasive lionfish. PEARL uses a precision system to identify the invasive lionfish, enabling high-yield targeted removal without damaging reefs or harming native species. Unlike human spearfishers, PEARL is not constrained by dive time or depth limitations and can operate continuously across the full depth range of known lionfish habitats. AI-driven detection systems enhance identification accuracy and ensure an environmentally responsible operation.
The primary objective of PEARL is to halt the spread of invasive lionfish in the Mediterranean Sea and beyond, protecting reef ecosystems and ocean health. To accomplish this, our key objectives are to reduce invasive lionfish populations, prevent further geographic expansion, and enable sustainable revenue through the use of lionfish byproducts. Incorporating economic viability alongside ecological impact is essential to ensuring long-term adoption and effective large-scale intervention.",true,Prof. Elena Tavella at the International University of Monaco,,,
The primary objective of PEARL is to halt the spread of invasive lionfish in the Mediterranean Sea and beyond, protecting reef ecosystems and ocean health. To accomplish this, our key objectives are to reduce invasive lionfish populations, prevent further geographic expansion, and enable sustainable revenue through the use of lionfish byproducts. Incorporating economic viability alongside ecological impact is essential to ensuring long-term adoption and effective large-scale intervention.",true,Prof. Elena Tavella at the International University of Monaco,Received,https://drive.google.com/drive/folders/1DD6FeWZxM0BsY4KDQavdb5yHJCbuE7_2?usp=drive_link,
,Kelvish Duval,+33768126798,Envoyer le message,kelvish.duval@efrei.net,Efrei grp 4,"Yassine Tenzekhti, Theotim Djelassi, El-Assad Said, Lucas Rinaudo","Villejuif, Île-de-France, France","Europe, France","Efrei, Villejuif",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,Creation of artificial habitats with adapted materials,false,No,,,
,Thamires Pontes,+5511992001931,Envoyer le message,thamires@phycolabs.com,Phycolabs,"Thamires Pontes, Wolgrand Neto, Diego Nunes, Gustavo Gonçalves","Sao Paulo, SP, Brésil",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-06-14,Reduction of pollution (plastics chemicals noise light...),"Phycolabs is a Brazilian biomaterials startup decarbonizing fashion with textile fibers made entirely from cultivated seaweed. Our SeaweedFibers replace traditional and man-made fibers that drive CO₂ emissions and marine microplastic pollution at the source, using a fast-growing ocean feedstock that requires no land, pesticides, or freshwater.
Our patent-pending process produces continuous seaweed filaments compatible with existing spinning and weaving machinery, enabling global brands to adopt ocean-positive materials at scale while building regenerative seaweed farming value chains, contributing to marine ecosystem restoration, and strengthening coastal livelihoods.",true,We were semifinalist last year and I would like to try again in 2026 :)),,,
Our patent-pending process produces continuous seaweed filaments compatible with existing spinning and weaving machinery, enabling global brands to adopt ocean-positive materials at scale while building regenerative seaweed farming value chains, contributing to marine ecosystem restoration, and strengthening coastal livelihoods.",true,We were semifinalist last year and I would like to try again in 2026 :)),Received,https://drive.google.com/drive/folders/1fqJe8wPVdDc0_yICZP1jGV8KB5HvWJSe?usp=drive_link,
,Davide Balbi,+393318344974,Envoyer le message,db@mondomigliore.eu,C.A.S.A. Marine Loop,"Davide Balbi, Santino Filippeddu, Gianluca Del Vecchio","Arzachena, Sardaigne, Italie","Europe, Italia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-09-19,Sustainable fishing and aquaculture & blue food,"C.A.S.A. Marine Loop is a modular retrofit solution for offshore fish cages, designed to operate around, inside and below cages. It aspirates and treats water to intercept solid waste and organic loads that affect fish welfare and surrounding marine areas. The core technology is a stainless-steel biofiltration system with organic BSA media, already validated in land-based RAS, designed not to generate microplastics compared to plastic-based alternatives. The system is conceived to be powered by offshore renewable energy and to enable circular economy pathways for recovered materials. Our objective is to make offshore aquaculture cleaner, more resilient and ocean-positive while improving farm performance.",true,Newsletter,Doublon,,
,Juan Carlos Saldaña Guerrero,+51932751524,Envoyer le message,juan.carlos.saldana.guerrero@gmail.com,Carlos Biologist SG Group,"Carmen Nallelí Saldaña Guerrero, Elizabeth Bridgit Saldaña Guerrero","Ica, Peru",South America,ESNECA Business School,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,Laboratorio de Biología Marina,true,LinkedIn,,,
,Juan Carlos Saldaña Guerrero,+51932751524,Envoyer le message,juan.carlos.saldana.guerrero@gmail.com,Carlos Biologist SG Group,"Carmen Nallelí Saldaña Guerrero, Elizabeth Bridgit Saldaña Guerrero","Ica, Peru",South America,ESNECA Business School,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,Laboratorio de Biología Marina,true,LinkedIn,Received,https://drive.google.com/drive/folders/11vBtdjYek8qMUF06RySGJkhbY34zYpBg?usp=drive_link,
,Lautaro Girones,+541151355710,Envoyer le message,lautaro.girones@uns.edu.ar,HydroTrace,Lautaro Girones; Andres Hugo Arias; Rosario Corradini; Rocio Luciana Bray; Alejandro Jose Vitale,"Bahía Blanca, Buenos Aires, Argentine",South America,"Universidad Nacional del Sur, Bahía Blanca, Argentina",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"HydroTrace is an environmental monitoring technology platform designed to reveal hydrophobic chemical contaminants that are often missed by conventional water monitoring methods. Many industrial pollutants, including hydrocarbons, PAHs and selected unintentional persistent organic pollutants (UPOPs), are not fully captured by dissolved water measurements, leading to underestimation of real environmental exposure and risks to marine ecosystems and ocean health.
HydroTrace uses standardized passive polymer-based sensors combined with analytical workflows and exposure interpretation tools to measure time-integrated chemical exposure across aquatic environments, from industrial discharges and rivers to estuaries, ports and offshore marine waters.
@@ -779,19 +783,19 @@ By detecting hidden chemical exposure pathways along the land-to-ocean continuum
The technology is currently being refined through pilot monitoring applications in industrial coastal environments, demonstrating technical feasibility under real-world conditions. Initial applications focus on hydrophobic hydrocarbon fractions (C10C40), PAHs and selected hydrophobic industrial byproducts. Future development includes evaluation of additional hydrophobic industrial and combustion-related contaminants depending on environmental behavior and monitoring needs.
HydroTrace is designed to complement existing water monitoring technologies by targeting the hydrophobic contaminant fraction that is often under-characterized in routine monitoring programs. The platform combines standardized deployment hardware, recurring replacement kits, laboratory analysis and exposure intelligence reporting, enabling scalable, standardize",true,Through a colleague working in the ocean and environmental sector,,,
HydroTrace is designed to complement existing water monitoring technologies by targeting the hydrophobic contaminant fraction that is often under-characterized in routine monitoring programs. The platform combines standardized deployment hardware, recurring replacement kits, laboratory analysis and exposure intelligence reporting, enabling scalable, standardize",true,Through a colleague working in the ocean and environmental sector,Received,https://drive.google.com/drive/folders/18VZom2ZxXwIrtuWWPwcj-k_YInxw3HV7?usp=drive_link,
,Sahil,+917488447394,Envoyer le message,sahilkumar8176xd@gmail.com,BlueVault,"Sahil Kumar , Ankit Kumar","Jehanabad, Bihar, Inde",Asia,"Magadh University,Bihar",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Blue Carbon,"BlueVault is an autonomous ""Proof-of-Ecosystem"" protocol. We deploy swarms of low-cost micro-AUVs to verify Blue Carbon assets (seagrass/reefs) with millimeter precision.
Objective: To unlock the $50B ocean finance market by replacing expensive human divers with scalable, audit-grade data.
Tech: Our proprietary C++ Edge-AI algorithms calculate biomass volume in real-time, providing the ""trust layer"" banks need to invest in ocean restoration without fear of greenwashing.",true,,Received,https://drive.google.com/drive/folders/11x14DmyXxxi8EaLG13q31QvHXYRUPAvN?usp=drive_link,
,S KAVIYA,+919042979984,Envoyer le message,sec23ec232@sairamtap.edu.in,ROBOVAC,ABIBA FATHIMA A & SIVASHANKER G,Inde,Asia,"SRI SAIRAM ENGINEERING COLLEGE , WEST TAMBARAM, CHENNAI,TAMIL NADU, INDIA",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial water-body operators, reducing manual effort while improving operational efficiency and environmental impact.",true,I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects.,,,
,S KAVIYA,+919042979984,Envoyer le message,sec23ec232@sairamtap.edu.in,NAUTILUS NEXUS,ABIBA FATHIMA A / SIVAHANKER G,Inde,Asia,"SRI SAIRAM ENGINEERING COLLEGE, WEST TAMBARAM , CHENNAI , TAMIL NADU , INDIA",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial waterbody operators, reducing manual effort while improving operational efficiency and environmental impact.",true,I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects.,Doublon,,
,S KAVIYA,+919042979984,Envoyer le message,sec23ec232@sairamtap.edu.in,ROBOVAC,ABIBA FATHIMA A & SIVASHANKER G,Inde,Asia,"SRI SAIRAM ENGINEERING COLLEGE , WEST TAMBARAM, CHENNAI,TAMIL NADU, INDIA",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial water-body operators, reducing manual effort while improving operational efficiency and environmental impact.",true,I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects.,Doublon,,
,S KAVIYA,+919042979984,Envoyer le message,sec23ec232@sairamtap.edu.in,NAUTILUS NEXUS,ABIBA FATHIMA A / SIVAHANKER G,Inde,Asia,"SRI SAIRAM ENGINEERING COLLEGE, WEST TAMBARAM , CHENNAI , TAMIL NADU , INDIA",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial waterbody operators, reducing manual effort while improving operational efficiency and environmental impact.",true,I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects.,Received,https://drive.google.com/drive/folders/1nXrjERUGyNhA1T0A7o_uqmpvd1jree60?usp=drive_link,
,Greta Puschmann,+33674308280,Envoyer le message,greta.puschmann@monaco.edu,BlueSupply ESG,Tom Döscher,"Monaco, Monaco","Europe, Monaco","International University of Monaco, Monaco",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Other,"BlueTrace ESG is a digital ESG intelligence platform designed to bring transparency to ocean-based supply chains.
The project helps companies and investors identify, measure and manage their marine environmental and social impact across sectors such as fisheries, aquaculture, fashion and maritime logistics.
By combining supply-chain data, ocean-specific ESG indicators and regulatory requirements (e.g. CSRD), BlueTrace ESG enables reliable reporting, risk assessment and decision-making.
Its objective is to make ocean impact measurable and actionable - and to redirect capital and business practices toward more sustainable, ocean-positive value chains.",true,From my university,,,
,TEMANO,+33746225522,Envoyer le message,clara.honore@temano.fr,TEMANO,Quentin DEMOULIN,"Ploemeur, Bretagne, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-01-17,Technology & innovations,"Development of anchors with minimal environmental impact, intended for recreational boating and renewable energy on floating bases",false,Pole Mer Bretagne Atlantique,,,
,TEMANO,+33746225522,Envoyer le message,clara.honore@temano.fr,TEMANO,Quentin DEMOULIN,"Ploemeur, Bretagne, France","Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-01-17,Technology & innovations,"Development of anchors with minimal environmental impact, intended for recreational boating and renewable energy on floating bases",false,Pole Mer Bretagne Atlantique,Received,https://drive.google.com/drive/folders/1hLQWBtegrcsvq_sDjRrn5l0bb4P9_pxt?usp=drive_link,
,Mathias Mondo,+237693514085,Envoyer le message,mathiasmondo@me.com,Blue Sentinel,Nguessong Donfack Marie-Ange; Tagne Wambo Elohim Junior; Bidzana Deugoue Erwin ; GUENTANG BADEFONA ODILE; Nkot-a-Nzok Etienne; Mondo Mathias; Adipauldi Sigot Sabine; Ndzana Nga Romaric; Lowe Nyat Fred,Cameroun,"Africa, Cameroun",INSTITUT UNIVERSITAIRE D'INNOVATION ET DE MANAGEMENT MARIE-ALBERT (ISIMMA),the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"Ocean pollution starts on land.
In Yoyo (Gulf of Guinea), accelerated mangrove degradation directly impacts coastal water quality, marine nurseries and coastal resilience. Despite awareness and initiatives, decisions remain fragmented, reactive and poorly governed — leaving the Ocean unprotected at its source.
@@ -817,28 +821,28 @@ Yoyo is the pilot. Monaco is the accelerator.",false,Via Linkedin and Mrs Manon
,Maya Bauer,+33768779375,Envoyer le message,mayabauer011@gmail.com,Veratech,Maya Bauer,"Cleveland, OH, États-Unis",US,Universite Cote D'Azur,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,,true,"I heard about MOPC through my university, and I participated in 2024 with my project NEMA. I want to be transparent, just in case I can not participate again. However my new project, Veratech, is a completely different concept/project and is at the prototype stage. If anyone would like to discuss this with me further I would be happy to! My last experience at MOPC was such a pleasant learning experience, and I would greatly like to participate again if it is possible.",,,
,Charles Maher,+16174609706,Envoyer le message,charles.maher@blueshadow.dk,BlueShadow ApS,Emil Luth,"Copenhague, Région de la Capitale, Danemark","Denmark, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-02-24,Sustainable fishing and aquaculture & blue food,"BlueShadow protects society and the environment by delivering Maritime Security as a Service - safeguarding critical infrastructure and natural resources across the maritime domain.
Through our BlueFisheries Mission Solution, we serve as a force-multiplier for responsible authorities in the fight against illegal and destructive fishing. We fuse multi-source data into real-time, predictive intelligence that cuts through deception and exposes suspicious activity, triggering the immediate deployment of autonomous systems to investigate, document, and secure evidence - enabling decisive enforcement action to ensure violators are held accountable.",true,Referral from Victor Cobos of Avantgard Capital,,,
Through our BlueFisheries Mission Solution, we serve as a force-multiplier for responsible authorities in the fight against illegal and destructive fishing. We fuse multi-source data into real-time, predictive intelligence that cuts through deception and exposes suspicious activity, triggering the immediate deployment of autonomous systems to investigate, document, and secure evidence - enabling decisive enforcement action to ensure violators are held accountable.",true,Referral from Victor Cobos of Avantgard Capital,Received,https://drive.google.com/drive/folders/1HOZF-ywLd22e_-S1-V6Ezw0CF_srypRA?usp=drive_link,
,Sudarsha Chanaka De Silva,+94776761788,Envoyer le message,sudarsha30384@gmail.com,Install High-Tech Water Purification Units in Fisheries Sector To Tackle Plastic Pollution in sea off Sri Lanka,"Sudarsha De Silva , Jagath Gunasekara, Madhura Delpachithra","Kudawella South, Southern, Sri Lanka","Africa, Sri Lanka",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2021-06-16,Reduction of pollution (plastics chemicals noise light...),"• Cleaner oceans and beaches: Less plastic waste entering our marine ecosystems.
• Protected marine biodiversity: Safeguarding our coral reefs and diverse marine life.
• Improved health for fishing crews of multi-day fishing boats: Access to clean, purified water on long voyages improves the hygiene and sanitary standards of fishermen
• Reduced operational costs of the fisheries sector: Fishermen save on bottled water purchases.
• Enhanced industry reputation: Sri Lanka leading the way in environmental stewardship
The project responds to the urgency to reduce plastic pollution caused by multiday fishing vessels in Sri Lanka, which is a major contribution to marine debris in the country's ocean waters. these fishing vessles spends 4-5 weeks in the sea and heavily depend on single-use plastic bottles for drinking water. with a limited waste storage and disposal infrastructure on board, much of this plastic is directly to the ocean, creating hotspots in the sea for marine litter which will result on microsplatic contamination on marine life and human body. This catostophe affects the tourism and fishries industry badly both sectors are vital for the economy of Sri Lanka. During their journey fisherman face challenges accessing safe drinking water while at sea affecting their health, hygiene and overall well-being. This highlights the importance clean water access and for reducing health impacts caused by plastic dependency. Therefore the project demonstrate the need for environment protection, ensure occupational health.",true,Through social media,,,
The project responds to the urgency to reduce plastic pollution caused by multiday fishing vessels in Sri Lanka, which is a major contribution to marine debris in the country's ocean waters. these fishing vessles spends 4-5 weeks in the sea and heavily depend on single-use plastic bottles for drinking water. with a limited waste storage and disposal infrastructure on board, much of this plastic is directly to the ocean, creating hotspots in the sea for marine litter which will result on microsplatic contamination on marine life and human body. This catostophe affects the tourism and fishries industry badly both sectors are vital for the economy of Sri Lanka. During their journey fisherman face challenges accessing safe drinking water while at sea affecting their health, hygiene and overall well-being. This highlights the importance clean water access and for reducing health impacts caused by plastic dependency. Therefore the project demonstrate the need for environment protection, ensure occupational health.",true,Through social media,Received,https://drive.google.com/drive/folders/1lsSAKHSXpo5En0QJGz2DumJpJFGntqoi?usp=drive_link,
,Trisna Oktavia,+6289530030014,Envoyer le message,trisna.jobs@gmail.com,Seawaste Tech,"Asadurrofiq, Jean Baptiste Sugihardjanto",,,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-01-01,Reduction of pollution (plastics chemicals noise light...),"SeaWaste Tech is an integrated fishery waste and marine waste processing through a zero-waste system approach. In 2025, We successfully processed 50 tons of fishery waste into animal feed and organic fertilizer. We collaborate with seafood processing plants to manage and transform their production waste. However, beyond organic fishery waste, plastic packaging waste has also become a growing environmental challenge for these facilities.
Currently, our fishery waste processing operations still rely on fossil fuels for energy. Unfortunately, fossil fuels are increasingly expensive and often subject to supply shortages, which directly impacts production stability and costs. Recognizing this challenge, we see a strong opportunity to replace fossil fuel consumption with fuel derived from plastic waste generated by seafood processing factories. Through our pilot project calculations, this transition has the potential to reduce fossil fuel usage by up to 80% and lower production costs (HPP) by approximately 20%.
In addition to utilizing factory plastic waste, we also plan to collect plastic waste from coastal communities. Many of these communities lack proper waste management infrastructure, leading to common practices such as dumping plastic into the ocean or burning it both of which create further environmental pollution and threaten marine ecosystems. Our core focus is to convert plastic waste into diesel fuel as an alternative energy source and make a collection point. This solution will reduce dependence on fossil fuels, Lower production costs, Create a community-based plastic savings system that encourages behavioral change, Deliver significant environmental impact.
By transforming plastic waste into energy, we are not only solving industrial and community waste challenges but also contributing to global sustainability goals, specifically SDG 12 (Responsible Consumption and Production), SDG 13 (Climate Action), and SDG 14 (Life Below Water).",true,"I first learned about the Monaco Ocean Protection Challenge through my participation in the 2025 edition, where SeaWaste Tech was shortlisted as a finalist in the Business Concepts category. That experience deepened my commitment to refining our ocean impact model and motivated me to reapply in 2026 with a more mature, measurable solution grounded in our 2025 operational results.",,,
By transforming plastic waste into energy, we are not only solving industrial and community waste challenges but also contributing to global sustainability goals, specifically SDG 12 (Responsible Consumption and Production), SDG 13 (Climate Action), and SDG 14 (Life Below Water).",true,"I first learned about the Monaco Ocean Protection Challenge through my participation in the 2025 edition, where SeaWaste Tech was shortlisted as a finalist in the Business Concepts category. That experience deepened my commitment to refining our ocean impact model and motivated me to reapply in 2026 with a more mature, measurable solution grounded in our 2025 operational results.",Received,https://drive.google.com/drive/folders/1ZgyOGhdVYJOFjKlK6EwyHWKbeExBWDlO?usp=drive_link,
,Tabe Brandon Njume,+237675068360,Envoyer le message,tabebrandon50@gmail.com,TBINDS,"Anthony Duxell Malle, Forbah Sandra, Atianjoh Laris, Warri Bilson","Tiko, Cameroun","Africa, Cameroun","University of Buea, Cameroon + University of Bamenda, Cameroon",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"My solution seeks to tackle microplastics pollution in our oceans by addressing point sources such as household and hospital diaper and surgical mask waste. To tackle this, we have engineered a circular, self-powering system that transforms waste from disposable diapers and surgical masks into TBinds, a soft, durable shoe sole.
Our process begins with a closed-loop collection system using leak-proof bins in hospitals and households to keep waste contained. To handle the biological load, we use a specialized fractionation method: waste is treated with a 2% Calcium Chloride solution to collapse the Superabsorbent Polymers (SAP), forcing them to release trapped liquids and excreta.
This organic slurry is diverted to an anaerobic digester to produce biogas, which powers our machinery. The remaining solids are sent to a wet shredder and centrifugal separator. A float-sink tank then isolates the high-grade Polypropylene (PP) and Polyethylene (PE) from the cellulose fibers. While our priority is stopping waste at the source, TBinds can process diaper waste from drains by adding a sedimentation step to wash off mud and grit.",true,,Received,https://drive.google.com/drive/folders/1pYFR66gjdcHQjRlOc24gUpstIhhmFyGO?usp=drive_link,
,Oscar Montoya,+573147802359,Envoyer le message,archivoadm@gmail.com,ISO Brick,2,"Medellin, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-01-01,Reduction of pollution (plastics chemicals noise light...),"The ISOBrick project aims to recover rigid polyurethane discarded during the manufacture of thermo-acoustic roof tiles and reuse that material as a filler within the cavities of clay bricks, transforming them into bricks with greater thermal and acoustic insulation capacity, reducing industrial waste and promoting more sustainable and energy-efficient construction.",true,By a research group,,,
,Oscar Montoya,+573147802359,Envoyer le message,archivoadm@gmail.com,ISO Brick,2,"Medellin, Colombie",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-01-01,Reduction of pollution (plastics chemicals noise light...),"The ISOBrick project aims to recover rigid polyurethane discarded during the manufacture of thermo-acoustic roof tiles and reuse that material as a filler within the cavities of clay bricks, transforming them into bricks with greater thermal and acoustic insulation capacity, reducing industrial waste and promoting more sustainable and energy-efficient construction.",true,By a research group,Received,https://drive.google.com/drive/folders/1nMXqz2TT_7QUixsQ1EljE9bE9_ThujcV?usp=drive_link,
,Anastasija Stefanovic,+33749014764,Envoyer le message,anastasija.stefanovic@univ-paris1.fr,BluePrint Reefs,"Anastasija Stefanovic, Florian Guichard","Paris, Île-de-France, France","Europe, France","Sorbonne Pantheon Paris 1, Paris",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"We offer a high-tech, scalable solution: 3D-bioprinted modular reefs.
Innovation: Using a proprietary ""Living Ink"" (recycled calcium carbonate + bio-triggers), we print structures that mimic natural reef complexity.
Result: These reefs don't just sit on the seabed; they actively ""invite"" coral larvae to settle, accelerating biodiversity recovery by 300% compared to traditional methods.",true,I am a resident of Fondation de Monaco at Cite Internationale Universitaire de Paris where MOPC was mentioned.,,,
Result: These reefs don't just sit on the seabed; they actively ""invite"" coral larvae to settle, accelerating biodiversity recovery by 300% compared to traditional methods.",true,I am a resident of Fondation de Monaco at Cite Internationale Universitaire de Paris where MOPC was mentioned.,Received,https://drive.google.com/drive/folders/1xcY1iVbFDOe9KQuLTSJlOpK8WPqFDpUP?usp=drive_link,
,Anvi Gowda,+4917623367782,Envoyer le message,anvi.gowda@myhsba.de,BlueReturn,"Jana Cisewski, Anvi Gowda, Malena Merkel & Kamar Haidar","Hambourg, Allemagne","Europe, Germany","Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"With our project ""BlueReturn"", we aim to motivate people to collect trash in coastal areas and dispose of it. It is a system that consists of AI supported clean up stations, which enable correct trash seperation and are connected to an app. The app tracks the amount of trash collected by users and converts it into points, which can either be donated to ocean protection initiatives or redeemed for sustanability related rewards. Rewards include e.g. vouchers for sustainable brands or a free public transport ticket. With BlueReturn our goal is to incentivize people to turn small actions into a visible impact on ocean protection.",true,Our Professor introduced the challenge during a sustanability course at the HSBA.,Received,https://drive.google.com/drive/folders/1iac3f__cDlv03ssLpfEVRHq5TzdjtYpa?usp=drive_link,
,Nasha Afrina Binte Abdul Mutalib,+6588345209,Envoyer le message,afrinaabdul77@gmail.com,Blue Miles Index,1.Nasha Abdul 2. Cassarah Matakena 3. Moh. Aufa Dany Damario 4. Pathinettan Philemon,"Singapour, Singapour",Asia,1. Singapore institute of technology/User experience and game design 2. Universitas Indonesia/Biology 3.Universitas Indonesia/Naval Architecture-Marine Engineering 4. Nanyang Technological University/Environmental and Earth Systems Science,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Blue Miles Index is a standardized decision-support framework that makes maritime transport distance visible in purchasing and procurement decisions. The project addresses a structural transparency gap in global trade: while over 80% of goods move by sea, shipping distance and its ocean impact remain invisible at the point of purchase. Blue Miles Index converts product origin and logistics data into a normalized, distance-based ocean pressure indicator that integrates into retail platforms and procurement systems. By introducing a comparable transport signal, the framework enables informed sourcing decisions without restricting choice. Its objective is to reduce cumulative vessel kilometres travelled by influencing upstream purchasing behaviour, thereby lowering emissions, underwater noise exposure, and maritime pressure on marine ecosystems. The system scales through voluntary integration and leverages existing supply chain data, requiring no new infrastructure.",true,linkedin,,,
,Nasha Afrina Binte Abdul Mutalib,+6588345209,Envoyer le message,afrinaabdul77@gmail.com,Blue Miles Index,1.Nasha Abdul 2. Cassarah Matakena 3. Moh. Aufa Dany Damario 4. Pathinettan Philemon,"Singapour, Singapour",Asia,1. Singapore institute of technology/User experience and game design 2. Universitas Indonesia/Biology 3.Universitas Indonesia/Naval Architecture-Marine Engineering 4. Nanyang Technological University/Environmental and Earth Systems Science,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Blue Miles Index is a standardized decision-support framework that makes maritime transport distance visible in purchasing and procurement decisions. The project addresses a structural transparency gap in global trade: while over 80% of goods move by sea, shipping distance and its ocean impact remain invisible at the point of purchase. Blue Miles Index converts product origin and logistics data into a normalized, distance-based ocean pressure indicator that integrates into retail platforms and procurement systems. By introducing a comparable transport signal, the framework enables informed sourcing decisions without restricting choice. Its objective is to reduce cumulative vessel kilometres travelled by influencing upstream purchasing behaviour, thereby lowering emissions, underwater noise exposure, and maritime pressure on marine ecosystems. The system scales through voluntary integration and leverages existing supply chain data, requiring no new infrastructure.",true,linkedin,Received,https://drive.google.com/drive/folders/1RvCTmMWpuNlhTk-zhQTezCbzeeUiunxd?usp=drive_link,
,Ratri Maria,+6596809257,Envoyer le message,ratri@changemakr.asia,ClimaFund,"Tsamara Tsabita, Anggy Priyo, Rizal Suryawan","Singapour, Singapour",Asia,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-22,Sustainable fishing and aquaculture & blue food,"ClimaFund makes coastal conservation economically self-sustaining by solving the ""first mile problem""—the gap between restoration investments and verifiable, community-level impact.
The Problem: Mangrove restoration programs suffer 40-60% failure rates due to late stress detection, while fishing communities—the essential stewards—remain economically invisible to premium markets and carbon finance.
@@ -854,7 +858,7 @@ Generate verifiable blue carbon credits that fund long-term community stewardshi
We're the first solution combining ecosystem verification with seafood traceability—making conservation profitable for those who protect it.
Current Traction: Operating in Indonesia's Delta Mahakam with mangrove custodians & social forestry groups, totalling more than 450 hectares, preparing Q1 2026 commercial launch.",true,linkedin,,,
Current Traction: Operating in Indonesia's Delta Mahakam with mangrove custodians & social forestry groups, totalling more than 450 hectares, preparing Q1 2026 commercial launch.",true,linkedin,Received,https://drive.google.com/drive/folders/11PVLhT2YOjlnSfG1nwOTgWbw5kjbgifZ?usp=drive_link,
,Karime Guillen Libien,+527222617967,Envoyer le message,karimeguillen@rearvora.com,Rearvora,"Estefania Vazquez Martinez, Estefania Valdes Vazquez, Juanjo Saldivar","Toluca, Edomex, Mexique",South America,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-01-31,Reduction of pollution (plastics chemicals noise light...),"We are a Mexican circular biotechnology company born from a simple but urgent realization: what tourism leaves behind does not disappear. It travels. It flows through rivers, drains, coastlines and eventually reaches the ocean.
In coastal and tourism destinations, millions of plastic amenities, chemically loaded personal care products, and poorly managed organic waste quietly accumulate every day. What seems harmless in a hotel room becomes microplastics in marine food chains, toxic residues in coral ecosystems, and nutrient overload that suffocates coastal waters. Organic waste, when mismanaged, releases methane in landfills and contributes to water contamination and eutrophication when it reaches marine ecosystems. These impacts are rarely associated with tourism, yet they are deeply connected.
@@ -863,8 +867,8 @@ At Rearvora, we transform organic waste into bioproducts and bio-based packaging
Our objectives are concrete: to provide hotels with sustainable amenities that prevent plastic and chemical leakage; intervene upstream within tourism systemsbefore pollution reaches the sea. To offer collection and treatment services for organic waste generated in tourism operations; and to establish circular transformation hubs in tourism regions where local communities participate directly, generating green and innovative jobs. Through circular production hubs, sustainable amenities, and environmental education for hotels and travelers, we turn prevention into ocean protection. These hubs close material loops locally, preventing waste from reaching waterways while strengthening community resilience.
For us, protecting the ocean does not start at the shoreline. It starts with what we choose to produce, consume, and discar",true,From Instagram and Recommendation of a program name TECA,,,
,Alfonso Rodríguez,+526461512748,Envoyer le message,alfonso.rodriguez@cicese.edu.mx,UniMar,"Alfonso Rodríguez, Jeremie Bauer, Manuel Acosta & Jorge Olmos","Ensenada, BC, Mexique",South America,"Centro de Investigación Científica y de Educación Superior de Ensenada Baja California. Ensenada, Baja California. Mexico.",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"UniMar produces a proprietary plant-based probiotic feed (zero fishmeal or macroalgae) that fattens starved barren urchins into premium gonads. Every urchin ranched removes a grazer from collapsing kelp forests, making restoration self-funding. 30 years R&D, peer-reviewed validation, only plant-based feed achieving commercial-grade results globally. Scaling from Baja California to global markets.",true,Colleagues,,,
For us, protecting the ocean does not start at the shoreline. It starts with what we choose to produce, consume, and discar",true,From Instagram and Recommendation of a program name TECA,Received,https://drive.google.com/drive/folders/1E0r6BdnMQ9-7-JSnjoNVNrAfolA_Pyv5?usp=drive_link,
,Alfonso Rodríguez,+526461512748,Envoyer le message,alfonso.rodriguez@cicese.edu.mx,UniMar,"Alfonso Rodríguez, Jeremie Bauer, Manuel Acosta & Jorge Olmos","Ensenada, BC, Mexique",South America,"Centro de Investigación Científica y de Educación Superior de Ensenada Baja California. Ensenada, Baja California. Mexico.",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"UniMar produces a proprietary plant-based probiotic feed (zero fishmeal or macroalgae) that fattens starved barren urchins into premium gonads. Every urchin ranched removes a grazer from collapsing kelp forests, making restoration self-funding. 30 years R&D, peer-reviewed validation, only plant-based feed achieving commercial-grade results globally. Scaling from Baja California to global markets.",true,Colleagues,Received,https://drive.google.com/drive/folders/10QsjUBj5C37qoteCpheqEfNySBNtvsLM?usp=drive_link,
,Aluora Annette Luttah,+22892077042,Envoyer le message,aluttah@gmail.com,EcoHarbor Services and Solutions,"Annette Luttah ALUORA, Edem ASSIGBLEY, Adeline ALOKPA, Yaovi ANDELE, Philippe AKUE-ABOSSE, Kokouvi Noviti YEHOUESSI","Lomé, Maritime, Togo","Africa, Togo",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-04,Reduction of pollution (plastics chemicals noise light...),"Transforming plastic ocean waste into prospects for change in Togo
1. Project Overview
EcoHarbor Services and Solutions is a nature-positive social enterprise that integrates marine plastic recovery with clean port and beach services. The initiative endeavors to reduce ocean pollution, create local jobs, and promote a circular blue economy in Togo.
@@ -874,7 +878,7 @@ JVE International leads the initiative in partnership with local NGOs, municipal
2. The Problem
Plastic pollution is a growing threat to Togos 56 km coastline, ports, lagoons, mangroves, fisheries, and tourism. Plastics represent more than 10% of municipal solid waste, while only about 48% of municipal waste is collected, creating high leakage risk into the marine environment affecting mangroves, fisheries, tourism, and coastal livelihoods.
Across coastal West Africa, around 80% of plastic waste is mismanaged, making ports and beaches major entry points for plastics into the ocean. Globally, 811 million tonnes of plastic enter the ocean every year, underscoring the urgency of local action.
Plastic pollution severely affects mangrove ecosystems, reducing seedling survival, blocking root respiration, and accelerating ecosystem degradation in coastal and lagoon environments. Limited waste recovery in ports, lagoons, and beaches leads to plastics entering the ocean, Currently, ports and beaches lack integrated solutions that target pollution prevention, waste recovery, and economic value creation. This project responds to a national need for locally adapted, job-creating ocean solutions rather than imported techno",true,Through a Partner,,,
Plastic pollution severely affects mangrove ecosystems, reducing seedling survival, blocking root respiration, and accelerating ecosystem degradation in coastal and lagoon environments. Limited waste recovery in ports, lagoons, and beaches leads to plastics entering the ocean, Currently, ports and beaches lack integrated solutions that target pollution prevention, waste recovery, and economic value creation. This project responds to a national need for locally adapted, job-creating ocean solutions rather than imported techno",true,Through a Partner,Received,https://drive.google.com/drive/folders/1ezy4ro_k2R45iTeFb1GNLx4qa7f1t1kW?usp=drive_link,
,Janne Springer,+491723211337,Envoyer le message,janne.springer@myhsba.de,The BetterCatch,"Mette Wolf, Lulu Kuhlwein, Janne Springer","Hambourg, Allemagne","Europe, Germany","Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"The BetterCatch is a whitelabel, sciencebased decision system integrated into existing supermarket apps and a stand-alone app. By scanning a barcode or searching for a product, shoppers receive an instant trafficlight rating (green/yellow/red) and a better instore alternative in under three seconds. The rating logic aggregates species, FAO fishing area, gear type, stock status, and additional risk factors into a clear recommendation grounded in peerreviewed research and established indicator frameworks.",false,University and our Sustainability Lecture,Received,https://drive.google.com/drive/folders/1lzWuGV8pyCjSyVRv1K2Ns1FhwIGfuH7O?usp=drive_link,
,Esméralda Mavrel,+46793575837,Envoyer le message,esmeralda.mavrel1@gmail.com,ROBOVAC,"Esméralda Mavrel, ABIBA FATHIMA A , S. KAVIYA & SIVASHANKER G","Goteborg, Comté de Vastra Gotaland, Suède","Europe, Sweden",University of Gothenburg,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"ROBOVAC is a semi-
autonomous floating robot
@@ -883,3 +887,137 @@ pollution in rivers, lakes,
and coastal waters before
its degradation in the
environment. It's AI-enhanced to classify the debris and generate pollution hotspots maps.",false,After the EU-India Ideathon,,,
,CHE DIVINE NSOH,+237653614474,Envoyer le message,chedivine@ascoa-cm.org,ASSOCIATION FOR COMMUNITY AWARENESS (ASCOA),"Linus Ayangwoh Embe, Epeh Nuela Ayuk, Ruth Enjema, Solomon Takwi","Buea, Cameroun","Africa, Cameroun",University of Buea,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),"Cameroon faces a growing crisis of plastic pollution, producing around 600,000 tonnes of plastic waste per year, with the greatest burden on coastal communities, due to an excess of pollution in rivers and waterways that drain into the Atlantic Ocean. Insufficiently regulated waste disposal has led to a number of severe and unexpected side effects, including increased disease burden and severe ecological degradation and disruption of marine biodiversity around estuaries and mangrove forests. Cameroon also has inadequate access to data regarding sources and mechanisms of plastic leakage into marine environments which, paired with a lack of funding and fragmented response, makes it difficult to implement effective interventions to address the impacts of marine plastic pollution.
ASCOA desires to address these issues by combining infrastructure, community engagement, and market incentives to intercept waste, create livelihoods, and foster a local circular economy.
Project Context: Marine plastic pollution poses a significant threat to coastal ecosystems, marine biodiversity, and human health, with coastal communities in developing regions facing some of the greatest impact. Due to a lack of infrastructure, resources, and awareness of effective waste management strategies, these areas face a continuous leakage of plastics into the marine environment, negatively impacting fisheries, tourism, and local livelihoods.
Project Goal: To establish and implement a comprehensive, community-based circular economy model in Buea-South West Region of Cameroon that significantly reduces the amount of plastic waste entering the marine environment, while simultaneously creating new economic opportunities for local residents.
Project Objectives:
- Reduce and prevent plastic waste through targeted source reduction program and promotion of reusable alternatives.
- Develop and establish a decentralized, efficient waste collection and sorting system.
- Pilot a circular economy initiati",true,on LinkedIn,Received,https://drive.google.com/drive/folders/1QwH-xxVgLXAaa91V1tsoH7-0ZDNjcIsf?usp=drive_link,
,Perry Chua,+639175184168,Envoyer le message,seaformsofficial@gmail.com,Seaforms,"Dino Chia, Julius Ang, Perry Chua","Singapour, Singapour",Asia,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-02-21,Restoration of marine habitats & ecosystems,"We would like to bring Seaforms to the Monaco Ocean Protection Challenge.
Seaforms deploys modular Electric Reef Systems (ERS) to accelerate coral reef restoration in sites where traditional restoration methods underperform. Using lowvoltage seawater electrolysis (mineral accretion) to collect calcium carbonate onto steel structures, we create a foundation that is biologically similar to coral skeleton and ideal for attaching coral fragments. Our innovation is converting a proven but often hardtoscale electrifiedreef approach into standardized, plugandplay components that can be configured for different sites, client preferences, and resource levels.
Objectives:
(1) increase coral growth, survival and stress recovery vs nonelectrified controls
(2) enable restoration in harsher environments where coastal protection is needed
(3) scale through repeatable installation and maintenance.
A 6month pilot (ONE15 Marina in Singapore) showed ~50% additional cumulative coral growth (Turbinaria & Pachyseris) vs control, faster recovery of stressed fragments, and markedly reduced biofouling. This supports SDG14 through measurable reef recovery.
We envision delivering the system as an endtoend “underwater landscaping” service (installation + maintenance) that also serves as a form of eco-tourism (plantacoral, ecotours, adoptacoral) to help fund longterm stewardship",true,Desk research,Received,https://drive.google.com/drive/folders/16pge5DYr5iABlKshi8cQBk2O7xMJpgmW?usp=drive_link,
,Sarah Driege,+32492836596,Envoyer le message,marescenter@gmail.com,marescenter,"Sarah Driege , Cassiopea Carrier Doneys and Pablo Calderon Cadiz","Mahahual, Othón P Blanco, QRoo, Mexique",South America,"Maastricht Univeristy, The Netherlands",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,Reef Guardianship Zone,true,instagram,Received,https://drive.google.com/drive/folders/1-ZKkAjeUFB6s5MH4TZwUXDeumgVQhnZE?usp=drive_link,
,Mohamed Amine Koubaa,+212762032608,Envoyer le message,koubaa.ma@gmail.com,Blue Fields Company,Mohamed Amine Koubaa,"Nador, L'Oriental, Maroc","Africa, Morocco",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-03-13,Sustainable fishing and aquaculture & blue food,"Our project, led by Blue Fields Company, is a nature-based and technology-driven solution to restore marine ecosystem health, improve water quality, and create sustainable economic opportunities in the Marchica Lagoon (Nador, Morocco). Marchica is a large Mediterranean lagoon facing significant environmental degradation from agricultural runoff, wastewater pollution, nutrient overload, and declining biodiversity, threatening coastal livelihoods.
The core idea is to implement a sustainable seaweed aquaculture model that functions as a living bioremediation system. By cultivating native species such as Gracilaria and Ulva across a 16-hectare marine concession, we enhance nutrient uptake, reduce eutrophication, and mitigate localized ocean acidification. Seaweed biomass is also valorized through markets — including bioplastics, biofertilizers, food additives, and carbon credit systems — creating a viable blue economy value chain.
Our objectives are to:
Regenerate and improve coastal water quality by reducing excess nutrients, pollutants, and hypoxic conditions.
Restore marine biodiversity through habitat creation and ecological balance.
Empower local communities by creating sustainable employment and training in seaweed cultivation and processing.
Demonstrate scalable innovation by integrating automated monitoring technologies — including underwater drones (ROVs) with imaging, sensors, and AI-assisted water quality analysis — to track ecological progress in real time.
Generate climate benefits through carbon sequestration, supporting blue carbon markets.
The project combines cutting-edge technology with community capacity building, making it replicable across similar coastal environments in the Mediterranean and beyond. By linking environmental restoration with tangible economic impact, our solution supports both ocean protection and sustainable development.",true,LinkedIn,Received,https://drive.google.com/drive/folders/11DQCGbj7bXhoyyjtzN3HJA7ay4qUt_kN?usp=drive_link,
,Rishav Mitra,+917044106267,Envoyer le message,sanjumitra1@gmail.com,The Four Module Framework,"Arnaaz Ali, Anshika Sharaaf","Delhi, Inde",Asia,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-05-12,Blue Carbon,"The project has 4 key modules that are currently being implemented in Delhi, and will soon be implemented in other cities as well, the goal being to make efficient use of the blue economy. We found our project idea highly relevant to this challenge, hence why we applied.",true,Through our University!,Received,https://drive.google.com/drive/folders/1PBalSBSOp9qIFJOvMIgf2_8mqDtniD-r?usp=drive_link,
,Mainul Islam Labib,+8801618376869,Envoyer le message,mainulislamlabib@gmail.com,Nirmol,Sadia Afrin,"Dumuria, Khulna, Bangladesh",Asia,"Khulna University, Khulna, Bangladesh",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"Nirmol is our startup based in Bangladesh. We remove microplastics from fish farms by building a filter that attaches directly to existing water pumps, cleaning out over 82% of microplastics. We also built an app that monitors water quality in real time and gives farmers a digital certificate proving their fish are raised in a microplastic-free environment. This helps farmers sell at higher prices and unlock export opportunities. We are targeting the 500,000+ small and medium fish farmers across Bangladesh.",true,Linkedin,Received,https://drive.google.com/drive/folders/1qORciecrc1xgimhrV46melcjJmIe6xIO?usp=drive_link,
,Sofie Boggio Sella,+61448568796,Envoyer le message,sofie.boggiosella@my.jcu.edu.au,PMRF: Probabilistic Multi Modal Reef Fusion,Lily Lewis | Mohammad Jahanbakht,Australie,Oceania,James Cook University Australia,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,"Our project develops a scalable marine protection analytics pipeline that transforms multi-source ocean data into actionable conservation insights. Using automated data processing, ecological indicators, and predictive modeling, the system identifies priority protection zones, biodiversity risk signals, and monitoring gaps to support faster, evidence-based ocean management decisions. The objective is to make advanced marine analysis accessible to conservation teams and decision-makers through a lightweight, reproducible framework that can run on public or partner-provided datasets while respecting data governance constraints.",true,Linkedln,Received,https://drive.google.com/drive/folders/134oV9x28Nq3SsXYdSLl8yRQqq5fr8sJr?usp=drive_link,
,Kristian Ojala,+358451955314,Envoyer le message,ojalakristian@outlook.com,AquaAdvisor,Aarne Leinonen,"Helsinki, Finlande du Sud, Finlande","Europe, Finland","Aalto University, Espoo Finland and International University of Monaco, Monaco",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Sustainable fishing and aquaculture & blue food,"AquaAdvisor is a data-driven SaaS platform that helps salmon farms make environmentally and financially optimal decisions. By integrating operational farm data with real-time market and cost data, its machine learning models recommend the most efficient responses to environmental risks and market fluctuations",true,We discovered the MOPC through online research while looking for entrepreneurship and sustainability-focused competitions in Monaco. We came across it via the official website and related coverage highlighting innovation initiatives in the region.,Received,https://drive.google.com/drive/folders/1C6174kO6cLZfSFx6p7wGy1iOCF4aWOwh?usp=drive_link,
,Heiner Camacho,+821042968546,Envoyer le message,2020925730@snu.ac.kr,TGB-Tidal Gravity Battery,"Heiner Camacho, Yimmy Hortua, Ifa Wahyunny, So Hee Lee","Bogota, Colombie",South America,Seoul National University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"TGB is a blue energy generation based on the gravitational power contained in the oceans as an alternative for contaminating oil rigs.
Objective 1. Build the blue hydrogen silo generator.
Objective 2. Create a silo- generators network along coastal areas.
Objective 3. Provides a hydrogen fueling service for small maritime vehicles.",true,Linkedin,,,
,Vera Lúcia Silva Morgado,+351964525998,Envoyer le message,veramorgado@agovi.pt,#SteelToTheBone Lab,"Vítor Fernandes, Francisco Morgado","Braga, Portugal","Europe, Portugal",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2022-11-20,Reduction of pollution (plastics chemicals noise light...),"Sustainable Innovation and Processes - Waste recovery and transformation, through industrial equipment and new value chains (fishing, industrial factories, algae, oysters shells)",true,Social media and local network,,,
,Lia Cara Schenkel,+4915225826793,Envoyer le message,lia.schenkel@myhsba.de,MarineBuddy Network,"Nina Schmits, Paula von Brand, Julietta Sivaschinski","Hambourg, Allemagne","Europe, Germany","HSBA Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Restoration of marine habitats & ecosystems,Corporate SeaAnimal Sponsorship to reduce the severe funding gap of SDG 14 - Life below water,false,from our lecturer,Received,https://drive.google.com/drive/folders/17y6Hh6ZWOr3_6dQxRN35NMYvkc3EZIaI?usp=drive_link,
,Gianfederico Guastalla,+41779802409,Envoyer le message,Info@marineseafloor.org,Marine Seafloor Conservation,Gianfederico Guastalla,"Lugano, Tessin, Suisse","Europe, Switzerland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-06-25,Reduction of pollution (plastics chemicals noise light...),Removing fishing ghost nets and gears from Mediterranean seafloor using a survey vessel and ROV.,true,,,,
,Bozhidar Hinkov,+359886319048,Envoyer le message,hinkov@arrchitects.com,Waves of renewal,"Martin Krastev, Jacquelline Dimitrova, Petko Bulkin","Sofia, Bulgarie","Bulgaria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-07-03,Restoration of marine habitats & ecosystems,"The ""Waves of Renewal"" project represents more than a solution for a single location; it heralds a fundamental change in perspective and a universally applicable approach to addressing waterfront challenges and fostering ecological regeneration.
While the unique dilemma of Gyaros island its stark past and rich marine biodiversity served as our initial inspiration, the principles underpinning ""Waves of Renewal"" are designed for broad adaptability and scalability, offering a practical yet groundbreaking toolkit for numerous global contexts.We are not merely proposing a development; we are offering a replicable blueprint for transforming environmentally stressed and historically burdened coastal locations into beacons of sustainability and innovation.
The Gyaros Catalyst A New Paradigm: Gyaros, with its legacy as a political prison and its delicate marine sanctuary, embodies the complex interplay between human history and natural ecosystems. It challenged us to think differently. Where many see insurmountable obstacles, Arrogant Architects identified an unparalleled opportunity: to develop and demonstrate a methodology where targeted, technologically advanced, and ecologically sensitive interventions allow the sea to heal the land, creating self-sufficient and thriving environments. This methodology, proven on Gyaros, is ready for wider application.
Our Solution A Symphony of Sea and Land, Designed for Adaptability:
The ""Waves of Renewal"" masterplan is a holistic system, not a collection of disparate parts. Its core components are:
SEA CYCLE The Floating Catalyst: A state-of-the-art, modular, and temporary marine research institute. Designed for minimal environmental impact and rapid deployment/retrieval, SEA CYCLE serves as an offshore hub for coastal ecosystem study, preservation, and the incubation of water management technologies. Its modularity allows for customized scale and function depending on site-specific needs. It is a living laboratory, not a pe",true,"We have been following the organizations impactful work for several years now. However, we first officially learned about the MOPC through the professional network we built during our participation in the Smart and Sustainable Marinas event, organized by M3 a year ago.",,,
,Manuela MIRUKU,+33767491647,Envoyer le message,manuela.miruku@skema.edu,Track your Treasure,"Amandine BOUVET, Marie GIBOUDEAUX, Stéphanie ZHAO, Thérèse-Anne MONBUREAU","Paris, Île-de-France, France","Europe, France","Skema Business School, Paris, France",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Reduction of pollution (plastics chemicals noise light...),Track your Treasure tackles the problem of pollution in the ocean by helping fishermen locate and recover their lost equipment. Our approach would benefit both the environment and financial loss.,false,Our professor is deeply invested in the environmental issues and introduced the MOPC to us.,Received,https://drive.google.com/drive/folders/1UKTEqXH_0c2NCDc9Xn6mnRzphPVntN0Y?usp=drive_link,
,Hüseyin Çiloğlu,+905320659986,Envoyer le message,info@skywatt.tech,Floating Ocean Safe Water/Pikare SkySource,1.Hüseyin Çiloğlu 2.Hülya Özbudun 3. Nihat Akın Ercan 4. Hakan Uğur,"Istanbul, Turquie","Africa, Turkey",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2024-09-10,Restoration of marine habitats & ecosystems,"Protect Marine Life: Eliminate seabed intake pipes (saving billions of organisms) and reduce toxic brine discharge by 40%.
Provide Affordable Water: Produce off-grid clean water at a target cost of $0.025$0.075 per liter.
Ensure Coastal Resilience: Deploy modular, scalable units without permanent coastal scarring or infrastructure damage.",true,LinkedIn / Social Media channels focusing on ocean sustainability and innovation challenges,Received,https://drive.google.com/drive/folders/1scPDdeiOKGk4GkbubRaQC6aZkGxKwK-_?usp=drive_link,
,Halima Mwazembe,+255758023760,Envoyer le message,halimamwazembe1@gmail.com,WasteGreen Company.,"Halima Mwazembe,Steward Amuli and Atupele Mwandenuka.","Dar es Salam, Tanzanie","Africa, Tanzania",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2023-11-02,Reduction of pollution (plastics chemicals noise light...),"WasteGreen is a Tanzanian-based plastic recycling company transforming plastic waste into valuable,sustainable products through innovative recycling technologies and strategic collaborations.The company produces high-quality plastic furniture designed from plastic lumber.
Our mission goes beyond recycling, We empower women and youth by creating jobs, promote environmental sustainability awareness and provide education to help mitigate environmental pollution.
WasteGreen Project supports communities, households and businesses by providing durable, eco-friendly products that reduce plastic pollution,clean our oceans and landfills.
We are committed to fostering
sustainability, improving living standards and building an inclusive circular economy that benefits both the environment and society.",true,I heard MOPC through social media(LinkedIn).,Received,https://drive.google.com/drive/folders/1gMf9O6zigb5bZyof7egT-XH2Nw277pfb?usp=drive_link,
,Amal Chebbi,+21694301242,Envoyer le message,amal.chebbi@pigmentoco.com,Pigmentoco,Dr. Ayoub Lassoued / Dr. Wided Fersi / Mahmoud Belhaouane,"Ben Arous, Tunisie","Africa, Tunisia",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-06-06,Reduction of pollution (plastics chemicals noise light...),"PigmentOCO develops a water-free textile dyeing technology using supercritical CO₂, eliminating wastewater entirely from the dyeing process. Conventional textile dyeing generates highly saline and chemically loaded effluents that often reach rivers and coastal waters, contributing to marine pollution and ecosystem degradation.
Our objective is to prevent pollution at its source by removing water from the process altogether, rather than attempting costly post-treatment. By eliminating saline discharge and dye effluents, PigmentOCO directly reduces chemical pollution entering marine environments, particularly in coastal textile-producing regions.
The project aims to scale this technology across industrial textile hubs, contributing to SDG 14 by protecting marine ecosystems through systemic industrial innovation.",true,"I was referred to the Monaco Ocean Protection Challenge by an ecosystem builder at The Wave Global in Saudi Arabia, who believed PigmentOCOs mission to prevent marine pollution through industrial innovation strongly aligns with the objectives of MOPC.",Received,https://drive.google.com/drive/folders/1yaU3NVGpghLQoygp_mOjVgy8S1ZxOhoc?usp=drive_link,
,Chloe So Tsz Man,+852 9884 5907,Envoyer le message,chloesotm@gmail.com,Our Gaia Museum,Chloe So,"Hong Kong, Hong Kong (Région admin. spéciale)",Asia,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Consumer awareness and education,"Our Gaia Museum is a museum that educates the world the importance of sustainability. Through art, we hope to create a better future by letting everyone appreciate nature of our Gaia.",true,LinkedIn,Received,https://drive.google.com/drive/folders/1QFDao_toPoF3jY2ufkWU9FJcwmkjWnmQ?usp=drive_link,
,Nele Christin Jordan,+4917622996672,Envoyer le message,nele.jordan@myhsba.de,Blue Score,"Jan Seller, Laura Volk Santamaria, Johannes Kopatz, Nele Jordan","Hambourg, Allemagne","Europe, Germany","Hamburg School of Business Administration, Hamburg",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Mitigation of ocean acidification,"Blue Score translates ocean acidification into financial risk scores to protect the Blue Economy. Our objective is to standardize marine risk monitoring, enabling institutions to mitigate hidden acidification threats while automating regulatory compliance.",false,Our Sustainability Professor suggested participating in the MOPC.,Received,https://drive.google.com/drive/folders/1FsGC6eNcB9T0ETB1lJSoakO_tpn2hGSF?usp=drive_link,
,Bozhidar Hinkov,+359886319048,Envoyer le message,hinkov@arrchitects.com,THE SQUARE SYSTEM,"Bozhidar Hinkov, Martin Krastev, Jacquelline Dimitrova, Alberto Carpanese, Camilla Bertolini, Eileen Horowitz, Petko Bulkin","Sofia, Bulgarie","Bulgaria, Europe",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-03-01,Technology & innovations,"Project Idea: THE SQUARE SYSTEM™
The project is a modular, regenerative ""man-made geology"" designed to transform inactive waterfronts into productive ecological hubs. Using a rigorous 6x6m grid, it creates a terraced public piazza that functions as both a social destination and a high-tech ""productive core"" for marine research and the circular economy.
Core Objectives
Decentralized Regeneration: To move beyond passive monuments toward ""living landscapes"" that generate more energy, biodiversity, and communal value than they consume.
Blue Circular Economy: To establish on-site ""Upcycling Hubs"" that intercept maritime waste (like decommissioned fiberglass boats) and transform it into new structural materials.
Energy Autonomy: To achieve 100% self-sufficiency through integrated floating photovoltaic farms and lagoon-sourced thermal regulation.
Relevant Details & Environmental Impact
Circular Material Innovation: The system pioneers a ""Precast Tectonic System."" The architectural skin uses recycled Glass-Reinforced Plastic (GRP) aggregates from local boat graveyards, creating a carbon-sequestering, terrazzo-like finish that tells the story of waste transformation.
Marine Habitat Restoration: The waterfront edge is designed as a ""Living Seawall""—using biologically receptive concrete to actively encourage the growth of local marine flora and fauna, turning infrastructure into a reef.
Minimal Intervention: Construction utilizes a steel pilot foundation system integrated into precast modules, allowing for ""surgical precision"" assembly from the seabed up with zero disruption to the delicate underwater ecosystem.
Integrated Programs: The framework houses Wet Labs for real-time water quality monitoring, ""Blue"" workshops for 3D-printing with reclaimed plastics, and closed-loop ""CEA"" (Controlled Environment Agriculture) to support sustainable local gastronomy.",true,"We've been long-time admirers of your initiatives. The deciding factor for our application was the positive feedback we received from the community at the Smart and Sustainable Marinas event by M3 last year, where we first heard about the MOPC.",,,
,Ivaldo De Jesus Fumo,+258846271354,Envoyer le message,ivaldodejesusfumo19@gmail.com,Ehopa Solutions,"Ivaldo Fumo, Gerson Simbine, Noemia Antonio","Beira, Sofala, Mozambique","Africa, Mozambic",Eduardo Mondlane University,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"Project Idea and Objectives
EHOPA is a community-centred digital platform designed to strengthen small-scale fisheries in Mozambique by reconnecting fishers directly to markets, improving transparency, and enhancing sustainable resource management.
The project combines simple, low-bandwidth digital tools with strong on-the-ground support (“Tech + Touch”) to ensure inclusion in contexts where smartphone access and internet connectivity are limited. EHOPA will enable fishers to record catches, access fair market prices, reduce post-harvest losses, and connect directly with local buyers and consumers.
Objectives:
Increase income and price transparency for small-scale fishers.
Reduce post-harvest losses through better coordination and basic processing linkages.
Improve data collection on catch and effort to support sustainable fisheries management.
Strengthen co-management by sharing aggregated data with local authorities and associations.
Build digital literacy and community ownership of the platform.
The initiative will begin with a small pilot in one coastal community, focusing on practical validation, user trust, and gradual scaling based on measurable economic and social impact.",true,linkedin and I've applied before.,,,
,Alexandre Khaida,+33666390895,Envoyer le message,bulkoc.eu@gmail.com,BULKOC,Lamya Mahjoub & Abdelatif Majoub,France,"Europe, France",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2015-09-02,Reduction of pollution (plastics chemicals noise light...),"BULKOC is an innovative antifouling system that protects boat hulls through the controlled diffusion of micro air bubbles across the entire submerged surface. Diffuser modules, positioned beneath the hull, generate a continuous and uniform flow of bubbles. As they rise along the hull, these bubbles create a dynamic barrier between the water and the vessels surface, preventing the adhesion of micro-organisms responsible for fouling.
BULKOC uses controlled micro-bubble diffusion to create a dynamic barrier between the hull and marine micro-organisms, preventing their adhesion without the use of chemical substances.
The protection is uniform, continuous, and highly effective when the vessel is stationary. The system operates preventively, without direct contact with the hull and without altering its structure.",true,"I discovered the MOPC while exploring key innovation platforms within the marine industry. Given BULKOCs focus on sustainable antifouling technologies, the MOPC immediately stood out as a natural and inspiring fit.",Received,https://drive.google.com/drive/folders/1P4EudDWb0le8eugywuu5ZYeB4nhWkkJy?usp=drive_link,
,Jann Brunken,+4915151530049,Envoyer le message,brunken.jann@gmail.com,Marine Growth Solutions,"Joelle Geller, Jonah Dahncke, Gesine Hanebuth, Larissa Willhoeft","Hambourg, Allemagne","Europe, Germany",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-27,Reduction of pollution (plastics chemicals noise light...),Create a consulting network to make solutions against marine growth more accesible e.G. for ship owners and ship companies to reduce emissions,true,Our professor at university presented about it :),Received,https://drive.google.com/drive/folders/1bDHM3Ofug6hNPaASz7RAJ4yLolAADjma?usp=drive_link,
,Solomon Ekundayo,+2347037184018,Envoyer le message,ekundayosolomon10@gmail.com,Blue Mapping,"Rodney Ighalo, Bello Haleemah, Adebimpe Hamzat",Nigéria,"Africa, Nigeria","Project COR / Ogun state, Nigeria",the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,Technology & innovations,"Blue Mapping is an inland-to-ocean pollution prevention project tackling plastic waste before it reaches the Atlantic Ocean. In Nigeria, rivers like the Ogun flow directly into the Lagos Lagoon, acting as hidden highways that carry mismanaged plastic waste from inland communities into marine ecosystems. This land-based pollution is silently damaging fisheries, mangroves, livelihoods, and the blue-economy potential of coastal communities.
Blue Mapping addresses this gap by using high-resolution drones and AI to map, detect, and quantify plastic waste along riverbanks and lagoons in Ogun and Oyo States. The project creates Nigerias first open-access Plastic Waste Distribution Database, giving governments, conservation actors, and donors the real-time evidence needed to design targeted, upstream interventions.
Beyond technology, Blue Mapping trains local youth and students as drone operators, embedding skills, ownership, and awareness within affected communities. By stopping plastic at the source before it reaches the ocean, the project protects marine ecosystems, supports coastal livelihoods, and connects inland action to ocean protection in a practical, scalable way.",true,Through a friend,Received,https://drive.google.com/drive/folders/1KGSMBzTOe296tvB2mA_EiPom7UB-W2zy?usp=drive_link,
,Mohamed Elamir,+358449737733,Envoyer le message,mohamed.elamir@sealeva.com,Sealevä,"Xuefei Shi , Ani Järvimäki, Mohamed Elamir, Coleman Piburn, Botond Kiss, Sini Kanerva","Helsinki, Finlande du Sud, Finlande","Europe, Finland",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2025-09-01,Other,"Sealevä valorizes industrial seaweed waste into high-performance materials. From injection-moldable pellets to 3D printing filaments, our materials contain 30-100% seaweed content and offer unique materiality and natural aesthetics. All products are supported by verified LCA data.
* 85% lower CO₂ vs conventional bioplastics
* 30-100% bio-content options
* No fresh water or land mass required for production",true,Found through Blue biomatch community,Received,https://drive.google.com/drive/folders/1oxf8C8elWn8Yfnc0Nodp_IYpIJ4tHgA4?usp=drive_link,
,Anthony Akpan,+2347068329371,Envoyer le message,ajakpan@yahoo.com,Nigeria Youth Blue Economy Value-Chain Local Capacities Development Project (NYBE-VCLCD),"Anthony Akpan, Victoria Aghaji, Solomon Ekundayo","Lagos, Nigéria","Africa, Nigeria",,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2007-12-04,Capacity building for coastal communities,"The goal of this project is to empower 10,000 Nigerian youths, aged 18 to 35, by providing specialized training in impactful value chains within the blue economy. It aims to shift traditional maritime practices towards a sustainable ""Blue Growth"" model, utilizing Nigerias 850km coastline to tackle unemployment and safeguard marine biodiversity.
The project zeroes in on four essential sectors:
- Sustainable Aquaculture: Expanding eco-friendly fish farming methods that curb nutrient pollution and protect wild fish populations.
- Marine Biotechnology & Waste-to-Value: Equipping youth with the skills to transform marine waste (both plastic and organic) into bio-fertilizers or sustainable packaging solutions.
- Eco-Tourism & Coastal Management: Professionalizing youth-led eco-tours and marine protection services, including roles akin to a ""Coast Guard"" to fight against piracy and pollution.
- Renewable Marine Energy: Building technical expertise for the maintenance of offshore wind and tidal energy infrastructures.
Innovative Business Model: The project introduces a ""Digital Blue Hub"" that connects trained youth with investors from the Blue Economy & Finance Forum (BEFF), ensuring their business ideas are market-ready.
Environmental Impact: A crucial 20% of the training is dedicated to ocean literacy and conservation, with the aim of achieving a measurable decrease in coastal plastic waste.",true,"Through Professor Ronán Long, Director, WMU-Sasakawa Global Ocean Institute, Nippon Foundation Professorial Chair of Ocean Governance & the Law of the Sea, World Maritime University (WMU) of the International Maritime Organization (IMO), a Specialized Agency of the United Nations.",Received,https://drive.google.com/drive/folders/1pdYCcCq7-SqLIiBomyF0kyQKAZzRxOhq?usp=drive_link,
,Logan Kiser,+16462518100,Envoyer le message,solutions@kisertech.io,Kiser Technologies,Logan Kiser,"WY, États-Unis",US,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,2026-01-30,Technology & innovations,Using private AI for any business need.,true,Gemini,,,
1 Jury 1 attribués Full name Téléphone Lien Whatsapp E-mail Project's name Team members Country Tri par zone University Category Date of creation Issue Comment Mentorship How did you hear about MOPC? Application status PHASE 1 - Submission PHASE 2 - Submission
2 Mutave Nelly +254704458380 Envoyer le message mutavenelly.mn@gmail.com Revamp Flips Nthatisi Lesala Nairobi, Kenya Africa, Kenya the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 1997-01-24 Technology & innovations Hrkb true Friend shared link
3 Omoding Olinga Simon +256773351242 Envoyer le message simonomoding.ace@gmail.com Dagim Fisheries (U) Ltd Omoding Simon, Ilukat Musa, Omiel Peter, Omongole Richard, Fellista Nakatabirwa Kampala, Ouganda Africa, Ouganda the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2024-01-05 Sustainable fishing and aquaculture & blue food Dagim Fisheries directly advances equitable access to safe, nutritious, affordable food while improving planetary health through zero-waste processing and sustainable fishing. Our multidisciplinary approach integrates nutrition science, food engineering, supply chain management, environmental conservation, and economics. We address malnutrition, reduce waste, empower fishing communities, and protect Lake Victoria's and Kyoga's ecosystem creating regenerative food systems scalable across East Africa toward the billion-lives impact goal. true Through Linkedinn social media Received https://drive.google.com/drive/folders/1uwb8O2na8OvbEp-3-QiUSQoPCVUbCYgE?usp=drive_link
4 SENI Abd-Ramane +2290161149564 Envoyer le message seniramane@gmail.com OceanClean Tech SENI Abd-Ramane, DJIBRIL Samir, SOULÉ SEIDOU Mansoura Banikoara, Bénin Africa, Bénin the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2025-12-29 Reduction of pollution (plastics chemicals noise light...) _Project Title_: OceanClean Tech _Objective_: The OceanClean Tech project aims to reduce plastic pollution in the oceans by developing a marine plastic waste collection system. The main goal is to clean up polluted marine areas and prevent new plastic waste from entering marine ecosystems. _Innovation_: The project's innovation lies in the use of autonomous drones equipped with artificial intelligence (AI) technologies to locate and collect plastic waste at sea. The drones are capable of navigating autonomously, identifying plastic waste using sensors and image recognition algorithms, and collecting it for transport to a treatment point. _Impact_: The OceanClean Tech project has several expected impacts: 1. _Environmental_: Significant reduction of plastic waste in the oceans, protecting marine biodiversity and ecosystems. 2. _Social_: Raising public awareness of marine pollution and involving local communities in clean-up actions. 3. _Economic_: Creating new economic opportunities related to sustainable marine waste management and the development of clean technologies. true I heard about the Monaco Ocean Protection Challenge on LinkedIn, it immediately caught my attention! Received https://drive.google.com/drive/folders/1Z5BgrICLZUdybRMBzxr0XXKzIJpmZOX5?usp=drive_link
5 Karl Mihhels +358447627444 Envoyer le message karl.mihhels@aalto.fi Shaving the Seas Karl Mihhels Espoo, Finlande du Sud, Finlande Europe, Finland Aalto University School of Chemical Engineering, Finland the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Blue Carbon The project is about converting fast-growing species of algae, with a high cellulose content (Cladophorales) into a direct replacement for wood based cellulose and cellulose products, such as paper. true 2nd EU Algae Awareness Summit held in Berlin on October 17th 2025 Received https://drive.google.com/drive/folders/1VAXgnkmTIOUjN3rW2d-BfUA9svvacV2G?usp=drive_link
6 Kabir Olaosebikan +2348142123656 Envoyer le message kabir@craftplanet.org Craft Planet - Blue Guard Kabir Olaosebikan, Aminat Abdulazeez, Promise Dalero, Hanatu Abdulakeem Kaduna, Nigéria Africa, Nigeria the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2023-04-17 Reduction of pollution (plastics chemicals noise light...) Craft Planet – Blue Guard for the Ocean is an integrated ocean-protection initiative that prevents plastic pollution before it reaches the sea. Using AI-enabled drones, we identify high-risk waste leakage points along riverbanks and coastal areas, enabling rapid collection of plastic waste before it enters rivers and oceans. Recovered plastics are recycled into durable construction materials—interlocking blocks, eco-bricks, floor and roof tiles—which are used to improve public school infrastructure, including classrooms, toilets, desks, and chairs. The project also builds capacity among coastal communities, teachers, and students through environmental education, waste management training, and circular economy skills, creating local ownership, green jobs, and long-term ocean stewardship. true Through online sustainability platforms and ocean innovation networks. Received https://drive.google.com/drive/folders/1jUFqGLk1zZ6afP4BysRPpp_jRXsw_9Kz?usp=drive_link
84 Sabira Ayesha Bokhari +33753635938 Envoyer le message sabirabokhari@gmail.com Eco-Pirates Aimen Akhtar Bengaluru, Karnataka, Inde Asia Universidad Catholica de Valencia the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Other A gamified app to encourage sustainable coastal tourism true Ocean Oppurtunities Received https://drive.google.com/drive/folders/1QO3N5Dd2PC5dTohn1BIjnA9MtRWEuLD7?usp=drive_link
85 Vera Emma Porcher +61466053917 Envoyer le message veraporcher20@gmail.com In-Depth Innovations Vera Porcher, Kane Dysart and Tynan Bartolo Darwin, NT 0800, Australie Oceania the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2023-11-29 Restoration of marine habitats & ecosystems Idea:  Eco-engineered reef systems, built on circular-economy principles, repurposing surplus marine-grade concrete and recycled oyster shells into high-performance aquatic habitats that enhance and restore biodiversity and ecosystem services, support food security, and protect coastal communities and infrastructure at scale.  Objectives: -Support long-term food security by restoring, conserving and enhancing productive marine habitats.  -Strengthen coastal protection by designing and deploying high-performance eco-structures that act as natural breakwaters, reducing wave energy and coastal erosion.  -Continuous tracking of ecosystem health in real time through automated ecological monitoring using AI-driven analysis to maximise reef performance. - Scalable nature-inclusive designs and eco-structure integration for offshore oil & gas and offshore wind infrastructure to enhance ecological performance and biodiversity protection. Other relevant details: We are currently testing prototypes in Australia and developing an autonomous monitoring system, with early results showing very positive outcomes and remarkable improvements in biodiversity. true Linkedin Received https://drive.google.com/drive/folders/1lktP9O-QU69NHylfTXEhJzbrRxHZ04JE?usp=drive_link
86 Lee patrick EKOUAGUET +33778199372 Envoyer le message ogoouecorpstechnologies@gmail.com OGOOUE CORPS TECHNOLOGIES ANDRE BIAYOUMOU, NGABOU PASCAL XAVIER, LYNDA NGARBAHAM, DUPUIS NOUILE NICOLAS Bordeaux, Nouvelle-Aquitaine, France Europe, France the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2023-10-23 Technology & innovations OCEAN-PATCH is an intelligent, autonomous maritime safety patch designed to protect human lives at sea. It detects critical situations (man overboard, distress, abnormal conditions) in real time and transmits alerts and data without batteries, using body or environmental energy. The project aims to improve maritime safety while generating valuable ocean data to support prevention, monitoring, and smarter decision-making through AI. true Through online research and innovation platforms focused on ocean protection and blue tech. Received https://drive.google.com/drive/folders/1BEc9s5h5H41vf2bRxpqvz4AHBWMZS1Xm?usp=drive_link
87 Tshephiso Kola +27671509841 Envoyer le message kolatshepisho@gmail.com Luminet Tshephiso Kola Johannesburg, Gauteng, Afrique du Sud Africa University of the Witwatersrand, Johannesburg the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Sustainable fishing and aquaculture & blue food LumiNet is our solution to the fishing industry’s two biggest headaches: catching the wrong fish and losing expensive gear that pollutes the ocean forever. We are replacing standard nylon nets with a smart, dual-action material that actually works with nature. First, our nets glow with a specific light underwater that sharks and turtles instinctively avoid, which keeps them out of the net while the target fish swim right in. Second, we’ve solved the ghost gear problem with a built-in fail-safe: as long as the net is used in the sun, it stays strong, but if it gets lost and sinks into the dark ocean, it rapidly breaks down and turns into fish food. Our goal is simple: to stop plastic pollution at the source and make fishing more efficient, saving marine life and money at the same time. true Social Media Received https://drive.google.com/drive/folders/1TD0eL9lEAMapQk7z65du-7l8VVC46aRm?usp=drive_link
88 Eric & Aurélie Viard +33695360436 Envoyer le message eric@biovie.fr Algues au quotidien Eric Viard, Aurélie Viard Nîmes, Occitanie, France Europe, France the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2007-03-11 Consumer awareness and education Use of organic edible seaweeds in daily food and gastronomy true We have been invited directly by Marine Jacq-Pietri to submit our project Received https://drive.google.com/drive/folders/1KUwDOwvZxoiXJB0cHtwVeIPjdYAYbOV0?usp=drive_link
89 BARHOUMI Nawress +21621898617 Envoyer le message nawressbarhoumigf@gmail.com El Makina Mustapha Zoghlami, Nawress Barhoumi Tunisie Africa, Tunisia the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2024-05-05 Technology & innovations The project aims to develop an autonomous intelligent robot for cleaning marine environments, specifically targeting oil spills, human hair, and other pollutants. It focuses on sustainable technology, environmental protection, and smart control systems. The robot is built using recovered and recycled plastic materials, reinforcing the project’s commitment to circular economy principles and eco-friendly engineering. false Newsletters
90 Yao Yinan +8615221826163 Envoyer le message yyn982715367@outlook.com BluePulse – Design, Protect, Inspire Yinan Yao Xinjiang Asia Communication University of China, Nanjing(Location: Nanjing, China) the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Consumer awareness and education BluePulse is an innovative project that combines art, design, and technology to raise awareness about ocean pollution and marine conservation. Its main objective is to educate and inspire the public through creative visual campaigns, interactive installations, and sustainable product concepts that highlight the importance of protecting our oceans. The project also explores solutions to reduce plastic and chemical pollution, fostering a culture of environmental responsibility. true I found out about the Monaco Ocean Protection Challenge through the organisers listed on the UArctic Congress 2026 website: https://www.uarcticcongress.fo/about
91 Antalya Fadiyatullathifah +6281110115560 Envoyer le message Antallathifah@gmail.com Environmental Consultant xxx Jakarta, JABODETABEK, Indonesie Asia the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2024-11-11 Blue Carbon xxx true xxxx
92 Moramade Blanc +50940809002 Envoyer le message blamo82@yahoo.fr « Suivi Intelligent des Récifs Coralliens et de la Pêche à Belle-Anse » « SIRECOP » Moramade Blanc, Wedeline Pierre, Chralens Calixte, Jacky Duvil,Ruth Catia Bernadin Belle Anse, Haïti Haïti Sorbonne Universite, France the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Technology & innovations Le projet SIRECOP – Suivi Intelligent des Récifs Coralliens et de la Pêche à Belle-Anse vise à renforcer la résilience des récifs coralliens du Parc Naturel National Lagon des Huîtres (PNN-LdH) et à promouvoir une pêche durable dans le Sud-Est d’Haïti. Face aux pressions climatiques et anthropiques, il combine des technologies innovantes (capteurs environnementaux, drones, caméras sous-marines et intelligence artificielle) et une approche participative impliquant les communautés de pêcheurs. Le projet permettra de suivre la santé des récifs, de restaurer les zones dégradées et d’améliorer la gestion des ressources halieutiques, contribuant ainsi à la conservation des écosystèmes marins, à la sécurité alimentaire et au développement durable des communautés côtières de Belle-Anse. true Through my university and professional networks and partnerships Received https://drive.google.com/drive/folders/1Tg4_Z3MOzYbpuBOAJ1EO3bKpn6Aw7kTW?usp=drive_link
93 Samuel Nnaji +2348161502448 Envoyer le message realstard247@gmail.com Zero Ocean Benjamin Odusanya Enugu, Nigéria Africa, Nigeria University of Nigeria the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Sustainable shipping & yachting Project: Zero Ocean Idea: Digital platform for transparent, efficient, and sustainable clean fuel supply chain in maritime Objectives: - Optimize clean fuel procurement and reduce emissions - Ensure compliance with global regulations - Enhance bunkering efficiency and audit trails Key Features: eBDN, AI-driven analytics, real-time tracking, supplier integration true WhatsApp Doublon
94 Hannah Gillespie +447887479247 Envoyer le message hggillespie12@gmail.com SeaBrew Coffee Anne Moullier, Joseph Flynn, Hannah Gillespie, Laura Coombs, Ronan Cooney Cambridge, Angleterre, Royaume-Uni UK University of Cambridge, Cambridge, UK the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Sustainable fishing and aquaculture & blue food SeaBrew is an early-stage food and drink start-up developing a seaweed-reinforced coffee designed to improve micronutrient intake through an existing daily habit. Our product combines sustainably sourced seaweed with coffee to deliver nutrients such as magnesium, while maintaining taste and consumer acceptability. We have already conducted a blind taste test with positive consumer feedback and recently pitched SeaBrew to EIT Food, where we were awarded second place, which has encouraged us to progress towards more rigorous technical validation and compliance ahead of scaling. true The Ocean Opportunity Lab (TOOL)
95 Rhea Thoppil +33745764372 Envoyer le message rmthoppil@gmail.com phytoflight Rhea Thoppil Kerala, Inde Asia Sorbonne University, France the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Reduction of pollution (plastics chemicals noise light...) PhytOFlight Plant-based mitigation of plastic pollution in Kerala’s backwaters PhytOFlight is a nature-based initiative that uses phytoremediation and native aquatic vegetation to mitigate plastic and microplastic pollution in Kerala’s backwaters. Inspired by the “fight or flight” response, the project uses plants as active ecological defenders that intercept, trap, and reduce plastic waste while restoring ecosystem health. Kerala’s backwaters are ecologically and economically vital, yet increasingly threatened by plastic pollution from domestic waste, and tourism. Conventional cleanup methods are costly and short-lived. PhytOFlight offers a low-cost, sustainable, and scalable alternative that works with natural processes rather than relying solely on mechanical removal. Objectives Reduce macroplastic and microplastic pollution in targeted backwater zones, improve water quality and support aquatic biodiversity and engage local communities in monitoring, maintenance and environmental awareness of such areas PhytOFlight integrates ecological restoration with pollution control, offering a cost-effective, climate-resilient solution tailored to Kerala’s backwaters in India. true Through my university
119 Nadine Hakim +573205421979 Envoyer le message nadinehakimm@gmail.com SEAMOSS COLOMBIA Sandra Bessudo and Irene Arroyave Bogota, Colombie South America the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2025-10-01 Sustainable fishing and aquaculture & blue food SEAMOSS is a sustainable biodesign and coastal livelihood project focused on the cultivation and transformation of sea moss (marine macroalgae) as a nature-based solution to environmental and social challenges in coastal communities. The project combines regenerative aquaculture, biomaterial development, and community-led value chains to reduce pressure on marine ecosystems while creating local economic opportunities. The core idea is to cultivate native sea moss species using low-impact, regenerative methods and transform the biomass into biodegradable materials and functional products that can replace plastic-based alternatives, particularly in packaging, design, and everyday consumer goods. true Received https://drive.google.com/drive/folders/1hLqybevHIM2yCrOslFLhh1QSv0--zBKz?usp=drive_link
120 Maria Ester Faiella +393311538952 Envoyer le message maria.ester.faiella@gmail.com ThermoShield Maria Ester Faiella Rome, Latium, Italie Europe, Italia The American University of Rome the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Restoration of marine habitats & ecosystems ThermoShield is a modular underwater panel system that passively reduces local heat from coastal infrastructure. Its objective is to prevent thermal stress on sensitive marine ecosystems, protecting coral reefs and seagrass worldwide. The panels are easy to install, require no electricity and provide measurable local temperature reductions of 0.3–0.5°C, making the solution scalable and globally applicable. true LinkedIn Received https://drive.google.com/drive/folders/11mSSY2USGnxyypDwJa5DYiVLUlo6iyvF?usp=drive_link
121 Kumari Anushka +919798061093 Envoyer le message nasabutbetter@gmail.com accore Kumari Anushka Jamshedpur, Jharkhand, Inde Asia Ashoka University the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Restoration of marine habitats & ecosystems Coral reefs are collapsing - rising ocean temperatures have triggered mass bleaching, 84.6% of corals in Lakshadweep bleached recently. India has 1,439 km² of mapped coral reefs, coasts have 80 ± 33 microplastic particles per cubic meter, and ~30% of sampled market fish have microplastics. Odisha’s Bay of Bengal estuaries have elevated metal concentrations. Each Reef Revival Pod is a solar-powered floating buoy deployed near degraded reefs with: 1. Underwater acoustics: healthy reefs produce sounds that can be played near dying reefs to attract marine life back to them. In trials, degraded patches with reef sounds saw fish population double. 2. Each pod pumps the surrounding seawater through fine filters to capture microplastic debris. 3. Water is also pumped through replaceable resin-based adsorption cartridges to bind with dissolved heavy metals in the water. 4. Onboard sensors log water quality (temperature, pH, turbidity, etc.) - collecting data for adaptive management. After success in India’s waters, the project will be expanded to coral regions globally. In India, the CRZ notification 2019 classifies coral reefs as ecologically sensitive (CRZ-I A) and regulates activities in coastal waters (CRZ-IV), so my revival pods should be permitted as non-invasive research/restoration infrastructure (no reef anchoring and removable). The MoEFCC National Coastal Mission Scheme funds coral/mangrove conservation action plans, marine & coastal R&D - this would help with scaling the number of buoys deployed. Also, the World Bank-supported Integrated Coastal Zone Management (ICZM) gives importance to science-based coastal planning; pods’ sensor data could be used for threat mapping and adaptive management in the deployed zones. For global scaling: Australia’s Reef 2050 Plan, Indonesia’s COREMAP, and the US NOAA Coral Reef Conservation Program exist, so the project could plug into existing national funding priorities across eligible countries. true My university's professor Received https://drive.google.com/drive/folders/1_T-uCTNFwzkeyKx70TZhM8OUEe7KZVYB?usp=drive_link
122 Daniela Nairita +254717162468 Envoyer le message nairita@yarsi.net Yarsi Aquacycle Christabell Adhambo, Snyder Phoebe, Ken Lenguro, Walter Mwaluma, Zuhura Ahmed Marsabit, Kenya Africa, Kenya the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2023-11-22 Reduction of pollution (plastics chemicals noise light...) YARSI Aquacycle is a circular blue economy enterprise focused on transforming aquatic waste into high-value, sustainable products. The core idea is to close resource loops in the fisheries and aquaculture sectors by recovering fish by-products and underutilized biomass and converting them into commercially viable outputs, reducing environmental pollution while creating new income streams. Objectives: 1.Valorize aquatic waste into high-value sustainable products (fish oil, fish skin, bio-compost). 2.Reduce environmental pollution and post-harvest losses in fisheries and aquaculture. 3.Advance circular blue economy solutions that create livelihoods and scalable green enterprises. true Social media Received https://drive.google.com/drive/folders/1UHBRDWWBZSsHqcL_itb8MIm7h73qmHVU?usp=drive_link
123 B +13065674532 Envoyer le message brulebennett@gmail.com f d the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 5555-05-05 Mitigation of climate change and sea-level rise Ignore
124 Yago Sierras +34655815216 Envoyer le message yagosierras@mediterraneanalgae.com Mediterranean Algae Technologies Yago Sierras, Silvia Antón, Guillermo del Barco, Alejandro Simón, Claudia Sanchez, Tamara Terceras, .... +18 people Alicante, Valence, Espagne Europe, Spain the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2021-03-26 Mitigation of ocean acidification Mediterranean Algae builds nature-based climate infrastructure for ports and coastal industries, cleaning water in real time, reducing emissions and turning pollution into revenue. true Linkedin Received https://drive.google.com/drive/folders/1J2_F3IIPouPjJWLPwGi0GUkCOXu441ca?usp=drive_link
125 h +12014445678 Envoyer le message h@web.de s s h the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Blue Carbon d false g Ignore
135 Gabriela Casuso Hernández +573152821916 Envoyer le message proyectoacuatica@gmail.com Proyecto Acuática Gabriela Casuso, José Casuso and Aida Hernández Barranquilla, Colombie South America the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2018-10-08 Consumer awareness and education Proyecto Acuática began as a youth-led ocean awareness initiative and progressively evolved into a structured educational system. Initial outreach and educational content allowed the project to test messages, engage young audiences and identify learning gaps, which later informed the development of in-person school programs. Over time, these programs were consolidated into Charlas El Océano as the project’s core educational component. The system was further strengthened through online and hybrid events that enable direct interaction between students and marine scientists, transforming education into dialogue and active participation in conservation thinking. Implemented continuously for seven years, Proyecto Acuática has reached over 1,900 students through in-person school-based sessions and international youth activities. A distinctive feature of the project is the direct interaction between children, youth and marine scientists, allowing educational spaces to inform conservation thinking and, in some cases, inspire new scientific inquiry. The current phase focuses on consolidating impact measurement and scaling the model through institutional partnerships, ensuring that ocean education translates into measurable and lasting conservation outcomes. true A friend and social media Received https://drive.google.com/drive/folders/1WT7pf7I6W0JR0YWHbzQTIzRSVL6btS7G?usp=drive_link
136 Nina Lantinga +14388630647 Envoyer le message nina@netsfornetzero.com Nets for Net Zero Purnank Shah, Shadi Khamani Montréal, QC, Canada Canada the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2021-03-21 Reduction of pollution (plastics chemicals noise light...) By 2030, Nets for Net Zero will be a global leader in circular marine plastic systems, leveraging partnerships with coastal communities, fisheries, and manufacturers to advance processing technologies that create resilient, circular, net-positive economies worldwide. true Student On Ice Foundation Received https://drive.google.com/drive/folders/1cMVVwL369wMyEcEYcVlfzoxCLPXilad8?usp=drive_link
137 Davide Balbi +393318344974 Envoyer le message db@mondomigliore.eu C.A.S.A. Marine Loop Santino Filippeddu, Davide Balbi, Gianluca Del Vecchio Olbia, Sardaigne, Italie Europe, Italia the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2024-09-19 Sustainable fishing and aquaculture & blue food C.A.S.A. Marine Loop is a modular retrofit solution for offshore fish cages, designed to operate around, inside and below cages. It aspirates and treats water to intercept solid waste and organic loads that negatively affect fish health and surrounding marine areas. The core technology is a stainless-steel biofiltration system with organic BSA media, already validated in land-based RAS, which does not generate microplastics, unlike many plastic-based alternatives. The system is designed to be powered by offshore renewable energy and to enable circular economy pathways for recovered materials. The objective is to transform offshore aquaculture into a cleaner, lower-impact and more resilient activity, contributing to ocean protection while improving farm performance. true I heard about the MOPC through the SUBMARINER Network’s communications and website, where the call was featured. Received https://drive.google.com/drive/folders/1jSB8rvtoNf5w8cRMbd4vV_7u2N-RKr6Z?usp=drive_link
138 Emily Hannah Purnell +4917644482080 Envoyer le message emily.purnell@web.de Seads Simona Töpfer, Matz Hüffner, Jonas Pfitzner, Leonie Schwarte, Malena Meyer Münster, Rhénanie-du-Nord-Westphalie, Allemagne Europe, Germany University of Münster in Münster the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Restoration of marine habitats & ecosystems We restore seagrass meadows at scale. We invented an autonomous, AI-powered seagrass seed harvester to overcome the main bottleneck in seagrass restoration: the lack of scalable and affordable seed supply. By enabling efficient seed harvesting, cultivation and replanting, we unlock large-scale seagrass restoration that was previously limited to small pilot projects. As a restoration service provider, we work with governments and companies to restore degraded marine habitats within regulated ecosystem compensation schemes. Our restored seagrass meadows store CO₂, protect coastlines from erosion, and bring biodiveristy back to our oceans. false We met Magnus Frohmann at the Enactus World Cup last year. Received https://drive.google.com/drive/folders/1fSLC7MjhdXkI3lHpl8Tf2WjCJCDKU09N?usp=drive_link
139 Maria Jose Castaño González +573245654738 Envoyer le message mjose.castano2020@gmail.com SEMOCEA - Numerical Modeling Seedbed of the Ocean and the Atmosphere María José Castaño González Turbo, Colombie South America the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2022-10-03 Mitigation of climate change and sea-level rise This project is based on the Ocean and Atmospheric Numerical Modeling Research Group and focuses on the use of numerical modeling to understand coastal and oceanic hydrodynamics influenced by river discharges. The core idea is to analyze the spatio-temporal variability of ocean circulation in coastal and estuarine systems, using the Gulf of Urabá (Colombia) as a case study. The project aims to model and analyze five years of oceanographic conditions to evaluate how river inflows affect currents, circulation patterns, and coastal dynamics. By integrating numerical models, observational data, and hydrodynamic analysis, the project seeks to generate scientific knowledge that supports coastal management, environmental protection, and climate resilience in vulnerable coastal regions. Additionally, the project has a strong educational and capacity-building component, promoting the training of young researchers in numerical modeling of the ocean and atmosphere, and fostering the use of science-based tools for sustainable ocean protection and decision-making. true I heard about the MOPC through a community leaders’ group in my local area.
140 Athavan RASENTHIRAM +33758298244 Envoyer le message athavan.rasenthiram@builders-ingenieurs.fr Investigation on modular artificial reefs to attenuate waves Dominique Mouaze Caen, Normandie, France Europe, France University of Caen Normandy, Caen the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Restoration of marine habitats & ecosystems enhancement, yet the influence of reef geometry on hydrodynamic performance remains insufficiently quantified. This study presents an experimental comparison of the hydrodynamic performance of two modular artificial reef geometries, focusing on their ability to modify wave transmission, reflection, and energy dissipation. The investigation aims to support reef-inspired design strategies that balance coastal engineering efficiency with ecological functionality. true LinkedIn Received https://drive.google.com/drive/folders/1u9uWfPyoLZgqJRUwqoxfNLjgNtqCtCTg?usp=drive_link
141 JHONATAN DANIEL PILLA ORTIZ +593991978337 Envoyer le message agroindustria_pilor@outlook.com DEL CAMPO ECUADOR JHONATAN DANIEL PILLA ORTIZ Galápagos, Équateur South America the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2024-02-08 Reduction of pollution (plastics chemicals noise light...) Mi empresa se dedica a la producción y comercialización de PULPAS DE FRUTAS congeladas, pero en la industria de alimentos todo es con plásticos de un solo uso. Nuestro objetivo es empezar a usar plásticos biodegradables en toda nuestras presentaciones, ser mas amigables con el medio ambiente y reducir la basura que siempre llegan a nuestras costas, mas a nuestra frágil biodiversidad en Galápagos. true Encuentro Empresarial Galápagos, donde nuestras autoridades nos comparten temas y organizan eventos para promocionar los emprendimientos de las Islas Galápagos Received https://drive.google.com/drive/folders/1XFF3LHXaTyjrwJYL9rNqcEDtwHxHLXjT?usp=drive_link
160 Laurent BUOB +33675090543 Envoyer le message a.calvet@ocean-owl.com Whisper eF Vincent Lebeault, Samir Ouhnia, Alix Calvet, Nicolas Lebeault Sète, Occitanie, France Europe, France the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2024-09-30 Sustainable shipping & yachting Whisper EF is developing a new generation of high-performance electric hydrofoil boats dedicated to professional maritime uses. Our flagship model, Whisper 360, is the first zero-emission electric foiler to offer performance comparable to thermal boats, with a top speed of 45 knots and an autonomy of up to 100 nautical miles. The project’s objective is to enable the decarbonisation of fast maritime transport and service vessels (passenger shuttles, pilot boats, surveillance and intervention units) without compromising speed, range, safety or operational efficiency. This is achieved through a patented intelligent foil system combining assisted take-off foils and actively controlled foils, coupled with an optimized electric propulsion and flight control software. Beyond the technological development, Whisper EF aims to contribute to the emergence of sustainable, high-performance maritime mobility solutions in Europe and internationally. false We learned about the MOPC through our previous participation, having applied to the programme last year. Received https://drive.google.com/drive/folders/1L1Gaac4QLxxXYfcUD3wsqM_mTo5vrpss?usp=drive_link
161 Amy Dzikowski +33652328060 Envoyer le message amy.dzikowski@monaco.edu PEARL (Protective Ecosystem AUV for Reef Longevity) Amy Dzikowski, Nathalie Falck, Anne Marie Medema, Rebecca Sidalova, Melissa Chera Pereybere, Grand Baie, Maurice Africa, Mauritius International University of Monaco, Monaco the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Restoration of marine habitats & ecosystems Our project is the development of the Protective Ecosystem AUV for Reef Longevity (PEARL), an autonomous underwater vehicle (AUV) designed to locate and capture invasive lionfish. PEARL uses a precision system to identify the invasive lionfish, enabling high-yield targeted removal without damaging reefs or harming native species. Unlike human spearfishers, PEARL is not constrained by dive time or depth limitations and can operate continuously across the full depth range of known lionfish habitats. AI-driven detection systems enhance identification accuracy and ensure an environmentally responsible operation. The primary objective of PEARL is to halt the spread of invasive lionfish in the Mediterranean Sea and beyond, protecting reef ecosystems and ocean health. To accomplish this, our key objectives are to reduce invasive lionfish populations, prevent further geographic expansion, and enable sustainable revenue through the use of lionfish byproducts. Incorporating economic viability alongside ecological impact is essential to ensuring long-term adoption and effective large-scale intervention. true Prof. Elena Tavella at the International University of Monaco Received https://drive.google.com/drive/folders/1DD6FeWZxM0BsY4KDQavdb5yHJCbuE7_2?usp=drive_link
162 Kelvish Duval +33768126798 Envoyer le message kelvish.duval@efrei.net Efrei grp 4 Yassine Tenzekhti, Theotim Djelassi, El-Assad Said, Lucas Rinaudo Villejuif, Île-de-France, France Europe, France Efrei, Villejuif the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Restoration of marine habitats & ecosystems Creation of artificial habitats with adapted materials false No
163 Thamires Pontes +5511992001931 Envoyer le message thamires@phycolabs.com Phycolabs Thamires Pontes, Wolgrand Neto, Diego Nunes, Gustavo Gonçalves Sao Paulo, SP, Brésil South America the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2022-06-14 Reduction of pollution (plastics chemicals noise light...) Phycolabs is a Brazilian biomaterials startup decarbonizing fashion with textile fibers made entirely from cultivated seaweed. Our SeaweedFibers replace traditional and man-made fibers that drive CO₂ emissions and marine microplastic pollution at the source, using a fast-growing ocean feedstock that requires no land, pesticides, or freshwater. Our patent-pending process produces continuous seaweed filaments compatible with existing spinning and weaving machinery, enabling global brands to adopt ocean-positive materials at scale while building regenerative seaweed farming value chains, contributing to marine ecosystem restoration, and strengthening coastal livelihoods. true We were semifinalist last year and I would like to try again in 2026 :)) Received https://drive.google.com/drive/folders/1fqJe8wPVdDc0_yICZP1jGV8KB5HvWJSe?usp=drive_link
164 Davide Balbi +393318344974 Envoyer le message db@mondomigliore.eu C.A.S.A. Marine Loop Davide Balbi, Santino Filippeddu, Gianluca Del Vecchio Arzachena, Sardaigne, Italie Europe, Italia the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2024-09-19 Sustainable fishing and aquaculture & blue food C.A.S.A. Marine Loop is a modular retrofit solution for offshore fish cages, designed to operate around, inside and below cages. It aspirates and treats water to intercept solid waste and organic loads that affect fish welfare and surrounding marine areas. The core technology is a stainless-steel biofiltration system with organic BSA media, already validated in land-based RAS, designed not to generate microplastics compared to plastic-based alternatives. The system is conceived to be powered by offshore renewable energy and to enable circular economy pathways for recovered materials. Our objective is to make offshore aquaculture cleaner, more resilient and ocean-positive while improving farm performance. true Newsletter Doublon
165 Juan Carlos Saldaña Guerrero +51932751524 Envoyer le message juan.carlos.saldana.guerrero@gmail.com Carlos Biologist SG Group Carmen Nallelí Saldaña Guerrero, Elizabeth Bridgit Saldaña Guerrero Ica, Peru South America ESNECA Business School the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Other Laboratorio de Biología Marina true LinkedIn Received https://drive.google.com/drive/folders/11vBtdjYek8qMUF06RySGJkhbY34zYpBg?usp=drive_link
166 Lautaro Girones +541151355710 Envoyer le message lautaro.girones@uns.edu.ar HydroTrace Lautaro Girones; Andres Hugo Arias; Rosario Corradini; Rocio Luciana Bray; Alejandro Jose Vitale Bahía Blanca, Buenos Aires, Argentine South America Universidad Nacional del Sur, Bahía Blanca, Argentina the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Reduction of pollution (plastics chemicals noise light...) HydroTrace is an environmental monitoring technology platform designed to reveal hydrophobic chemical contaminants that are often missed by conventional water monitoring methods. Many industrial pollutants, including hydrocarbons, PAHs and selected unintentional persistent organic pollutants (UPOPs), are not fully captured by dissolved water measurements, leading to underestimation of real environmental exposure and risks to marine ecosystems and ocean health. HydroTrace uses standardized passive polymer-based sensors combined with analytical workflows and exposure interpretation tools to measure time-integrated chemical exposure across aquatic environments, from industrial discharges and rivers to estuaries, ports and offshore marine waters. By detecting hidden chemical exposure pathways along the land-to-ocean continuum, HydroTrace supports early identification of chronic pollution sources that can impact marine ecosystems, coastal biodiversity and ocean food webs. The system is designed to generate actionable environmental intelligence that supports pollution prevention strategies and evidence-based ocean protection decision making. The technology is currently being refined through pilot monitoring applications in industrial coastal environments, demonstrating technical feasibility under real-world conditions. Initial applications focus on hydrophobic hydrocarbon fractions (C10–C40), PAHs and selected hydrophobic industrial byproducts. Future development includes evaluation of additional hydrophobic industrial and combustion-related contaminants depending on environmental behavior and monitoring needs. HydroTrace is designed to complement existing water monitoring technologies by targeting the hydrophobic contaminant fraction that is often under-characterized in routine monitoring programs. The platform combines standardized deployment hardware, recurring replacement kits, laboratory analysis and exposure intelligence reporting, enabling scalable, standardize true Through a colleague working in the ocean and environmental sector Received https://drive.google.com/drive/folders/18VZom2ZxXwIrtuWWPwcj-k_YInxw3HV7?usp=drive_link
167 Sahil +917488447394 Envoyer le message sahilkumar8176xd@gmail.com BlueVault Sahil Kumar , Ankit Kumar Jehanabad, Bihar, Inde Asia Magadh University,Bihar the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Blue Carbon BlueVault is an autonomous "Proof-of-Ecosystem" protocol. We deploy swarms of low-cost micro-AUVs to verify Blue Carbon assets (seagrass/reefs) with millimeter precision. Objective: To unlock the $50B ocean finance market by replacing expensive human divers with scalable, audit-grade data. Tech: Our proprietary C++ Edge-AI algorithms calculate biomass volume in real-time, providing the "trust layer" banks need to invest in ocean restoration without fear of greenwashing. true Received https://drive.google.com/drive/folders/11x14DmyXxxi8EaLG13q31QvHXYRUPAvN?usp=drive_link
168 S KAVIYA +919042979984 Envoyer le message sec23ec232@sairamtap.edu.in ROBOVAC ABIBA FATHIMA A & SIVASHANKER G Inde Asia SRI SAIRAM ENGINEERING COLLEGE , WEST TAMBARAM, CHENNAI,TAMIL NADU, INDIA the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Reduction of pollution (plastics chemicals noise light...) This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial water-body operators, reducing manual effort while improving operational efficiency and environmental impact. true I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects. Doublon
169 S KAVIYA +919042979984 Envoyer le message sec23ec232@sairamtap.edu.in NAUTILUS NEXUS ABIBA FATHIMA A / SIVAHANKER G Inde Asia SRI SAIRAM ENGINEERING COLLEGE, WEST TAMBARAM , CHENNAI , TAMIL NADU , INDIA the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Reduction of pollution (plastics chemicals noise light...) This venture delivers a scalable, semi-autonomous solution for water surface cleanup by combining AI-based visual detection with geospatial analytics. The system identifies and geo-tags floating plastic waste, converting raw detections into actionable pollution hotspot intelligence for targeted, cost-efficient cleanup operations. Designed with a modular hardware architecture and cloud-enabled analytics, the platform enables data-driven, repeatable deployments for municipalities, environmental agencies, and industrial waterbody operators, reducing manual effort while improving operational efficiency and environmental impact. true I learned about the MOPC through institutional communications and peer networks within the student innovation and project development community. I also came across it via online announcements highlighting opportunities for showcasing interdisciplinary projects. Doublon Received https://drive.google.com/drive/folders/1nXrjERUGyNhA1T0A7o_uqmpvd1jree60?usp=drive_link
170 Greta Puschmann +33674308280 Envoyer le message greta.puschmann@monaco.edu BlueSupply ESG Tom Döscher Monaco, Monaco Europe, Monaco International University of Monaco, Monaco the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Other BlueTrace ESG is a digital ESG intelligence platform designed to bring transparency to ocean-based supply chains. The project helps companies and investors identify, measure and manage their marine environmental and social impact across sectors such as fisheries, aquaculture, fashion and maritime logistics. By combining supply-chain data, ocean-specific ESG indicators and regulatory requirements (e.g. CSRD), BlueTrace ESG enables reliable reporting, risk assessment and decision-making. Its objective is to make ocean impact measurable and actionable - and to redirect capital and business practices toward more sustainable, ocean-positive value chains. true From my university
171 TEMANO +33746225522 Envoyer le message clara.honore@temano.fr TEMANO Quentin DEMOULIN Ploemeur, Bretagne, France Europe, France the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2022-01-17 Technology & innovations Development of anchors with minimal environmental impact, intended for recreational boating and renewable energy on floating bases false Pole Mer Bretagne Atlantique Received https://drive.google.com/drive/folders/1hLQWBtegrcsvq_sDjRrn5l0bb4P9_pxt?usp=drive_link
172 Mathias Mondo +237693514085 Envoyer le message mathiasmondo@me.com Blue Sentinel Nguessong Donfack Marie-Ange; Tagne Wambo Elohim Junior; Bidzana Deugoue Erwin ; GUENTANG BADEFONA ODILE; Nkot-a-Nzok Etienne; Mondo Mathias; Adipauldi Sigot Sabine; Ndzana Nga Romaric; Lowe Nyat Fred Cameroun Africa, Cameroun INSTITUT UNIVERSITAIRE D'INNOVATION ET DE MANAGEMENT MARIE-ALBERT (ISIMMA) the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Technology & innovations Ocean pollution starts on land. In Yoyo (Gulf of Guinea), accelerated mangrove degradation directly impacts coastal water quality, marine nurseries and coastal resilience. Despite awareness and initiatives, decisions remain fragmented, reactive and poorly governed — leaving the Ocean unprotected at its source. BLUE SENTINEL protects the Ocean by protecting mangroves as frontline natural infrastructure. The system combines satellite data (Sentinel/Landsat), low-cost IoT sensors, community observations and artificial intelligence into one operational platform. Environmental signals are transformed into clear decisions: protect, restore, invest. Mangroves become living dashboards for coastal governance. Measurable Ocean impact within 12 months (pilot): • −15% coastal water turbidity (baseline M1–M2) • Zero net loss of mangrove cover, restoration initiated • Protection of 1–2 critical marine nursery zones • Average response time to alerts < 14 days. Impact is tracked through the Coastal Health Index (ISC) and aligned with SDG 14 (supported by SDGs 13, 15, 5 and 8). How it works (in brief): Satellites ensure continuous monitoring; IoT sensors measure turbidity, salinity and temperature; communities provide ground observations. AI generates alerts, scores and trends. A purpose-built Human–Machine Interface offers four user modes (Decision-makers, Field teams, Communities, Funders) with maps, indicators and actions. Business model: Designed to live beyond grants through B2G/B2NGO governance services, impact reporting (SDG/ESG/RBM), blue economy risk mapping, and responsible value chains (certification, training, women and youth empowerment). Pilot, scalability & Monaco: A 12-month pilot in Yoyo with a total budget of €120,000, directly linked to measurable Ocean outcomes. Designed for replication: Yoyo → Gulf of Guinea → global coastal systems. Yoyo is the pilot. Monaco is the accelerator. false Via Linkedin and Mrs Manon Aminatou Received https://drive.google.com/drive/folders/1MsQCrGF7S9B03cEvmirWgf0K36PmjeEN?usp=drive_link
176 Sudarsha Chanaka De Silva +94776761788 Envoyer le message sudarsha30384@gmail.com Install High-Tech Water Purification Units in Fisheries Sector To Tackle Plastic Pollution in sea off Sri Lanka Sudarsha De Silva , Jagath Gunasekara, Madhura Delpachithra Kudawella South, Southern, Sri Lanka Africa, Sri Lanka the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2021-06-16 Reduction of pollution (plastics chemicals noise light...) • Cleaner oceans and beaches: Less plastic waste entering our marine ecosystems. • Protected marine biodiversity: Safeguarding our coral reefs and diverse marine life. • Improved health for fishing crews of multi-day fishing boats: Access to clean, purified water on long voyages improves the hygiene and sanitary standards of fishermen • Reduced operational costs of the fisheries sector: Fishermen save on bottled water purchases. • Enhanced industry reputation: Sri Lanka leading the way in environmental stewardship The project responds to the urgency to reduce plastic pollution caused by multiday fishing vessels in Sri Lanka, which is a major contribution to marine debris in the country's ocean waters. these fishing vessles spends 4-5 weeks in the sea and heavily depend on single-use plastic bottles for drinking water. with a limited waste storage and disposal infrastructure on board, much of this plastic is directly to the ocean, creating hotspots in the sea for marine litter which will result on microsplatic contamination on marine life and human body. This catostophe affects the tourism and fishries industry badly both sectors are vital for the economy of Sri Lanka. During their journey fisherman face challenges accessing safe drinking water while at sea affecting their health, hygiene and overall well-being. This highlights the importance clean water access and for reducing health impacts caused by plastic dependency. Therefore the project demonstrate the need for environment protection, ensure occupational health. true Through social media Received https://drive.google.com/drive/folders/1lsSAKHSXpo5En0QJGz2DumJpJFGntqoi?usp=drive_link
177 Trisna Oktavia +6289530030014 Envoyer le message trisna.jobs@gmail.com Seawaste Tech Asadurrofiq, Jean Baptiste Sugihardjanto the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2025-01-01 Reduction of pollution (plastics chemicals noise light...) SeaWaste Tech is an integrated fishery waste and marine waste processing through a zero-waste system approach. In 2025, We successfully processed 50 tons of fishery waste into animal feed and organic fertilizer. We collaborate with seafood processing plants to manage and transform their production waste. However, beyond organic fishery waste, plastic packaging waste has also become a growing environmental challenge for these facilities. Currently, our fishery waste processing operations still rely on fossil fuels for energy. Unfortunately, fossil fuels are increasingly expensive and often subject to supply shortages, which directly impacts production stability and costs. Recognizing this challenge, we see a strong opportunity to replace fossil fuel consumption with fuel derived from plastic waste generated by seafood processing factories. Through our pilot project calculations, this transition has the potential to reduce fossil fuel usage by up to 80% and lower production costs (HPP) by approximately 20%. In addition to utilizing factory plastic waste, we also plan to collect plastic waste from coastal communities. Many of these communities lack proper waste management infrastructure, leading to common practices such as dumping plastic into the ocean or burning it both of which create further environmental pollution and threaten marine ecosystems. Our core focus is to convert plastic waste into diesel fuel as an alternative energy source and make a collection point. This solution will reduce dependence on fossil fuels, Lower production costs, Create a community-based plastic savings system that encourages behavioral change, Deliver significant environmental impact. By transforming plastic waste into energy, we are not only solving industrial and community waste challenges but also contributing to global sustainability goals, specifically SDG 12 (Responsible Consumption and Production), SDG 13 (Climate Action), and SDG 14 (Life Below Water). true I first learned about the Monaco Ocean Protection Challenge through my participation in the 2025 edition, where SeaWaste Tech was shortlisted as a finalist in the Business Concepts category. That experience deepened my commitment to refining our ocean impact model and motivated me to reapply in 2026 with a more mature, measurable solution grounded in our 2025 operational results. Received https://drive.google.com/drive/folders/1ZgyOGhdVYJOFjKlK6EwyHWKbeExBWDlO?usp=drive_link
178 Tabe Brandon Njume +237675068360 Envoyer le message tabebrandon50@gmail.com TBINDS Anthony Duxell Malle, Forbah Sandra, Atianjoh Laris, Warri Bilson Tiko, Cameroun Africa, Cameroun University of Buea, Cameroon + University of Bamenda, Cameroon the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Reduction of pollution (plastics chemicals noise light...) My solution seeks to tackle microplastics pollution in our oceans by addressing point sources such as household and hospital diaper and surgical mask waste. To tackle this, we have engineered a circular, self-powering system that transforms waste from disposable diapers and surgical masks into TBinds, a soft, durable shoe sole. Our process begins with a closed-loop collection system using leak-proof bins in hospitals and households to keep waste contained. To handle the biological load, we use a specialized fractionation method: waste is treated with a 2% Calcium Chloride solution to collapse the Superabsorbent Polymers (SAP), forcing them to release trapped liquids and excreta. This organic slurry is diverted to an anaerobic digester to produce biogas, which powers our machinery. The remaining solids are sent to a wet shredder and centrifugal separator. A float-sink tank then isolates the high-grade Polypropylene (PP) and Polyethylene (PE) from the cellulose fibers. While our priority is stopping waste at the source, TBinds can process diaper waste from drains by adding a sedimentation step to wash off mud and grit. true Received https://drive.google.com/drive/folders/1pYFR66gjdcHQjRlOc24gUpstIhhmFyGO?usp=drive_link
179 Oscar Montoya +573147802359 Envoyer le message archivoadm@gmail.com ISO Brick 2 Medellin, Colombie South America the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2022-01-01 Reduction of pollution (plastics chemicals noise light...) The ISOBrick project aims to recover rigid polyurethane discarded during the manufacture of thermo-acoustic roof tiles and reuse that material as a filler within the cavities of clay bricks, transforming them into bricks with greater thermal and acoustic insulation capacity, reducing industrial waste and promoting more sustainable and energy-efficient construction. true By a research group Received https://drive.google.com/drive/folders/1nMXqz2TT_7QUixsQ1EljE9bE9_ThujcV?usp=drive_link
180 Anastasija Stefanovic +33749014764 Envoyer le message anastasija.stefanovic@univ-paris1.fr BluePrint Reefs Anastasija Stefanovic, Florian Guichard Paris, Île-de-France, France Europe, France Sorbonne Pantheon Paris 1, Paris the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Restoration of marine habitats & ecosystems We offer a high-tech, scalable solution: 3D-bioprinted modular reefs. Innovation: Using a proprietary "Living Ink" (recycled calcium carbonate + bio-triggers), we print structures that mimic natural reef complexity. Result: These reefs don't just sit on the seabed; they actively "invite" coral larvae to settle, accelerating biodiversity recovery by 300% compared to traditional methods. true I am a resident of Fondation de Monaco at Cite Internationale Universitaire de Paris where MOPC was mentioned. Received https://drive.google.com/drive/folders/1xcY1iVbFDOe9KQuLTSJlOpK8WPqFDpUP?usp=drive_link
181 Anvi Gowda +4917623367782 Envoyer le message anvi.gowda@myhsba.de BlueReturn Jana Cisewski, Anvi Gowda, Malena Merkel & Kamar Haidar Hambourg, Allemagne Europe, Germany Hamburg School of Business Administration, Hamburg the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Reduction of pollution (plastics chemicals noise light...) With our project "BlueReturn", we aim to motivate people to collect trash in coastal areas and dispose of it. It is a system that consists of AI supported clean up stations, which enable correct trash seperation and are connected to an app. The app tracks the amount of trash collected by users and converts it into points, which can either be donated to ocean protection initiatives or redeemed for sustanability related rewards. Rewards include e.g. vouchers for sustainable brands or a free public transport ticket. With BlueReturn our goal is to incentivize people to turn small actions into a visible impact on ocean protection. true Our Professor introduced the challenge during a sustanability course at the HSBA. Received https://drive.google.com/drive/folders/1iac3f__cDlv03ssLpfEVRHq5TzdjtYpa?usp=drive_link
182 Nasha Afrina Binte Abdul Mutalib +6588345209 Envoyer le message afrinaabdul77@gmail.com Blue Miles Index 1.Nasha Abdul 2. Cassarah Matakena 3. Moh. Aufa Dany Damario 4. Pathinettan Philemon Singapour, Singapour Asia 1. Singapore institute of technology/User experience and game design 2. Universitas Indonesia/Biology 3.Universitas Indonesia/Naval Architecture-Marine Engineering 4. Nanyang Technological University/Environmental and Earth Systems Science the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Consumer awareness and education Blue Miles Index is a standardized decision-support framework that makes maritime transport distance visible in purchasing and procurement decisions. The project addresses a structural transparency gap in global trade: while over 80% of goods move by sea, shipping distance and its ocean impact remain invisible at the point of purchase. Blue Miles Index converts product origin and logistics data into a normalized, distance-based ocean pressure indicator that integrates into retail platforms and procurement systems. By introducing a comparable transport signal, the framework enables informed sourcing decisions without restricting choice. Its objective is to reduce cumulative vessel kilometres travelled by influencing upstream purchasing behaviour, thereby lowering emissions, underwater noise exposure, and maritime pressure on marine ecosystems. The system scales through voluntary integration and leverages existing supply chain data, requiring no new infrastructure. true linkedin Received https://drive.google.com/drive/folders/1RvCTmMWpuNlhTk-zhQTezCbzeeUiunxd?usp=drive_link
215 Logan Kiser +16462518100 Envoyer le message solutions@kisertech.io Kiser Technologies Logan Kiser WY, États-Unis US the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 2026-01-30 Technology & innovations Using private AI for any business need. true Gemini
216
217
218
219
220
221
341
342
343
344
345
346
347
365
366
367
368
369
370
371
380
381
382
383
384
385
386
397
398
399
400
401
402
403
404
405
441
442
443
444
445
446
447
448
449
450
451
452
455
456
457
458
459
460
461
464
465
466
467
468
469
470
479
480
481
482
483
484
485
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
527
528
529
530
531
532
533
554
555
556
557
558
559
560
561
562
563
573
574
575
576
577
578
579
580
581
582
583
595
596
597
598
599
600
601
612
613
614
615
616
617
618
619
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
659
660
661
662
663
664
665
666
667
668
669
682
683
684
685
686
687
688
689
690
691
692
724
725
726
727
728
729
730
736
737
738
739
740
741
742
749
750
751
752
753
754
755
768
769
770
771
772
773
774
775
776
777
778
779
780
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
858
859
860
861
862
863
864
867
868
869
870
871
872
873
874
878
879
880
881
882
883
884
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023

1091
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
@@ -50,9 +51,11 @@
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0",
"@tremor/react": "^3.18.7",
"@trpc/client": "^11.0.0-rc.678",
"@trpc/react-query": "^11.0.0-rc.678",
"@trpc/server": "^11.0.0-rc.678",
@@ -62,11 +65,13 @@
"cmdk": "^1.0.4",
"csv-parse": "^6.1.0",
"date-fns": "^4.1.0",
"franc": "^6.2.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.1.0",
"jspdf-autotable": "^5.0.7",
"leaflet": "^1.9.4",
"lucide-react": "^0.563.0",
"mammoth": "^1.11.0",
"minio": "^8.0.2",
"motion": "^11.15.0",
"next": "^15.1.0",
@@ -75,6 +80,7 @@
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"pdf-parse": "^2.4.5",
"react": "^19.0.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.0.0",
@@ -82,10 +88,10 @@
"react-hook-form": "^7.54.2",
"react-leaflet": "^5.0.0",
"react-phone-number-input": "^3.4.14",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"superjson": "^2.2.2",
"tailwind-merge": "^3.4.0",
"unpdf": "^1.4.0",
"use-debounce": "^10.0.4",
"zod": "^3.24.1"
},
@@ -96,6 +102,7 @@
"@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15",
"@types/pdf-parse": "^1.1.5",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"eslint": "^9.17.0",

View File

@@ -16,105 +16,143 @@
-- the enum.
ALTER TYPE "SettingCategory" ADD VALUE 'DIGEST';
ALTER TYPE "SettingCategory" ADD VALUE 'ANALYTICS';
ALTER TYPE "SettingCategory" ADD VALUE 'AUDIT_CONFIG';
ALTER TYPE "SettingCategory" ADD VALUE 'INTEGRATIONS';
ALTER TYPE "SettingCategory" ADD VALUE 'LOCALIZATION';
ALTER TYPE "SettingCategory" ADD VALUE 'COMMUNICATION';
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'DIGEST'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'ANALYTICS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'AUDIT_CONFIG'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'INTEGRATIONS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'LOCALIZATION'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'COMMUNICATION'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- DropForeignKey
ALTER TABLE "ApplicationForm" DROP CONSTRAINT "ApplicationForm_programId_fkey";
ALTER TABLE "ApplicationForm" DROP CONSTRAINT IF EXISTS "ApplicationForm_programId_fkey";
-- DropForeignKey
ALTER TABLE "ApplicationForm" DROP CONSTRAINT "ApplicationForm_roundId_fkey";
ALTER TABLE "ApplicationForm" DROP CONSTRAINT IF EXISTS "ApplicationForm_roundId_fkey";
-- DropForeignKey
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT "ApplicationFormField_formId_fkey";
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT IF EXISTS "ApplicationFormField_formId_fkey";
-- DropForeignKey
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT "ApplicationFormField_stepId_fkey";
ALTER TABLE "ApplicationFormField" DROP CONSTRAINT IF EXISTS "ApplicationFormField_stepId_fkey";
-- DropForeignKey
ALTER TABLE "ApplicationFormSubmission" DROP CONSTRAINT "ApplicationFormSubmission_formId_fkey";
ALTER TABLE "ApplicationFormSubmission" DROP CONSTRAINT IF EXISTS "ApplicationFormSubmission_formId_fkey";
-- DropForeignKey
ALTER TABLE "OnboardingStep" DROP CONSTRAINT "OnboardingStep_formId_fkey";
ALTER TABLE "OnboardingStep" DROP CONSTRAINT IF EXISTS "OnboardingStep_formId_fkey";
-- DropForeignKey
ALTER TABLE "SubmissionFile" DROP CONSTRAINT "SubmissionFile_submissionId_fkey";
ALTER TABLE "SubmissionFile" DROP CONSTRAINT IF EXISTS "SubmissionFile_submissionId_fkey";
-- DropIndex
DROP INDEX "User_email_idx";
DROP INDEX IF EXISTS "User_email_idx";
-- AlterTable
ALTER TABLE "AssignmentJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
DO $$ BEGIN ALTER TABLE "AssignmentJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
-- AlterTable
ALTER TABLE "AuditLog" ADD COLUMN "previousDataJson" JSONB,
ADD COLUMN "sessionId" TEXT;
DO $$ BEGIN
ALTER TABLE "AuditLog" ADD COLUMN "previousDataJson" JSONB;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "AuditLog" ADD COLUMN "sessionId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- AlterTable
ALTER TABLE "FilteringJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
DO $$ BEGIN ALTER TABLE "FilteringJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
-- AlterTable
DO $$ BEGIN
ALTER TABLE "LiveVote" ADD COLUMN "isAudienceVote" BOOLEAN NOT NULL DEFAULT false;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- AlterTable
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowAudienceVotes" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "audienceVoteWeight" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "presentationSettingsJson" JSONB,
ADD COLUMN "tieBreakerMethod" TEXT NOT NULL DEFAULT 'admin_decides';
DO $$ BEGIN
ALTER TABLE "LiveVotingSession" ADD COLUMN "allowAudienceVotes" BOOLEAN NOT NULL DEFAULT false;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "LiveVotingSession" ADD COLUMN "audienceVoteWeight" DOUBLE PRECISION NOT NULL DEFAULT 0;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "LiveVotingSession" ADD COLUMN "presentationSettingsJson" JSONB;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "LiveVotingSession" ADD COLUMN "tieBreakerMethod" TEXT NOT NULL DEFAULT 'admin_decides';
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- AlterTable
ALTER TABLE "MentorAssignment" ADD COLUMN "completionStatus" TEXT NOT NULL DEFAULT 'in_progress',
ADD COLUMN "lastViewedAt" TIMESTAMP(3);
DO $$ BEGIN
ALTER TABLE "MentorAssignment" ADD COLUMN "completionStatus" TEXT NOT NULL DEFAULT 'in_progress';
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorAssignment" ADD COLUMN "lastViewedAt" TIMESTAMP(3);
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- AlterTable
ALTER TABLE "NotificationEmailSetting" ALTER COLUMN "updatedAt" DROP DEFAULT;
DO $$ BEGIN ALTER TABLE "NotificationEmailSetting" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "draftDataJson" JSONB,
ADD COLUMN "draftExpiresAt" TIMESTAMP(3),
ADD COLUMN "isDraft" BOOLEAN NOT NULL DEFAULT false;
DO $$ BEGIN
ALTER TABLE "Project" ADD COLUMN "draftDataJson" JSONB;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "Project" ADD COLUMN "draftExpiresAt" TIMESTAMP(3);
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "Project" ADD COLUMN "isDraft" BOOLEAN NOT NULL DEFAULT false;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- AlterTable
ALTER TABLE "ProjectFile" ADD COLUMN "isLate" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "replacedById" TEXT,
ADD COLUMN "roundId" TEXT,
ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD COLUMN "isLate" BOOLEAN NOT NULL DEFAULT false;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD COLUMN "replacedById" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD COLUMN "roundId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 1;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- AlterTable
ALTER TABLE "TaggingJob" ALTER COLUMN "updatedAt" DROP DEFAULT;
DO $$ BEGIN ALTER TABLE "TaggingJob" ALTER COLUMN "updatedAt" DROP DEFAULT; EXCEPTION WHEN others THEN NULL; END $$;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "availabilityJson" JSONB,
ADD COLUMN "digestFrequency" TEXT NOT NULL DEFAULT 'none',
ADD COLUMN "preferredWorkload" INTEGER;
DO $$ BEGIN
ALTER TABLE "User" ADD COLUMN "availabilityJson" JSONB;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "User" ADD COLUMN "digestFrequency" TEXT NOT NULL DEFAULT 'none';
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "User" ADD COLUMN "preferredWorkload" INTEGER;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- DropTable
DROP TABLE "ApplicationForm";
DROP TABLE IF EXISTS "ApplicationForm";
-- DropTable
DROP TABLE "ApplicationFormField";
DROP TABLE IF EXISTS "ApplicationFormField";
-- DropTable
DROP TABLE "ApplicationFormSubmission";
DROP TABLE IF EXISTS "ApplicationFormSubmission";
-- DropTable
DROP TABLE "OnboardingStep";
DROP TABLE IF EXISTS "OnboardingStep";
-- DropTable
DROP TABLE "SubmissionFile";
DROP TABLE IF EXISTS "SubmissionFile";
-- DropEnum
DROP TYPE "FormFieldType";
DROP TYPE IF EXISTS "FormFieldType";
-- DropEnum
DROP TYPE "SpecialFieldType";
DROP TYPE IF EXISTS "SpecialFieldType";
-- CreateTable
CREATE TABLE "ReminderLog" (
CREATE TABLE IF NOT EXISTS "ReminderLog" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
@@ -125,7 +163,7 @@ CREATE TABLE "ReminderLog" (
);
-- CreateTable
CREATE TABLE "ConflictOfInterest" (
CREATE TABLE IF NOT EXISTS "ConflictOfInterest" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
@@ -143,7 +181,7 @@ CREATE TABLE "ConflictOfInterest" (
);
-- CreateTable
CREATE TABLE "EvaluationSummary" (
CREATE TABLE IF NOT EXISTS "EvaluationSummary" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
@@ -157,7 +195,7 @@ CREATE TABLE "EvaluationSummary" (
);
-- CreateTable
CREATE TABLE "ProjectStatusHistory" (
CREATE TABLE IF NOT EXISTS "ProjectStatusHistory" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"status" "ProjectStatus" NOT NULL,
@@ -168,7 +206,7 @@ CREATE TABLE "ProjectStatusHistory" (
);
-- CreateTable
CREATE TABLE "MentorMessage" (
CREATE TABLE IF NOT EXISTS "MentorMessage" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
@@ -180,7 +218,7 @@ CREATE TABLE "MentorMessage" (
);
-- CreateTable
CREATE TABLE "DigestLog" (
CREATE TABLE IF NOT EXISTS "DigestLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"digestType" TEXT NOT NULL,
@@ -191,7 +229,7 @@ CREATE TABLE "DigestLog" (
);
-- CreateTable
CREATE TABLE "RoundTemplate" (
CREATE TABLE IF NOT EXISTS "RoundTemplate" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
@@ -208,7 +246,7 @@ CREATE TABLE "RoundTemplate" (
);
-- CreateTable
CREATE TABLE "MentorNote" (
CREATE TABLE IF NOT EXISTS "MentorNote" (
"id" TEXT NOT NULL,
"mentorAssignmentId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
@@ -221,7 +259,7 @@ CREATE TABLE "MentorNote" (
);
-- CreateTable
CREATE TABLE "MentorMilestone" (
CREATE TABLE IF NOT EXISTS "MentorMilestone" (
"id" TEXT NOT NULL,
"programId" TEXT NOT NULL,
"name" TEXT NOT NULL,
@@ -236,7 +274,7 @@ CREATE TABLE "MentorMilestone" (
);
-- CreateTable
CREATE TABLE "MentorMilestoneCompletion" (
CREATE TABLE IF NOT EXISTS "MentorMilestoneCompletion" (
"id" TEXT NOT NULL,
"milestoneId" TEXT NOT NULL,
"mentorAssignmentId" TEXT NOT NULL,
@@ -247,7 +285,7 @@ CREATE TABLE "MentorMilestoneCompletion" (
);
-- CreateTable
CREATE TABLE "Message" (
CREATE TABLE IF NOT EXISTS "Message" (
"id" TEXT NOT NULL,
"senderId" TEXT NOT NULL,
"recipientType" TEXT NOT NULL,
@@ -266,7 +304,7 @@ CREATE TABLE "Message" (
);
-- CreateTable
CREATE TABLE "MessageTemplate" (
CREATE TABLE IF NOT EXISTS "MessageTemplate" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT NOT NULL,
@@ -282,7 +320,7 @@ CREATE TABLE "MessageTemplate" (
);
-- CreateTable
CREATE TABLE "MessageRecipient" (
CREATE TABLE IF NOT EXISTS "MessageRecipient" (
"id" TEXT NOT NULL,
"messageId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
@@ -295,7 +333,7 @@ CREATE TABLE "MessageRecipient" (
);
-- CreateTable
CREATE TABLE "Webhook" (
CREATE TABLE IF NOT EXISTS "Webhook" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
@@ -312,7 +350,7 @@ CREATE TABLE "Webhook" (
);
-- CreateTable
CREATE TABLE "WebhookDelivery" (
CREATE TABLE IF NOT EXISTS "WebhookDelivery" (
"id" TEXT NOT NULL,
"webhookId" TEXT NOT NULL,
"event" TEXT NOT NULL,
@@ -328,7 +366,7 @@ CREATE TABLE "WebhookDelivery" (
);
-- CreateTable
CREATE TABLE "EvaluationDiscussion" (
CREATE TABLE IF NOT EXISTS "EvaluationDiscussion" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
@@ -341,7 +379,7 @@ CREATE TABLE "EvaluationDiscussion" (
);
-- CreateTable
CREATE TABLE "DiscussionComment" (
CREATE TABLE IF NOT EXISTS "DiscussionComment" (
"id" TEXT NOT NULL,
"discussionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
@@ -352,199 +390,257 @@ CREATE TABLE "DiscussionComment" (
);
-- CreateIndex
CREATE INDEX "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
CREATE INDEX IF NOT EXISTS "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
-- CreateIndex
CREATE UNIQUE INDEX "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
CREATE UNIQUE INDEX IF NOT EXISTS "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
-- CreateIndex
CREATE UNIQUE INDEX "ConflictOfInterest_assignmentId_key" ON "ConflictOfInterest"("assignmentId");
CREATE UNIQUE INDEX IF NOT EXISTS "ConflictOfInterest_assignmentId_key" ON "ConflictOfInterest"("assignmentId");
-- CreateIndex
CREATE INDEX "ConflictOfInterest_userId_idx" ON "ConflictOfInterest"("userId");
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_userId_idx" ON "ConflictOfInterest"("userId");
-- CreateIndex
CREATE INDEX "ConflictOfInterest_roundId_hasConflict_idx" ON "ConflictOfInterest"("roundId", "hasConflict");
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_roundId_hasConflict_idx" ON "ConflictOfInterest"("roundId", "hasConflict");
-- CreateIndex
CREATE INDEX "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
CREATE INDEX IF NOT EXISTS "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
-- CreateIndex
CREATE UNIQUE INDEX "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
-- CreateIndex
CREATE INDEX "ProjectStatusHistory_projectId_changedAt_idx" ON "ProjectStatusHistory"("projectId", "changedAt");
CREATE INDEX IF NOT EXISTS "ProjectStatusHistory_projectId_changedAt_idx" ON "ProjectStatusHistory"("projectId", "changedAt");
-- CreateIndex
CREATE INDEX "MentorMessage_projectId_createdAt_idx" ON "MentorMessage"("projectId", "createdAt");
CREATE INDEX IF NOT EXISTS "MentorMessage_projectId_createdAt_idx" ON "MentorMessage"("projectId", "createdAt");
-- CreateIndex
CREATE INDEX "DigestLog_userId_idx" ON "DigestLog"("userId");
CREATE INDEX IF NOT EXISTS "DigestLog_userId_idx" ON "DigestLog"("userId");
-- CreateIndex
CREATE INDEX "DigestLog_sentAt_idx" ON "DigestLog"("sentAt");
CREATE INDEX IF NOT EXISTS "DigestLog_sentAt_idx" ON "DigestLog"("sentAt");
-- CreateIndex
CREATE INDEX "RoundTemplate_programId_idx" ON "RoundTemplate"("programId");
CREATE INDEX IF NOT EXISTS "RoundTemplate_programId_idx" ON "RoundTemplate"("programId");
-- CreateIndex
CREATE INDEX "MentorNote_mentorAssignmentId_idx" ON "MentorNote"("mentorAssignmentId");
CREATE INDEX IF NOT EXISTS "MentorNote_mentorAssignmentId_idx" ON "MentorNote"("mentorAssignmentId");
-- CreateIndex
CREATE INDEX "MentorMilestone_programId_idx" ON "MentorMilestone"("programId");
CREATE INDEX IF NOT EXISTS "MentorMilestone_programId_idx" ON "MentorMilestone"("programId");
-- CreateIndex
CREATE INDEX "MentorMilestone_sortOrder_idx" ON "MentorMilestone"("sortOrder");
CREATE INDEX IF NOT EXISTS "MentorMilestone_sortOrder_idx" ON "MentorMilestone"("sortOrder");
-- CreateIndex
CREATE INDEX "MentorMilestoneCompletion_mentorAssignmentId_idx" ON "MentorMilestoneCompletion"("mentorAssignmentId");
CREATE INDEX IF NOT EXISTS "MentorMilestoneCompletion_mentorAssignmentId_idx" ON "MentorMilestoneCompletion"("mentorAssignmentId");
-- CreateIndex
CREATE UNIQUE INDEX "MentorMilestoneCompletion_milestoneId_mentorAssignmentId_key" ON "MentorMilestoneCompletion"("milestoneId", "mentorAssignmentId");
CREATE UNIQUE INDEX IF NOT EXISTS "MentorMilestoneCompletion_milestoneId_mentorAssignmentId_key" ON "MentorMilestoneCompletion"("milestoneId", "mentorAssignmentId");
-- CreateIndex
CREATE INDEX "Message_senderId_idx" ON "Message"("senderId");
CREATE INDEX IF NOT EXISTS "Message_senderId_idx" ON "Message"("senderId");
-- CreateIndex
CREATE INDEX "Message_sentAt_idx" ON "Message"("sentAt");
CREATE INDEX IF NOT EXISTS "Message_sentAt_idx" ON "Message"("sentAt");
-- CreateIndex
CREATE INDEX "Message_scheduledAt_idx" ON "Message"("scheduledAt");
CREATE INDEX IF NOT EXISTS "Message_scheduledAt_idx" ON "Message"("scheduledAt");
-- CreateIndex
CREATE INDEX "MessageTemplate_category_idx" ON "MessageTemplate"("category");
CREATE INDEX IF NOT EXISTS "MessageTemplate_category_idx" ON "MessageTemplate"("category");
-- CreateIndex
CREATE INDEX "MessageTemplate_isActive_idx" ON "MessageTemplate"("isActive");
CREATE INDEX IF NOT EXISTS "MessageTemplate_isActive_idx" ON "MessageTemplate"("isActive");
-- CreateIndex
CREATE INDEX "MessageRecipient_messageId_idx" ON "MessageRecipient"("messageId");
CREATE INDEX IF NOT EXISTS "MessageRecipient_messageId_idx" ON "MessageRecipient"("messageId");
-- CreateIndex
CREATE INDEX "MessageRecipient_userId_isRead_idx" ON "MessageRecipient"("userId", "isRead");
CREATE INDEX IF NOT EXISTS "MessageRecipient_userId_isRead_idx" ON "MessageRecipient"("userId", "isRead");
-- CreateIndex
CREATE INDEX "Webhook_isActive_idx" ON "Webhook"("isActive");
CREATE INDEX IF NOT EXISTS "Webhook_isActive_idx" ON "Webhook"("isActive");
-- CreateIndex
CREATE INDEX "WebhookDelivery_webhookId_idx" ON "WebhookDelivery"("webhookId");
CREATE INDEX IF NOT EXISTS "WebhookDelivery_webhookId_idx" ON "WebhookDelivery"("webhookId");
-- CreateIndex
CREATE INDEX "WebhookDelivery_status_idx" ON "WebhookDelivery"("status");
CREATE INDEX IF NOT EXISTS "WebhookDelivery_status_idx" ON "WebhookDelivery"("status");
-- CreateIndex
CREATE INDEX "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
CREATE INDEX IF NOT EXISTS "WebhookDelivery_createdAt_idx" ON "WebhookDelivery"("createdAt");
-- CreateIndex
CREATE INDEX "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
-- CreateIndex
CREATE INDEX "EvaluationDiscussion_status_idx" ON "EvaluationDiscussion"("status");
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_status_idx" ON "EvaluationDiscussion"("status");
-- CreateIndex
CREATE UNIQUE INDEX "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
-- CreateIndex
CREATE INDEX "DiscussionComment_discussionId_createdAt_idx" ON "DiscussionComment"("discussionId", "createdAt");
CREATE INDEX IF NOT EXISTS "DiscussionComment_discussionId_createdAt_idx" ON "DiscussionComment"("discussionId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_entityType_entityId_timestamp_idx" ON "AuditLog"("entityType", "entityId", "timestamp");
CREATE INDEX IF NOT EXISTS "AuditLog_entityType_entityId_timestamp_idx" ON "AuditLog"("entityType", "entityId", "timestamp");
-- CreateIndex
CREATE INDEX "Evaluation_status_formId_idx" ON "Evaluation"("status", "formId");
CREATE INDEX IF NOT EXISTS "Evaluation_status_formId_idx" ON "Evaluation"("status", "formId");
-- CreateIndex
CREATE INDEX "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
-- CreateIndex
CREATE INDEX "LiveVote_isAudienceVote_idx" ON "LiveVote"("isAudienceVote");
CREATE INDEX IF NOT EXISTS "LiveVote_isAudienceVote_idx" ON "LiveVote"("isAudienceVote");
-- CreateIndex
CREATE INDEX "ProjectFile_roundId_idx" ON "ProjectFile"("roundId");
CREATE INDEX IF NOT EXISTS "ProjectFile_roundId_idx" ON "ProjectFile"("roundId");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_winnerOverriddenBy_fkey" FOREIGN KEY ("winnerOverriddenBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_reviewedById_fkey" FOREIGN KEY ("reviewedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_generatedById_fkey" FOREIGN KEY ("generatedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ProjectStatusHistory" ADD CONSTRAINT "ProjectStatusHistory_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "DigestLog" ADD CONSTRAINT "DigestLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MentorNote" ADD CONSTRAINT "MentorNote_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MentorMilestone" ADD CONSTRAINT "MentorMilestone_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_milestoneId_fkey" FOREIGN KEY ("milestoneId") REFERENCES "MentorMilestone"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MentorMilestoneCompletion" ADD CONSTRAINT "MentorMilestoneCompletion_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "Message" ADD CONSTRAINT "Message_senderId_fkey" FOREIGN KEY ("senderId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "Message" ADD CONSTRAINT "Message_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "MessageTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "Message"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "MessageRecipient" ADD CONSTRAINT "MessageRecipient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "WebhookDelivery" ADD CONSTRAINT "WebhookDelivery_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_discussionId_fkey" FOREIGN KEY ("discussionId") REFERENCES "EvaluationDiscussion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "DiscussionComment" ADD CONSTRAINT "DiscussionComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

View File

@@ -6,36 +6,46 @@
-- Missing Foreign Keys
-- =====================================================
-- RoundTemplate Program
-- RoundTemplate -> Program
DO $$ BEGIN
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_programId_fkey"
FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- RoundTemplate User (creator)
-- RoundTemplate -> User (creator)
DO $$ BEGIN
ALTER TABLE "RoundTemplate" ADD CONSTRAINT "RoundTemplate_createdBy_fkey"
FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Message Round
-- Message -> Round
DO $$ BEGIN
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- EvaluationDiscussion Round
-- EvaluationDiscussion -> Round
DO $$ BEGIN
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ProjectFile ProjectFile (self-relation for file versioning)
-- ProjectFile -> ProjectFile (self-relation for file versioning)
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_replacedById_fkey"
FOREIGN KEY ("replacedById") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- =====================================================
-- Missing Indexes
-- =====================================================
CREATE INDEX "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
CREATE INDEX "MentorNote_authorId_idx" ON "MentorNote"("authorId");
CREATE INDEX "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
CREATE INDEX "Webhook_createdById_idx" ON "Webhook"("createdById");
CREATE INDEX "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
CREATE INDEX "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
CREATE INDEX "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
CREATE INDEX "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");
CREATE INDEX IF NOT EXISTS "RoundTemplate_roundType_idx" ON "RoundTemplate"("roundType");
CREATE INDEX IF NOT EXISTS "MentorNote_authorId_idx" ON "MentorNote"("authorId");
CREATE INDEX IF NOT EXISTS "MentorMilestoneCompletion_completedById_idx" ON "MentorMilestoneCompletion"("completedById");
CREATE INDEX IF NOT EXISTS "Webhook_createdById_idx" ON "Webhook"("createdById");
CREATE INDEX IF NOT EXISTS "WebhookDelivery_event_idx" ON "WebhookDelivery"("event");
CREATE INDEX IF NOT EXISTS "Message_roundId_idx" ON "Message"("roundId");
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_closedById_idx" ON "EvaluationDiscussion"("closedById");
CREATE INDEX IF NOT EXISTS "DiscussionComment_discussionId_idx" ON "DiscussionComment"("discussionId");
CREATE INDEX IF NOT EXISTS "DiscussionComment_userId_idx" ON "DiscussionComment"("userId");

View File

@@ -3,11 +3,15 @@
-- Add SET NULL on ProjectFile.roundId so deleting Round nullifies the reference
-- AlterTable: Evaluation.formId -> onDelete CASCADE
ALTER TABLE "Evaluation" DROP CONSTRAINT "Evaluation_formId_fkey";
ALTER TABLE "Evaluation" DROP CONSTRAINT IF EXISTS "Evaluation_formId_fkey";
DO $$ BEGIN
ALTER TABLE "Evaluation" ADD CONSTRAINT "Evaluation_formId_fkey"
FOREIGN KEY ("formId") REFERENCES "EvaluationForm"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AlterTable: ProjectFile.roundId -> onDelete SET NULL
ALTER TABLE "ProjectFile" DROP CONSTRAINT "ProjectFile_roundId_fkey";
ALTER TABLE "ProjectFile" DROP CONSTRAINT IF EXISTS "ProjectFile_roundId_fkey";
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

View File

@@ -1,5 +1,5 @@
-- CreateTable
CREATE TABLE "FileRequirement" (
CREATE TABLE IF NOT EXISTS "FileRequirement" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"name" TEXT NOT NULL,
@@ -15,16 +15,22 @@ CREATE TABLE "FileRequirement" (
);
-- CreateIndex
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
CREATE INDEX IF NOT EXISTS "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AlterTable: add requirementId to ProjectFile
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD COLUMN "requirementId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- CreateIndex
CREATE INDEX "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
CREATE INDEX IF NOT EXISTS "ProjectFile_requirementId_idx" ON "ProjectFile"("requirementId");
-- AddForeignKey
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_requirementId_fkey" FOREIGN KEY ("requirementId") REFERENCES "FileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

View File

@@ -1,2 +1,2 @@
-- CreateIndex
CREATE INDEX "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");
CREATE INDEX IF NOT EXISTS "AwardVote_awardId_userId_idx" ON "AwardVote"("awardId", "userId");

View File

@@ -1,20 +1,26 @@
-- Simplify RoutingMode enum: remove POST_MAIN, rename PARALLEL SHARED
-- Simplify RoutingMode enum: remove POST_MAIN, rename PARALLEL -> SHARED
-- Drop RoutingRule table (routing is now handled via award assignment)
-- 1. Update existing PARALLEL values to SHARED, POST_MAIN to SHARED
-- (safe to run even if no rows match)
UPDATE "Track" SET "routingMode" = 'PARALLEL' WHERE "routingMode" = 'POST_MAIN';
-- 2. Rename PARALLEL SHARED in the enum
-- 2. Rename PARALLEL -> SHARED in the enum (only if PARALLEL still exists)
DO $$ BEGIN
ALTER TYPE "RoutingMode" RENAME VALUE 'PARALLEL' TO 'SHARED';
EXCEPTION WHEN invalid_parameter_value THEN NULL; WHEN others THEN NULL; END $$;
-- 3. Remove POST_MAIN from the enum
-- PostgreSQL doesn't support DROP VALUE directly, so we recreate the enum
-- Since we already converted POST_MAIN values to PARALLEL (now SHARED), this is safe
-- Create new enum without POST_MAIN
-- Actually, since we already renamed PARALLEL to SHARED and converted POST_MAIN rows,
-- we just need to remove the POST_MAIN value. PostgreSQL 13+ doesn't support dropping
-- enum values natively, but since all rows are already migrated, we can:
-- Only recreate if the old enum still has POST_MAIN (i.e., hasn't been done yet)
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'POST_MAIN'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'RoutingMode')
) THEN
CREATE TYPE "RoutingMode_new" AS ENUM ('SHARED', 'EXCLUSIVE');
ALTER TABLE "Track"
@@ -23,6 +29,8 @@ ALTER TABLE "Track"
DROP TYPE "RoutingMode";
ALTER TYPE "RoutingMode_new" RENAME TO "RoutingMode";
END IF;
END $$;
-- 4. Drop the RoutingRule table (no longer needed)
DROP TABLE IF EXISTS "RoutingRule";

View File

@@ -1,36 +1,36 @@
-- =============================================================================
-- Phase 0+1: Add Competition/Round Architecture (additive no breaking changes)
-- Phase 0+1: Add Competition/Round Architecture (additive -- no breaking changes)
-- =============================================================================
-- New enums, new tables, new optional columns on existing tables.
-- Old Pipeline/Track/Stage tables are untouched.
-- ─── New Enum Types ──────────────────────────────────────────────────────────
-- --- New Enum Types ---
CREATE TYPE "CompetitionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED');
CREATE TYPE "RoundType" AS ENUM ('INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION');
CREATE TYPE "RoundStatus" AS ENUM ('ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED');
CREATE TYPE "ProjectRoundStateValue" AS ENUM ('PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN');
CREATE TYPE "AdvancementRuleType" AS ENUM ('AUTO_ADVANCE', 'SCORE_THRESHOLD', 'TOP_N', 'ADMIN_SELECTION', 'AI_RECOMMENDED');
CREATE TYPE "CapMode" AS ENUM ('HARD', 'SOFT', 'NONE');
CREATE TYPE "DeadlinePolicy" AS ENUM ('HARD_DEADLINE', 'FLAG', 'GRACE');
CREATE TYPE "JuryGroupMemberRole" AS ENUM ('CHAIR', 'MEMBER', 'OBSERVER');
CREATE TYPE "AssignmentIntentSource" AS ENUM ('INVITE', 'ADMIN', 'SYSTEM');
CREATE TYPE "AssignmentIntentStatus" AS ENUM ('INTENT_PENDING', 'HONORED', 'OVERRIDDEN', 'EXPIRED', 'CANCELLED');
CREATE TYPE "MentorMessageRole" AS ENUM ('MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE');
CREATE TYPE "SubmissionPromotionSource" AS ENUM ('MENTOR_FILE', 'ADMIN_REPLACEMENT');
CREATE TYPE "DeliberationMode" AS ENUM ('SINGLE_WINNER_VOTE', 'FULL_RANKING');
CREATE TYPE "DeliberationStatus" AS ENUM ('DELIB_OPEN', 'VOTING', 'TALLYING', 'RUNOFF', 'DELIB_LOCKED');
CREATE TYPE "TieBreakMethod" AS ENUM ('TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK');
CREATE TYPE "DeliberationParticipantStatus" AS ENUM ('REQUIRED', 'ABSENT_EXCUSED', 'REPLACED', 'REPLACEMENT_ACTIVE');
CREATE TYPE "AwardEligibilityMode" AS ENUM ('SEPARATE_POOL', 'STAY_IN_MAIN');
DO $$ BEGIN CREATE TYPE "CompetitionStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "RoundType" AS ENUM ('INTAKE', 'FILTERING', 'EVALUATION', 'SUBMISSION', 'MENTORING', 'LIVE_FINAL', 'DELIBERATION'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "RoundStatus" AS ENUM ('ROUND_DRAFT', 'ROUND_ACTIVE', 'ROUND_CLOSED', 'ROUND_ARCHIVED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "ProjectRoundStateValue" AS ENUM ('PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "AdvancementRuleType" AS ENUM ('AUTO_ADVANCE', 'SCORE_THRESHOLD', 'TOP_N', 'ADMIN_SELECTION', 'AI_RECOMMENDED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "CapMode" AS ENUM ('HARD', 'SOFT', 'NONE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "DeadlinePolicy" AS ENUM ('HARD_DEADLINE', 'FLAG', 'GRACE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "JuryGroupMemberRole" AS ENUM ('CHAIR', 'MEMBER', 'OBSERVER'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "AssignmentIntentSource" AS ENUM ('INVITE', 'ADMIN', 'SYSTEM'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "AssignmentIntentStatus" AS ENUM ('INTENT_PENDING', 'HONORED', 'OVERRIDDEN', 'EXPIRED', 'CANCELLED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "MentorMessageRole" AS ENUM ('MENTOR_ROLE', 'APPLICANT_ROLE', 'ADMIN_ROLE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "SubmissionPromotionSource" AS ENUM ('MENTOR_FILE', 'ADMIN_REPLACEMENT'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "DeliberationMode" AS ENUM ('SINGLE_WINNER_VOTE', 'FULL_RANKING'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "DeliberationStatus" AS ENUM ('DELIB_OPEN', 'VOTING', 'TALLYING', 'RUNOFF', 'DELIB_LOCKED'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "TieBreakMethod" AS ENUM ('TIE_RUNOFF', 'TIE_ADMIN_DECIDES', 'SCORE_FALLBACK'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "DeliberationParticipantStatus" AS ENUM ('REQUIRED', 'ABSENT_EXCUSED', 'REPLACED', 'REPLACEMENT_ACTIVE'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE "AwardEligibilityMode" AS ENUM ('SEPARATE_POOL', 'STAY_IN_MAIN'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Add FEATURE_FLAGS to SettingCategory enum
ALTER TYPE "SettingCategory" ADD VALUE 'FEATURE_FLAGS';
DO $$ BEGIN ALTER TYPE "SettingCategory" ADD VALUE 'FEATURE_FLAGS'; EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── New Tables ──────────────────────────────────────────────────────────────
-- --- New Tables ---
-- Competition (replaces Pipeline)
CREATE TABLE "Competition" (
CREATE TABLE IF NOT EXISTS "Competition" (
"id" TEXT NOT NULL,
"programId" TEXT NOT NULL,
"name" TEXT NOT NULL,
@@ -49,7 +49,7 @@ CREATE TABLE "Competition" (
);
-- Round (replaces Stage)
CREATE TABLE "Round" (
CREATE TABLE IF NOT EXISTS "Round" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"name" TEXT NOT NULL,
@@ -70,7 +70,7 @@ CREATE TABLE "Round" (
);
-- ProjectRoundState
CREATE TABLE "ProjectRoundState" (
CREATE TABLE IF NOT EXISTS "ProjectRoundState" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
@@ -85,7 +85,7 @@ CREATE TABLE "ProjectRoundState" (
);
-- AdvancementRule
CREATE TABLE "AdvancementRule" (
CREATE TABLE IF NOT EXISTS "AdvancementRule" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"targetRoundId" TEXT,
@@ -99,7 +99,7 @@ CREATE TABLE "AdvancementRule" (
);
-- JuryGroup
CREATE TABLE "JuryGroup" (
CREATE TABLE IF NOT EXISTS "JuryGroup" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"name" TEXT NOT NULL,
@@ -120,7 +120,7 @@ CREATE TABLE "JuryGroup" (
);
-- JuryGroupMember
CREATE TABLE "JuryGroupMember" (
CREATE TABLE IF NOT EXISTS "JuryGroupMember" (
"id" TEXT NOT NULL,
"juryGroupId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
@@ -138,7 +138,7 @@ CREATE TABLE "JuryGroupMember" (
);
-- SubmissionWindow
CREATE TABLE "SubmissionWindow" (
CREATE TABLE IF NOT EXISTS "SubmissionWindow" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"name" TEXT NOT NULL,
@@ -158,7 +158,7 @@ CREATE TABLE "SubmissionWindow" (
);
-- SubmissionFileRequirement
CREATE TABLE "SubmissionFileRequirement" (
CREATE TABLE IF NOT EXISTS "SubmissionFileRequirement" (
"id" TEXT NOT NULL,
"submissionWindowId" TEXT NOT NULL,
"label" TEXT NOT NULL,
@@ -175,7 +175,7 @@ CREATE TABLE "SubmissionFileRequirement" (
);
-- RoundSubmissionVisibility
CREATE TABLE "RoundSubmissionVisibility" (
CREATE TABLE IF NOT EXISTS "RoundSubmissionVisibility" (
"id" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
"submissionWindowId" TEXT NOT NULL,
@@ -186,7 +186,7 @@ CREATE TABLE "RoundSubmissionVisibility" (
);
-- AssignmentIntent
CREATE TABLE "AssignmentIntent" (
CREATE TABLE IF NOT EXISTS "AssignmentIntent" (
"id" TEXT NOT NULL,
"juryGroupMemberId" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
@@ -200,7 +200,7 @@ CREATE TABLE "AssignmentIntent" (
);
-- AssignmentException
CREATE TABLE "AssignmentException" (
CREATE TABLE IF NOT EXISTS "AssignmentException" (
"id" TEXT NOT NULL,
"assignmentId" TEXT NOT NULL,
"reason" TEXT NOT NULL,
@@ -212,7 +212,7 @@ CREATE TABLE "AssignmentException" (
);
-- MentorFile
CREATE TABLE "MentorFile" (
CREATE TABLE IF NOT EXISTS "MentorFile" (
"id" TEXT NOT NULL,
"mentorAssignmentId" TEXT NOT NULL,
"uploadedByUserId" TEXT NOT NULL,
@@ -232,7 +232,7 @@ CREATE TABLE "MentorFile" (
);
-- MentorFileComment
CREATE TABLE "MentorFileComment" (
CREATE TABLE IF NOT EXISTS "MentorFileComment" (
"id" TEXT NOT NULL,
"mentorFileId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
@@ -245,7 +245,7 @@ CREATE TABLE "MentorFileComment" (
);
-- SubmissionPromotionEvent
CREATE TABLE "SubmissionPromotionEvent" (
CREATE TABLE IF NOT EXISTS "SubmissionPromotionEvent" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
@@ -259,7 +259,7 @@ CREATE TABLE "SubmissionPromotionEvent" (
);
-- DeliberationSession
CREATE TABLE "DeliberationSession" (
CREATE TABLE IF NOT EXISTS "DeliberationSession" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
@@ -277,7 +277,7 @@ CREATE TABLE "DeliberationSession" (
);
-- DeliberationVote
CREATE TABLE "DeliberationVote" (
CREATE TABLE IF NOT EXISTS "DeliberationVote" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"juryMemberId" TEXT NOT NULL,
@@ -291,7 +291,7 @@ CREATE TABLE "DeliberationVote" (
);
-- DeliberationResult
CREATE TABLE "DeliberationResult" (
CREATE TABLE IF NOT EXISTS "DeliberationResult" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
@@ -304,7 +304,7 @@ CREATE TABLE "DeliberationResult" (
);
-- DeliberationParticipant
CREATE TABLE "DeliberationParticipant" (
CREATE TABLE IF NOT EXISTS "DeliberationParticipant" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
@@ -315,7 +315,7 @@ CREATE TABLE "DeliberationParticipant" (
);
-- ResultLock
CREATE TABLE "ResultLock" (
CREATE TABLE IF NOT EXISTS "ResultLock" (
"id" TEXT NOT NULL,
"competitionId" TEXT NOT NULL,
"roundId" TEXT NOT NULL,
@@ -328,7 +328,7 @@ CREATE TABLE "ResultLock" (
);
-- ResultUnlockEvent
CREATE TABLE "ResultUnlockEvent" (
CREATE TABLE IF NOT EXISTS "ResultUnlockEvent" (
"id" TEXT NOT NULL,
"resultLockId" TEXT NOT NULL,
"unlockedById" TEXT NOT NULL,
@@ -338,235 +338,365 @@ CREATE TABLE "ResultUnlockEvent" (
CONSTRAINT "ResultUnlockEvent_pkey" PRIMARY KEY ("id")
);
-- ─── Add Columns to Existing Tables ──────────────────────────────────────────
-- --- Add Columns to Existing Tables ---
-- Assignment: add juryGroupId
DO $$ BEGIN
ALTER TABLE "Assignment" ADD COLUMN "juryGroupId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- SpecialAward: add competition/round architecture fields
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD COLUMN "competitionId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD COLUMN "evaluationRoundId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD COLUMN "juryGroupId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD COLUMN "eligibilityMode" "AwardEligibilityMode" NOT NULL DEFAULT 'STAY_IN_MAIN';
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD COLUMN "decisionMode" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- MentorAssignment: add workspace fields
DO $$ BEGIN
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceEnabled" BOOLEAN NOT NULL DEFAULT false;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceOpenAt" TIMESTAMP(3);
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorAssignment" ADD COLUMN "workspaceCloseAt" TIMESTAMP(3);
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- MentorMessage: add workspace fields
DO $$ BEGIN
ALTER TABLE "MentorMessage" ADD COLUMN "workspaceId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorMessage" ADD COLUMN "senderRole" "MentorMessageRole";
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- ProjectFile: add submission window link
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD COLUMN "submissionWindowId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD COLUMN "submissionFileRequirementId" TEXT;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
-- ─── Unique Constraints ──────────────────────────────────────────────────────
-- --- Unique Constraints ---
CREATE UNIQUE INDEX "Competition_slug_key" ON "Competition"("slug");
CREATE UNIQUE INDEX "Round_competitionId_slug_key" ON "Round"("competitionId", "slug");
CREATE UNIQUE INDEX "Round_competitionId_sortOrder_key" ON "Round"("competitionId", "sortOrder");
CREATE UNIQUE INDEX "ProjectRoundState_projectId_roundId_key" ON "ProjectRoundState"("projectId", "roundId");
CREATE UNIQUE INDEX "JuryGroup_competitionId_slug_key" ON "JuryGroup"("competitionId", "slug");
CREATE UNIQUE INDEX "JuryGroupMember_juryGroupId_userId_key" ON "JuryGroupMember"("juryGroupId", "userId");
CREATE UNIQUE INDEX "SubmissionWindow_competitionId_slug_key" ON "SubmissionWindow"("competitionId", "slug");
CREATE UNIQUE INDEX "SubmissionWindow_competitionId_roundNumber_key" ON "SubmissionWindow"("competitionId", "roundNumber");
CREATE UNIQUE INDEX "RoundSubmissionVisibility_roundId_submissionWindowId_key" ON "RoundSubmissionVisibility"("roundId", "submissionWindowId");
CREATE UNIQUE INDEX "AssignmentIntent_juryGroupMemberId_roundId_projectId_key" ON "AssignmentIntent"("juryGroupMemberId", "roundId", "projectId");
CREATE UNIQUE INDEX "MentorFile_promotedToFileId_key" ON "MentorFile"("promotedToFileId");
CREATE UNIQUE INDEX "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" ON "DeliberationVote"("sessionId", "juryMemberId", "projectId", "runoffRound");
CREATE UNIQUE INDEX "DeliberationResult_sessionId_projectId_key" ON "DeliberationResult"("sessionId", "projectId");
CREATE UNIQUE INDEX "DeliberationParticipant_sessionId_userId_key" ON "DeliberationParticipant"("sessionId", "userId");
CREATE UNIQUE INDEX "SubmissionFileRequirement_submissionWindowId_slug_key" ON "SubmissionFileRequirement"("submissionWindowId", "slug");
CREATE UNIQUE INDEX "AdvancementRule_roundId_sortOrder_key" ON "AdvancementRule"("roundId", "sortOrder");
CREATE UNIQUE INDEX IF NOT EXISTS "Competition_slug_key" ON "Competition"("slug");
CREATE UNIQUE INDEX IF NOT EXISTS "Round_competitionId_slug_key" ON "Round"("competitionId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "Round_competitionId_sortOrder_key" ON "Round"("competitionId", "sortOrder");
CREATE UNIQUE INDEX IF NOT EXISTS "ProjectRoundState_projectId_roundId_key" ON "ProjectRoundState"("projectId", "roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "JuryGroup_competitionId_slug_key" ON "JuryGroup"("competitionId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "JuryGroupMember_juryGroupId_userId_key" ON "JuryGroupMember"("juryGroupId", "userId");
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_slug_key" ON "SubmissionWindow"("competitionId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_roundNumber_key" ON "SubmissionWindow"("competitionId", "roundNumber");
CREATE UNIQUE INDEX IF NOT EXISTS "RoundSubmissionVisibility_roundId_submissionWindowId_key" ON "RoundSubmissionVisibility"("roundId", "submissionWindowId");
CREATE UNIQUE INDEX IF NOT EXISTS "AssignmentIntent_juryGroupMemberId_roundId_projectId_key" ON "AssignmentIntent"("juryGroupMemberId", "roundId", "projectId");
CREATE UNIQUE INDEX IF NOT EXISTS "MentorFile_promotedToFileId_key" ON "MentorFile"("promotedToFileId");
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationVote_sessionId_juryMemberId_projectId_runoffRo_key" ON "DeliberationVote"("sessionId", "juryMemberId", "projectId", "runoffRound");
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationResult_sessionId_projectId_key" ON "DeliberationResult"("sessionId", "projectId");
CREATE UNIQUE INDEX IF NOT EXISTS "DeliberationParticipant_sessionId_userId_key" ON "DeliberationParticipant"("sessionId", "userId");
CREATE UNIQUE INDEX IF NOT EXISTS "SubmissionFileRequirement_submissionWindowId_slug_key" ON "SubmissionFileRequirement"("submissionWindowId", "slug");
CREATE UNIQUE INDEX IF NOT EXISTS "AdvancementRule_roundId_sortOrder_key" ON "AdvancementRule"("roundId", "sortOrder");
-- ─── Indexes ─────────────────────────────────────────────────────────────────
-- --- Indexes ---
-- Competition
CREATE INDEX "Competition_programId_idx" ON "Competition"("programId");
CREATE INDEX "Competition_status_idx" ON "Competition"("status");
CREATE INDEX IF NOT EXISTS "Competition_programId_idx" ON "Competition"("programId");
CREATE INDEX IF NOT EXISTS "Competition_status_idx" ON "Competition"("status");
-- Round
CREATE INDEX "Round_competitionId_idx" ON "Round"("competitionId");
CREATE INDEX "Round_roundType_idx" ON "Round"("roundType");
CREATE INDEX "Round_status_idx" ON "Round"("status");
CREATE INDEX IF NOT EXISTS "Round_competitionId_idx" ON "Round"("competitionId");
CREATE INDEX IF NOT EXISTS "Round_roundType_idx" ON "Round"("roundType");
CREATE INDEX IF NOT EXISTS "Round_status_idx" ON "Round"("status");
-- ProjectRoundState
CREATE INDEX "ProjectRoundState_projectId_idx" ON "ProjectRoundState"("projectId");
CREATE INDEX "ProjectRoundState_roundId_idx" ON "ProjectRoundState"("roundId");
CREATE INDEX "ProjectRoundState_state_idx" ON "ProjectRoundState"("state");
CREATE INDEX IF NOT EXISTS "ProjectRoundState_projectId_idx" ON "ProjectRoundState"("projectId");
CREATE INDEX IF NOT EXISTS "ProjectRoundState_roundId_idx" ON "ProjectRoundState"("roundId");
CREATE INDEX IF NOT EXISTS "ProjectRoundState_state_idx" ON "ProjectRoundState"("state");
-- AdvancementRule
CREATE INDEX "AdvancementRule_roundId_idx" ON "AdvancementRule"("roundId");
CREATE INDEX IF NOT EXISTS "AdvancementRule_roundId_idx" ON "AdvancementRule"("roundId");
-- JuryGroup
CREATE INDEX "JuryGroup_competitionId_idx" ON "JuryGroup"("competitionId");
CREATE INDEX IF NOT EXISTS "JuryGroup_competitionId_idx" ON "JuryGroup"("competitionId");
-- JuryGroupMember
CREATE INDEX "JuryGroupMember_juryGroupId_idx" ON "JuryGroupMember"("juryGroupId");
CREATE INDEX "JuryGroupMember_userId_idx" ON "JuryGroupMember"("userId");
CREATE INDEX IF NOT EXISTS "JuryGroupMember_juryGroupId_idx" ON "JuryGroupMember"("juryGroupId");
CREATE INDEX IF NOT EXISTS "JuryGroupMember_userId_idx" ON "JuryGroupMember"("userId");
-- SubmissionWindow
CREATE INDEX "SubmissionWindow_competitionId_idx" ON "SubmissionWindow"("competitionId");
CREATE INDEX IF NOT EXISTS "SubmissionWindow_competitionId_idx" ON "SubmissionWindow"("competitionId");
-- SubmissionFileRequirement
CREATE INDEX "SubmissionFileRequirement_submissionWindowId_idx" ON "SubmissionFileRequirement"("submissionWindowId");
CREATE INDEX IF NOT EXISTS "SubmissionFileRequirement_submissionWindowId_idx" ON "SubmissionFileRequirement"("submissionWindowId");
-- RoundSubmissionVisibility
CREATE INDEX "RoundSubmissionVisibility_roundId_idx" ON "RoundSubmissionVisibility"("roundId");
CREATE INDEX IF NOT EXISTS "RoundSubmissionVisibility_roundId_idx" ON "RoundSubmissionVisibility"("roundId");
-- AssignmentIntent
CREATE INDEX "AssignmentIntent_roundId_idx" ON "AssignmentIntent"("roundId");
CREATE INDEX "AssignmentIntent_projectId_idx" ON "AssignmentIntent"("projectId");
CREATE INDEX "AssignmentIntent_status_idx" ON "AssignmentIntent"("status");
CREATE INDEX IF NOT EXISTS "AssignmentIntent_roundId_idx" ON "AssignmentIntent"("roundId");
CREATE INDEX IF NOT EXISTS "AssignmentIntent_projectId_idx" ON "AssignmentIntent"("projectId");
CREATE INDEX IF NOT EXISTS "AssignmentIntent_status_idx" ON "AssignmentIntent"("status");
-- AssignmentException
CREATE INDEX "AssignmentException_assignmentId_idx" ON "AssignmentException"("assignmentId");
CREATE INDEX "AssignmentException_approvedById_idx" ON "AssignmentException"("approvedById");
CREATE INDEX IF NOT EXISTS "AssignmentException_assignmentId_idx" ON "AssignmentException"("assignmentId");
CREATE INDEX IF NOT EXISTS "AssignmentException_approvedById_idx" ON "AssignmentException"("approvedById");
-- MentorFile
CREATE INDEX "MentorFile_mentorAssignmentId_idx" ON "MentorFile"("mentorAssignmentId");
CREATE INDEX "MentorFile_uploadedByUserId_idx" ON "MentorFile"("uploadedByUserId");
CREATE INDEX IF NOT EXISTS "MentorFile_mentorAssignmentId_idx" ON "MentorFile"("mentorAssignmentId");
CREATE INDEX IF NOT EXISTS "MentorFile_uploadedByUserId_idx" ON "MentorFile"("uploadedByUserId");
-- MentorFileComment
CREATE INDEX "MentorFileComment_mentorFileId_idx" ON "MentorFileComment"("mentorFileId");
CREATE INDEX "MentorFileComment_authorId_idx" ON "MentorFileComment"("authorId");
CREATE INDEX "MentorFileComment_parentCommentId_idx" ON "MentorFileComment"("parentCommentId");
CREATE INDEX IF NOT EXISTS "MentorFileComment_mentorFileId_idx" ON "MentorFileComment"("mentorFileId");
CREATE INDEX IF NOT EXISTS "MentorFileComment_authorId_idx" ON "MentorFileComment"("authorId");
CREATE INDEX IF NOT EXISTS "MentorFileComment_parentCommentId_idx" ON "MentorFileComment"("parentCommentId");
-- SubmissionPromotionEvent
CREATE INDEX "SubmissionPromotionEvent_projectId_idx" ON "SubmissionPromotionEvent"("projectId");
CREATE INDEX "SubmissionPromotionEvent_roundId_idx" ON "SubmissionPromotionEvent"("roundId");
CREATE INDEX "SubmissionPromotionEvent_sourceFileId_idx" ON "SubmissionPromotionEvent"("sourceFileId");
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_projectId_idx" ON "SubmissionPromotionEvent"("projectId");
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_roundId_idx" ON "SubmissionPromotionEvent"("roundId");
CREATE INDEX IF NOT EXISTS "SubmissionPromotionEvent_sourceFileId_idx" ON "SubmissionPromotionEvent"("sourceFileId");
-- DeliberationSession
CREATE INDEX "DeliberationSession_competitionId_idx" ON "DeliberationSession"("competitionId");
CREATE INDEX "DeliberationSession_roundId_idx" ON "DeliberationSession"("roundId");
CREATE INDEX "DeliberationSession_status_idx" ON "DeliberationSession"("status");
CREATE INDEX IF NOT EXISTS "DeliberationSession_competitionId_idx" ON "DeliberationSession"("competitionId");
CREATE INDEX IF NOT EXISTS "DeliberationSession_roundId_idx" ON "DeliberationSession"("roundId");
CREATE INDEX IF NOT EXISTS "DeliberationSession_status_idx" ON "DeliberationSession"("status");
-- DeliberationVote
CREATE INDEX "DeliberationVote_sessionId_idx" ON "DeliberationVote"("sessionId");
CREATE INDEX "DeliberationVote_juryMemberId_idx" ON "DeliberationVote"("juryMemberId");
CREATE INDEX "DeliberationVote_projectId_idx" ON "DeliberationVote"("projectId");
CREATE INDEX IF NOT EXISTS "DeliberationVote_sessionId_idx" ON "DeliberationVote"("sessionId");
CREATE INDEX IF NOT EXISTS "DeliberationVote_juryMemberId_idx" ON "DeliberationVote"("juryMemberId");
CREATE INDEX IF NOT EXISTS "DeliberationVote_projectId_idx" ON "DeliberationVote"("projectId");
-- DeliberationResult
CREATE INDEX "DeliberationResult_sessionId_idx" ON "DeliberationResult"("sessionId");
CREATE INDEX "DeliberationResult_projectId_idx" ON "DeliberationResult"("projectId");
CREATE INDEX IF NOT EXISTS "DeliberationResult_sessionId_idx" ON "DeliberationResult"("sessionId");
CREATE INDEX IF NOT EXISTS "DeliberationResult_projectId_idx" ON "DeliberationResult"("projectId");
-- DeliberationParticipant
CREATE INDEX "DeliberationParticipant_sessionId_idx" ON "DeliberationParticipant"("sessionId");
CREATE INDEX "DeliberationParticipant_userId_idx" ON "DeliberationParticipant"("userId");
CREATE INDEX IF NOT EXISTS "DeliberationParticipant_sessionId_idx" ON "DeliberationParticipant"("sessionId");
CREATE INDEX IF NOT EXISTS "DeliberationParticipant_userId_idx" ON "DeliberationParticipant"("userId");
-- ResultLock
CREATE INDEX "ResultLock_competitionId_idx" ON "ResultLock"("competitionId");
CREATE INDEX "ResultLock_roundId_idx" ON "ResultLock"("roundId");
CREATE INDEX "ResultLock_category_idx" ON "ResultLock"("category");
CREATE INDEX IF NOT EXISTS "ResultLock_competitionId_idx" ON "ResultLock"("competitionId");
CREATE INDEX IF NOT EXISTS "ResultLock_roundId_idx" ON "ResultLock"("roundId");
CREATE INDEX IF NOT EXISTS "ResultLock_category_idx" ON "ResultLock"("category");
-- ResultUnlockEvent
CREATE INDEX "ResultUnlockEvent_resultLockId_idx" ON "ResultUnlockEvent"("resultLockId");
CREATE INDEX "ResultUnlockEvent_unlockedById_idx" ON "ResultUnlockEvent"("unlockedById");
CREATE INDEX IF NOT EXISTS "ResultUnlockEvent_resultLockId_idx" ON "ResultUnlockEvent"("resultLockId");
CREATE INDEX IF NOT EXISTS "ResultUnlockEvent_unlockedById_idx" ON "ResultUnlockEvent"("unlockedById");
-- Indexes on modified existing tables
CREATE INDEX "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
CREATE INDEX "SpecialAward_competitionId_idx" ON "SpecialAward"("competitionId");
CREATE INDEX "SpecialAward_evaluationRoundId_idx" ON "SpecialAward"("evaluationRoundId");
CREATE INDEX "MentorMessage_workspaceId_idx" ON "MentorMessage"("workspaceId");
CREATE INDEX "ProjectFile_submissionWindowId_idx" ON "ProjectFile"("submissionWindowId");
CREATE INDEX "ProjectFile_submissionFileRequirementId_idx" ON "ProjectFile"("submissionFileRequirementId");
CREATE INDEX IF NOT EXISTS "Assignment_juryGroupId_idx" ON "Assignment"("juryGroupId");
CREATE INDEX IF NOT EXISTS "SpecialAward_competitionId_idx" ON "SpecialAward"("competitionId");
CREATE INDEX IF NOT EXISTS "SpecialAward_evaluationRoundId_idx" ON "SpecialAward"("evaluationRoundId");
CREATE INDEX IF NOT EXISTS "MentorMessage_workspaceId_idx" ON "MentorMessage"("workspaceId");
CREATE INDEX IF NOT EXISTS "ProjectFile_submissionWindowId_idx" ON "ProjectFile"("submissionWindowId");
CREATE INDEX IF NOT EXISTS "ProjectFile_submissionFileRequirementId_idx" ON "ProjectFile"("submissionFileRequirementId");
-- ─── Foreign Keys ────────────────────────────────────────────────────────────
-- --- Foreign Keys ---
-- Competition
DO $$ BEGIN
ALTER TABLE "Competition" ADD CONSTRAINT "Competition_programId_fkey" FOREIGN KEY ("programId") REFERENCES "Program"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Round
DO $$ BEGIN
ALTER TABLE "Round" ADD CONSTRAINT "Round_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "Round" ADD CONSTRAINT "Round_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "Round" ADD CONSTRAINT "Round_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ProjectRoundState
DO $$ BEGIN
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ProjectRoundState" ADD CONSTRAINT "ProjectRoundState_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AdvancementRule
DO $$ BEGIN
ALTER TABLE "AdvancementRule" ADD CONSTRAINT "AdvancementRule_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- JuryGroup
DO $$ BEGIN
ALTER TABLE "JuryGroup" ADD CONSTRAINT "JuryGroup_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- JuryGroupMember
DO $$ BEGIN
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "JuryGroupMember" ADD CONSTRAINT "JuryGroupMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- SubmissionWindow
DO $$ BEGIN
ALTER TABLE "SubmissionWindow" ADD CONSTRAINT "SubmissionWindow_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- SubmissionFileRequirement
DO $$ BEGIN
ALTER TABLE "SubmissionFileRequirement" ADD CONSTRAINT "SubmissionFileRequirement_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- RoundSubmissionVisibility
DO $$ BEGIN
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "RoundSubmissionVisibility" ADD CONSTRAINT "RoundSubmissionVisibility_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AssignmentIntent
DO $$ BEGIN
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_juryGroupMemberId_fkey" FOREIGN KEY ("juryGroupMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "AssignmentIntent" ADD CONSTRAINT "AssignmentIntent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- AssignmentException
DO $$ BEGIN
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_assignmentId_fkey" FOREIGN KEY ("assignmentId") REFERENCES "Assignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "AssignmentException" ADD CONSTRAINT "AssignmentException_approvedById_fkey" FOREIGN KEY ("approvedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- MentorFile
DO $$ BEGIN
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_mentorAssignmentId_fkey" FOREIGN KEY ("mentorAssignmentId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_uploadedByUserId_fkey" FOREIGN KEY ("uploadedByUserId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedByUserId_fkey" FOREIGN KEY ("promotedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorFile" ADD CONSTRAINT "MentorFile_promotedToFileId_fkey" FOREIGN KEY ("promotedToFileId") REFERENCES "ProjectFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- MentorFileComment
DO $$ BEGIN
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_mentorFileId_fkey" FOREIGN KEY ("mentorFileId") REFERENCES "MentorFile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorFileComment" ADD CONSTRAINT "MentorFileComment_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "MentorFileComment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- SubmissionPromotionEvent
DO $$ BEGIN
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_sourceFileId_fkey" FOREIGN KEY ("sourceFileId") REFERENCES "MentorFile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SubmissionPromotionEvent" ADD CONSTRAINT "SubmissionPromotionEvent_promotedById_fkey" FOREIGN KEY ("promotedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- DeliberationSession
DO $$ BEGIN
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "DeliberationSession" ADD CONSTRAINT "DeliberationSession_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- DeliberationVote
DO $$ BEGIN
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_juryMemberId_fkey" FOREIGN KEY ("juryMemberId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "DeliberationVote" ADD CONSTRAINT "DeliberationVote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- DeliberationResult
DO $$ BEGIN
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "DeliberationResult" ADD CONSTRAINT "DeliberationResult_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- DeliberationParticipant
DO $$ BEGIN
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "DeliberationSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "JuryGroupMember"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "DeliberationParticipant" ADD CONSTRAINT "DeliberationParticipant_replacedById_fkey" FOREIGN KEY ("replacedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ResultLock
DO $$ BEGIN
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_roundId_fkey" FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ResultLock" ADD CONSTRAINT "ResultLock_lockedById_fkey" FOREIGN KEY ("lockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ResultUnlockEvent
DO $$ BEGIN
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_resultLockId_fkey" FOREIGN KEY ("resultLockId") REFERENCES "ResultLock"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ResultUnlockEvent" ADD CONSTRAINT "ResultUnlockEvent_unlockedById_fkey" FOREIGN KEY ("unlockedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- FKs on modified existing tables
DO $$ BEGIN
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_competitionId_fkey" FOREIGN KEY ("competitionId") REFERENCES "Competition"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_evaluationRoundId_fkey" FOREIGN KEY ("evaluationRoundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "SpecialAward" ADD CONSTRAINT "SpecialAward_juryGroupId_fkey" FOREIGN KEY ("juryGroupId") REFERENCES "JuryGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "MentorMessage" ADD CONSTRAINT "MentorMessage_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "MentorAssignment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionWindowId_fkey" FOREIGN KEY ("submissionWindowId") REFERENCES "SubmissionWindow"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_submissionFileRequirementId_fkey" FOREIGN KEY ("submissionFileRequirementId") REFERENCES "SubmissionFileRequirement"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

View File

@@ -1,3 +1,7 @@
-- AlterTable
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceCap" INTEGER,
ADD COLUMN "selfServiceRatio" DOUBLE PRECISION;
DO $$ BEGIN
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceCap" INTEGER;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;
DO $$ BEGIN
ALTER TABLE "JuryGroupMember" ADD COLUMN "selfServiceRatio" DOUBLE PRECISION;
EXCEPTION WHEN duplicate_column THEN NULL; END $$;

View File

@@ -1,14 +1,14 @@
-- =============================================================================
-- Phase 7/8 Migration Part 1: Rename stageId roundId on 15 tables
-- Phase 7/8 Migration Part 1: Rename stageId -> roundId on 15 tables
-- =============================================================================
-- This migration renames stageId columns to roundId and updates FK constraints
-- to point to the Round table instead of Stage table.
--
-- NOTE: After the pipeline migration (20260213), most tables have BOTH a
-- nullable roundId column (legacy, no FK) AND a stageId column. We must
-- drop the old roundId column before renaming stageId roundId.
-- drop the old roundId column before renaming stageId -> roundId.
-- ─── 1. EvaluationForm ───────────────────────────────────────────────────────
-- --- 1. EvaluationForm ---
-- Drop old roundId column (nullable, no FK since 20260213 migration)
ALTER TABLE "EvaluationForm" DROP COLUMN IF EXISTS "roundId";
@@ -20,18 +20,22 @@ ALTER TABLE "EvaluationForm" DROP CONSTRAINT IF EXISTS "EvaluationForm_stageId_f
DROP INDEX IF EXISTS "EvaluationForm_stageId_version_key";
DROP INDEX IF EXISTS "EvaluationForm_stageId_isActive_idx";
-- Rename column
-- Rename column (only if stageId exists)
DO $$ BEGIN
ALTER TABLE "EvaluationForm" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
-- Recreate indexes with new name
CREATE UNIQUE INDEX "EvaluationForm_roundId_version_key" ON "EvaluationForm"("roundId", "version");
CREATE INDEX "EvaluationForm_roundId_isActive_idx" ON "EvaluationForm"("roundId", "isActive");
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationForm_roundId_version_key" ON "EvaluationForm"("roundId", "version");
CREATE INDEX IF NOT EXISTS "EvaluationForm_roundId_isActive_idx" ON "EvaluationForm"("roundId", "isActive");
-- Recreate FK pointing to Round
DO $$ BEGIN
ALTER TABLE "EvaluationForm" ADD CONSTRAINT "EvaluationForm_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 2. FileRequirement ──────────────────────────────────────────────────────
-- --- 2. FileRequirement ---
ALTER TABLE "FileRequirement" DROP COLUMN IF EXISTS "roundId";
@@ -39,14 +43,18 @@ ALTER TABLE "FileRequirement" DROP CONSTRAINT IF EXISTS "FileRequirement_stageId
DROP INDEX IF EXISTS "FileRequirement_stageId_idx";
DO $$ BEGIN
ALTER TABLE "FileRequirement" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE INDEX "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
CREATE INDEX IF NOT EXISTS "FileRequirement_roundId_idx" ON "FileRequirement"("roundId");
DO $$ BEGIN
ALTER TABLE "FileRequirement" ADD CONSTRAINT "FileRequirement_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 3. Assignment ───────────────────────────────────────────────────────────
-- --- 3. Assignment ---
ALTER TABLE "Assignment" DROP COLUMN IF EXISTS "roundId";
@@ -55,15 +63,19 @@ ALTER TABLE "Assignment" DROP CONSTRAINT IF EXISTS "Assignment_stageId_fkey";
DROP INDEX IF EXISTS "Assignment_userId_projectId_stageId_key";
DROP INDEX IF EXISTS "Assignment_stageId_idx";
DO $$ BEGIN
ALTER TABLE "Assignment" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE UNIQUE INDEX "Assignment_userId_projectId_roundId_key" ON "Assignment"("userId", "projectId", "roundId");
CREATE INDEX "Assignment_roundId_idx" ON "Assignment"("roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "Assignment_userId_projectId_roundId_key" ON "Assignment"("userId", "projectId", "roundId");
CREATE INDEX IF NOT EXISTS "Assignment_roundId_idx" ON "Assignment"("roundId");
DO $$ BEGIN
ALTER TABLE "Assignment" ADD CONSTRAINT "Assignment_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 4. GracePeriod ──────────────────────────────────────────────────────────
-- --- 4. GracePeriod ---
ALTER TABLE "GracePeriod" DROP COLUMN IF EXISTS "roundId";
@@ -72,15 +84,19 @@ ALTER TABLE "GracePeriod" DROP CONSTRAINT IF EXISTS "GracePeriod_stageId_fkey";
DROP INDEX IF EXISTS "GracePeriod_stageId_idx";
DROP INDEX IF EXISTS "GracePeriod_stageId_userId_extendedUntil_idx";
DO $$ BEGIN
ALTER TABLE "GracePeriod" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE INDEX "GracePeriod_roundId_idx" ON "GracePeriod"("roundId");
CREATE INDEX "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_idx" ON "GracePeriod"("roundId");
CREATE INDEX IF NOT EXISTS "GracePeriod_roundId_userId_extendedUntil_idx" ON "GracePeriod"("roundId", "userId", "extendedUntil");
DO $$ BEGIN
ALTER TABLE "GracePeriod" ADD CONSTRAINT "GracePeriod_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 5. LiveVotingSession ────────────────────────────────────────────────────
-- --- 5. LiveVotingSession ---
ALTER TABLE "LiveVotingSession" DROP COLUMN IF EXISTS "roundId";
@@ -88,14 +104,18 @@ ALTER TABLE "LiveVotingSession" DROP CONSTRAINT IF EXISTS "LiveVotingSession_sta
DROP INDEX IF EXISTS "LiveVotingSession_stageId_key";
DO $$ BEGIN
ALTER TABLE "LiveVotingSession" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE UNIQUE INDEX "LiveVotingSession_roundId_key" ON "LiveVotingSession"("roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "LiveVotingSession_roundId_key" ON "LiveVotingSession"("roundId");
DO $$ BEGIN
ALTER TABLE "LiveVotingSession" ADD CONSTRAINT "LiveVotingSession_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 6. FilteringRule ────────────────────────────────────────────────────────
-- --- 6. FilteringRule ---
ALTER TABLE "FilteringRule" DROP COLUMN IF EXISTS "roundId";
@@ -103,14 +123,18 @@ ALTER TABLE "FilteringRule" DROP CONSTRAINT IF EXISTS "FilteringRule_stageId_fke
DROP INDEX IF EXISTS "FilteringRule_stageId_idx";
DO $$ BEGIN
ALTER TABLE "FilteringRule" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE INDEX "FilteringRule_roundId_idx" ON "FilteringRule"("roundId");
CREATE INDEX IF NOT EXISTS "FilteringRule_roundId_idx" ON "FilteringRule"("roundId");
DO $$ BEGIN
ALTER TABLE "FilteringRule" ADD CONSTRAINT "FilteringRule_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 7. FilteringResult ──────────────────────────────────────────────────────
-- --- 7. FilteringResult ---
ALTER TABLE "FilteringResult" DROP COLUMN IF EXISTS "roundId";
@@ -119,15 +143,19 @@ ALTER TABLE "FilteringResult" DROP CONSTRAINT IF EXISTS "FilteringResult_stageId
DROP INDEX IF EXISTS "FilteringResult_stageId_projectId_key";
DROP INDEX IF EXISTS "FilteringResult_stageId_idx";
DO $$ BEGIN
ALTER TABLE "FilteringResult" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE UNIQUE INDEX "FilteringResult_roundId_projectId_key" ON "FilteringResult"("roundId", "projectId");
CREATE INDEX "FilteringResult_roundId_idx" ON "FilteringResult"("roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "FilteringResult_roundId_projectId_key" ON "FilteringResult"("roundId", "projectId");
CREATE INDEX IF NOT EXISTS "FilteringResult_roundId_idx" ON "FilteringResult"("roundId");
DO $$ BEGIN
ALTER TABLE "FilteringResult" ADD CONSTRAINT "FilteringResult_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 8. FilteringJob ─────────────────────────────────────────────────────────
-- --- 8. FilteringJob ---
ALTER TABLE "FilteringJob" DROP COLUMN IF EXISTS "roundId";
@@ -135,14 +163,18 @@ ALTER TABLE "FilteringJob" DROP CONSTRAINT IF EXISTS "FilteringJob_stageId_fkey"
DROP INDEX IF EXISTS "FilteringJob_stageId_idx";
DO $$ BEGIN
ALTER TABLE "FilteringJob" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE INDEX "FilteringJob_roundId_idx" ON "FilteringJob"("roundId");
CREATE INDEX IF NOT EXISTS "FilteringJob_roundId_idx" ON "FilteringJob"("roundId");
DO $$ BEGIN
ALTER TABLE "FilteringJob" ADD CONSTRAINT "FilteringJob_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 9. AssignmentJob ────────────────────────────────────────────────────────
-- --- 9. AssignmentJob ---
ALTER TABLE "AssignmentJob" DROP COLUMN IF EXISTS "roundId";
@@ -150,14 +182,18 @@ ALTER TABLE "AssignmentJob" DROP CONSTRAINT IF EXISTS "AssignmentJob_stageId_fke
DROP INDEX IF EXISTS "AssignmentJob_stageId_idx";
DO $$ BEGIN
ALTER TABLE "AssignmentJob" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE INDEX "AssignmentJob_roundId_idx" ON "AssignmentJob"("roundId");
CREATE INDEX IF NOT EXISTS "AssignmentJob_roundId_idx" ON "AssignmentJob"("roundId");
DO $$ BEGIN
ALTER TABLE "AssignmentJob" ADD CONSTRAINT "AssignmentJob_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 10. ReminderLog ─────────────────────────────────────────────────────────
-- --- 10. ReminderLog ---
ALTER TABLE "ReminderLog" DROP COLUMN IF EXISTS "roundId";
@@ -166,15 +202,19 @@ ALTER TABLE "ReminderLog" DROP CONSTRAINT IF EXISTS "ReminderLog_stageId_fkey";
DROP INDEX IF EXISTS "ReminderLog_stageId_userId_type_key";
DROP INDEX IF EXISTS "ReminderLog_stageId_idx";
DO $$ BEGIN
ALTER TABLE "ReminderLog" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE UNIQUE INDEX "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
CREATE INDEX "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "ReminderLog_roundId_userId_type_key" ON "ReminderLog"("roundId", "userId", "type");
CREATE INDEX IF NOT EXISTS "ReminderLog_roundId_idx" ON "ReminderLog"("roundId");
DO $$ BEGIN
ALTER TABLE "ReminderLog" ADD CONSTRAINT "ReminderLog_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 11. EvaluationSummary ───────────────────────────────────────────────────
-- --- 11. EvaluationSummary ---
ALTER TABLE "EvaluationSummary" DROP COLUMN IF EXISTS "roundId";
@@ -183,15 +223,19 @@ ALTER TABLE "EvaluationSummary" DROP CONSTRAINT IF EXISTS "EvaluationSummary_sta
DROP INDEX IF EXISTS "EvaluationSummary_projectId_stageId_key";
DROP INDEX IF EXISTS "EvaluationSummary_stageId_idx";
DO $$ BEGIN
ALTER TABLE "EvaluationSummary" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE UNIQUE INDEX "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
CREATE INDEX "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationSummary_projectId_roundId_key" ON "EvaluationSummary"("projectId", "roundId");
CREATE INDEX IF NOT EXISTS "EvaluationSummary_roundId_idx" ON "EvaluationSummary"("roundId");
DO $$ BEGIN
ALTER TABLE "EvaluationSummary" ADD CONSTRAINT "EvaluationSummary_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 12. EvaluationDiscussion ────────────────────────────────────────────────
-- --- 12. EvaluationDiscussion ---
ALTER TABLE "EvaluationDiscussion" DROP COLUMN IF EXISTS "roundId";
@@ -200,15 +244,19 @@ ALTER TABLE "EvaluationDiscussion" DROP CONSTRAINT IF EXISTS "EvaluationDiscussi
DROP INDEX IF EXISTS "EvaluationDiscussion_projectId_stageId_key";
DROP INDEX IF EXISTS "EvaluationDiscussion_stageId_idx";
DO $$ BEGIN
ALTER TABLE "EvaluationDiscussion" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE UNIQUE INDEX "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
CREATE INDEX "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "EvaluationDiscussion_projectId_roundId_key" ON "EvaluationDiscussion"("projectId", "roundId");
CREATE INDEX IF NOT EXISTS "EvaluationDiscussion_roundId_idx" ON "EvaluationDiscussion"("roundId");
DO $$ BEGIN
ALTER TABLE "EvaluationDiscussion" ADD CONSTRAINT "EvaluationDiscussion_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 13. Message ─────────────────────────────────────────────────────────────
-- --- 13. Message ---
-- Message has roundId (from init, nullable) and stageId (from pipeline, nullable)
ALTER TABLE "Message" DROP COLUMN IF EXISTS "roundId";
@@ -217,42 +265,54 @@ ALTER TABLE "Message" DROP CONSTRAINT IF EXISTS "Message_stageId_fkey";
DROP INDEX IF EXISTS "Message_stageId_idx";
DO $$ BEGIN
ALTER TABLE "Message" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE INDEX "Message_roundId_idx" ON "Message"("roundId");
CREATE INDEX IF NOT EXISTS "Message_roundId_idx" ON "Message"("roundId");
DO $$ BEGIN
ALTER TABLE "Message" ADD CONSTRAINT "Message_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 14. Cohort ──────────────────────────────────────────────────────────────
-- --- 14. Cohort ---
-- Cohort was created in pipeline migration with stageId only (no roundId)
ALTER TABLE "Cohort" DROP CONSTRAINT IF EXISTS "Cohort_stageId_fkey";
DROP INDEX IF EXISTS "Cohort_stageId_idx";
DO $$ BEGIN
ALTER TABLE "Cohort" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE INDEX "Cohort_roundId_idx" ON "Cohort"("roundId");
CREATE INDEX IF NOT EXISTS "Cohort_roundId_idx" ON "Cohort"("roundId");
DO $$ BEGIN
ALTER TABLE "Cohort" ADD CONSTRAINT "Cohort_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 15. LiveProgressCursor ──────────────────────────────────────────────────
-- --- 15. LiveProgressCursor ---
-- LiveProgressCursor was created in pipeline migration with stageId only (no roundId)
ALTER TABLE "LiveProgressCursor" DROP CONSTRAINT IF EXISTS "LiveProgressCursor_stageId_fkey";
DROP INDEX IF EXISTS "LiveProgressCursor_stageId_key";
DO $$ BEGIN
ALTER TABLE "LiveProgressCursor" RENAME COLUMN "stageId" TO "roundId";
EXCEPTION WHEN undefined_column THEN NULL; END $$;
CREATE UNIQUE INDEX "LiveProgressCursor_roundId_key" ON "LiveProgressCursor"("roundId");
CREATE UNIQUE INDEX IF NOT EXISTS "LiveProgressCursor_roundId_key" ON "LiveProgressCursor"("roundId");
DO $$ BEGIN
ALTER TABLE "LiveProgressCursor" ADD CONSTRAINT "LiveProgressCursor_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 16. SpecialAward: Drop trackId column ───────────────────────────────────
-- --- 16. SpecialAward: Drop trackId column ---
ALTER TABLE "SpecialAward" DROP CONSTRAINT IF EXISTS "SpecialAward_trackId_fkey";
@@ -260,12 +320,16 @@ DROP INDEX IF EXISTS "SpecialAward_trackId_key";
ALTER TABLE "SpecialAward" DROP COLUMN IF EXISTS "trackId";
-- ─── 17. ConflictOfInterest: roundId was made nullable in pipeline migration
-- --- 17. ConflictOfInterest: roundId was made nullable in pipeline migration ---
-- It still exists, just restore FK to new Round table
DO $$ BEGIN
ALTER TABLE "ConflictOfInterest" ADD CONSTRAINT "ConflictOfInterest_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─── 18. TaggingJob: roundId was made nullable in pipeline migration ─────────
-- --- 18. TaggingJob: roundId was made nullable in pipeline migration ---
-- Restore FK to new Round table
DO $$ BEGIN
ALTER TABLE "TaggingJob" ADD CONSTRAINT "TaggingJob_roundId_fkey"
FOREIGN KEY ("roundId") REFERENCES "Round"("id") ON DELETE SET NULL ON UPDATE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;

View File

@@ -0,0 +1,2 @@
-- Add pageCount column to ProjectFile (was in schema but missing migration)
ALTER TABLE "ProjectFile" ADD COLUMN IF NOT EXISTS "pageCount" INTEGER;

View File

@@ -0,0 +1,19 @@
-- =============================================================================
-- Schema Reconciliation: Fill remaining gaps between migrations and schema.prisma
-- =============================================================================
-- All statements are idempotent (safe to re-run on any database state).
-- 1. ConflictOfInterest: add standalone hasConflict index (schema has @@index([hasConflict]))
-- Migration 20260205223133 only created composite (roundId, hasConflict) index.
CREATE INDEX IF NOT EXISTS "ConflictOfInterest_hasConflict_idx" ON "ConflictOfInterest"("hasConflict");
-- 2. Ensure ConflictOfInterest.roundId is nullable (schema says String?)
-- Pipeline migration (20260213) makes it nullable, but guard for safety.
DO $$ BEGIN
ALTER TABLE "ConflictOfInterest" ALTER COLUMN "roundId" DROP NOT NULL;
EXCEPTION WHEN others THEN NULL;
END $$;
-- 3. Drop stale composite index that no longer matches schema
-- Schema only has @@index([hasConflict]) and @@index([userId]), not (roundId, hasConflict).
DROP INDEX IF EXISTS "ConflictOfInterest_roundId_hasConflict_idx";

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "ProjectFile" ADD COLUMN "textPreview" TEXT;
ALTER TABLE "ProjectFile" ADD COLUMN "detectedLang" TEXT;
ALTER TABLE "ProjectFile" ADD COLUMN "langConfidence" DOUBLE PRECISION;
ALTER TABLE "ProjectFile" ADD COLUMN "analyzedAt" TIMESTAMP(3);

View File

@@ -0,0 +1,20 @@
-- AlterTable: Add shortlistSize to SpecialAward
ALTER TABLE "SpecialAward" ADD COLUMN IF NOT EXISTS "shortlistSize" INTEGER NOT NULL DEFAULT 10;
-- AlterTable: Add qualityScore, shortlisted, confirmedAt, confirmedBy to AwardEligibility
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "qualityScore" DOUBLE PRECISION;
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "shortlisted" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedAt" TIMESTAMP(3);
ALTER TABLE "AwardEligibility" ADD COLUMN IF NOT EXISTS "confirmedBy" TEXT;
-- AlterTable: Add specialAwardId to Round
ALTER TABLE "Round" ADD COLUMN IF NOT EXISTS "specialAwardId" TEXT;
-- AddForeignKey: AwardEligibility.confirmedBy -> User.id
ALTER TABLE "AwardEligibility" ADD CONSTRAINT "AwardEligibility_confirmedBy_fkey" FOREIGN KEY ("confirmedBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey: Round.specialAwardId -> SpecialAward.id
ALTER TABLE "Round" ADD CONSTRAINT "Round_specialAwardId_fkey" FOREIGN KEY ("specialAwardId") REFERENCES "SpecialAward"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX IF NOT EXISTS "Round_specialAwardId_idx" ON "Round"("specialAwardId");

View File

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

View File

@@ -0,0 +1,13 @@
-- Delete any existing LOCALIZATION settings
DELETE FROM "SystemSettings" WHERE category = 'LOCALIZATION';
-- Add provider field to AIUsageLog for cross-provider cost tracking
ALTER TABLE "AIUsageLog" ADD COLUMN "provider" TEXT;
-- Remove LOCALIZATION from SettingCategory enum
-- First create new enum without the value, then swap
CREATE TYPE "SettingCategory_new" AS ENUM ('AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'AUDIT_CONFIG', 'DIGEST', 'ANALYTICS', 'INTEGRATIONS', 'COMMUNICATION', 'FEATURE_FLAGS');
ALTER TABLE "SystemSettings" ALTER COLUMN "category" TYPE "SettingCategory_new" USING ("category"::text::"SettingCategory_new");
ALTER TYPE "SettingCategory" RENAME TO "SettingCategory_old";
ALTER TYPE "SettingCategory_new" RENAME TO "SettingCategory";
DROP TYPE "SettingCategory_old";

View File

@@ -101,7 +101,6 @@ enum SettingCategory {
DEFAULTS
WHATSAPP
AUDIT_CONFIG
LOCALIZATION
DIGEST
ANALYTICS
INTEGRATIONS
@@ -351,6 +350,9 @@ model User {
preferredWorkload Int?
availabilityJson Json? @db.JsonB // { startDate?: string, endDate?: string }
// Test environment isolation
isTest Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
@@ -379,6 +381,7 @@ model User {
// Award overrides
awardEligibilityOverrides AwardEligibility[] @relation("AwardEligibilityOverriddenBy")
awardEligibilityConfirms AwardEligibility[] @relation("AwardEligibilityConfirmer")
awardWinnerOverrides SpecialAward[] @relation("AwardOverriddenBy")
// In-app notifications
@@ -494,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
@@ -618,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
@@ -687,6 +696,13 @@ model ProjectFile {
fileName String
mimeType String
size Int // bytes
pageCount Int? // Number of pages (PDFs, presentations, etc.)
// Document analysis (optional, populated by document-analyzer service)
textPreview String? @db.Text // First ~2000 chars of extracted text
detectedLang String? // ISO 639-3 code (e.g. 'eng', 'fra', 'und')
langConfidence Float? // 0.01.0 confidence
analyzedAt DateTime? // When analysis last ran
// MinIO location
bucket String
@@ -899,7 +915,8 @@ model AIUsageLog {
entityId String?
// What was used
model String // gpt-4o, gpt-4o-mini, o1, etc.
model String // gpt-4o, gpt-4o-mini, o1, claude-sonnet-4-5, etc.
provider String? // openai, anthropic, litellm
promptTokens Int
completionTokens Int
totalTokens Int
@@ -1500,6 +1517,7 @@ model SpecialAward {
juryGroupId String?
eligibilityMode AwardEligibilityMode @default(STAY_IN_MAIN)
decisionMode String? // "JURY_VOTE" | "AWARD_MASTER_DECISION" | "ADMIN_DECISION"
shortlistSize Int @default(10)
// Eligibility job tracking
eligibilityJobStatus String? // PENDING, PROCESSING, COMPLETED, FAILED
@@ -1523,6 +1541,7 @@ model SpecialAward {
competition Competition? @relation(fields: [competitionId], references: [id], onDelete: SetNull)
evaluationRound Round? @relation(fields: [evaluationRoundId], references: [id], onDelete: SetNull)
awardJuryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
rounds Round[] @relation("AwardRounds")
@@index([programId])
@@index([status])
@@ -1538,11 +1557,17 @@ model AwardEligibility {
method EligibilityMethod @default(AUTO)
eligible Boolean @default(false)
aiReasoningJson Json? @db.JsonB
qualityScore Float?
shortlisted Boolean @default(false)
// Admin override
overriddenBy String?
overriddenAt DateTime?
// Shortlist confirmation
confirmedAt DateTime?
confirmedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -1550,6 +1575,7 @@ model AwardEligibility {
award SpecialAward @relation(fields: [awardId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
overriddenByUser User? @relation("AwardEligibilityOverriddenBy", fields: [overriddenBy], references: [id], onDelete: SetNull)
confirmer User? @relation("AwardEligibilityConfirmer", fields: [confirmedBy], references: [id], onDelete: SetNull)
@@unique([awardId, projectId])
@@index([awardId])
@@ -2073,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
@@ -2087,6 +2116,7 @@ model Competition {
@@index([programId])
@@index([status])
@@index([isTest])
}
model Round {
@@ -2111,12 +2141,14 @@ model Round {
// Links to other entities
juryGroupId String?
submissionWindowId String?
specialAwardId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
competition Competition @relation(fields: [competitionId], references: [id], onDelete: Cascade)
specialAward SpecialAward? @relation("AwardRounds", fields: [specialAwardId], references: [id], onDelete: SetNull)
juryGroup JuryGroup? @relation(fields: [juryGroupId], references: [id], onDelete: SetNull)
submissionWindow SubmissionWindow? @relation(fields: [submissionWindowId], references: [id], onDelete: SetNull)
projectRoundStates ProjectRoundState[]
@@ -2150,6 +2182,7 @@ model Round {
@@index([competitionId])
@@index([roundType])
@@index([status])
@@index([specialAwardId])
}
model ProjectRoundState {

View File

@@ -131,7 +131,7 @@ async function main() {
const existingTags = await prisma.expertiseTag.findMany({
select: { name: true },
})
const existingNames = new Set(existingTags.map((t) => t.name))
const existingNames = new Set(existingTags.map((t: { name: string }) => t.name))
// Filter out tags that already exist
const newTags = EXPERTISE_TAGS.filter((t) => !existingNames.has(t.name))

View File

@@ -83,6 +83,11 @@ const ACTION_TYPES = [
'ROLE_CHANGED',
'PASSWORD_SET',
'PASSWORD_CHANGED',
'JUROR_DROPOUT_RESHUFFLE',
'COI_REASSIGNMENT',
'APPLY_AI_SUGGESTIONS',
'APPLY_SUGGESTIONS',
'NOTIFY_JURORS_OF_ASSIGNMENTS',
]
// Entity type options
@@ -118,6 +123,11 @@ const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'ou
ROLE_CHANGED: 'secondary',
PASSWORD_SET: 'outline',
PASSWORD_CHANGED: 'outline',
JUROR_DROPOUT_RESHUFFLE: 'destructive',
COI_REASSIGNMENT: 'secondary',
APPLY_AI_SUGGESTIONS: 'default',
APPLY_SUGGESTIONS: 'default',
NOTIFY_JURORS_OF_ASSIGNMENTS: 'outline',
}
export default function AuditLogPage() {
@@ -151,7 +161,7 @@ export default function AuditLogPage() {
)
// Fetch audit logs
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput)
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput, { refetchInterval: 30_000 })
// Fetch users for filter dropdown
const { data: usersData } = trpc.user.list.useQuery({
@@ -516,9 +526,15 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground mb-1">
Details
</p>
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
) : log.action === 'COI_REASSIGNMENT' ? (
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
) : (
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
)}
</div>
)}
{!!(log as Record<string, unknown>).previousDataJson && (
@@ -622,9 +638,15 @@ export default function AuditLogPage() {
<p className="text-xs font-medium text-muted-foreground mb-1">
Details
</p>
{log.action === 'JUROR_DROPOUT_RESHUFFLE' ? (
<ReshuffleDetailView details={log.detailsJson as Record<string, unknown>} />
) : log.action === 'COI_REASSIGNMENT' ? (
<COIReassignmentDetailView details={log.detailsJson as Record<string, unknown>} />
) : (
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
)}
</div>
)}
</div>
@@ -693,6 +715,129 @@ export default function AuditLogPage() {
)
}
function ReshuffleDetailView({ details }: { details: Record<string, unknown> }) {
const reassignedTo = (details.reassignedTo ?? {}) as Record<string, number>
const jurorIds = Object.keys(reassignedTo)
const moves = (details.moves ?? []) as { projectId: string; projectTitle: string; newJurorId: string; newJurorName: string }[]
// Resolve juror IDs to names
const { data: nameMap } = trpc.user.resolveNames.useQuery(
{ ids: [...jurorIds, details.droppedJurorId as string].filter(Boolean) },
{ enabled: jurorIds.length > 0 },
)
const droppedName = (details.droppedJurorName as string) || (nameMap && details.droppedJurorId ? nameMap[details.droppedJurorId as string] : null) || (details.droppedJurorId as string)
return (
<div className="rounded-lg border bg-white overflow-hidden text-sm">
{/* Summary */}
<div className="p-3 bg-muted/50 border-b space-y-1">
<div className="flex items-center gap-2">
<Badge variant="destructive">Juror Dropout</Badge>
<span className="font-semibold">{droppedName}</span>
</div>
<p className="text-xs text-muted-foreground">
{String(details.movedCount)} project(s) reassigned, {String(details.failedCount)} failed
{details.removedFromGroup ? ' — removed from jury group' : ''}
</p>
</div>
{/* Per-project moves (new format) */}
{moves.length > 0 && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Project New Juror</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Project</th>
<th className="text-left py-1 font-medium">Reassigned To</th>
</tr>
</thead>
<tbody>
{moves.map((move, i) => (
<tr key={i} className="border-b last:border-0">
<td className="py-1.5 pr-2">{move.projectTitle}</td>
<td className="py-1.5 font-medium">{move.newJurorName}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Fallback: count-based view (old format, no per-project detail) */}
{moves.length === 0 && jurorIds.length > 0 && (
<div className="p-3">
<p className="text-xs font-medium text-muted-foreground mb-2">Reassignment Summary (project detail not available)</p>
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b">
<th className="text-left py-1 font-medium">Juror</th>
<th className="text-right py-1 font-medium">Projects Received</th>
</tr>
</thead>
<tbody>
{jurorIds.map((id) => (
<tr key={id} className="border-b last:border-0">
<td className="py-1.5">{nameMap?.[id] || id}</td>
<td className="py-1.5 text-right font-medium">{reassignedTo[id]}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Failed projects */}
{Array.isArray(details.failedProjects) && (details.failedProjects as string[]).length > 0 && (
<div className="p-3 border-t bg-red-50/50">
<p className="text-xs font-medium text-red-700 mb-1">Could not reassign:</p>
<ul className="text-xs text-muted-foreground list-disc list-inside">
{(details.failedProjects as string[]).map((p, i) => (
<li key={i}>{p}</li>
))}
</ul>
</div>
)}
</div>
)
}
function COIReassignmentDetailView({ details }: { details: Record<string, unknown> }) {
const ids = [details.oldJurorId, details.newJurorId].filter(Boolean) as string[]
const { data: nameMap } = trpc.user.resolveNames.useQuery(
{ ids },
{ enabled: ids.length > 0 },
)
const oldName = nameMap?.[details.oldJurorId as string] || (details.oldJurorId as string)
const newName = nameMap?.[details.newJurorId as string] || (details.newJurorId as string)
return (
<div className="rounded-lg border bg-white overflow-hidden text-sm">
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">COI Reassignment</Badge>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<p className="text-muted-foreground">From</p>
<p className="font-medium">{oldName}</p>
</div>
<div>
<p className="text-muted-foreground">To</p>
<p className="font-medium">{newName}</p>
</div>
</div>
<div className="text-xs text-muted-foreground">
Project: <span className="font-mono">{(details.projectId as string)?.slice(0, 12)}...</span>
{' | '}Round: <span className="font-mono">{(details.roundId as string)?.slice(0, 12)}...</span>
</div>
</div>
</div>
)
}
function DiffViewer({ before, after }: { before: unknown; after: unknown }) {
const beforeObj = typeof before === 'object' && before !== null ? before as Record<string, unknown> : {}
const afterObj = typeof after === 'object' && after !== null ? after as Record<string, unknown> : {}

View File

@@ -25,15 +25,7 @@ import {
import { Switch } from '@/components/ui/switch'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2, Plus, X, Info } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
type AutoTagRule = {
id: string
field: 'competitionCategory' | 'country' | 'geographicZone' | 'tags' | 'oceanIssue'
operator: 'equals' | 'contains' | 'in'
value: string
}
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
export default function EditAwardPage({
params,
@@ -46,12 +38,8 @@ export default function EditAwardPage({
const utils = trpc.useUtils()
const { data: award, isLoading } = trpc.specialAward.get.useQuery({ id: awardId })
// Fetch competition rounds for source round selector
const competitionId = award?.competitionId
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId! },
{ enabled: !!competitionId }
)
// Rounds come from the award's included competition relation
const competitionRounds = award?.competition?.rounds ?? []
const updateAward = trpc.specialAward.update.useMutation({
onSuccess: () => {
@@ -70,7 +58,6 @@ export default function EditAwardPage({
const [votingEndAt, setVotingEndAt] = useState('')
const [evaluationRoundId, setEvaluationRoundId] = useState('')
const [eligibilityMode, setEligibilityMode] = useState<'STAY_IN_MAIN' | 'SEPARATE_POOL'>('STAY_IN_MAIN')
const [autoTagRules, setAutoTagRules] = useState<AutoTagRule[]>([])
// Helper to format date for datetime-local input
const formatDateForInput = (date: Date | string | null | undefined): string => {
@@ -93,14 +80,6 @@ export default function EditAwardPage({
setVotingEndAt(formatDateForInput(award.votingEndAt))
setEvaluationRoundId(award.evaluationRoundId || '')
setEligibilityMode(award.eligibilityMode as 'STAY_IN_MAIN' | 'SEPARATE_POOL')
// Parse autoTagRulesJson
if (award.autoTagRulesJson && typeof award.autoTagRulesJson === 'object') {
const rules = award.autoTagRulesJson as { rules?: AutoTagRule[] }
setAutoTagRules(rules.rules || [])
} else {
setAutoTagRules([])
}
}
}, [award])
@@ -119,7 +98,6 @@ export default function EditAwardPage({
votingEndAt: votingEndAt ? new Date(votingEndAt) : undefined,
evaluationRoundId: evaluationRoundId || undefined,
eligibilityMode,
autoTagRulesJson: autoTagRules.length > 0 ? { rules: autoTagRules } : undefined,
})
toast.success('Award updated')
router.push(`/admin/awards/${awardId}`)
@@ -130,28 +108,6 @@ export default function EditAwardPage({
}
}
const addRule = () => {
setAutoTagRules([
...autoTagRules,
{
id: `rule-${Date.now()}`,
field: 'competitionCategory',
operator: 'equals',
value: '',
},
])
}
const removeRule = (id: string) => {
setAutoTagRules(autoTagRules.filter((r) => r.id !== id))
}
const updateRule = (id: string, updates: Partial<AutoTagRule>) => {
setAutoTagRules(
autoTagRules.map((r) => (r.id === id ? { ...r, ...updates } : r))
)
}
if (isLoading) {
return (
<div className="space-y-6">
@@ -306,9 +262,7 @@ export default function EditAwardPage({
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No source round</SelectItem>
{competition?.rounds
?.sort((a, b) => a.sortOrder - b.sortOrder)
.map((round) => (
{competitionRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name} ({round.roundType})
</SelectItem>
@@ -348,135 +302,6 @@ export default function EditAwardPage({
</CardContent>
</Card>
{/* Auto-Tag Rules */}
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Auto-Tag Rules</CardTitle>
<CardDescription>
Deterministic eligibility rules based on project metadata
</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={addRule}>
<Plus className="mr-2 h-4 w-4" />
Add Rule
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{autoTagRules.length === 0 ? (
<div className="flex items-start gap-2 rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p>
No rules defined. Add rules to automatically filter projects based on category, location, tags, or ocean issues.
Rules work together with the source round setting.
</p>
</div>
) : (
<div className="space-y-3">
{autoTagRules.map((rule, index) => (
<div
key={rule.id}
className="flex items-start gap-3 rounded-lg border p-3"
>
<div className="flex-1 grid gap-3 sm:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Field</Label>
<Select
value={rule.field}
onValueChange={(v) =>
updateRule(rule.id, {
field: v as AutoTagRule['field'],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="competitionCategory">
Competition Category
</SelectItem>
<SelectItem value="country">Country</SelectItem>
<SelectItem value="geographicZone">
Geographic Zone
</SelectItem>
<SelectItem value="tags">Tags</SelectItem>
<SelectItem value="oceanIssue">Ocean Issue</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Operator</Label>
<Select
value={rule.operator}
onValueChange={(v) =>
updateRule(rule.id, {
operator: v as AutoTagRule['operator'],
})
}
>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals">Equals</SelectItem>
<SelectItem value="contains">Contains</SelectItem>
<SelectItem value="in">In (comma-separated)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Value</Label>
<Input
className="h-9"
value={rule.value}
onChange={(e) =>
updateRule(rule.id, { value: e.target.value })
}
placeholder={
rule.operator === 'in'
? 'value1,value2,value3'
: 'Enter value...'
}
/>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => removeRule(rule.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
{autoTagRules.length > 0 && (
<div className="flex items-start gap-2 rounded-lg bg-muted p-3 text-xs text-muted-foreground">
<Info className="h-3 w-3 mt-0.5 shrink-0" />
<p>
<strong>How it works:</strong> Filter from{' '}
<Badge variant="outline" className="mx-1">
{evaluationRoundId
? competition?.rounds?.find((r) => r.id === evaluationRoundId)
?.name || 'Selected Round'
: 'All Projects'}
</Badge>
, where ALL rules match (AND logic). Projects matching these deterministic rules will be marked eligible.
</p>
</div>
)}
</CardContent>
</Card>
{/* Voting Window Card */}
<Card>
<CardHeader>

View File

@@ -89,6 +89,8 @@ import {
Vote,
ChevronDown,
AlertCircle,
Layers,
Info,
} from 'lucide-react'
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
@@ -151,6 +153,8 @@ export default function AwardDetailPage({
const [projectSearchQuery, setProjectSearchQuery] = useState('')
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [activeTab, setActiveTab] = useState('eligibility')
const [addRoundOpen, setAddRoundOpen] = useState(false)
const [roundForm, setRoundForm] = useState({ name: '', roundType: 'EVALUATION' as string })
// Pagination for eligibility list
const [eligibilityPage, setEligibilityPage] = useState(1)
@@ -158,7 +162,7 @@ export default function AwardDetailPage({
// Core queries — lazy-load tab-specific data based on activeTab
const { data: award, isLoading, refetch } =
trpc.specialAward.get.useQuery({ id: awardId })
trpc.specialAward.get.useQuery({ id: awardId }, { refetchInterval: 30_000 })
const { data: eligibilityData, refetch: refetchEligibility } =
trpc.specialAward.listEligible.useQuery({
awardId,
@@ -175,6 +179,10 @@ export default function AwardDetailPage({
trpc.specialAward.getVoteResults.useQuery({ awardId }, {
enabled: activeTab === 'results',
})
const { data: awardRounds, refetch: refetchRounds } =
trpc.specialAward.listRounds.useQuery({ awardId }, {
enabled: activeTab === 'rounds',
})
// Deferred queries - only load when needed
const { data: allUsers } = trpc.user.list.useQuery(
@@ -258,6 +266,22 @@ export default function AwardDetailPage({
const deleteAward = trpc.specialAward.delete.useMutation({
onSuccess: () => utils.specialAward.list.invalidate(),
})
const createRound = trpc.specialAward.createRound.useMutation({
onSuccess: () => {
refetchRounds()
setAddRoundOpen(false)
setRoundForm({ name: '', roundType: 'EVALUATION' })
toast.success('Round created')
},
onError: (err) => toast.error(err.message),
})
const deleteRound = trpc.specialAward.deleteRound.useMutation({
onSuccess: () => {
refetchRounds()
toast.success('Round deleted')
},
onError: (err) => toast.error(err.message),
})
const handleStatusChange = async (
status: 'DRAFT' | 'NOMINATIONS_OPEN' | 'VOTING_OPEN' | 'CLOSED' | 'ARCHIVED'
@@ -414,7 +438,7 @@ export default function AwardDetailPage({
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
{award.status.replace('_', ' ')}
{award.status.replace(/_/g, ' ')}
</Badge>
<span className="text-muted-foreground">
{award.program.year} Edition
@@ -570,7 +594,7 @@ export default function AwardDetailPage({
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Evaluated</p>
<p className="text-2xl font-bold tabular-nums">{award._count.eligibilities}</p>
<p className="text-2xl font-bold tabular-nums">{(award as any).totalAssessed ?? award._count.eligibilities}</p>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-950/40">
<ListChecks className="h-5 w-5 text-blue-600 dark:text-blue-400" />
@@ -619,6 +643,10 @@ export default function AwardDetailPage({
<Users className="mr-2 h-4 w-4" />
Jurors ({award._count.jurors})
</TabsTrigger>
<TabsTrigger value="rounds">
<Layers className="mr-2 h-4 w-4" />
Rounds {awardRounds ? `(${awardRounds.length})` : ''}
</TabsTrigger>
<TabsTrigger value="results">
<BarChart3 className="mr-2 h-4 w-4" />
Results
@@ -629,7 +657,7 @@ export default function AwardDetailPage({
<TabsContent value="eligibility" className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<p className="text-sm text-muted-foreground">
{award.eligibleCount} of {award._count.eligibilities} projects
{award.eligibleCount} of {(award as any).totalAssessed ?? award._count.eligibilities} projects
eligible
</p>
<div className="flex items-center gap-4">
@@ -1083,6 +1111,199 @@ export default function AwardDetailPage({
)}
</TabsContent>
{/* Rounds Tab */}
<TabsContent value="rounds" className="space-y-4">
{award.eligibilityMode !== 'SEPARATE_POOL' && (
<div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50 p-3 text-blue-800 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300">
<Info className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Rounds are used in <strong>Separate Pool</strong> mode to create a dedicated evaluation track for shortlisted projects.
</p>
</div>
)}
{!award.competitionId && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
Link this award to a competition first before creating rounds.
</p>
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-lg font-semibold">Award Rounds ({awardRounds?.length ?? 0})</h2>
<Dialog open={addRoundOpen} onOpenChange={setAddRoundOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" disabled={!award.competitionId}>
<Plus className="h-4 w-4 mr-1" />
Add Round
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Award Round</DialogTitle>
<DialogDescription>
Add a new round to the &quot;{award.name}&quot; award evaluation track.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="round-name">Round Name</Label>
<Input
id="round-name"
placeholder="e.g. Award Evaluation"
value={roundForm.name}
onChange={(e) => setRoundForm({ ...roundForm, name: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="round-type">Round Type</Label>
<Select
value={roundForm.roundType}
onValueChange={(v) => setRoundForm({ ...roundForm, roundType: v })}
>
<SelectTrigger id="round-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVALUATION">Evaluation</SelectItem>
<SelectItem value="FILTERING">Filtering</SelectItem>
<SelectItem value="SUBMISSION">Submission</SelectItem>
<SelectItem value="MENTORING">Mentoring</SelectItem>
<SelectItem value="LIVE_FINAL">Live Final</SelectItem>
<SelectItem value="DELIBERATION">Deliberation</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddRoundOpen(false)}>Cancel</Button>
<Button
onClick={() => createRound.mutate({
awardId,
name: roundForm.name.trim(),
roundType: roundForm.roundType as any,
})}
disabled={!roundForm.name.trim() || createRound.isPending}
>
{createRound.isPending ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Creating...</>
) : 'Create Round'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{!awardRounds ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
) : awardRounds.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No rounds yet. Create your first award round to build an evaluation track.
</CardContent>
</Card>
) : (
<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 &quot;{round.name}&quot;. 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>
{/* Results Tab */}
<TabsContent value="results" className="space-y-4">
{voteResults && voteResults.results.length > 0 ? (() => {

View File

@@ -40,7 +40,10 @@ const SCORING_LABELS: Record<string, string> = {
}
export default function AwardsListPage() {
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({})
const { data: awards, isLoading } = trpc.specialAward.list.useQuery(
{},
{ refetchInterval: 30_000 }
)
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)
@@ -168,7 +171,7 @@ export default function AwardsListPage() {
{award.name}
</CardTitle>
<Badge variant={STATUS_COLORS[award.status] || 'secondary'}>
{award.status.replace('_', ' ')}
{award.status.replace(/_/g, ' ')}
</Badge>
</div>
{award.description && (

View File

@@ -2,7 +2,8 @@
import { useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { ArrowLeft, PlayCircle } from 'lucide-react'
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'
@@ -26,13 +27,30 @@ export default function AssignmentsDashboardPage() {
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: 3 },
{ roundId: selectedRoundId, requiredReviews },
{ enabled: !!selectedRoundId }
)
@@ -51,7 +69,18 @@ export default function AssignmentsDashboardPage() {
if (!competition) {
return (
<div className="container mx-auto space-y-6 p-4 sm:p-6">
<p>Competition not found</p>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="font-medium">Competition not found</p>
<p className="text-sm text-muted-foreground mt-1">
The requested competition does not exist or you don&apos;t have access.
</p>
<Button variant="outline" className="mt-4" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" />
Go Back
</Button>
</CardContent>
</Card>
</div>
)
}
@@ -97,11 +126,24 @@ export default function AssignmentsDashboardPage() {
{selectedRoundId && (
<div className="space-y-6">
<div className="flex justify-end">
<Button onClick={() => setPreviewSheetOpen(true)}>
<PlayCircle className="mr-2 h-4 w-4" />
Generate Assignments
<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">
@@ -111,7 +153,7 @@ export default function AssignmentsDashboardPage() {
</TabsList>
<TabsContent value="coverage" className="mt-6">
<CoverageReport roundId={selectedRoundId} />
<CoverageReport roundId={selectedRoundId} requiredReviews={requiredReviews} />
</TabsContent>
<TabsContent value="unassigned" className="mt-6">
@@ -119,7 +161,7 @@ export default function AssignmentsDashboardPage() {
<CardHeader>
<CardTitle>Unassigned Projects</CardTitle>
<CardDescription>
Projects with fewer than 3 assignments
Projects with fewer than {requiredReviews} assignments
</CardDescription>
</CardHeader>
<CardContent>
@@ -143,7 +185,7 @@ export default function AssignmentsDashboardPage() {
</p>
</div>
<div className="text-sm text-muted-foreground">
{project.assignmentCount || 0} / 3 assignments
{project.assignmentCount || 0} / {requiredReviews} assignments
</div>
</div>
))}
@@ -162,6 +204,11 @@ export default function AssignmentsDashboardPage() {
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>
)}

View File

@@ -22,8 +22,10 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
const [formData, setFormData] = useState({
name: '',
description: '',
criteriaText: '',
useAiEligibility: false,
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED'
scoringMode: 'PICK_WINNER' as 'PICK_WINNER' | 'RANKED' | 'SCORED',
maxRankedPicks: '3',
});
const { data: competition } = trpc.competition.getById.useQuery({
@@ -60,10 +62,13 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
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
useAiEligibility: formData.useAiEligibility,
maxRankedPicks: formData.scoringMode === 'RANKED' ? parseInt(formData.maxRankedPicks) : undefined,
});
};
@@ -113,22 +118,17 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
</div>
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={formData.scoringMode}
onValueChange={(value) =>
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner</SelectItem>
<SelectItem value="RANKED">Ranked</SelectItem>
<SelectItem value="SCORED">Scored</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="criteriaText">Eligibility Criteria</Label>
<Textarea
id="criteriaText"
value={formData.criteriaText}
onChange={(e) => setFormData({ ...formData, criteriaText: e.target.value })}
placeholder="Describe the criteria in plain language. AI will interpret this to evaluate project eligibility."
rows={4}
/>
<p className="text-xs text-muted-foreground">
This text will be used by AI to determine which projects are eligible for this award.
</p>
</div>
<div className="flex items-center space-x-2">
@@ -144,6 +144,41 @@ export default function NewAwardPage({ params: paramsPromise }: { params: Promis
</Label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="scoringMode">Scoring Mode</Label>
<Select
value={formData.scoringMode}
onValueChange={(value) =>
setFormData({ ...formData, scoringMode: value as 'PICK_WINNER' | 'RANKED' | 'SCORED' })
}
>
<SelectTrigger id="scoringMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PICK_WINNER">Pick Winner Each juror picks 1</SelectItem>
<SelectItem value="RANKED">Ranked Each juror ranks top N</SelectItem>
<SelectItem value="SCORED">Scored Use evaluation form</SelectItem>
</SelectContent>
</Select>
</div>
{formData.scoringMode === 'RANKED' && (
<div className="space-y-2">
<Label htmlFor="maxPicks">Max Ranked Picks</Label>
<Input
id="maxPicks"
type="number"
min="1"
max="20"
value={formData.maxRankedPicks}
onChange={(e) => setFormData({ ...formData, maxRankedPicks: e.target.value })}
/>
</div>
)}
</div>
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Button
type="button"

View File

@@ -13,16 +13,34 @@ 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 } = trpc.competition.getById.useQuery({
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery({
id: params.competitionId
});
const { data: awards, isLoading } = trpc.specialAward.list.useQuery({
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">

View File

@@ -1,6 +1,6 @@
'use client';
import { use } from 'react';
import { use, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { trpc } from '@/lib/trpc/client';
import { Button } from '@/components/ui/button';
@@ -12,6 +12,30 @@ 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
}: {
@@ -21,9 +45,10 @@ export default function DeliberationSessionPage({
const router = useRouter();
const utils = trpc.useUtils();
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
sessionId: params.sessionId
});
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
{ sessionId: params.sessionId },
{ refetchInterval: 10_000 }
);
const openVotingMutation = trpc.deliberation.openVoting.useMutation({
onSuccess: () => {
@@ -45,6 +70,12 @@ export default function DeliberationSessionPage({
}
});
// 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">
@@ -90,10 +121,10 @@ export default function DeliberationSessionPage({
<div className="flex-1">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">Deliberation Session</h1>
<Badge>{session.status}</Badge>
<Badge variant={STATUS_VARIANTS[session.status] ?? 'outline'}>{STATUS_LABELS[session.status] ?? session.status}</Badge>
</div>
<p className="text-muted-foreground">
{session.round?.name} - {session.category}
{session.round?.name} - {CATEGORY_LABELS[session.category] ?? session.category}
</p>
</div>
</div>
@@ -121,7 +152,7 @@ export default function DeliberationSessionPage({
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Tie Break Method</p>
<p className="mt-1">{session.tieBreakMethod}</p>
<p className="mt-1">{TIE_BREAK_LABELS[session.tieBreakMethod] ?? session.tieBreakMethod}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">
@@ -149,11 +180,11 @@ export default function DeliberationSessionPage({
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="font-medium">{participant.user?.name}</p>
<p className="text-sm text-muted-foreground">{participant.user?.email}</p>
<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={participant.hasVoted ? 'default' : 'outline'}>
{participant.hasVoted ? 'Voted' : 'Pending'}
<Badge variant={voterUserIds.has(participant.user?.user?.id) ? 'default' : 'outline'}>
{voterUserIds.has(participant.user?.user?.id) ? 'Voted' : 'Pending'}
</Badge>
</div>
))}
@@ -183,7 +214,7 @@ export default function DeliberationSessionPage({
variant="destructive"
onClick={() => closeVotingMutation.mutate({ sessionId: params.sessionId })}
disabled={
closeVotingMutation.isPending || session.status !== 'DELIB_VOTING'
closeVotingMutation.isPending || session.status !== 'VOTING'
}
className="flex-1"
>
@@ -204,9 +235,9 @@ export default function DeliberationSessionPage({
key={participant.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<span>{participant.user?.name}</span>
<Badge variant={participant.hasVoted ? 'default' : 'secondary'}>
{participant.hasVoted ? 'Submitted' : 'Not Voted'}
<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>
))}

View File

@@ -32,6 +32,7 @@ export default function DeliberationListPage({
const router = useRouter();
const utils = trpc.useUtils();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [selectedJuryGroupId, setSelectedJuryGroupId] = useState('');
const [formData, setFormData] = useState({
roundId: '',
category: 'STARTUP' as 'STARTUP' | 'BUSINESS_CONCEPT',
@@ -42,20 +43,29 @@ export default function DeliberationListPage({
participantUserIds: [] as string[]
});
const { data: sessions = [], isLoading } = trpc.deliberation.listSessions.useQuery(
const { data: sessions = [], isLoading, isError: isSessionsError } = trpc.deliberation.listSessions.useQuery(
{ competitionId: params.competitionId },
{ enabled: !!params.competitionId }
);
// Get rounds for this competition
const { data: competition } = trpc.competition.getById.useQuery(
const { data: competition, isError: isCompError } = trpc.competition.getById.useQuery(
{ id: params.competitionId },
{ enabled: !!params.competitionId }
);
const rounds = competition?.rounds || [];
// TODO: Add getJuryMembers endpoint if needed for participant selection
const juryMembers: any[] = [];
// Jury groups & members for participant selection
const { data: juryGroups = [] } = trpc.juryGroup.list.useQuery(
{ competitionId: params.competitionId },
{ enabled: !!params.competitionId }
);
const { data: selectedJuryGroup } = trpc.juryGroup.getById.useQuery(
{ id: selectedJuryGroupId },
{ enabled: !!selectedJuryGroupId }
);
const juryMembers = selectedJuryGroup?.members ?? [];
const createSessionMutation = trpc.deliberation.createSession.useMutation({
onSuccess: (data) => {
@@ -76,6 +86,10 @@ export default function DeliberationListPage({
toast.error('Please select a round');
return;
}
if (formData.participantUserIds.length === 0) {
toast.error('Please select at least one participant');
return;
}
createSessionMutation.mutate({
competitionId: params.competitionId,
@@ -92,12 +106,38 @@ export default function DeliberationListPage({
const getStatusBadge = (status: string) => {
const variants: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
DELIB_OPEN: 'outline',
DELIB_VOTING: 'default',
DELIB_TALLYING: 'secondary',
DELIB_LOCKED: 'destructive'
VOTING: 'default',
TALLYING: 'secondary',
RUNOFF: 'secondary',
DELIB_LOCKED: 'secondary',
};
return <Badge variant={variants[status] || 'outline'}>{status}</Badge>;
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 (
@@ -151,7 +191,7 @@ export default function DeliberationListPage({
<div className="flex items-start justify-between">
<div>
<CardTitle>
{session.round?.name} - {session.category}
{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'}
@@ -164,7 +204,7 @@ export default function DeliberationListPage({
<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}</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>
@@ -273,6 +313,78 @@ export default function DeliberationListPage({
</Select>
</div>
{/* Participant Selection */}
<div className="space-y-2">
<Label htmlFor="juryGroup">Jury Group *</Label>
<Select
value={selectedJuryGroupId}
onValueChange={(value) => {
setSelectedJuryGroupId(value);
setFormData({ ...formData, participantUserIds: [] });
}}
>
<SelectTrigger id="juryGroup">
<SelectValue placeholder="Select jury group" />
</SelectTrigger>
<SelectContent>
{juryGroups.map((group: any) => (
<SelectItem key={group.id} value={group.id}>
{group.name} ({group._count?.members ?? 0} members)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{juryMembers.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Participants ({formData.participantUserIds.length}/{juryMembers.length})</Label>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
const allIds = juryMembers.map((m: any) => m.user.id);
const allSelected = allIds.every((id: string) => formData.participantUserIds.includes(id));
setFormData({
...formData,
participantUserIds: allSelected ? [] : allIds,
});
}}
>
{juryMembers.every((m: any) => formData.participantUserIds.includes(m.user.id))
? 'Deselect All'
: 'Select All'}
</Button>
</div>
<div className="max-h-48 space-y-2 overflow-y-auto rounded-md border p-3">
{juryMembers.map((member: any) => (
<div key={member.id} className="flex items-center space-x-2">
<Checkbox
id={`member-${member.user.id}`}
checked={formData.participantUserIds.includes(member.user.id)}
onCheckedChange={(checked) => {
setFormData({
...formData,
participantUserIds: checked
? [...formData.participantUserIds, member.user.id]
: formData.participantUserIds.filter((id: string) => id !== member.user.id),
});
}}
/>
<Label htmlFor={`member-${member.user.id}`} className="flex-1 font-normal">
{member.user.name || member.user.email}
<span className="ml-2 text-xs text-muted-foreground">
{member.role === 'CHAIR' ? 'Chair' : member.role === 'OBSERVER' ? 'Observer' : 'Member'}
</span>
</Label>
</div>
))}
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox

View File

@@ -15,7 +15,10 @@ export default function JuryGroupDetailPage() {
const router = useRouter()
const juryGroupId = params.juryGroupId as string
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery({ id: juryGroupId })
const { data: juryGroup, isLoading } = trpc.juryGroup.getById.useQuery(
{ id: juryGroupId },
{ refetchInterval: 30_000 }
)
if (isLoading) {
return (

View File

@@ -40,13 +40,15 @@ import {
ChevronDown,
Layers,
Users,
FileBox,
FolderKanban,
ClipboardList,
Settings,
MoreHorizontal,
Archive,
Loader2,
Plus,
CalendarDays,
Radio,
} from 'lucide-react'
import { CompetitionTimeline } from '@/components/admin/competition/competition-timeline'
@@ -103,9 +105,10 @@ export default function CompetitionDetailPage() {
roundType: '' as string,
})
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const { data: competition, isLoading } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ refetchInterval: 30_000 }
)
const updateMutation = trpc.competition.update.useMutation({
onSuccess: () => {
@@ -283,7 +286,7 @@ export default function CompetitionDetailPage() {
<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.length}</p>
<p className="text-2xl font-bold mt-1">{competition.rounds.filter((r: any) => !r.specialAwardId).length}</p>
</CardContent>
</Card>
<Card>
@@ -298,10 +301,12 @@ export default function CompetitionDetailPage() {
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center gap-2">
<FileBox className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium">Windows</span>
<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.submissionWindows.length}</p>
<p className="text-2xl font-bold mt-1">
{(competition as any).distinctProjectCount ?? 0}
</p>
</CardContent>
</Card>
<Card>
@@ -328,61 +333,127 @@ export default function CompetitionDetailPage() {
<TabsContent value="overview" className="space-y-6">
<CompetitionTimeline
competitionId={competitionId}
rounds={competition.rounds}
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.length})</h2>
<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.length === 0 ? (
{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="space-y-2">
{competition.rounds.map((round, index) => (
<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/competitions/${competitionId}/rounds/${round.id}` as Route}
href={`/admin/rounds/${round.id}` as Route}
>
<Card className="hover:shadow-sm transition-shadow cursor-pointer">
<CardContent className="flex items-center gap-3 py-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-bold shrink-0">
<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-medium truncate">{round.name}</p>
<p className="text-xs text-muted-foreground font-mono">{round.slug}</p>
</div>
<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] shrink-0',
'text-[10px]',
roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'
)}
>
{round.roundType.replace('_', ' ')}
{round.roundType.replace(/_/g, ' ')}
</Badge>
<Badge
variant="outline"
className="text-[10px] shrink-0 hidden sm:inline-flex"
className={cn('text-[10px]', statusColors[statusLabel])}
>
{round.status.replace('ROUND_', '')}
{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>
@@ -454,9 +525,6 @@ export default function CompetitionDetailPage() {
<div>
<label className="text-sm font-medium text-muted-foreground">Notifications</label>
<div className="flex flex-wrap gap-2 mt-1">
{competition.notifyOnRoundAdvance && (
<Badge variant="secondary" className="text-[10px]">Round Advance</Badge>
)}
{competition.notifyOnDeadlineApproach && (
<Badge variant="secondary" className="text-[10px]">Deadline Approach</Badge>
)}

View File

@@ -1,178 +0,0 @@
'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 { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Save, Loader2 } from 'lucide-react'
import { RoundConfigForm } from '@/components/admin/competition/round-config-form'
import { ProjectStatesTable } from '@/components/admin/round/project-states-table'
import { SubmissionWindowManager } from '@/components/admin/round/submission-window-manager'
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 RoundDetailPage() {
const params = useParams()
const competitionId = params.competitionId as string
const roundId = params.roundId as string
const [config, setConfig] = useState<Record<string, unknown>>({})
const [hasChanges, setHasChanges] = useState(false)
const utils = trpc.useUtils()
const { data: round, isLoading } = trpc.round.getById.useQuery({ id: roundId })
// Update local config when round data changes
if (round && !hasChanges) {
const roundConfig = (round.configJson as Record<string, unknown>) ?? {}
if (JSON.stringify(roundConfig) !== JSON.stringify(config)) {
setConfig(roundConfig)
}
}
const updateMutation = trpc.round.update.useMutation({
onSuccess: () => {
utils.round.getById.invalidate({ id: roundId })
toast.success('Round configuration saved')
setHasChanges(false)
},
onError: (err) => toast.error(err.message),
})
const handleConfigChange = (newConfig: Record<string, unknown>) => {
setConfig(newConfig)
setHasChanges(true)
}
const handleSave = () => {
updateMutation.mutate({
id: roundId,
configJson: config,
})
}
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 (!round) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link href={`/admin/competitions/${competitionId}` as Route}>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-xl font-bold">Round Not Found</h1>
<p className="text-sm text-muted-foreground">
The requested round does not exist
</p>
</div>
</div>
</div>
)
}
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/${competitionId}` as Route} className="mt-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Back to competition details">
<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">{round.name}</h1>
<Badge
variant="secondary"
className={roundTypeColors[round.roundType] ?? 'bg-gray-100 text-gray-700'}
>
{round.roundType.replace('_', ' ')}
</Badge>
</div>
<p className="text-sm text-muted-foreground font-mono">{round.slug}</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{hasChanges && (
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 mr-2" />
)}
Save Changes
</Button>
)}
</div>
</div>
{/* Tabs */}
<Tabs defaultValue="config" className="space-y-4">
<TabsList className="w-full sm:w-auto overflow-x-auto">
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="projects">Projects</TabsTrigger>
<TabsTrigger value="windows">Submission Windows</TabsTrigger>
</TabsList>
{/* Config Tab */}
<TabsContent value="config" className="space-y-4">
<RoundConfigForm
roundType={round.roundType}
config={config}
onChange={handleConfigChange}
/>
</TabsContent>
{/* Projects Tab */}
<TabsContent value="projects" className="space-y-4">
<ProjectStatesTable competitionId={competitionId} roundId={roundId} />
</TabsContent>
{/* Submission Windows Tab */}
<TabsContent value="windows" className="space-y-4">
<SubmissionWindowManager competitionId={competitionId} roundId={roundId} />
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -53,7 +53,7 @@ export default function CompetitionListPage() {
const { data: competitions, isLoading } = trpc.competition.list.useQuery(
{ programId: programId! },
{ enabled: !!programId }
{ enabled: !!programId, refetchInterval: 30_000 }
)
if (!programId) {

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,6 @@ import {
DialogTitle,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
Table,
@@ -523,11 +522,6 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
name: group.name,
description: group.description || '',
defaultMaxAssignments: group.defaultMaxAssignments,
defaultCapMode: group.defaultCapMode,
softCapBuffer: group.softCapBuffer,
categoryQuotasEnabled: group.categoryQuotasEnabled,
allowJurorCapAdjustment: group.allowJurorCapAdjustment,
allowJurorRatioAdjustment: group.allowJurorRatioAdjustment,
})
const handleSubmit = (e: React.FormEvent) => {
@@ -562,100 +556,21 @@ function SettingsForm({ group, onSave, isPending }: SettingsFormProps) {
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label>Default Max Assignments</Label>
<Input
type="number"
min="1"
max="50"
value={formData.defaultMaxAssignments}
onChange={(e) =>
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) })
}
/>
</div>
<div className="space-y-2">
<Label>Cap Mode</Label>
<Select
value={formData.defaultCapMode}
onValueChange={(v) => setFormData({ ...formData, defaultCapMode: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{formData.defaultCapMode === 'SOFT' && (
<div className="space-y-2">
<Label>Soft Cap Buffer</Label>
<Input
type="number"
min="0"
value={formData.softCapBuffer}
onChange={(e) =>
setFormData({ ...formData, softCapBuffer: parseInt(e.target.value, 10) })
setFormData({ ...formData, defaultMaxAssignments: parseInt(e.target.value, 10) || 15 })
}
/>
<p className="text-xs text-muted-foreground">
Number of assignments allowed above the cap when in soft mode
Suggested cap for new members. Per-member overrides and juror self-service preferences take priority.
</p>
</div>
)}
<div className="space-y-3 border-t pt-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Category Quotas Enabled</Label>
<p className="text-xs text-muted-foreground">
Enable category-based assignment quotas
</p>
</div>
<Switch
checked={formData.categoryQuotasEnabled}
onCheckedChange={(checked) =>
setFormData({ ...formData, categoryQuotasEnabled: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Juror Cap Adjustment</Label>
<p className="text-xs text-muted-foreground">
Allow jurors to set their own assignment cap during onboarding
</p>
</div>
<Switch
checked={formData.allowJurorCapAdjustment}
onCheckedChange={(checked) =>
setFormData({ ...formData, allowJurorCapAdjustment: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Allow Juror Ratio Adjustment</Label>
<p className="text-xs text-muted-foreground">
Allow jurors to set their own startup/concept ratio during onboarding
</p>
</div>
<Switch
checked={formData.allowJurorRatioAdjustment}
onCheckedChange={(checked) =>
setFormData({ ...formData, allowJurorRatioAdjustment: checked })
}
/>
</div>
</div>
<Button type="submit" disabled={isPending} className="w-full sm:w-auto">
{isPending ? (

View File

@@ -58,6 +58,7 @@ import {
export default function MemberDetailPage() {
const params = useParams()
const router = useRouter()
const utils = trpc.useUtils()
const userId = params.id as string
const { data: user, isLoading, error, refetch } = trpc.user.get.useQuery({ id: userId })
@@ -103,6 +104,8 @@ export default function MemberDetailPage() {
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
})
utils.user.get.invalidate({ id: userId })
utils.user.list.invalidate()
toast.success('Member updated successfully')
router.push('/admin/members')
} catch (error) {
@@ -115,6 +118,7 @@ export default function MemberDetailPage() {
await sendInvitation.mutateAsync({ userId })
toast.success('Invitation email sent successfully')
refetch()
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
}

View File

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

View File

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

View File

@@ -79,7 +79,7 @@ const ROLES = ['JURY_MEMBER', 'MENTOR', 'OBSERVER', 'APPLICANT', 'PROGRAM_ADMIN'
export default function MessagesPage() {
const [recipientType, setRecipientType] = useState<RecipientType>('ALL')
const [selectedRole, setSelectedRole] = useState('')
const [roundId, setStageId] = useState('')
const [roundId, setRoundId] = useState('')
const [selectedProgramId, setSelectedProgramId] = useState('')
const [selectedUserId, setSelectedUserId] = useState('')
const [subject, setSubject] = useState('')
@@ -104,9 +104,10 @@ export default function MessagesPage() {
{ enabled: recipientType === 'USER' }
)
// Fetch sent messages for history
const { data: sentMessages, isLoading: loadingSent } = trpc.message.inbox.useQuery(
{ page: 1, pageSize: 50 }
// Fetch sent messages for history (messages sent BY this admin)
const { data: sentMessages, isLoading: loadingSent } = trpc.message.sent.useQuery(
{ page: 1, pageSize: 50 },
{ refetchInterval: 30_000 }
)
const sendMutation = trpc.message.send.useMutation({
@@ -114,7 +115,7 @@ export default function MessagesPage() {
const count = (data as Record<string, unknown>)?.recipientCount || ''
toast.success(`Message sent successfully${count ? ` to ${count} recipients` : ''}`)
resetForm()
utils.message.inbox.invalidate()
utils.message.sent.invalidate()
},
onError: (e) => toast.error(e.message),
})
@@ -124,7 +125,7 @@ export default function MessagesPage() {
setBody('')
setSelectedTemplateId('')
setSelectedRole('')
setStageId('')
setRoundId('')
setSelectedProgramId('')
setSelectedUserId('')
setIsScheduled(false)
@@ -218,7 +219,7 @@ export default function MessagesPage() {
return
}
if (recipientType === 'ROUND_JURY' && !roundId) {
toast.error('Please select a stage')
toast.error('Please select a round')
return
}
if (recipientType === 'PROGRAM_TEAM' && !selectedProgramId) {
@@ -295,7 +296,7 @@ export default function MessagesPage() {
onValueChange={(v) => {
setRecipientType(v as RecipientType)
setSelectedRole('')
setStageId('')
setRoundId('')
setSelectedProgramId('')
setSelectedUserId('')
}}
@@ -334,10 +335,10 @@ export default function MessagesPage() {
{recipientType === 'ROUND_JURY' && (
<div className="space-y-2">
<Label>Select Stage</Label>
<Select value={roundId} onValueChange={setStageId}>
<Label>Select Round</Label>
<Select value={roundId} onValueChange={setRoundId}>
<SelectTrigger>
<SelectValue placeholder="Choose a stage..." />
<SelectValue placeholder="Choose a round..." />
</SelectTrigger>
<SelectContent>
{rounds?.map((round) => (
@@ -564,56 +565,73 @@ export default function MessagesPage() {
<TableHeader>
<TableRow>
<TableHead>Subject</TableHead>
<TableHead className="hidden md:table-cell">From</TableHead>
<TableHead className="hidden md:table-cell">Channel</TableHead>
<TableHead className="hidden md:table-cell">Recipients</TableHead>
<TableHead className="hidden md:table-cell">Channels</TableHead>
<TableHead className="hidden lg:table-cell">Status</TableHead>
<TableHead className="text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sentMessages.items.map((item: Record<string, unknown>) => {
const msg = item.message as Record<string, unknown> | undefined
const sender = msg?.sender as Record<string, unknown> | undefined
const channel = String(item.channel || 'EMAIL')
const isRead = !!item.isRead
{sentMessages.items.map((msg: any) => {
const channels = (msg.deliveryChannels as string[]) || []
const recipientCount = msg._count?.recipients ?? 0
const isSent = !!msg.sentAt
return (
<TableRow key={String(item.id)}>
<TableRow key={msg.id}>
<TableCell>
<div className="flex items-center gap-2">
{!isRead && (
<div className="h-2 w-2 rounded-full bg-primary shrink-0" />
)}
<span className={isRead ? 'text-muted-foreground' : 'font-medium'}>
{String(msg?.subject || 'No subject')}
<span className="font-medium">
{msg.subject || 'No subject'}
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{String(sender?.name || sender?.email || 'System')}
{msg.recipientType === 'ALL'
? 'All users'
: msg.recipientType === 'ROLE'
? `By role`
: msg.recipientType === 'ROUND_JURY'
? 'Round jury'
: msg.recipientType === 'USER'
? `${recipientCount || 1} user${recipientCount > 1 ? 's' : ''}`
: msg.recipientType}
{recipientCount > 0 && ` (${recipientCount})`}
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex gap-1">
{channels.includes('EMAIL') && (
<Badge variant="outline" className="text-xs">
{channel === 'EMAIL' ? (
<><Mail className="mr-1 h-3 w-3" />Email</>
) : (
<><Bell className="mr-1 h-3 w-3" />In-App</>
)}
<Mail className="mr-1 h-3 w-3" />Email
</Badge>
)}
{channels.includes('IN_APP') && (
<Badge variant="outline" className="text-xs">
<Bell className="mr-1 h-3 w-3" />In-App
</Badge>
)}
</div>
</TableCell>
<TableCell className="hidden lg:table-cell">
{isRead ? (
{isSent ? (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Read
Sent
</Badge>
) : msg.scheduledAt ? (
<Badge variant="default" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
Scheduled
</Badge>
) : (
<Badge variant="default" className="text-xs">New</Badge>
<Badge variant="outline" className="text-xs">
Draft
</Badge>
)}
</TableCell>
<TableCell className="text-right text-sm text-muted-foreground">
{msg?.createdAt
? formatDate(msg.createdAt as string | Date)
{msg.sentAt
? formatDate(msg.sentAt)
: msg.scheduledAt
? formatDate(msg.scheduledAt)
: ''}
</TableCell>
</TableRow>

View File

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

View File

@@ -19,7 +19,7 @@ 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'
interface ProgramDetailPageProps {
@@ -65,6 +65,13 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</p>
</div>
</div>
<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" />
@@ -72,6 +79,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
</Link>
</Button>
</div>
</div>
{program.description && (
<Card>
@@ -108,7 +116,7 @@ export default async function ProgramDetailPage({ params }: ProgramDetailPagePro
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead>

View File

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

View File

@@ -21,7 +21,6 @@ import {
Bot,
Loader2,
Users,
User,
Check,
RefreshCw,
} from 'lucide-react'
@@ -338,24 +337,6 @@ function MentorAssignmentContent({ projectId }: { projectId: string }) {
</CardContent>
</Card>
{/* Manual Assignment */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5" />
Manual Assignment
</CardTitle>
<CardDescription>
Search and select a mentor manually
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Use the AI suggestions above or search for a specific user in the Users section
to assign them as a mentor manually.
</p>
</CardContent>
</Card>
</>
)}
</div>

View File

@@ -1,6 +1,6 @@
'use client'
import { Suspense, use } from 'react'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
@@ -28,6 +28,13 @@ 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 {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { AnimatedCard } from '@/components/shared/animated-container'
import {
ArrowLeft,
@@ -36,9 +43,6 @@ import {
Users,
FileText,
Calendar,
CheckCircle2,
XCircle,
Circle,
Clock,
BarChart3,
ThumbsUp,
@@ -49,7 +53,12 @@ import {
Heart,
Crown,
UserPlus,
Loader2,
ScanSearch,
Eye,
MessageSquare,
} from 'lucide-react'
import { toast } from 'sonner'
import { formatDate, formatDateOnly } from '@/lib/utils'
interface PageProps {
@@ -76,9 +85,10 @@ const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' |
function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch project + assignments + stats in a single combined query
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery({
id: projectId,
})
const { data: fullDetail, isLoading } = trpc.project.getFullDetail.useQuery(
{ id: projectId },
{ refetchInterval: 30_000 }
)
const project = fullDetail?.project
const assignments = fullDetail?.assignments
@@ -105,16 +115,19 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
// Extract all rounds from the competition
const competitionRounds = competition?.rounds || []
// Fetch requirements for each round
const requirementQueries = competitionRounds.map((round: { id: string; name: string }) =>
trpc.file.listRequirements.useQuery({ roundId: round.id })
// Fetch requirements for all rounds in a single query (avoids dynamic hook violation)
const roundIds = competitionRounds.map((r: { id: string }) => r.id)
const { data: allRequirements = [] } = trpc.file.listRequirementsByRounds.useQuery(
{ roundIds },
{ enabled: roundIds.length > 0 }
)
// Combine requirements from all rounds
const allRequirements = requirementQueries.flatMap((q: { data?: unknown[] }) => q.data || [])
const utils = trpc.useUtils()
// State for evaluation detail sheet
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [selectedEvalAssignment, setSelectedEvalAssignment] = useState<any>(null)
if (isLoading) {
return <ProjectDetailSkeleton />
}
@@ -530,6 +543,8 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<AnimatedCard index={4}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2.5 text-lg">
<div className="rounded-lg bg-rose-500/10 p-1.5">
<FileText className="h-4 w-4 text-rose-500" />
@@ -539,107 +554,14 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<CardDescription>
Project documents and materials organized by competition round
</CardDescription>
</div>
<AnalyzeDocumentsButton projectId={projectId} onComplete={() => utils.file.listByProject.invalidate({ projectId })} />
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Requirements organized by round */}
{competitionRounds.length > 0 && allRequirements.length > 0 ? (
<>
{competitionRounds.map((round: { id: string; name: string }) => {
const roundRequirements = allRequirements.filter((req: any) => req.roundId === round.id)
if (roundRequirements.length === 0) return null
return (
<div key={round.id} className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">{round.name}</h3>
<Badge variant="outline" className="text-xs">
{roundRequirements.length} requirement{roundRequirements.length !== 1 ? 's' : ''}
</Badge>
</div>
<div className="grid gap-2">
{roundRequirements.map((req: any) => {
// Find file that fulfills this requirement
const fulfilledFile = files?.find((f: any) => f.requirementId === req.id)
const isFulfilled = !!fulfilledFile
return (
<div
key={req.id}
className={`flex items-center justify-between rounded-lg border p-3 ${
isFulfilled
? 'border-green-200 bg-green-50/50 dark:border-green-900 dark:bg-green-950/20'
: 'border-muted'
}`}
>
<div className="flex items-center gap-3 min-w-0">
{isFulfilled ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-600" />
) : (
<Circle className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{req.name}</p>
{req.isRequired && (
<Badge variant="destructive" className="text-xs shrink-0">
Required
</Badge>
)}
</div>
{req.description && (
<p className="text-xs text-muted-foreground truncate">
{req.description}
</p>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
{req.acceptedMimeTypes.length > 0 && (
<span>
{req.acceptedMimeTypes.map((mime: string) => {
if (mime === 'application/pdf') return 'PDF'
if (mime === 'image/*') return 'Images'
if (mime === 'video/*') return 'Video'
if (mime.includes('wordprocessing')) return 'Word'
if (mime.includes('spreadsheet')) return 'Excel'
if (mime.includes('presentation')) return 'PowerPoint'
return mime.split('/')[1] || mime
}).join(', ')}
</span>
)}
{req.maxSizeMB && (
<span className="shrink-0"> Max {req.maxSizeMB}MB</span>
)}
</div>
{isFulfilled && fulfilledFile && (
<p className="text-xs text-green-700 dark:text-green-400 mt-1 font-medium">
{fulfilledFile.fileName}
</p>
)}
</div>
</div>
{!isFulfilled && (
<span className="text-xs text-amber-600 dark:text-amber-400 shrink-0 ml-2 font-medium">
Missing
</span>
)}
</div>
)
})}
</div>
</div>
)
})}
<Separator />
</>
) : null}
{/* General file upload section */}
{/* File upload */}
<div>
<p className="text-sm font-semibold mb-3">
{allRequirements.length > 0 ? 'Additional Documents' : 'Upload Files'}
</p>
<p className="text-xs text-muted-foreground mb-3">
Upload files not tied to specific requirements
</p>
<p className="text-sm font-semibold mb-3">Upload Files</p>
<FileUpload
projectId={projectId}
availableRounds={competitionRounds?.map((r: any) => ({ id: r.id, name: r.name }))}
@@ -653,8 +575,6 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
{files && files.length > 0 && (
<>
<Separator />
<div>
<p className="text-sm font-semibold mb-3">All Uploaded Files</p>
<FileViewer
projectId={projectId}
files={files.map((f) => ({
@@ -665,9 +585,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
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,
}))}
/>
</div>
</>
)}
</CardContent>
@@ -709,11 +640,20 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<TableHead>Status</TableHead>
<TableHead>Score</TableHead>
<TableHead>Decision</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableRow
key={assignment.id}
className={assignment.evaluation?.status === 'SUBMITTED' ? 'cursor-pointer hover:bg-muted/50' : ''}
onClick={() => {
if (assignment.evaluation?.status === 'SUBMITTED') {
setSelectedEvalAssignment(assignment)
}
}}
>
<TableCell>
<div className="flex items-center gap-2">
<UserAvatar
@@ -787,6 +727,11 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{assignment.evaluation?.status === 'SUBMITTED' && (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
</TableRow>
))}
</TableBody>
@@ -796,6 +741,13 @@ function ProjectDetailContent({ projectId }: { projectId: string }) {
</AnimatedCard>
)}
{/* Evaluation Detail Sheet */}
<EvaluationDetailSheet
assignment={selectedEvalAssignment}
open={!!selectedEvalAssignment}
onOpenChange={(open) => { if (!open) setSelectedEvalAssignment(null) }}
/>
{/* AI Evaluation Summary */}
{assignments && assignments.length > 0 && stats && stats.totalEvaluations > 0 && (
<EvaluationSummaryCard
@@ -848,6 +800,203 @@ function ProjectDetailSkeleton() {
)
}
function AnalyzeDocumentsButton({ projectId, onComplete }: { projectId: string; onComplete: () => void }) {
const analyzeMutation = trpc.file.analyzeProjectFiles.useMutation({
onSuccess: (result) => {
toast.success(
`Analyzed ${result.analyzed} file${result.analyzed !== 1 ? 's' : ''}${result.failed > 0 ? ` (${result.failed} failed)` : ''}`
)
onComplete()
},
onError: (error) => {
toast.error(error.message || 'Analysis failed')
},
})
return (
<Button
variant="outline"
size="sm"
onClick={() => analyzeMutation.mutate({ projectId })}
disabled={analyzeMutation.isPending}
>
{analyzeMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<ScanSearch className="mr-2 h-4 w-4" />
)}
{analyzeMutation.isPending ? 'Analyzing...' : 'Analyze Documents'}
</Button>
)
}
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)

View File

@@ -1,6 +1,6 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
@@ -46,6 +46,8 @@ import {
Loader2,
FileUp,
AlertCircle,
ExternalLink,
Trash2,
} from 'lucide-react'
import { cn, formatFileSize } from '@/lib/utils'
import { Pagination } from '@/components/shared/pagination'
@@ -60,7 +62,7 @@ type UploadState = {
type UploadMap = Record<string, UploadState>
export default function BulkUploadPage() {
const [windowId, setWindowId] = useState('')
const [roundId, setRoundId] = useState('')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'missing' | 'complete'>('all')
@@ -77,12 +79,13 @@ export default function BulkUploadPage() {
label: string
mimeTypes: string[]
required: boolean
file: { id: string; fileName: string } | null
file: { id: string; fileName: string; bucket: string; objectKey: string } | null
}>
} | null>(null)
const [bulkFiles, setBulkFiles] = useState<Record<string, File | null>>({})
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
const utils = trpc.useUtils()
// Debounce search
const searchTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
@@ -96,20 +99,84 @@ export default function BulkUploadPage() {
}, [])
// Queries
const { data: windows, isLoading: windowsLoading } = trpc.file.listSubmissionWindows.useQuery()
const { data: rounds, isLoading: roundsLoading } = trpc.file.listRoundsForBulkUpload.useQuery()
const { data, isLoading, refetch } = trpc.file.listProjectsWithUploadStatus.useQuery(
const { data, isLoading, refetch } = trpc.file.listProjectsByRoundRequirements.useQuery(
{
submissionWindowId: windowId,
roundId,
search: debouncedSearch || undefined,
status: statusFilter,
page,
pageSize: perPage,
},
{ enabled: !!windowId }
{ enabled: !!roundId }
)
const uploadMutation = trpc.file.adminUploadForRequirement.useMutation()
// Collect all files from current data for existence verification
const filesToVerify = useMemo(() => {
if (!data?.projects) return []
const files: { bucket: string; objectKey: string }[] = []
for (const row of data.projects) {
for (const req of row.requirements) {
if (req.file?.bucket && req.file?.objectKey) {
files.push({ bucket: req.file.bucket, objectKey: req.file.objectKey })
}
}
}
return files
}, [data])
// Verify files actually exist in storage
const { data: fileExistence } = trpc.file.verifyFilesExist.useQuery(
{ files: filesToVerify },
{ enabled: filesToVerify.length > 0, staleTime: 30_000 }
)
// Track which files are missing from storage (objectKey → true means missing)
const missingFiles = useMemo(() => {
if (!fileExistence) return new Set<string>()
const missing = new Set<string>()
for (const [key, exists] of Object.entries(fileExistence)) {
if (!exists) missing.add(key)
}
return missing
}, [fileExistence])
// Open file in new tab via presigned URL
const handleViewFile = useCallback(
async (bucket: string, objectKey: string) => {
try {
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.')
refetch()
}
},
[utils, refetch]
)
// Delete a file
const deleteMutation = trpc.file.delete.useMutation({
onSuccess: () => {
toast.success('File removed')
refetch()
},
onError: (err) => {
toast.error(`Failed to remove file: ${err.message}`)
},
})
const handleDeleteFile = useCallback(
(fileId: string) => {
if (confirm('Remove this uploaded file?')) {
deleteMutation.mutate({ id: fileId })
}
},
[deleteMutation]
)
const uploadMutation = trpc.file.adminUploadForRoundRequirement.useMutation()
// Upload a single file for a project requirement
const uploadFileForRequirement = useCallback(
@@ -117,7 +184,7 @@ export default function BulkUploadPage() {
projectId: string,
requirementId: string,
file: File,
submissionWindowId: string
targetRoundId: string
) => {
const key = `${projectId}:${requirementId}`
setUploads((prev) => ({
@@ -131,8 +198,8 @@ export default function BulkUploadPage() {
fileName: file.name,
mimeType: file.type || 'application/octet-stream',
size: file.size,
submissionWindowId,
submissionFileRequirementId: requirementId,
roundId: targetRoundId,
requirementId,
})
// XHR upload with progress
@@ -186,18 +253,18 @@ export default function BulkUploadPage() {
}
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file && windowId) {
uploadFileForRequirement(projectId, requirementId, file, windowId)
if (file && roundId) {
uploadFileForRequirement(projectId, requirementId, file, roundId)
}
}
input.click()
},
[windowId, uploadFileForRequirement]
[roundId, uploadFileForRequirement]
)
// Handle bulk row upload
const handleBulkUploadAll = useCallback(async () => {
if (!bulkProject || !windowId) return
if (!bulkProject || !roundId) return
const entries = Object.entries(bulkFiles).filter(
([, file]) => file !== null
@@ -211,14 +278,14 @@ export default function BulkUploadPage() {
// Upload all in parallel
await Promise.allSettled(
entries.map(([reqId, file]) =>
uploadFileForRequirement(bulkProject.id, reqId, file, windowId)
uploadFileForRequirement(bulkProject.id, reqId, file, roundId)
)
)
setBulkProject(null)
setBulkFiles({})
toast.success('Bulk upload complete')
}, [bulkProject, bulkFiles, windowId, uploadFileForRequirement])
}, [bulkProject, bulkFiles, roundId, uploadFileForRequirement])
const progressPercent =
data && data.totalProjects > 0
@@ -242,32 +309,37 @@ export default function BulkUploadPage() {
</div>
</div>
{/* Window Selector */}
{/* Round Selector */}
<Card>
<CardHeader>
<CardTitle className="text-base">Submission Window</CardTitle>
<CardTitle className="text-base">Round</CardTitle>
</CardHeader>
<CardContent>
{windowsLoading ? (
{roundsLoading ? (
<Skeleton className="h-10 w-full" />
) : !rounds || rounds.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertCircle className="h-4 w-4" />
<span>No rounds have file requirements configured. Add file requirements to a round first.</span>
</div>
) : (
<Select
value={windowId}
value={roundId}
onValueChange={(v) => {
setWindowId(v)
setRoundId(v)
setPage(1)
setUploads({})
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a submission window..." />
<SelectValue placeholder="Select a round..." />
</SelectTrigger>
<SelectContent>
{windows?.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.competition.program.name} {w.competition.program.year} &mdash; {w.name}{' '}
({w.fileRequirements.length} requirement
{w.fileRequirements.length !== 1 ? 's' : ''})
{rounds.map((r) => (
<SelectItem key={r.id} value={r.id}>
{r.competition.program.name} {r.competition.program.year} &mdash; {r.name}{' '}
({r.fileRequirements.length} requirement
{r.fileRequirements.length !== 1 ? 's' : ''})
</SelectItem>
))}
</SelectContent>
@@ -276,8 +348,8 @@ export default function BulkUploadPage() {
</CardContent>
</Card>
{/* Content (only if window selected) */}
{windowId && data && (
{/* Content (only if round selected) */}
{roundId && data && (
<>
{/* Progress Summary */}
<Card>
@@ -385,7 +457,7 @@ export default function BulkUploadPage() {
<TableBody>
{data.projects.map((row) => {
const missingRequired = row.requirements.filter(
(r) => r.required && !r.file
(r) => r.required && (!r.file || (r.file?.objectKey && missingFiles.has(r.file.objectKey)))
)
return (
<TableRow
@@ -441,12 +513,57 @@ export default function BulkUploadPage() {
Retry
</Button>
</div>
) : req.file && req.file.objectKey && missingFiles.has(req.file.objectKey) ? (
<div className="flex flex-col items-center gap-1">
<AlertCircle className="h-4 w-4 text-amber-500" />
<span className="text-[10px] text-amber-600 font-medium">Missing</span>
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() =>
handleCellUpload(
row.project.id,
req.requirementId,
req.mimeTypes
)
}
>
Re-upload
</Button>
</div>
) : req.file || uploadState?.status === 'complete' ? (
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-1">
<CheckCircle2 className="h-4 w-4 text-green-600" />
{req.file && (
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors cursor-pointer"
title="Remove file"
onClick={() => handleDeleteFile(req.file!.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
{req.file?.bucket && req.file?.objectKey ? (
<button
type="button"
className="text-[10px] text-teal-600 hover:text-teal-800 hover:underline truncate max-w-[120px] flex items-center gap-0.5 cursor-pointer"
onClick={() =>
handleViewFile(req.file!.bucket, req.file!.objectKey)
}
>
{req.file.fileName}
<ExternalLink className="h-2.5 w-2.5 shrink-0" />
</button>
) : (
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
{req.file?.fileName ?? 'Uploaded'}
</span>
)}
</div>
) : (
<Button

View File

@@ -92,7 +92,7 @@ function ImportPageContent() {
Create a competition with rounds before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/competitions">View Competitions</Link>
<Link href="/admin/rounds">View Rounds</Link>
</Button>
</div>
) : (

View File

@@ -366,8 +366,9 @@ export default function ProjectsPage() {
}
const handleCloseTaggingDialog = () => {
if (!taggingInProgress) {
setAiTagDialogOpen(false)
// Only reset job state if not in progress (preserve polling for background jobs)
if (!taggingInProgress) {
setActiveTaggingJobId(null)
setSelectedRoundForTagging('')
setSelectedProgramForTagging('')
@@ -618,9 +619,28 @@ export default function ProjectsPage() {
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setAiTagDialogOpen(true)}>
<Button
variant="outline"
onClick={() => setAiTagDialogOpen(true)}
className={taggingInProgress ? 'border-amber-400 bg-amber-50 dark:bg-amber-950/20' : ''}
>
{taggingInProgress ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin text-amber-600" />
) : (
<Bot className="mr-2 h-4 w-4" />
)}
AI Tags
{taggingInProgress && (
<span className="ml-1.5 text-[10px] text-amber-600 font-medium">
{taggingProgressPercent}%
</span>
)}
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/pool">
<Layers className="mr-2 h-4 w-4" />
Assign to Round
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/bulk-upload">
@@ -691,13 +711,7 @@ export default function ProjectsPage() {
{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.projects.reduce<Record<string, number>>((acc, p) => {
const s = p.status ?? 'SUBMITTED'
acc[s] = (acc[s] || 0) + 1
return acc
}, {})
)
{Object.entries(data.statusCounts ?? {})
.sort(([a], [b]) => {
const order = ['SUBMITTED', 'ELIGIBLE', 'ASSIGNED', 'SEMIFINALIST', 'FINALIST', 'WINNER', 'REJECTED', 'WITHDRAWN']
return order.indexOf(a) - order.indexOf(b)
@@ -850,7 +864,7 @@ export default function ProjectsPage() {
</TableHead>
<TableHead className="min-w-[280px]">Project</TableHead>
<TableHead>Category</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Program</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
@@ -893,17 +907,8 @@ export default function ProjectsPage() {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return flag ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
</TooltipTrigger>
<TooltipContent side="top"><p>{name}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
return (
<span className="text-xs text-muted-foreground/70"> · {flag && <span className="text-sm">{flag}</span>} {name}</span>
)
})()}
</p>
@@ -1051,7 +1056,7 @@ export default function ProjectsPage() {
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Stage</span>
<span className="text-muted-foreground">Program</span>
<span>{project.program?.name ?? 'Unassigned'}</span>
</div>
{project.competitionCategory && (
@@ -1162,17 +1167,8 @@ export default function ProjectsPage() {
const code = normalizeCountryToCode(project.country)
const flag = code ? getCountryFlag(code) : null
const name = code ? getCountryName(code) : project.country
return flag ? (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs cursor-default"> · <span className="text-sm">{flag}</span></span>
</TooltipTrigger>
<TooltipContent side="top"><p>{name}</p></TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-xs text-muted-foreground/70"> · {project.country}</span>
return (
<span className="text-xs text-muted-foreground/70"> · {flag && <span className="text-sm">{flag}</span>} {name}</span>
)
})()}
</CardDescription>
@@ -1827,9 +1823,8 @@ export default function ProjectsPage() {
<Button
variant="outline"
onClick={handleCloseTaggingDialog}
disabled={taggingInProgress}
>
Cancel
{taggingInProgress ? 'Run in Background' : 'Cancel'}
</Button>
<Button
onClick={handleStartTagging}

View File

@@ -1,9 +1,14 @@
'use client'
import { useState } from 'react'
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,
@@ -21,63 +26,137 @@ import {
} from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
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 { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import Link from 'next/link'
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 [selectedProgramId, setSelectedProgramId] = useState<string>('')
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 [targetStageId, setTargetStageId] = useState<string>('')
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
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
// Pre-select target round from URL param
useEffect(() => {
if (urlRoundId) setTargetRoundId(urlRoundId)
}, [urlRoundId])
const { data: poolData, isLoading: isLoadingPool, refetch } = trpc.projectPool.listUnassigned.useQuery(
{
programId: selectedProgramId,
programId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
search: searchQuery || undefined,
unassignedOnly: showUnassignedOnly,
excludeRoundId: urlRoundId || undefined,
page: currentPage,
perPage,
},
{ enabled: !!selectedProgramId }
{ enabled: !!programId }
)
// Get stages from the selected program (program.list includes rounds/stages)
const { data: selectedProgramData, isLoading: isLoadingStages } = trpc.program.get.useQuery(
{ id: selectedProgramId },
{ enabled: !!selectedProgramId }
// Load rounds from program (flattened from all competitions, now with competitionId)
const { data: programData, isLoading: isLoadingRounds } = trpc.program.get.useQuery(
{ id: programId },
{ enabled: !!programId }
)
const stages = (selectedProgramData?.stages || []) as Array<{ id: string; name: string }>
// 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.program.get.invalidate()
utils.projectPool.listUnassigned.invalidate()
toast.success(`Assigned ${result.assignedCount} project${result.assignedCount !== 1 ? 's' : ''} to round`)
setSelectedProjects([])
setAssignDialogOpen(false)
setTargetStageId('')
setTargetRoundId(urlRoundId)
refetch()
},
onError: (error: any) => {
toast.error(error.message || 'Failed to assign projects')
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 || !targetStageId) return
if (selectedProjects.length === 0 || !targetRoundId) return
assignMutation.mutate({
projectIds: selectedProjects,
roundId: targetStageId,
roundId: targetRoundId,
})
}
const handleAssignAll = () => {
if (!targetRoundId || !programId) return
assignAllMutation.mutate({
programId,
roundId: targetRoundId,
competitionCategory: categoryFilter === 'all' ? undefined : categoryFilter,
unassignedOnly: showUnassignedOnly,
})
}
@@ -105,43 +184,70 @@ export default function ProjectPoolPage() {
}
}
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>
<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">
Assign unassigned projects to evaluation stages
{currentEdition
? `${currentEdition.name} ${currentEdition.year} \u2014 ${poolData?.total ?? '...'} projects`
: 'No edition selected'}
</p>
</div>
</div>
{/* Program Selector */}
{/* 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">Program</label>
<Select value={selectedProgramId} onValueChange={(value) => {
setSelectedProgramId(value)
setSelectedProjects([])
setCurrentPage(1)
}}>
<SelectTrigger>
<SelectValue placeholder="Select program..." />
</SelectTrigger>
<SelectContent>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-2">
<label className="text-sm font-medium">Category</label>
<Select value={categoryFilter} onValueChange={(value: any) => {
setCategoryFilter(value)
<Select value={categoryFilter} onValueChange={(value: string) => {
setCategoryFilter(value as 'STARTUP' | 'BUSINESS_CONCEPT' | 'all')
setCurrentPage(1)
}}>
<SelectTrigger>
@@ -167,16 +273,48 @@ export default function ProjectPoolPage() {
/>
</div>
{selectedProjects.length > 0 && (
<Button onClick={() => setAssignDialogOpen(true)} className="whitespace-nowrap">
Assign {selectedProjects.length} Project{selectedProjects.length > 1 ? 's' : ''}
</Button>
)}
<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 */}
{selectedProgramId && (
{programId ? (
<>
{isLoadingPool ? (
<Card className="p-4">
@@ -193,17 +331,18 @@ export default function ProjectPoolPage() {
<table className="w-full">
<thead className="border-b">
<tr className="text-sm">
<th className="p-3 text-left">
<th className="p-3 text-left w-[40px]">
<Checkbox
checked={selectedProjects.length === poolData.projects.length && poolData.projects.length > 0}
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">Action</th>
<th className="p-3 text-left font-medium">Quick Assign</th>
</tr>
</thead>
<tbody>
@@ -217,7 +356,7 @@ export default function ProjectPoolPage() {
</td>
<td className="p-3">
<Link
href={`/admin/projects/${project.id}`}
href={`/admin/projects/${project.id}` as Route}
className="hover:underline"
>
<div className="font-medium">{project.title}</div>
@@ -225,12 +364,36 @@ export default function ProjectPoolPage() {
</Link>
</td>
<td className="p-3">
<Badge variant="outline">
{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 || '-'}
{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
@@ -238,20 +401,20 @@ export default function ProjectPoolPage() {
: '-'}
</td>
<td className="p-3">
{isLoadingStages ? (
{isLoadingRounds ? (
<Skeleton className="h-9 w-[200px]" />
) : (
<Select
onValueChange={(roundId) => handleQuickAssign(project.id, roundId)}
disabled={assignMutation.isPending}
disabled={isPending}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Assign to stage..." />
<SelectValue placeholder="Assign to round..." />
</SelectTrigger>
<SelectContent>
{stages?.map((stage: { id: string; name: string }) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
@@ -269,7 +432,7 @@ export default function ProjectPoolPage() {
{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} projects
Showing {((currentPage - 1) * perPage) + 1} to {Math.min(currentPage * perPage, poolData.total)} of {poolData.total}
</p>
<div className="flex gap-2">
<Button
@@ -296,36 +459,43 @@ export default function ProjectPoolPage() {
</>
) : (
<Card className="p-8 text-center text-muted-foreground">
No unassigned projects found for this program
<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>
)}
</>
)}
{!selectedProgramId && (
) : (
<Card className="p-8 text-center text-muted-foreground">
Select a program to view unassigned projects
No edition selected. Please select an edition from the sidebar.
</Card>
)}
{/* Bulk Assignment Dialog */}
{/* Bulk Assignment Dialog (selected projects) */}
<Dialog open={assignDialogOpen} onOpenChange={setAssignDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Assign Projects to Stage</DialogTitle>
<DialogTitle>Assign Selected Projects</DialogTitle>
<DialogDescription>
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to:
Assign {selectedProjects.length} selected project{selectedProjects.length > 1 ? 's' : ''} to a round:
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Select value={targetStageId} onValueChange={setTargetStageId}>
<Select value={targetRoundId} onValueChange={setTargetRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select stage..." />
<SelectValue placeholder="Select round..." />
</SelectTrigger>
<SelectContent>
{stages?.map((stage: { id: string; name: string }) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.name}
{filteredRounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.name}
</SelectItem>
))}
</SelectContent>
@@ -337,10 +507,48 @@ export default function ProjectPoolPage() {
</Button>
<Button
onClick={handleBulkAssign}
disabled={!targetStageId || assignMutation.isPending}
disabled={!targetRoundId || isPending}
>
{assignMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Assign
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>

View File

@@ -1,8 +1,7 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -72,9 +71,11 @@ function ReportsOverview() {
// Project reporting scope (default: latest program, all rounds)
const [selectedValue, setSelectedValue] = useState<string | null>(null)
useEffect(() => {
if (programs?.length && !selectedValue) {
setSelectedValue(`all:${programs[0].id}`)
}
}, [programs, selectedValue])
const scopeInput = parseSelection(selectedValue)
const hasScope = !!scopeInput.roundId || !!scopeInput.programId
@@ -110,7 +111,7 @@ function ReportsOverview() {
const activeRounds = dashStats?.activeRoundCount ?? rounds.filter((r: { status: string }) => r.status === 'ROUND_ACTIVE').length
const jurorCount = dashStats?.jurorCount ?? 0
const submittedEvaluations = dashStats?.submittedEvaluations ?? 0
const totalEvaluations = dashStats?.totalEvaluations ?? 0
const totalAssignments = dashStats?.totalAssignments ?? 0
const completionRate = dashStats?.completionRate ?? 0
return (
@@ -178,7 +179,7 @@ function ReportsOverview() {
<p className="text-sm font-medium text-muted-foreground">Evaluations</p>
<p className="text-2xl font-bold mt-1">{submittedEvaluations}</p>
<p className="text-xs text-muted-foreground mt-1">
{totalEvaluations > 0
{totalAssignments > 0
? `${completionRate}% completion rate`
: 'No assignments yet'}
</p>
@@ -355,14 +356,14 @@ function ReportsOverview() {
<TableCell>
<Badge
variant={
round.status === 'ACTIVE'
round.status === 'ROUND_ACTIVE'
? 'default'
: round.status === 'CLOSED'
: round.status === 'ROUND_CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
{round.status?.replace('ROUND_', '') || round.status}
</Badge>
</TableCell>
<TableCell className="text-right">
@@ -418,9 +419,11 @@ function StageAnalytics() {
) || []
// Set default selected stage
useEffect(() => {
if (rounds.length && !selectedValue) {
setSelectedValue(rounds[0].id)
}
}, [rounds.length, selectedValue])
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
@@ -529,9 +532,9 @@ function StageAnalytics() {
<Skeleton className="h-[350px]" />
) : scoreDistribution ? (
<ScoreDistributionChart
data={scoreDistribution.distribution}
averageScore={scoreDistribution.averageScore}
totalScores={scoreDistribution.totalScores}
data={scoreDistribution.distribution ?? []}
averageScore={scoreDistribution.averageScore ?? 0}
totalScores={scoreDistribution.totalScores ?? 0}
/>
) : null}
@@ -654,7 +657,7 @@ function CrossStageTab() {
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(stage.id)}
>
{stage.programName} - {stage.name}
{stage.name}
</Badge>
)
})}
@@ -702,9 +705,11 @@ function JurorConsistencyTab() {
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
}
}, [stages.length, selectedValue])
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
@@ -735,7 +740,7 @@ function JurorConsistencyTab() {
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{stage.name}
</SelectItem>
))}
</SelectContent>
@@ -774,9 +779,11 @@ function DiversityTab() {
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programId: p.id, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
}
}, [stages.length, selectedValue])
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
@@ -807,7 +814,7 @@ function DiversityTab() {
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{stage.name}
</SelectItem>
))}
</SelectContent>
@@ -831,6 +838,97 @@ function DiversityTab() {
)
}
function RoundPipelineTab() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
const rounds = programs?.flatMap(p =>
((p.stages ?? []) as Array<{ id: string; name: string; status: string; type?: string }>).map((s) => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
const roundIds = rounds.map(r => r.id)
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds },
{ enabled: roundIds.length >= 2 }
)
if (isLoading || comparisonLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-24" />)}
</div>
)
}
if (!rounds.length) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Layers className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No rounds available</p>
</CardContent>
</Card>
)
}
const comparisonMap = new Map(
(comparison ?? []).map((c: any) => [c.roundId, c])
)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="rounded-lg bg-violet-500/10 p-1.5">
<Layers className="h-4 w-4 text-violet-600" />
</div>
Round Pipeline
</CardTitle>
<CardDescription>Project flow across competition rounds</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{rounds.map((round, idx) => {
const stats = comparisonMap.get(round.id) as any
return (
<div key={round.id} className="flex items-center gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
{idx + 1}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{round.name}</p>
<p className="text-xs text-muted-foreground">{round.programName}</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="tabular-nums">{stats?.projectCount ?? 0} projects</span>
<span className="tabular-nums">{stats?.evaluationCount ?? 0} evals</span>
<Badge variant={round.status === 'ROUND_ACTIVE' ? 'default' : round.status === 'ROUND_CLOSED' ? 'secondary' : 'outline'}>
{round.status?.replace('ROUND_', '') ?? 'DRAFT'}
</Badge>
</div>
</div>
{stats?.completionRate != null && (
<Progress value={stats.completionRate} className="mt-2 h-2" />
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
</div>
)
}
export default function ReportsPage() {
const [pdfStageId, setPdfStageId] = useState<string | null>(null)
@@ -839,9 +937,11 @@ export default function ReportsPage() {
((p.stages ?? []) as Array<{ id: string; name: string }>).map((s: { id: string; name: string }) => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
useEffect(() => {
if (pdfStages.length && !pdfStageId) {
setPdfStageId(pdfStages[0].id)
}
}, [pdfStages.length, pdfStageId])
const selectedPdfStage = pdfStages.find((r) => r.id === pdfStageId)
@@ -879,11 +979,9 @@ export default function ReportsPage() {
<Globe className="h-4 w-4" />
Diversity
</TabsTrigger>
<TabsTrigger value="pipeline" className="gap-2" asChild>
<Link href={"/admin/reports/stages" as Route}>
<TabsTrigger value="pipeline" className="gap-2">
<Layers className="h-4 w-4" />
By Round
</Link>
</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
@@ -894,7 +992,7 @@ export default function ReportsPage() {
<SelectContent>
{pdfStages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{stage.name}
</SelectItem>
))}
</SelectContent>
@@ -928,6 +1026,10 @@ export default function ReportsPage() {
<TabsContent value="diversity">
<DiversityTab />
</TabsContent>
<TabsContent value="pipeline">
<RoundPipelineTab />
</TabsContent>
</Tabs>
</div>
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
// Categories that only super admins can access
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY'])
const SUPER_ADMIN_CATEGORIES = new Set(['AI', 'EMAIL', 'STORAGE', 'SECURITY', 'WHATSAPP'])
async function SettingsLoader({ isSuperAdmin }: { isSuperAdmin: boolean }) {
const settings = await prisma.systemSettings.findMany({

View File

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

View File

@@ -1,6 +1,7 @@
import { redirect } from 'next/navigation'
import Image from 'next/image'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export default async function AuthLayout({
children,
@@ -18,6 +19,13 @@ export default async function AuthLayout({
// Redirect logged-in users to their dashboard
// But NOT if they still need to set their password
if (session?.user && !session.user.mustSetPassword) {
// Verify user still exists in DB (handles deleted accounts with stale sessions)
const dbUser = await prisma.user.findUnique({
where: { id: session.user.id },
select: { id: true },
})
if (dbUser) {
const role = session.user.role
if (role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN') {
redirect('/admin')
@@ -29,6 +37,8 @@ export default async function AuthLayout({
redirect('/mentor')
}
}
// If user doesn't exist in DB, fall through and show auth page
}
return (
<div className="min-h-screen flex flex-col">

View File

@@ -51,10 +51,9 @@ type JuryPref = {
juryGroupMemberId: string
juryGroupName: string
currentCap: number
allowCapAdjustment: boolean
allowRatioAdjustment: boolean
selfServiceCap: number | null
selfServiceRatio: number | null
preferredStartupRatio: number | null
}
export default function OnboardingPage() {
@@ -221,7 +220,7 @@ export default function OnboardingPage() {
// Show loading while session hydrates or fetching user data
if (sessionStatus === 'loading' || userLoading || !initialized) {
return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
<AnimatedCard>
<Card className="w-full max-w-lg shadow-2xl overflow-hidden">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
@@ -236,9 +235,9 @@ export default function OnboardingPage() {
}
return (
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-gradient-to-br from-[#053d57] to-[#557f8c]">
<div className="absolute inset-0 -m-4 flex items-center justify-center p-4 md:p-8 bg-[#053d57] bg-[url('https://s3.monaco-opc.com/public/ocean.png')] bg-cover bg-center bg-no-repeat">
<AnimatedCard>
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-hidden shadow-2xl">
<Card className="w-full max-w-lg max-h-[85vh] overflow-y-auto overflow-x-hidden shadow-2xl">
<div className="h-1 w-full bg-gradient-to-r from-brand-blue via-brand-teal to-brand-blue" />
{/* Progress indicator */}
<div className="px-6 pt-6">
@@ -530,13 +529,12 @@ export default function OnboardingPage() {
{juryMemberships.map((m) => {
const pref = juryPrefs.get(m.juryGroupMemberId) ?? {}
const capValue = pref.cap ?? m.selfServiceCap ?? m.currentCap
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? 0.5
const ratioValue = pref.ratio ?? m.selfServiceRatio ?? m.preferredStartupRatio ?? 0.5
return (
<div key={m.juryGroupMemberId} className="rounded-lg border p-4 space-y-4">
<h4 className="font-medium text-sm">{m.juryGroupName}</h4>
{m.allowCapAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Maximum assignments: {capValue}
@@ -551,19 +549,17 @@ export default function OnboardingPage() {
})
}
min={1}
max={m.currentCap}
max={50}
step={1}
/>
<p className="text-xs text-muted-foreground">
Admin default: {m.currentCap}. You may reduce this to match your availability.
Admin suggestion: {m.currentCap}. Adjust to match your availability.
</p>
</div>
)}
{m.allowRatioAdjustment && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
Startup vs Business Concept ratio: {Math.round(ratioValue * 100)}% / {Math.round((1 - ratioValue) * 100)}%
Category preference: {Math.round(ratioValue * 100)}% Startups / {Math.round((1 - ratioValue) * 100)}% Business Concepts
</Label>
<Slider
value={[ratioValue * 100]}
@@ -576,14 +572,16 @@ export default function OnboardingPage() {
}
min={0}
max={100}
step={5}
step={10}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>More Business Concepts</span>
<span>More Startups</span>
</div>
<p className="text-xs text-muted-foreground/70 italic">
This is a preference, not a guarantee. Due to the number of projects, the system will try to match your preference but exact ratios cannot be ensured.
</p>
</div>
)}
</div>
)
})}

View File

@@ -17,12 +17,12 @@ export default function JuryRoundDetailPage() {
const { data: assignments, isLoading } = trpc.roundAssignment.getMyAssignments.useQuery(
{ roundId },
{ enabled: !!roundId }
{ enabled: !!roundId, refetchInterval: 30_000 }
)
const { data: round } = trpc.round.getById.useQuery(
{ id: roundId },
{ enabled: !!roundId }
{ enabled: !!roundId, refetchInterval: 30_000 }
)
if (isLoading) {

View File

@@ -1,27 +1,22 @@
'use client'
import { use, useState, useEffect } from 'react'
import { use, useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Checkbox } from '@/components/ui/checkbox'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { Badge } from '@/components/ui/badge'
import { COIDeclarationDialog } from '@/components/forms/coi-declaration-dialog'
import { ArrowLeft, Save, Send, AlertCircle, ThumbsUp, ThumbsDown, Clock, CheckCircle2, ShieldAlert } from 'lucide-react'
import { toast } from 'sonner'
import type { EvaluationConfig } from '@/types/competition-configs'
@@ -35,15 +30,19 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const { roundId, projectId } = params
const utils = trpc.useUtils()
const [showCOIDialog, setShowCOIDialog] = useState(true)
const [coiAccepted, setCoiAccepted] = useState(false)
// Evaluation form state
const [criteriaScores, setCriteriaScores] = useState<Record<string, number>>({})
// Evaluation form state — stores all criterion values (numeric, boolean, text)
const [criteriaValues, setCriteriaValues] = useState<Record<string, number | boolean | string>>({})
const [globalScore, setGlobalScore] = useState('')
const [binaryDecision, setBinaryDecision] = useState<'accept' | 'reject' | ''>('')
const [feedbackText, setFeedbackText] = useState('')
// Track dirty state for autosave
const isDirtyRef = useRef(false)
const evaluationIdRef = useRef<string | null>(null)
const isSubmittedRef = useRef(false)
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
// Fetch project
const { data: project } = trpc.project.get.useQuery(
{ id: projectId },
@@ -70,20 +69,36 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{ enabled: !!myAssignment?.id }
)
// COI (Conflict of Interest) check
const { data: coiStatus, isLoading: coiLoading } = trpc.evaluation.getCOIStatus.useQuery(
{ assignmentId: myAssignment?.id ?? '' },
{ enabled: !!myAssignment?.id }
)
const [coiCompleted, setCOICompleted] = useState(false)
const [coiHasConflict, setCOIHasConflict] = useState(false)
// Fetch the active evaluation form for this round
const { data: activeForm } = trpc.evaluation.getStageForm.useQuery(
{ roundId },
{ enabled: !!roundId }
)
// Start evaluation mutation (creates draft)
const startMutation = trpc.evaluation.start.useMutation()
// Autosave mutation
// Autosave mutation (silent)
const autosaveMutation = trpc.evaluation.autosave.useMutation({
onSuccess: () => {
toast.success('Draft saved', { duration: 1500 })
isDirtyRef.current = false
setLastSavedAt(new Date())
},
onError: (err) => toast.error(err.message),
})
// Submit mutation
const submitMutation = trpc.evaluation.submit.useMutation({
onSuccess: () => {
isSubmittedRef.current = true
isDirtyRef.current = false
utils.roundAssignment.getMyAssignments.invalidate()
utils.evaluation.get.invalidate()
toast.success('Evaluation submitted successfully')
@@ -92,15 +107,24 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
onError: (err) => toast.error(err.message),
})
// Track evaluation ID
useEffect(() => {
if (existingEvaluation?.id) {
evaluationIdRef.current = existingEvaluation.id
}
}, [existingEvaluation?.id])
// Load existing evaluation data
useEffect(() => {
if (existingEvaluation) {
if (existingEvaluation.criterionScoresJson) {
const scores: Record<string, number> = {}
const values: Record<string, number | boolean | string> = {}
Object.entries(existingEvaluation.criterionScoresJson).forEach(([key, value]) => {
scores[key] = typeof value === 'number' ? value : 0
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
values[key] = value
}
})
setCriteriaScores(scores)
setCriteriaValues(values)
}
if (existingEvaluation.globalScore) {
setGlobalScore(existingEvaluation.globalScore.toString())
@@ -111,24 +135,139 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
if (existingEvaluation.feedbackText) {
setFeedbackText(existingEvaluation.feedbackText)
}
isDirtyRef.current = false
}
}, [existingEvaluation])
// Parse evaluation config from round
const evalConfig: EvaluationConfig | null = round?.configJson as EvaluationConfig | null
const scoringMode = evalConfig?.scoringMode ?? 'global'
const scoringMode = evalConfig?.scoringMode ?? 'criteria'
const requireFeedback = evalConfig?.requireFeedback ?? true
const feedbackMinLength = evalConfig?.feedbackMinLength ?? 10
// Get criteria from evaluation form
const criteria = existingEvaluation?.form?.criteriaJson as Array<{
id: string
label: string
description?: string
weight?: number
minScore?: number
maxScore?: number
}> | undefined
// Parse criteria from the active form
const criteria = (activeForm?.criteriaJson ?? []).map((c) => {
const type = (c as any).type || 'numeric'
let minScore = 1
let maxScore = 10
if (type === 'numeric' && c.scale) {
const parts = c.scale.split('-').map(Number)
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
minScore = parts[0]
maxScore = parts[1]
}
}
return {
id: c.id,
label: c.label,
description: c.description,
type: type as 'numeric' | 'text' | 'boolean' | 'section_header',
weight: c.weight,
minScore,
maxScore,
required: (c as any).required ?? true,
trueLabel: (c as any).trueLabel || 'Yes',
falseLabel: (c as any).falseLabel || 'No',
maxLength: (c as any).maxLength || 1000,
placeholder: (c as any).placeholder || '',
}
})
// Build current form data for autosave
const buildSavePayload = useCallback(() => {
return {
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : undefined,
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
feedbackText: feedbackText || null,
}
}, [scoringMode, criteriaValues, globalScore, binaryDecision, feedbackText])
// Perform autosave
const performAutosave = useCallback(async () => {
if (!isDirtyRef.current || isSubmittedRef.current) return
if (existingEvaluation?.status === 'SUBMITTED') return
let evalId = evaluationIdRef.current
if (!evalId && myAssignment) {
try {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evalId = newEval.id
evaluationIdRef.current = evalId
} catch {
return
}
}
if (!evalId) return
autosaveMutation.mutate({ id: evalId, ...buildSavePayload() })
}, [myAssignment, existingEvaluation?.status, startMutation, autosaveMutation, buildSavePayload])
// Debounced autosave: save 3 seconds after last change
useEffect(() => {
if (!isDirtyRef.current) return
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
}
autosaveTimerRef.current = setTimeout(() => {
performAutosave()
}, 3000)
return () => {
if (autosaveTimerRef.current) {
clearTimeout(autosaveTimerRef.current)
}
}
}, [criteriaValues, globalScore, binaryDecision, feedbackText, performAutosave])
// Save on page leave (beforeunload)
useEffect(() => {
const handleBeforeUnload = () => {
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
const payload = JSON.stringify({
id: evaluationIdRef.current,
...buildSavePayload(),
})
navigator.sendBeacon?.('/api/trpc/evaluation.autosave', payload)
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [buildSavePayload])
// Save on component unmount (navigating away within the app)
useEffect(() => {
return () => {
if (isDirtyRef.current && !isSubmittedRef.current && evaluationIdRef.current) {
performAutosave()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Mark dirty when form values change
const handleCriterionChange = (key: string, value: number | boolean | string) => {
setCriteriaValues((prev) => ({ ...prev, [key]: value }))
isDirtyRef.current = true
}
const handleGlobalScoreChange = (value: string) => {
setGlobalScore(value)
isDirtyRef.current = true
}
const handleBinaryChange = (value: 'accept' | 'reject') => {
setBinaryDecision(value)
isDirtyRef.current = true
}
const handleFeedbackChange = (value: string) => {
setFeedbackText(value)
isDirtyRef.current = true
}
const handleSaveDraft = async () => {
if (!myAssignment) {
@@ -136,21 +275,21 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
return
}
// Create evaluation if it doesn't exist
let evaluationId = existingEvaluation?.id
let evaluationId = evaluationIdRef.current
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
evaluationIdRef.current = evaluationId
}
// Autosave current state
autosaveMutation.mutate({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : undefined,
globalScore: scoringMode === 'global' && globalScore ? parseInt(globalScore, 10) : null,
binaryDecision: scoringMode === 'binary' && binaryDecision ? binaryDecision === 'accept' : null,
feedbackText: feedbackText || null,
})
autosaveMutation.mutate(
{ id: evaluationId, ...buildSavePayload() },
{ onSuccess: () => {
isDirtyRef.current = false
setLastSavedAt(new Date())
toast.success('Draft saved', { duration: 1500 })
}}
)
}
const handleSubmit = async () => {
@@ -159,17 +298,23 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
return
}
// Validation based on scoring mode
// Validation for criteria mode
if (scoringMode === 'criteria') {
if (!criteria || criteria.length === 0) {
toast.error('No criteria found for this evaluation')
const requiredCriteria = criteria.filter((c) =>
c.type !== 'section_header' && c.required
)
for (const c of requiredCriteria) {
const val = criteriaValues[c.id]
if (c.type === 'numeric' && (val === undefined || val === null)) {
toast.error(`Please score "${c.label}"`)
return
}
const requiredCriteria = evalConfig?.requireAllCriteriaScored !== false
if (requiredCriteria) {
const allScored = criteria.every((c) => criteriaScores[c.id] !== undefined)
if (!allScored) {
toast.error('Please score all criteria')
if (c.type === 'boolean' && val === undefined) {
toast.error(`Please answer "${c.label}"`)
return
}
if (c.type === 'text' && (!val || (typeof val === 'string' && !val.trim()))) {
toast.error(`Please fill in "${c.label}"`)
return
}
}
@@ -197,72 +342,43 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
}
}
// Create evaluation if needed
let evaluationId = existingEvaluation?.id
let evaluationId = evaluationIdRef.current
if (!evaluationId) {
const newEval = await startMutation.mutateAsync({ assignmentId: myAssignment.id })
evaluationId = newEval.id
evaluationIdRef.current = evaluationId
}
// Compute a weighted global score from numeric criteria for the global score field
const numericCriteria = criteria.filter((c) => c.type === 'numeric')
let computedGlobalScore = 5
if (scoringMode === 'criteria' && numericCriteria.length > 0) {
let totalWeight = 0
let weightedSum = 0
for (const c of numericCriteria) {
const val = criteriaValues[c.id]
if (typeof val === 'number') {
const w = c.weight ?? 1
// Normalize to 1-10 scale
const normalized = ((val - c.minScore) / (c.maxScore - c.minScore)) * 9 + 1
weightedSum += normalized * w
totalWeight += w
}
}
if (totalWeight > 0) {
computedGlobalScore = Math.round(weightedSum / totalWeight)
}
}
// Submit
submitMutation.mutate({
id: evaluationId,
criterionScoresJson: scoringMode === 'criteria' ? criteriaScores : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : 5,
criterionScoresJson: scoringMode === 'criteria' ? criteriaValues : {},
globalScore: scoringMode === 'global' ? parseInt(globalScore, 10) : computedGlobalScore,
binaryDecision: scoringMode === 'binary' ? binaryDecision === 'accept' : true,
feedbackText: feedbackText || 'No feedback provided',
})
}
// COI Dialog
if (!coiAccepted && showCOIDialog && evalConfig?.coiRequired !== false) {
return (
<Dialog open={showCOIDialog} onOpenChange={setShowCOIDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Conflict of Interest Declaration</DialogTitle>
<DialogDescription className="space-y-3 pt-2">
<p>
Before evaluating this project, you must confirm that you have no conflict of
interest.
</p>
<p>
A conflict of interest exists if you have a personal, professional, or financial
relationship with the project team that could influence your judgment.
</p>
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-3 py-4">
<Checkbox
id="coi"
checked={coiAccepted}
onCheckedChange={(checked) => setCoiAccepted(checked as boolean)}
/>
<Label htmlFor="coi" className="text-sm leading-relaxed cursor-pointer">
I confirm that I have no conflict of interest with this project and can provide an
unbiased evaluation.
</Label>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => router.push(`/jury/competitions/${roundId}` as Route)}
>
Cancel
</Button>
<Button
onClick={() => setShowCOIDialog(false)}
disabled={!coiAccepted}
className="bg-brand-blue hover:bg-brand-blue-light"
>
Continue to Evaluation
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
if (!round || !project) {
return (
<div className="space-y-6">
@@ -278,6 +394,123 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
)
}
// COI config
const coiRequired = evalConfig?.coiRequired ?? true
// Determine COI state: declared via server or just completed in this session
// coiStatus is null when no COI record exists, truthy when declared
const coiDeclared = coiCompleted || (coiStatus != null)
const coiConflict = coiHasConflict || (coiStatus?.hasConflict ?? false)
// Check if round is active
const isRoundActive = round.status === 'ROUND_ACTIVE'
if (!isRoundActive) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
<Clock className="h-6 w-6 text-amber-600" />
</div>
<div>
<h2 className="text-lg font-semibold">Evaluation Not Available</h2>
<p className="text-sm text-muted-foreground mt-1">
This round is not currently active. Evaluations can only be submitted during an active round.
</p>
<Button variant="outline" size="sm" className="mt-4" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
View Project Details
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
// COI gate: if COI is required, not yet declared, and we have an assignment
if (coiRequired && myAssignment && !coiLoading && !coiDeclared) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">{project.title}</p>
</div>
</div>
<COIDeclarationDialog
open={true}
assignmentId={myAssignment.id}
projectTitle={project.title}
onComplete={(hasConflict) => {
setCOICompleted(true)
setCOIHasConflict(hasConflict)
}}
/>
</div>
)
}
// COI conflict declared — block evaluation
if (coiRequired && coiConflict) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href={`/jury/competitions/${roundId}/projects/${projectId}` as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">{project.title}</p>
</div>
</div>
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-4 p-6">
<div className="rounded-xl bg-amber-50 dark:bg-amber-950/40 p-3">
<ShieldAlert className="h-6 w-6 text-amber-600" />
</div>
<div>
<h2 className="text-lg font-semibold">Conflict of Interest Declared</h2>
<p className="text-sm text-muted-foreground mt-1">
You declared a conflict of interest for this project. An administrator will
review your declaration. You cannot evaluate this project while the conflict
is under review.
</p>
<Button variant="outline" size="sm" className="mt-4" asChild>
<Link href={`/jury/competitions/${roundId}` as Route}>
Back to Round
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
@@ -291,9 +524,26 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
Evaluate Project
</h1>
<p className="text-muted-foreground mt-1">{project.title}</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-muted-foreground">{project.title}</p>
{project.competitionCategory && (
<Badge
variant="secondary"
className={
project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
}
>
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
</div>
</div>
</div>
{/* Project Documents */}
<MultiWindowDocViewer roundId={roundId} projectId={projectId} />
<Card className="border-l-4 border-l-amber-500">
<CardContent className="flex items-start gap-3 p-4">
@@ -302,7 +552,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<p className="font-medium text-sm">Important Reminder</p>
<p className="text-sm text-muted-foreground mt-1">
Your evaluation will be used to assess this project. Please provide thoughtful and
constructive feedback to help the team improve.
constructive feedback. Your progress is automatically saved as a draft.
</p>
</div>
</CardContent>
@@ -310,64 +560,218 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle>Evaluation Form</CardTitle>
<CardDescription>
Provide your assessment using the {scoringMode} scoring method
{scoringMode === 'criteria'
? 'Complete all required fields below'
: `Provide your assessment using the ${scoringMode} scoring method`}
</CardDescription>
</div>
{lastSavedAt && (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
Saved {lastSavedAt.toLocaleTimeString()}
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Criteria-based scoring */}
{/* Criteria-based scoring with mixed types */}
{scoringMode === 'criteria' && criteria && criteria.length > 0 && (
<div className="space-y-4">
<h3 className="font-semibold">Criteria Scores</h3>
{criteria.map((criterion) => (
<div key={criterion.id} className="space-y-2 p-4 border rounded-lg">
<Label htmlFor={criterion.id}>
{criterion.label}
{evalConfig?.requireAllCriteriaScored !== false && (
<span className="text-destructive ml-1">*</span>
{criteria.map((criterion) => {
if (criterion.type === 'section_header') {
return (
<div key={criterion.id} className="border-b pb-2 pt-4 first:pt-0">
<h3 className="font-semibold text-lg">{criterion.label}</h3>
{criterion.description && (
<p className="text-sm text-muted-foreground mt-1">{criterion.description}</p>
)}
</div>
)
}
if (criterion.type === 'boolean') {
const currentValue = criteriaValues[criterion.id]
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{criterion.required && <span className="text-destructive ml-1">*</span>}
</Label>
{criterion.description && (
<p className="text-xs text-muted-foreground">{criterion.description}</p>
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
<Input
id={criterion.id}
type="number"
min={criterion.minScore ?? 0}
max={criterion.maxScore ?? 10}
value={criteriaScores[criterion.id] ?? ''}
onChange={(e) =>
setCriteriaScores({
...criteriaScores,
[criterion.id]: parseInt(e.target.value, 10) || 0,
})
}
placeholder={`Score (${criterion.minScore ?? 0}-${criterion.maxScore ?? 10})`}
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, true)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === true
? 'border-emerald-500 bg-emerald-50 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
: 'border-border hover:border-emerald-300 hover:bg-emerald-50/50'
)}
>
<ThumbsUp className="mr-2 h-4 w-4" />
{criterion.trueLabel}
</button>
<button
type="button"
onClick={() => handleCriterionChange(criterion.id, false)}
className={cn(
'flex-1 h-12 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all',
currentValue === false
? 'border-red-500 bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400'
: 'border-border hover:border-red-300 hover:bg-red-50/50'
)}
>
<ThumbsDown className="mr-2 h-4 w-4" />
{criterion.falseLabel}
</button>
</div>
</div>
)
}
if (criterion.type === 'text') {
const currentValue = (criteriaValues[criterion.id] as string) || ''
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{criterion.required && <span className="text-destructive ml-1">*</span>}
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<Textarea
value={currentValue}
onChange={(e) => handleCriterionChange(criterion.id, e.target.value)}
placeholder={criterion.placeholder || 'Enter your response...'}
rows={4}
maxLength={criterion.maxLength}
/>
<p className="text-xs text-muted-foreground text-right">
{currentValue.length}/{criterion.maxLength}
</p>
</div>
)
}
// Default: numeric criterion
const min = criterion.minScore ?? 1
const max = criterion.maxScore ?? 10
const currentValue = criteriaValues[criterion.id]
const displayValue = typeof currentValue === 'number' ? currentValue : undefined
const sliderValue = typeof currentValue === 'number' ? currentValue : Math.ceil((min + max) / 2)
return (
<div key={criterion.id} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-base font-medium">
{criterion.label}
{criterion.required && <span className="text-destructive ml-1">*</span>}
</Label>
{criterion.description && (
<p className="text-sm text-muted-foreground">{criterion.description}</p>
)}
</div>
<span className="shrink-0 rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
{displayValue !== undefined ? displayValue : '\u2014'}/{max}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-4">{min}</span>
<Slider
min={min}
max={max}
step={1}
value={[sliderValue]}
onValueChange={(v) => handleCriterionChange(criterion.id, v[0])}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-4">{max}</span>
</div>
<div className="flex gap-1 flex-wrap">
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map((num) => (
<button
key={num}
type="button"
onClick={() => handleCriterionChange(criterion.id, num)}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
displayValue !== undefined && displayValue === num
? 'bg-primary text-primary-foreground'
: displayValue !== undefined && displayValue > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80'
)}
>
{num}
</button>
))}
</div>
</div>
)
})}
</div>
)}
{/* Global scoring */}
{scoringMode === 'global' && (
<div className="space-y-2">
<Label htmlFor="globalScore">
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label>
Overall Score <span className="text-destructive">*</span>
</Label>
<Input
id="globalScore"
type="number"
min="1"
max="10"
value={globalScore}
onChange={(e) => setGlobalScore(e.target.value)}
placeholder="Enter score (1-10)"
<span className="rounded-md bg-muted px-2.5 py-1 text-sm font-bold tabular-nums">
{globalScore || '\u2014'}/10
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">1</span>
<Slider
min={1}
max={10}
step={1}
value={[globalScore ? parseInt(globalScore, 10) : 5]}
onValueChange={(v) => handleGlobalScoreChange(v[0].toString())}
className="flex-1"
/>
<p className="text-xs text-muted-foreground">
Provide a score from 1 to 10 based on your overall assessment
</p>
<span className="text-xs text-muted-foreground">10</span>
</div>
<div className="flex gap-1 flex-wrap">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => {
const current = globalScore ? parseInt(globalScore, 10) : 0
return (
<button
key={num}
type="button"
onClick={() => handleGlobalScoreChange(num.toString())}
className={cn(
'w-9 h-9 rounded-md text-sm font-medium transition-colors',
current === num
? 'bg-primary text-primary-foreground'
: current > num
? 'bg-primary/20 text-primary'
: 'bg-muted hover:bg-muted/80'
)}
>
{num}
</button>
)
})}
</div>
</div>
)}
@@ -377,7 +781,7 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
<Label>
Decision <span className="text-destructive">*</span>
</Label>
<RadioGroup value={binaryDecision} onValueChange={(v) => setBinaryDecision(v as 'accept' | 'reject')}>
<RadioGroup value={binaryDecision} onValueChange={(v) => handleBinaryChange(v as 'accept' | 'reject')}>
<div className="flex items-center space-x-2 p-4 border rounded-lg hover:bg-emerald-50/50">
<RadioGroupItem value="accept" id="accept" />
<Label htmlFor="accept" className="flex items-center gap-2 cursor-pointer flex-1">
@@ -399,13 +803,13 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
{/* Feedback */}
<div className="space-y-2">
<Label htmlFor="feedbackText">
Feedback
General Comment / Feedback
{requireFeedback && <span className="text-destructive ml-1">*</span>}
</Label>
<Textarea
id="feedbackText"
value={feedbackText}
onChange={(e) => setFeedbackText(e.target.value)}
onChange={(e) => handleFeedbackChange(e.target.value)}
placeholder="Provide your feedback on the project..."
rows={8}
/>

View File

@@ -9,8 +9,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { MultiWindowDocViewer } from '@/components/jury/multi-window-doc-viewer'
import { ArrowLeft, FileText, Users, MapPin, Target } from 'lucide-react'
import { toast } from 'sonner'
import { ArrowLeft, FileText, Users, MapPin, Target, Tag } from 'lucide-react'
export default function JuryProjectDetailPage() {
const params = useParams()
@@ -22,6 +21,14 @@ export default function JuryProjectDetailPage() {
{ enabled: !!projectId }
)
const { data: round } = trpc.round.getById.useQuery(
{ id: roundId },
{ enabled: !!roundId }
)
// Round status is the primary gate for evaluations
const isVotingOpen = round?.status === 'ROUND_ACTIVE'
if (isLoading) {
return (
<div className="space-y-6">
@@ -71,34 +78,75 @@ export default function JuryProjectDetailPage() {
<p className="text-muted-foreground mt-1">{project.teamName}</p>
)}
</div>
{isVotingOpen ? (
<Button asChild className="bg-brand-blue hover:bg-brand-blue-light">
<Link href={`/jury/competitions/${roundId}/projects/${projectId}/evaluate` as Route}>
<Target className="mr-2 h-4 w-4" />
Evaluate Project
</Link>
</Button>
) : (
<Badge variant="outline" className="text-muted-foreground">
Voting not open
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Project metadata */}
<div className="flex flex-wrap gap-3">
{project.competitionCategory && (
<Badge
variant="secondary"
className={
project.competitionCategory === 'STARTUP'
? 'bg-violet-100 text-violet-700 border-violet-200 dark:bg-violet-950 dark:text-violet-300'
: 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-950 dark:text-sky-300'
}
>
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
)}
{project.country && (
<Badge variant="outline" className="gap-1">
<MapPin className="h-3 w-3" />
{project.country}
</Badge>
)}
{project.competitionCategory && (
<Badge variant="outline">{project.competitionCategory}</Badge>
</div>
{/* Project tags */}
{((project.projectTags && project.projectTags.length > 0) ||
(project.tags && project.tags.length > 0)) && (
<div>
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Tag className="h-4 w-4" />
Tags
</h3>
<div className="flex flex-wrap gap-2">
{project.projectTags && project.projectTags.length > 0
? project.projectTags.map((pt: any) => (
<Badge
key={pt.id}
variant="secondary"
style={pt.tag.color ? { backgroundColor: pt.tag.color + '20', borderColor: pt.tag.color, color: pt.tag.color } : undefined}
className="text-xs"
>
{pt.tag.name}
{pt.tag.category && (
<span className="ml-1 opacity-60">({pt.tag.category})</span>
)}
{project.tags && project.tags.length > 0 && (
project.tags.slice(0, 3).map((tag: string) => (
<Badge key={tag} variant="secondary">
</Badge>
))
: project.tags.map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))
)}
}
</div>
</div>
)}
{/* Description */}
{project.description && (

View File

@@ -12,9 +12,10 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
const params = use(paramsPromise);
const utils = trpc.useUtils();
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery({
sessionId: params.sessionId
});
const { data: session, isLoading } = trpc.deliberation.getSession.useQuery(
{ sessionId: params.sessionId },
{ refetchInterval: 10_000 },
);
const submitVoteMutation = trpc.deliberation.submitVote.useMutation({
onSuccess: () => {
@@ -30,7 +31,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
votes.forEach((vote) => {
submitVoteMutation.mutate({
sessionId: params.sessionId,
juryMemberId: session?.currentUser?.id || '',
juryMemberId: '', // TODO: resolve current user's jury member ID from session participants
projectId: vote.projectId,
rank: vote.rank,
isWinnerPick: vote.isWinnerPick
@@ -62,9 +63,9 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
);
}
const hasVoted = session.currentUser?.hasVoted;
const hasVoted = false; // TODO: check if current user has voted in this session
if (session.status !== 'DELIB_VOTING') {
if (session.status !== 'VOTING') {
return (
<div className="space-y-6">
<Card>
@@ -78,7 +79,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
<p className="text-muted-foreground">
{session.status === 'DELIB_OPEN'
? 'Voting has not started yet. Please wait for the admin to open voting.'
: session.status === 'DELIB_TALLYING'
: session.status === 'TALLYING'
? 'Voting is closed. Results are being tallied.'
: 'This session is locked.'}
</p>
@@ -139,7 +140,7 @@ export default function JuryDeliberationPage({ params: paramsPromise }: { params
</Card>
<DeliberationRankingForm
projects={session.projects || []}
projects={session.results?.map((r) => r.project) ?? []}
mode={session.mode}
onSubmit={handleSubmitVote}
disabled={submitVoteMutation.isPending}

View File

@@ -3,41 +3,62 @@
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { ArrowLeft, ArrowRight, ClipboardList, Target } from 'lucide-react'
import { toast } from 'sonner'
import { StatusBadge } from '@/components/shared/status-badge'
import { ProjectLogo } from '@/components/shared/project-logo'
import {
ArrowLeft,
ArrowRight,
ClipboardList,
CheckCircle2,
Clock,
FileEdit,
} from 'lucide-react'
import { formatDateOnly, formatEnumLabel } from '@/lib/utils'
export default function JuryCompetitionsPage() {
const { data: competitions, isLoading } = trpc.competition.getMyCompetitions.useQuery()
export default function JuryAssignmentsPage() {
const { data: assignments, isLoading } = trpc.assignment.myAssignments.useQuery({})
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-64" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-40" />
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
</div>
)
}
// Group assignments by round
const byRound = new Map<string, { round: { id: string; name: string; roundType: string; status: string; windowCloseAt: Date | null }; items: typeof assignments }>()
for (const a of assignments ?? []) {
if (!a.round) continue
if (!byRound.has(a.round.id)) {
byRound.set(a.round.id, { round: a.round, items: [] })
}
byRound.get(a.round.id)!.items!.push(a)
}
const roundGroups = Array.from(byRound.values())
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight text-brand-blue dark:text-foreground">
My Competitions
My Assignments
</h1>
<p className="text-muted-foreground mt-1">
View competitions and rounds you&apos;re assigned to
Projects assigned to you for evaluation
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Button variant="ghost" size="sm" asChild className="hidden md:inline-flex">
<Link href={'/jury' as Route}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Dashboard
@@ -45,65 +66,101 @@ export default function JuryCompetitionsPage() {
</Button>
</div>
{!competitions || competitions.length === 0 ? (
{roundGroups.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="rounded-2xl bg-brand-teal/10 p-4 mb-4">
<ClipboardList className="h-8 w-8 text-brand-teal/60" />
</div>
<h2 className="text-xl font-semibold mb-2">No Competitions</h2>
<h2 className="text-xl font-semibold mb-2">No Assignments</h2>
<p className="text-muted-foreground text-center max-w-md">
You don&apos;t have any active competition assignments yet.
You don&apos;t have any assignments yet. Assignments will appear once an administrator assigns projects to you.
</p>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{competitions.map((competition) => {
const activeRounds = competition.rounds?.filter(r => r.status !== 'ROUND_ARCHIVED') || []
const totalRounds = competition.rounds?.length || 0
<div className="space-y-6">
{roundGroups.map(({ round, items }) => {
const completed = (items ?? []).filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const total = items?.length ?? 0
return (
<Card key={competition.id} className="flex flex-col transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardHeader>
<div className="flex items-start justify-between">
<div>
<CardTitle className="text-lg">{competition.name}</CardTitle>
</div>
<Badge variant="secondary">
{totalRounds} round{totalRounds !== 1 ? 's' : ''}
<Card key={round.id}>
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">{round.name}</CardTitle>
<Badge variant="secondary" className="text-xs shrink-0">
{formatEnumLabel(round.roundType)}
</Badge>
{round.status !== 'ROUND_ACTIVE' && (
<Badge variant="outline" className="text-xs text-muted-foreground shrink-0">
{formatEnumLabel(round.status)}
</Badge>
)}
<span className="text-xs text-muted-foreground ml-auto shrink-0">
{completed}/{total} completed
</span>
{round.windowCloseAt && (
<Badge variant="outline" className="text-xs gap-1 shrink-0">
<Clock className="h-3 w-3" />
Due {formatDateOnly(round.windowCloseAt)}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col space-y-4">
<CardContent>
<div className="divide-y">
{(items ?? []).map((assignment) => {
const project = assignment.project
const evalStatus = assignment.evaluation?.status
const isSubmitted = evalStatus === 'SUBMITTED'
const isDraft = evalStatus === 'DRAFT'
<div className="flex-1" />
<div className="space-y-2">
{activeRounds.length > 0 ? (
activeRounds.slice(0, 2).map((round) => (
return (
<Link
key={round.id}
href={`/jury/competitions/${round.id}` as Route}
className="flex items-center justify-between p-3 rounded-lg border border-border/60 hover:border-brand-blue/30 hover:bg-brand-blue/5 transition-all group"
key={assignment.id}
href={`/jury/competitions/${round.id}/projects/${project.id}` as Route}
className="block"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Target className="h-4 w-4 text-brand-teal shrink-0" />
<span className="text-sm font-medium truncate">{round.name}</span>
<div className="flex items-center gap-3 py-3 px-1 transition-colors hover:bg-muted/40 rounded-lg group">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate group-hover:text-brand-blue transition-colors">
{project.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{[project.teamName, project.country].filter(Boolean).join(' \u00b7 ')}
</p>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors shrink-0" />
</Link>
))
<div className="flex items-center gap-2 shrink-0">
{isSubmitted ? (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
Submitted
</Badge>
) : isDraft ? (
<Badge variant="warning" className="gap-1">
<FileEdit className="h-3 w-3" />
Draft
</Badge>
) : (
<p className="text-sm text-muted-foreground text-center py-2">
No active rounds
</p>
)}
{activeRounds.length > 2 && (
<p className="text-xs text-muted-foreground text-center">
+{activeRounds.length - 2} more round{activeRounds.length - 2 !== 1 ? 's' : ''}
</p>
<Badge variant="secondary" className="gap-1">
<Clock className="h-3 w-3" />
Pending
</Badge>
)}
<ArrowRight className="h-4 w-4 text-muted-foreground group-hover:text-brand-blue transition-colors" />
</div>
</div>
</Link>
)
})}
</div>
</CardContent>
</Card>

View File

@@ -3,6 +3,7 @@ import { Suspense } from 'react'
import Link from 'next/link'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { AutoRefresh } from '@/components/shared/auto-refresh'
export const metadata: Metadata = { title: 'Jury Dashboard' }
export const dynamic = 'force-dynamic'
@@ -24,12 +25,12 @@ import {
GitCompare,
Zap,
BarChart3,
Target,
Waves,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import { CountdownTimer } from '@/components/shared/countdown-timer'
import { AnimatedCard } from '@/components/shared/animated-container'
import { JuryPreferencesBanner } from '@/components/jury/preferences-banner'
import { cn } from '@/lib/utils'
function getGreeting(): string {
@@ -47,8 +48,8 @@ async function JuryDashboardContent() {
return null
}
// Get assignments and grace periods in parallel
const [assignments, gracePeriods] = await Promise.all([
// Get assignments, grace periods, and feature flags in parallel
const [assignments, gracePeriods, compareFlag] = await Promise.all([
prisma.assignment.findMany({
where: { userId },
include: {
@@ -106,8 +107,11 @@ async function JuryDashboardContent() {
extendedUntil: true,
},
}),
prisma.systemSettings.findUnique({ where: { key: 'jury_compare_enabled' } }),
])
const juryCompareEnabled = compareFlag?.value === 'true'
// Calculate stats
const totalAssignments = assignments.length
const completedAssignments = assignments.filter(
@@ -186,36 +190,28 @@ async function JuryDashboardContent() {
const stats = [
{
label: 'Total Assignments',
value: totalAssignments,
icon: ClipboardList,
accentColor: 'border-l-blue-500',
iconBg: 'bg-blue-50 dark:bg-blue-950/40',
iconColor: 'text-blue-600 dark:text-blue-400',
label: 'Assigned',
detail: 'Total projects',
accent: 'text-brand-blue',
},
{
label: 'Completed',
value: completedAssignments,
icon: CheckCircle2,
accentColor: 'border-l-emerald-500',
iconBg: 'bg-emerald-50 dark:bg-emerald-950/40',
iconColor: 'text-emerald-600 dark:text-emerald-400',
label: 'Completed',
detail: `${completionRate.toFixed(0)}% done`,
accent: 'text-emerald-600',
},
{
label: 'In Progress',
value: inProgressAssignments,
icon: Clock,
accentColor: 'border-l-amber-500',
iconBg: 'bg-amber-50 dark:bg-amber-950/40',
iconColor: 'text-amber-600 dark:text-amber-400',
label: 'In draft',
detail: inProgressAssignments > 0 ? 'Work in progress' : 'None started',
accent: inProgressAssignments > 0 ? 'text-amber-600' : 'text-emerald-600',
},
{
label: 'Pending',
value: pendingAssignments,
icon: Target,
accentColor: 'border-l-slate-400',
iconBg: 'bg-slate-50 dark:bg-slate-800/50',
iconColor: 'text-slate-500 dark:text-slate-400',
label: 'Pending',
detail: pendingAssignments > 0 ? 'Not yet started' : 'All started',
accent: pendingAssignments > 0 ? 'text-amber-600' : 'text-emerald-600',
},
]
@@ -235,7 +231,7 @@ async function JuryDashboardContent() {
Your project assignments will appear here once an administrator assigns them to you.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 max-w-md mx-auto">
<div className={`grid gap-3 max-w-md mx-auto ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link
href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
@@ -248,6 +244,7 @@ async function JuryDashboardContent() {
<p className="text-xs text-muted-foreground">View evaluations</p>
</div>
</Link>
{juryCompareEnabled && (
<Link
href="/jury/competitions"
className="group flex items-center gap-3 rounded-xl border border-border/60 p-3 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
@@ -260,6 +257,7 @@ async function JuryDashboardContent() {
<p className="text-xs text-muted-foreground">Side-by-side view</p>
</div>
</Link>
)}
</div>
</CardContent>
</Card>
@@ -300,48 +298,34 @@ async function JuryDashboardContent() {
</AnimatedCard>
)}
{/* Stats + Overall Completion in one row */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{stats.map((stat, i) => (
<AnimatedCard key={stat.label} index={i + 1}>
<Card className={cn(
'border-l-4 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md',
stat.accentColor,
)}>
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className={cn('rounded-xl p-3', stat.iconBg)}>
<stat.icon className={cn('h-5 w-5', stat.iconColor)} />
{/* Stats — editorial strip */}
<AnimatedCard index={1}>
{/* Mobile: compact horizontal data strip */}
<div className="flex items-baseline justify-between border-b border-t py-3 md:hidden">
{stats.map((s, i) => (
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
<span className="text-xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mt-0.5">{s.label}</p>
</div>
<div>
<p className="text-2xl font-bold tabular-nums tracking-tight">{stat.value}</p>
<p className="text-sm text-muted-foreground font-medium">{stat.label}</p>
</div>
</CardContent>
</Card>
</AnimatedCard>
))}
{/* Overall completion as 5th stat card */}
<AnimatedCard index={5}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="flex items-center gap-4 py-5 px-5">
<div className="rounded-xl p-3 bg-brand-blue/10 dark:bg-brand-blue/20">
<BarChart3 className="h-5 w-5 text-brand-blue dark:text-brand-teal" />
</div>
<div className="flex-1 min-w-0">
<p className="text-2xl font-bold tabular-nums tracking-tight text-brand-blue dark:text-brand-teal">
{completionRate.toFixed(0)}%
</p>
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted/60 mt-1">
{/* Desktop: editorial stat row */}
<div className="hidden md:block">
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
{stats.map((s, i) => (
<div
className="h-full rounded-full bg-gradient-to-r from-brand-teal to-brand-blue transition-all duration-500 ease-out"
style={{ width: `${completionRate}%` }}
/>
key={i}
className="bg-background px-5 py-4 group hover:bg-muted/30 transition-colors"
>
<span className="text-3xl font-bold tabular-nums tracking-tight">{s.value}</span>
<p className="text-xs font-medium text-muted-foreground mt-1">{s.label}</p>
<p className={`text-xs mt-0.5 ${s.accent}`}>{s.detail}</p>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Main content -- two column layout */}
<div className="grid gap-4 lg:grid-cols-12">
@@ -373,12 +357,7 @@ async function JuryDashboardContent() {
const evaluation = assignment.evaluation
const isCompleted = evaluation?.status === 'SUBMITTED'
const isDraft = evaluation?.status === 'DRAFT'
const isVotingOpen =
assignment.round.status === 'ROUND_ACTIVE' &&
assignment.round.windowOpenAt &&
assignment.round.windowCloseAt &&
new Date(assignment.round.windowOpenAt) <= now &&
new Date(assignment.round.windowCloseAt) >= now
const isVotingOpen = assignment.round.status === 'ROUND_ACTIVE'
return (
<div
@@ -472,7 +451,7 @@ async function JuryDashboardContent() {
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
<div className={`grid gap-3 ${juryCompareEnabled ? 'sm:grid-cols-2' : ''}`}>
<Link
href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-blue/30 hover:bg-brand-blue/5 hover:-translate-y-0.5 hover:shadow-md dark:hover:border-brand-teal/30 dark:hover:bg-brand-teal/5"
@@ -485,6 +464,7 @@ async function JuryDashboardContent() {
<p className="text-xs text-muted-foreground mt-0.5">View and manage evaluations</p>
</div>
</Link>
{juryCompareEnabled && (
<Link
href="/jury/competitions"
className="group flex items-center gap-4 rounded-xl border border-border/60 p-4 transition-all duration-200 hover:border-brand-teal/30 hover:bg-brand-teal/5 hover:-translate-y-0.5 hover:shadow-md"
@@ -497,6 +477,7 @@ async function JuryDashboardContent() {
<p className="text-xs text-muted-foreground mt-0.5">Side-by-side comparison</p>
</div>
</Link>
)}
</div>
</CardContent>
</Card>
@@ -669,30 +650,25 @@ function DashboardSkeleton() {
return (
<>
{/* Stats skeleton */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex items-baseline justify-between border-b border-t py-3 md:hidden">
{[...Array(4)].map((_, i) => (
<Card key={i} className="border-l-4 border-l-muted">
<CardContent className="flex items-center gap-4 py-5 px-5">
<Skeleton className="h-11 w-11 rounded-xl" />
<div className="space-y-2">
<Skeleton className="h-7 w-12" />
<Skeleton className="h-4 w-24" />
<div key={i} className={`flex-1 text-center ${i > 0 ? 'border-l border-border/50' : ''}`}>
<Skeleton className="h-6 w-8 mx-auto" />
<Skeleton className="h-3 w-14 mx-auto mt-1" />
</div>
))}
</div>
<div className="hidden md:block">
<div className="grid grid-cols-4 gap-px rounded-lg bg-border/40 overflow-hidden">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-background px-5 py-4">
<Skeleton className="h-9 w-12" />
<Skeleton className="h-4 w-20 mt-1" />
<Skeleton className="h-3 w-16 mt-1" />
</div>
</CardContent>
</Card>
))}
</div>
{/* Progress bar skeleton */}
<Card className="overflow-hidden">
<div className="h-1 w-full bg-muted" />
<CardContent className="py-5 px-6">
<div className="flex items-center justify-between mb-3">
<Skeleton className="h-4 w-36" />
<Skeleton className="h-7 w-16" />
</div>
<Skeleton className="h-3 w-full rounded-full" />
</CardContent>
</Card>
{/* Two-column skeleton */}
<div className="grid gap-6 lg:grid-cols-12">
<div className="lg:col-span-7">
@@ -757,10 +733,16 @@ export default async function JuryDashboardPage() {
</p>
</div>
{/* Preferences banner (shown when juror has unconfirmed preferences) */}
<JuryPreferencesBanner />
{/* Content */}
<Suspense fallback={<DashboardSkeleton />}>
<JuryDashboardContent />
</Suspense>
{/* Auto-refresh every 30s so voting round changes appear promptly */}
<AutoRefresh intervalMs={30_000} />
</div>
)
}

View File

@@ -18,7 +18,12 @@ export default async function JuryLayout({
select: { onboardingCompletedAt: true },
})
if (!user?.onboardingCompletedAt) {
if (!user) {
// User was deleted — session is stale, send to login
redirect('/login')
}
if (!user.onboardingCompletedAt) {
redirect('/onboarding')
}

View File

@@ -19,7 +19,12 @@ export default async function MentorLayout({
select: { onboardingCompletedAt: true },
})
if (!user?.onboardingCompletedAt) {
if (!user) {
// User was deleted — session is stale, send to login
redirect('/login')
}
if (!user.onboardingCompletedAt) {
redirect('/onboarding')
}
}

View File

@@ -1,5 +1,6 @@
import { requireRole } from '@/lib/auth-redirect'
import { ObserverNav } from '@/components/layouts/observer-nav'
import { EditionProvider } from '@/components/observer/observer-edition-context'
export default async function ObserverLayout({
children,
@@ -10,6 +11,7 @@ export default async function ObserverLayout({
return (
<div className="min-h-screen bg-background">
<EditionProvider>
<ObserverNav
user={{
name: session.user.name,
@@ -17,6 +19,7 @@ export default async function ObserverLayout({
}}
/>
<main className="container-app py-6">{children}</main>
</EditionProvider>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
export default function ObserverLoading() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-32" />
</div>
<Skeleton className="h-10 w-[200px]" />
</div>
{/* 6 stat tiles */}
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
{[...Array(6)].map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="space-y-2">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-8 w-12" />
<Skeleton className="h-3 w-20" />
</div>
</CardContent>
</Card>
))}
</div>
{/* Pipeline */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent>
<div className="flex gap-4 overflow-hidden">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-24 w-48 shrink-0 rounded-lg" />
))}
</div>
</CardContent>
</Card>
{/* 3-col middle row */}
<div className="grid gap-4 lg:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent>
<Skeleton className="h-[200px] w-full" />
</CardContent>
</Card>
))}
</div>
{/* 2-col bottom row */}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-3 w-16" />
</div>
))}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,15 @@
import type { Metadata } from 'next'
import { ObserverProjectDetail } from '@/components/observer/observer-project-detail'
export const metadata: Metadata = { title: 'Project Detail' }
export const dynamic = 'force-dynamic'
export default async function ObserverProjectDetailPage({
params,
}: {
params: Promise<{ projectId: string }>
}) {
const { projectId } = await params
return <ObserverProjectDetail projectId={projectId} />
}

View File

@@ -0,0 +1,37 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
export default function ObserverProjectsLoading() {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-2">
<Skeleton className="h-8 w-36" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-9 w-28" />
</div>
<Card>
<CardHeader className="pb-3">
<Skeleton className="h-5 w-14" />
</CardHeader>
<CardContent>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Skeleton className="h-10 flex-1" />
<Skeleton className="h-10 w-full sm:w-[220px]" />
<Skeleton className="h-10 w-full sm:w-[180px]" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 space-y-2">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,8 @@
import { ObserverProjectsContent } from '@/components/observer/observer-projects-content'
export const metadata = { title: 'Observer — Projects' }
export const dynamic = 'force-dynamic'
export default function ObserverProjectsPage() {
return <ObserverProjectsContent />
}

View File

@@ -0,0 +1,57 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
export default function ObserverReportsLoading() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="mt-2 h-4 w-56" />
</div>
{/* Round selector */}
<Skeleton className="h-10 w-full sm:w-[300px]" />
{/* Tab bar */}
<Skeleton className="h-10 w-80" />
{/* 3 stat tiles */}
<div className="grid gap-4 sm:grid-cols-3">
{[...Array(3)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-2 w-full" />
</CardContent>
</Card>
))}
</div>
{/* Chart skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-[300px] w-full" />
</CardContent>
</Card>
{/* Table skeleton */}
<Card>
<CardHeader>
<Skeleton className="h-5 w-36" />
</CardHeader>
<CardContent className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,25 +1,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, Suspense } from 'react'
import { useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Select,
SelectContent,
@@ -29,702 +13,258 @@ import {
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
FileSpreadsheet,
BarChart3,
Users,
ClipboardList,
CheckCircle2,
TrendingUp,
GitCompare,
UserCheck,
Globe,
LayoutDashboard,
Filter,
FolderOpen,
TrendingUp,
Users,
BarChart3,
Upload,
Presentation,
Vote,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
CrossStageComparisonChart,
JurorConsistencyChart,
DiversityMetricsChart,
} from '@/components/charts'
import { ExportPdfButton } from '@/components/shared/export-pdf-button'
import { AnimatedCard } from '@/components/shared/animated-container'
import type { LucideIcon } from 'lucide-react'
// Parse selection value: "all:programId" for edition-wide, or roundId
function parseSelection(value: string | null): { roundId?: string; programId?: string } {
if (!value) return {}
if (value.startsWith('all:')) return { programId: value.slice(4) }
return { roundId: value }
import { GlobalAnalyticsTab } from '@/components/observer/reports/global-analytics-tab'
import { IntakeReportTabs } from '@/components/observer/reports/intake-report-tabs'
import { FilteringReportTabs } from '@/components/observer/reports/filtering-report-tabs'
import { EvaluationReportTabs } from '@/components/observer/reports/evaluation-report-tabs'
import { SubmissionReportTabs } from '@/components/observer/reports/submission-report-tabs'
import { MentoringReportTabs } from '@/components/observer/reports/mentoring-report-tabs'
import { LiveFinalReportTabs } from '@/components/observer/reports/live-final-report-tabs'
import { DeliberationReportTabs } from '@/components/observer/reports/deliberation-report-tabs'
const ROUND_TYPE_LABELS: Record<string, string> = {
INTAKE: 'Intake',
FILTERING: 'Filtering',
EVALUATION: 'Evaluation',
SUBMISSION: 'Submission',
MENTORING: 'Mentoring',
LIVE_FINAL: 'Live Final',
DELIBERATION: 'Deliberation',
}
function OverviewTab({ selectedValue }: { selectedValue: string | null }) {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeStages: true })
type Stage = {
id: string
name: string
status: string
roundType: string
windowCloseAt: Date | null
_count: { projects: number; assignments: number; evaluations: number }
programId: string
programName: string
}
const stages = programs?.flatMap(p =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
...s,
programName: `${p.year} Edition`,
}))
) || []
type TabDef = { value: string; label: string; icon: LucideIcon }
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
function getRoundTabs(roundType: string): TabDef[] {
switch (roundType) {
case 'INTAKE':
return [{ value: 'overview', label: 'Overview', icon: LayoutDashboard }]
case 'FILTERING':
return [
{ value: 'screening', label: 'Screening', icon: Filter },
]
case 'EVALUATION':
return [
{ value: 'evaluation', label: 'Evaluation', icon: TrendingUp },
]
case 'SUBMISSION':
return [{ value: 'overview', label: 'Overview', icon: Upload }]
case 'MENTORING':
return [{ value: 'overview', label: 'Overview', icon: Users }]
case 'LIVE_FINAL':
return [{ value: 'session', label: 'Session', icon: Presentation }]
case 'DELIBERATION':
return [
{ value: 'deliberation', label: 'Deliberation', icon: Vote },
]
default:
return []
}
}
const { data: overviewStats, isLoading: statsLoading } =
trpc.analytics.getOverviewStats.useQuery(
queryInput,
{ enabled: hasSelection }
)
if (isLoading) {
function RoundTypeContent({
roundType,
roundId,
programId,
stages,
selectedValue,
}: {
roundType: string
roundId: string
programId: string
stages: Stage[]
selectedValue: string | null
}) {
switch (roundType) {
case 'INTAKE':
return <IntakeReportTabs roundId={roundId} programId={programId} />
case 'FILTERING':
return <FilteringReportTabs roundId={roundId} programId={programId} />
case 'EVALUATION':
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
</div>
)
}
const totalProjects = stages.reduce((acc, s) => acc + (s._count?.projects || 0), 0)
const activeStages = stages.filter((s) => s.status === 'ROUND_ACTIVE').length
const totalPrograms = programs?.length || 0
return (
<div className="space-y-6">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<AnimatedCard index={0}>
<Card className="border-l-4 border-l-blue-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Stages</p>
<p className="text-2xl font-bold mt-1">{stages.length}</p>
<p className="text-xs text-muted-foreground mt-1">
{activeStages} active
</p>
</div>
<div className="rounded-xl bg-blue-50 p-3">
<BarChart3 className="h-5 w-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={1}>
<Card className="border-l-4 border-l-emerald-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total Projects</p>
<p className="text-2xl font-bold mt-1">{totalProjects}</p>
<p className="text-xs text-muted-foreground mt-1">Across all stages</p>
</div>
<div className="rounded-xl bg-emerald-50 p-3">
<ClipboardList className="h-5 w-5 text-emerald-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={2}>
<Card className="border-l-4 border-l-violet-500 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Active Stages</p>
<p className="text-2xl font-bold mt-1">{activeStages}</p>
<p className="text-xs text-muted-foreground mt-1">Currently active</p>
</div>
<div className="rounded-xl bg-violet-50 p-3">
<Users className="h-5 w-5 text-violet-600" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
<AnimatedCard index={3}>
<Card className="border-l-4 border-l-brand-teal transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md">
<CardContent className="p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Programs</p>
<p className="text-2xl font-bold mt-1">{totalPrograms}</p>
<p className="text-xs text-muted-foreground mt-1">Total programs</p>
</div>
<div className="rounded-xl bg-brand-teal/10 p-3">
<CheckCircle2 className="h-5 w-5 text-brand-teal" />
</div>
</div>
</CardContent>
</Card>
</AnimatedCard>
</div>
{/* Round/edition-specific overview stats */}
{hasSelection && (
<>
{statsLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Card key={i}>
<CardHeader className="space-y-0 pb-2">
<Skeleton className="h-4 w-20" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-2 h-3 w-24" />
</CardContent>
</Card>
))}
</div>
) : overviewStats ? (
<div className="space-y-4">
<h3 className="text-lg font-semibold">{queryInput.programId ? 'Edition Overview' : 'Selected Round Details'}</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.projectCount}</div>
<p className="text-xs text-muted-foreground">In this round</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.assignmentCount}</div>
<p className="text-xs text-muted-foreground">
{overviewStats.jurorCount} jurors
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Evaluations</CardTitle>
<FileSpreadsheet className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.evaluationCount}</div>
<p className="text-xs text-muted-foreground">Submitted</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completion</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{overviewStats.completionRate}%</div>
<Progress value={overviewStats.completionRate} className="mt-2 h-2" gradient />
</CardContent>
</Card>
</div>
</div>
) : null}
</>
)}
{/* Stages Table - Desktop */}
<Card className="hidden md:block">
<CardHeader>
<CardTitle>Stage Reports</CardTitle>
<CardDescription>Progress overview for each stage</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stages.map((stage) => (
<TableRow key={stage.id}>
<TableCell>
<div>
<p className="font-medium">{stage.name}</p>
{stage.windowCloseAt && (
<p className="text-sm text-muted-foreground">
Ends: {formatDateOnly(stage.windowCloseAt)}
</p>
)}
</div>
</TableCell>
<TableCell>{stage.programName}</TableCell>
<TableCell>{stage._count?.projects || '-'}</TableCell>
<TableCell>
<Badge
variant={
stage.status === 'STAGE_ACTIVE'
? 'default'
: stage.status === 'STAGE_CLOSED'
? 'secondary'
: 'outline'
}
>
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Stages Cards - Mobile */}
<div className="space-y-4 md:hidden">
<h2 className="text-lg font-semibold">Stage Reports</h2>
{stages.map((stage) => (
<Card key={stage.id}>
<CardContent className="pt-4 space-y-3">
<div className="flex items-center justify-between">
<p className="font-medium">{stage.name}</p>
<Badge
variant={
stage.status === 'STAGE_ACTIVE'
? 'default'
: stage.status === 'STAGE_CLOSED'
? 'secondary'
: 'outline'
}
>
{stage.status === 'STAGE_ACTIVE' ? 'Active' : stage.status === 'STAGE_CLOSED' ? 'Closed' : stage.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{stage.programName}</p>
{stage.windowCloseAt && (
<p className="text-xs text-muted-foreground">
Ends: {formatDateOnly(stage.windowCloseAt)}
</p>
)}
<div className="text-sm">
<span>{stage._count?.projects || 0} projects</span>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)
}
function AnalyticsTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
queryInput,
{ enabled: hasSelection }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
queryInput,
{ enabled: hasSelection }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
queryInput,
{ enabled: hasSelection }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
queryInput,
{ enabled: hasSelection }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ ...queryInput, limit: 15 },
{ enabled: hasSelection }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
queryInput,
{ enabled: hasSelection }
)
return (
<div className="space-y-6">
{/* Row 1: Score Distribution & Status Breakdown */}
<div className="grid gap-6 lg:grid-cols-2">
{scoreLoading ? (
<Skeleton className="h-[350px]" />
) : scoreDistribution ? (
<ScoreDistributionChart
data={scoreDistribution.distribution}
averageScore={scoreDistribution.averageScore}
totalScores={scoreDistribution.totalScores}
/>
) : null}
{statusLoading ? (
<Skeleton className="h-[350px]" />
) : statusBreakdown ? (
<StatusBreakdownChart data={statusBreakdown} />
) : null}
</div>
{/* Row 2: Evaluation Timeline */}
{timelineLoading ? (
<Skeleton className="h-[350px]" />
) : timeline?.length ? (
<EvaluationTimelineChart data={timeline} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No evaluation data available yet
</p>
</CardContent>
</Card>
)}
{/* Row 3: Criteria Scores */}
{criteriaLoading ? (
<Skeleton className="h-[350px]" />
) : criteriaScores?.length ? (
<CriteriaScoresChart data={criteriaScores} />
) : null}
{/* Row 4: Juror Workload */}
{workloadLoading ? (
<Skeleton className="h-[450px]" />
) : jurorWorkload?.length ? (
<JurorWorkloadChart data={jurorWorkload} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No juror assignments yet
</p>
</CardContent>
</Card>
)}
{/* Row 5: Project Rankings */}
{rankingsLoading ? (
<Skeleton className="h-[550px]" />
) : projectRankings?.length ? (
<ProjectRankingsChart data={projectRankings} limit={15} />
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">
No project scores available yet
</p>
</CardContent>
</Card>
)}
</div>
)
}
function CrossStageTab() {
const { data: programs, isLoading: programsLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap(p =>
((p.stages || []) as Array<{ id: string; name: string }>).map(s => ({ id: s.id, name: s.name, programName: `${p.year} Edition` }))
) || []
const [selectedRoundIds, setSelectedRoundIds] = useState<string[]>([])
const { data: comparison, isLoading: comparisonLoading } =
trpc.analytics.getCrossRoundComparison.useQuery(
{ roundIds: selectedRoundIds },
{ enabled: selectedRoundIds.length >= 2 }
)
const toggleRound = (roundId: string) => {
setSelectedRoundIds((prev) =>
prev.includes(roundId)
? prev.filter((id) => id !== roundId)
: [...prev, roundId]
)
}
if (programsLoading) return <Skeleton className="h-[400px]" />
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Select Stages to Compare</CardTitle>
<CardDescription>Choose at least 2 stages</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{stages.map((stage) => (
<Badge
key={stage.id}
variant={selectedRoundIds.includes(stage.id) ? 'default' : 'outline'}
className="cursor-pointer text-sm py-1.5 px-3"
onClick={() => toggleRound(stage.id)}
>
{stage.programName} - {stage.name}
</Badge>
))}
</div>
{selectedRoundIds.length < 2 && (
<p className="text-sm text-muted-foreground mt-3">
Select at least 2 stages to enable comparison
</p>
)}
</CardContent>
</Card>
{comparisonLoading && selectedRoundIds.length >= 2 && <Skeleton className="h-[350px]" />}
{comparison && (
<CrossStageComparisonChart data={comparison as Array<{
roundId: string; roundName: string; projectCount: number; evaluationCount: number
completionRate: number; averageScore: number | null
scoreDistribution: { score: number; count: number }[]
}>} />
)}
</div>
)
}
function JurorConsistencyTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: consistency, isLoading } =
trpc.analytics.getJurorConsistency.useQuery(
queryInput,
{ enabled: hasSelection }
)
if (isLoading) return <Skeleton className="h-[400px]" />
if (!consistency) return null
return (
<JurorConsistencyChart
data={consistency as {
overallAverage: number
jurors: Array<{
userId: string; name: string; email: string
evaluationCount: number; averageScore: number
stddev: number; deviationFromOverall: number; isOutlier: boolean
}>
}}
<EvaluationReportTabs
roundId={roundId}
programId={programId}
stages={stages}
selectedValue={selectedValue}
/>
)
case 'SUBMISSION':
return <SubmissionReportTabs roundId={roundId} programId={programId} />
case 'MENTORING':
return <MentoringReportTabs roundId={roundId} programId={programId} />
case 'LIVE_FINAL':
return <LiveFinalReportTabs roundId={roundId} programId={programId} />
case 'DELIBERATION':
return <DeliberationReportTabs roundId={roundId} programId={programId} />
default:
return null
}
}
function DiversityTab({ selectedValue }: { selectedValue: string }) {
const queryInput = parseSelection(selectedValue)
const hasSelection = !!queryInput.roundId || !!queryInput.programId
const { data: diversity, isLoading } =
trpc.analytics.getDiversityMetrics.useQuery(
queryInput,
{ enabled: hasSelection }
)
if (isLoading) return <Skeleton className="h-[400px]" />
if (!diversity) return null
return (
<DiversityMetricsChart
data={diversity as {
total: number
byCountry: { country: string; count: number; percentage: number }[]
byCategory: { category: string; count: number; percentage: number }[]
byOceanIssue: { issue: string; count: number; percentage: number }[]
byTag: { tag: string; count: number; percentage: number }[]
}}
/>
)
}
export default function ObserverReportsPage() {
const [selectedValue, setSelectedValue] = useState<string | null>(null)
function ReportsPageContent() {
const searchParams = useSearchParams()
const roundFromUrl = searchParams.get('round')
const [selectedValue, setSelectedValue] = useState<string | null>(roundFromUrl)
const [activeTab, setActiveTab] = useState<string | null>(null)
const { data: programs, isLoading: stagesLoading } = trpc.program.list.useQuery({ includeStages: true })
const stages = programs?.flatMap(p =>
(p.stages as { id: string; name: string; status: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number } }[]).map(s => ({
const stages: Stage[] = programs?.flatMap(p =>
((p.stages || []) as { id: string; name: string; status: string; roundType: string; windowCloseAt: Date | null; _count: { projects: number; assignments: number; evaluations: number } }[]).map(s => ({
...s,
programId: p.id,
programName: `${p.year} Edition`,
}))
) || []
) ?? []
// Set default selected stage
const allRoundIds = stages.map((s) => s.id)
useEffect(() => {
if (stages.length && !selectedValue) {
setSelectedValue(stages[0].id)
const active = stages.find((s) => s.status === 'ROUND_ACTIVE')
setSelectedValue(active ? active.id : stages[0].id)
}
}, [stages.length, selectedValue])
const hasSelection = !!selectedValue
// Reset to first round-specific tab when round selection changes
useEffect(() => {
setActiveTab(null)
}, [selectedValue])
const isAllRounds = selectedValue?.startsWith('all:')
const selectedRound = stages.find((s) => s.id === selectedValue)
const roundType = selectedRound?.roundType ?? ''
const programId = isAllRounds
? selectedValue!.slice(4)
: selectedRound?.programId ?? programs?.[0]?.id ?? ''
const roundSpecificTabs = isAllRounds
? [{ value: 'progress', label: 'Progress', icon: TrendingUp }]
: getRoundTabs(roundType)
const allTabs: TabDef[] = [
...roundSpecificTabs,
{ value: 'global', label: 'Global', icon: Globe },
]
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
<p className="text-muted-foreground">
View evaluation progress and statistics
</p>
<p className="text-muted-foreground">View evaluation progress and statistics</p>
</div>
{/* Stage Selector */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<label className="text-sm font-medium">Select Stage:</label>
<label className="text-sm font-medium">Select Round:</label>
{stagesLoading ? (
<Skeleton className="h-10 w-full sm:w-[300px]" />
) : stages.length > 0 ? (
<Select value={selectedValue || ''} onValueChange={setSelectedValue}>
<SelectTrigger className="w-full sm:w-[300px]">
<SelectValue placeholder="Select a stage" />
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{programs?.map((p) => (
<SelectItem key={`all:${p.id}`} value={`all:${p.id}`}>
{p.year} Edition All Stages
{p.year} Edition All Rounds
</SelectItem>
))}
{stages.map((stage) => (
<SelectItem key={stage.id} value={stage.id}>
{stage.programName} - {stage.name}
{stage.name}{stage.roundType ? ` (${ROUND_TYPE_LABELS[stage.roundType] || stage.roundType})` : ''}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="text-sm text-muted-foreground">No stages available</p>
<p className="text-sm text-muted-foreground">No rounds available</p>
)}
</div>
{/* Tabs */}
<Tabs defaultValue="overview" className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
{selectedValue && (
<Tabs value={activeTab ?? allTabs[0]?.value ?? 'global'} onValueChange={setActiveTab} className="space-y-6">
<TabsList>
<TabsTrigger value="overview" className="gap-2">
<FileSpreadsheet className="h-4 w-4" />
Overview
</TabsTrigger>
<TabsTrigger value="analytics" className="gap-2" disabled={!hasSelection}>
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
<TabsTrigger value="cross-stage" className="gap-2">
<GitCompare className="h-4 w-4" />
Cross-Round
</TabsTrigger>
<TabsTrigger value="consistency" className="gap-2" disabled={!hasSelection}>
<UserCheck className="h-4 w-4" />
Juror Consistency
</TabsTrigger>
<TabsTrigger value="diversity" className="gap-2" disabled={!hasSelection}>
<Globe className="h-4 w-4" />
Diversity
{allTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
{selectedValue && !selectedValue.startsWith('all:') && (
<ExportPdfButton
roundId={selectedValue}
roundName={selectedRound?.name}
programName={selectedRound?.programName}
<TabsContent value="global">
<GlobalAnalyticsTab
programId={programId}
roundIds={allRoundIds.length >= 2 ? allRoundIds : undefined}
/>
)}
</div>
<TabsContent value="overview">
<OverviewTab selectedValue={selectedValue} />
</TabsContent>
<TabsContent value="analytics">
{hasSelection ? (
<AnalyticsTab selectedValue={selectedValue!} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<BarChart3 className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round or edition from the dropdown above to view analytics
</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="cross-stage">
<CrossStageTab />
</TabsContent>
<TabsContent value="consistency">
{hasSelection ? (
<JurorConsistencyTab selectedValue={selectedValue!} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<UserCheck className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round or edition above to view juror consistency metrics
</p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="diversity">
{hasSelection ? (
<DiversityTab selectedValue={selectedValue!} />
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Globe className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Select a round</p>
<p className="text-sm text-muted-foreground">
Choose a round or edition above to view diversity metrics
</p>
</CardContent>
</Card>
)}
{/* Round-type-specific or "All Rounds" progress tab */}
{roundSpecificTabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
{isAllRounds ? (
<EvaluationReportTabs
roundId=""
programId={programId}
stages={stages}
selectedValue={selectedValue}
/>
) : selectedRound ? (
<RoundTypeContent
roundType={roundType}
roundId={selectedRound.id}
programId={programId}
stages={stages}
selectedValue={selectedValue}
/>
) : null}
</TabsContent>
))}
</Tabs>
)}
</div>
)
}
export default function ObserverReportsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div>
<Skeleton className="h-8 w-32" />
<Skeleton className="mt-2 h-4 w-56" />
</div>
<Skeleton className="h-10 w-full sm:w-[300px]" />
<Skeleton className="h-[400px] w-full" />
</div>
}
>
<ReportsPageContent />
</Suspense>
)
}

View File

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

View File

@@ -43,6 +43,44 @@
/* Source the JS config for extended theme values */
@config "../../tailwind.config.ts";
/* Tremor generates Tailwind utility classes dynamically via template literals
(e.g. `fill-${color}-${shade}`). Tailwind v4's scanner cannot detect these,
so we must explicitly safelist every color+shade+property combination. */
@source "../../node_modules/@tremor/react/dist/**/*.js";
/* Safelist Tremor chart color utilities — all colors × key shades × fill/stroke/bg */
@source inline("{fill,stroke,bg,text}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("hover:{bg,text,border}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
@source inline("{border,ring}-{blue,emerald,amber,violet,rose,indigo,sky,fuchsia,lime,orange,cyan,teal,purple,slate,gray,zinc,neutral,stone,red,yellow,green,pink}-{50,100,200,300,400,500,600,700,800,900,950}");
/* Safelist Tremor design token utility classes */
@source inline("{fill,stroke,bg,text,border}-tremor-{brand,background,border,content,content-emphasis,default,label,card,dropdown}");
/* Tremor design tokens — normally registered by Tremor's TW3 plugin.
We define them manually for Tailwind v4 compatibility. */
@theme {
--color-tremor-brand: var(--color-blue-500);
--color-tremor-brand-emphasis: var(--color-blue-700);
--color-tremor-brand-inverted: #fff;
--color-tremor-brand-muted: var(--color-blue-200);
--color-tremor-brand-faint: var(--color-blue-50);
--color-tremor-background: #fff;
--color-tremor-background-emphasis: var(--color-gray-700);
--color-tremor-background-muted: var(--color-gray-50);
--color-tremor-background-subtle: var(--color-gray-100);
--color-tremor-border: var(--color-gray-200);
--color-tremor-content: var(--color-gray-500);
--color-tremor-content-emphasis: var(--color-gray-700);
--color-tremor-content-strong: var(--color-gray-900);
--color-tremor-content-subtle: var(--color-gray-400);
--color-tremor-content-inverted: #fff;
--color-tremor-ring: var(--color-gray-200);
--color-tremor-default: var(--color-gray-500);
--color-tremor-label: var(--color-gray-400);
--color-tremor-card: #fff;
--color-tremor-dropdown: #fff;
}
/* Theme variables - using CSS custom properties with Tailwind v4 @theme */
@theme {
/* Container */
@@ -294,3 +332,46 @@
background: hsl(var(--muted-foreground) / 0.5);
}
}
/* Tremor chart tooltip fix — ensure solid background */
[class*="tremor-"] [role="tooltip"],
.recharts-tooltip-wrapper .recharts-default-tooltip,
div[class*="tremor"][class*="tooltip"],
div[class*="recharts-tooltip"] {
background-color: hsl(var(--card)) !important;
border: 1px solid hsl(var(--border)) !important;
border-radius: 0.5rem !important;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1) !important;
opacity: 1 !important;
}
.dark div[class*="tremor"][class*="tooltip"],
.dark .recharts-tooltip-wrapper .recharts-default-tooltip,
.dark div[class*="recharts-tooltip"] {
background-color: hsl(var(--card)) !important;
border-color: hsl(var(--border)) !important;
}
/* Tremor/Recharts tooltip color indicator icons — fix rendering */
.recharts-tooltip-wrapper svg.recharts-surface {
display: inline-block !important;
overflow: visible !important;
}
/* Tremor custom tooltip color dots */
[class*="tremor"] [role="tooltip"] span[class*="bg-"],
[class*="tremor"] [role="tooltip"] span[style*="background"] {
border-radius: 2px !important;
min-width: 10px !important;
min-height: 10px !important;
flex-shrink: 0 !important;
}
/* Recharts default tooltip icon fix — ensure SVG paths have correct fill */
.recharts-default-tooltip .recharts-tooltip-item-list {
padding: 0 !important;
}
.recharts-default-tooltip .recharts-tooltip-item svg {
border: none !important;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,14 @@ import { AlertCircle, CheckCircle2, Users } from 'lucide-react'
interface CoverageReportProps {
roundId: string
requiredReviews?: number
}
export function CoverageReport({ roundId }: CoverageReportProps) {
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery({
roundId,
requiredReviews: 3,
})
export function CoverageReport({ roundId, requiredReviews = 3 }: CoverageReportProps) {
const { data: coverage, isLoading } = trpc.roundAssignment.coverageReport.useQuery(
{ roundId, requiredReviews },
{ refetchInterval: 15_000 },
)
if (isLoading) {
return (
@@ -71,7 +72,7 @@ export function CoverageReport({ roundId }: CoverageReportProps) {
<CardContent>
<div className="text-2xl font-bold">{unassignedCount}</div>
<p className="text-xs text-muted-foreground">
Projects below 3 reviews
Projects below {requiredReviews} reviews
</p>
</CardContent>
</Card>

View File

@@ -37,10 +37,9 @@ type RoundSummary = {
}
export function CompetitionTimeline({
competitionId,
rounds,
}: {
competitionId: string
competitionId?: string
rounds: RoundSummary[]
}) {
if (rounds.length === 0) {
@@ -70,7 +69,7 @@ export function CompetitionTimeline({
return (
<div key={round.id} className="flex items-start">
<Link
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
href={`/admin/rounds/${round.id}` as Route}
className="group flex flex-col items-center text-center w-32 shrink-0"
>
<div className="relative">
@@ -116,7 +115,7 @@ export function CompetitionTimeline({
return (
<div key={round.id}>
<Link
href={`/admin/competitions/${competitionId}/rounds/${round.id}` as Route}
href={`/admin/rounds/${round.id}` as Route}
className="flex items-start gap-3 py-2 hover:bg-muted/50 rounded-md px-2 -mx-2 transition-colors"
>
<div className="flex flex-col items-center shrink-0">

View File

@@ -35,7 +35,7 @@ export function AdminOverrideDialog({
const { data: session } = trpc.deliberation.getSession.useQuery(
{ sessionId },
{ enabled: open }
{ enabled: open, refetchInterval: 10_000 }
);
const adminDecideMutation = trpc.deliberation.adminDecide.useMutation({
@@ -91,7 +91,7 @@ export function AdminOverrideDialog({
<Label>Project Rankings</Label>
<div className="space-y-2">
{projectIds.map((projectId) => {
const project = session?.projects?.find((p: any) => p.id === projectId);
const project = session?.results?.find((r) => r.project.id === projectId)?.project;
return (
<div key={projectId} className="flex items-center gap-3">
<Input

View File

@@ -18,8 +18,17 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
const utils = trpc.useUtils();
const [overrideDialogOpen, setOverrideDialogOpen] = useState(false);
const { data: session } = trpc.deliberation.getSession.useQuery({ sessionId });
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery({ sessionId });
const { data: session } = trpc.deliberation.getSession.useQuery(
{ sessionId },
{ refetchInterval: 10_000 }
);
const { data: aggregatedResults } = trpc.deliberation.aggregate.useQuery(
{ sessionId },
{
refetchInterval: 10_000,
enabled: session?.status === 'TALLYING' || session?.status === 'RUNOFF' || session?.status === 'DELIB_LOCKED',
}
);
const initRunoffMutation = trpc.deliberation.initRunoff.useMutation({
onSuccess: () => {
@@ -46,35 +55,33 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
return (
<Card>
<CardContent className="p-12 text-center">
<p className="text-muted-foreground">No voting results yet</p>
<p className="text-muted-foreground">
{session?.status === 'DELIB_OPEN' || session?.status === 'VOTING'
? 'Voting has not been tallied yet'
: 'No voting results yet'}
</p>
</CardContent>
</Card>
);
}
// Detect ties: check if two or more top-ranked candidates share the same totalScore
const hasTie = (() => {
const rankings = aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }> | undefined;
// Detect ties using the backend-computed flag, with client-side fallback
const hasTie = aggregatedResults.hasTies ?? (() => {
const rankings = aggregatedResults.rankings as Array<{ score?: number; projectId: string }> | undefined;
if (!rankings || rankings.length < 2) return false;
// Group projects by totalScore
const scoreGroups = new Map<number, string[]>();
for (const r of rankings) {
const score = r.totalScore ?? 0;
const score = r.score ?? 0;
const group = scoreGroups.get(score) || [];
group.push(r.projectId);
scoreGroups.set(score, group);
}
// A tie exists if the highest score is shared by 2+ projects
const topScore = Math.max(...scoreGroups.keys());
const topGroup = scoreGroups.get(topScore);
return (topGroup?.length ?? 0) >= 2;
})();
const tiedProjectIds = hasTie
? (aggregatedResults.rankings as Array<{ totalScore?: number; projectId: string }>)
.filter((r) => r.totalScore === (aggregatedResults.rankings as Array<{ totalScore?: number }>)[0]?.totalScore)
.map((r) => r.projectId)
: [];
const canFinalize = session?.status === 'DELIB_TALLYING' && !hasTie;
const tiedProjectIds = aggregatedResults.tiedProjectIds ?? [];
const canFinalize = session?.status === 'TALLYING' && !hasTie;
return (
<div className="space-y-4">
@@ -95,17 +102,17 @@ export function ResultsPanel({ sessionId }: ResultsPanelProps) {
>
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 font-bold">
#{index + 1}
#{result.rank ?? index + 1}
</div>
<div>
<p className="font-medium">{result.projectTitle}</p>
<p className="font-medium">{result.projectTitle ?? result.projectId}</p>
<p className="text-sm text-muted-foreground">
{result.votes} votes {result.averageRank?.toFixed(2)} avg rank
{result.voteCount} votes
</p>
</div>
</div>
<Badge variant="outline" className="text-lg">
{result.totalScore?.toFixed(1) || 0}
{result.score?.toFixed?.(1) ?? 0}
</Badge>
</div>
))}

View File

@@ -1,6 +1,5 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card,
@@ -42,10 +41,21 @@ interface EvaluationSummaryCardProps {
roundId: string
}
interface BooleanStats {
yesCount: number
noCount: number
total: number
yesPercent: number
trueLabel: string
falseLabel: string
}
interface ScoringPatterns {
averageGlobalScore: number | null
consensus: number
criterionAverages: Record<string, number>
booleanCriteria?: Record<string, BooleanStats>
textResponses?: Record<string, string[]>
evaluatorCount: number
}
@@ -74,8 +84,6 @@ export function EvaluationSummaryCard({
projectId,
roundId,
}: EvaluationSummaryCardProps) {
const [isGenerating, setIsGenerating] = useState(false)
const {
data: summary,
isLoading,
@@ -86,19 +94,18 @@ export function EvaluationSummaryCard({
onSuccess: () => {
toast.success('AI summary generated successfully')
refetch()
setIsGenerating(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to generate summary')
setIsGenerating(false)
},
})
const handleGenerate = () => {
setIsGenerating(true)
generateMutation.mutate({ projectId, roundId })
}
const isGenerating = generateMutation.isPending
if (isLoading) {
return (
<Card>
@@ -296,10 +303,10 @@ export function EvaluationSummaryCard({
</div>
)}
{/* Criterion Averages */}
{/* Criterion Averages (Numeric) */}
{Object.keys(patterns.criterionAverages).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Criterion Averages</p>
<p className="text-sm font-medium mb-2">Score Averages</p>
<div className="space-y-2">
{Object.entries(patterns.criterionAverages).map(([label, avg]) => (
<div key={label} className="flex items-center gap-3">
@@ -323,6 +330,69 @@ export function EvaluationSummaryCard({
</div>
)}
{/* Boolean Criteria (Yes/No) */}
{patterns.booleanCriteria && Object.keys(patterns.booleanCriteria).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Yes/No Decisions</p>
<div className="space-y-3">
{Object.entries(patterns.booleanCriteria).map(([label, stats]) => (
<div key={label} className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground truncate">
{label}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{stats.yesCount} {stats.trueLabel} / {stats.noCount} {stats.falseLabel}
</span>
</div>
<div className="flex h-2 rounded-full overflow-hidden bg-muted">
{stats.yesCount > 0 && (
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${stats.yesPercent}%` }}
/>
)}
{stats.noCount > 0 && (
<div
className="h-full bg-red-400 transition-all"
style={{ width: `${100 - stats.yesPercent}%` }}
/>
)}
</div>
<div className="flex justify-between text-xs">
<span className="text-emerald-600">{stats.yesPercent}% {stats.trueLabel}</span>
<span className="text-red-500">{100 - stats.yesPercent}% {stats.falseLabel}</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Text Responses */}
{patterns.textResponses && Object.keys(patterns.textResponses).length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Text Responses</p>
<div className="space-y-3">
{Object.entries(patterns.textResponses).map(([label, responses]) => (
<div key={label} className="space-y-1.5">
<p className="text-sm text-muted-foreground">{label}</p>
<div className="space-y-1.5">
{responses.map((text, i) => (
<div
key={i}
className="text-sm p-2 rounded border bg-muted/50 whitespace-pre-wrap"
>
{text}
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
{/* Recommendation */}
{summaryData.recommendation && (
<div className="p-3 rounded-lg bg-blue-500/10 border border-blue-200">

View File

@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Search } from 'lucide-react'
import { Search, UserPlus, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
@@ -15,6 +15,8 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
@@ -22,6 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
interface AddMemberDialogProps {
juryGroupId: string
@@ -30,10 +33,26 @@ interface AddMemberDialogProps {
}
export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDialogProps) {
const [tab, setTab] = useState<'search' | 'invite'>('search')
// Search existing user state
const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string>('')
const [role, setRole] = useState<'CHAIR' | 'MEMBER' | 'OBSERVER'>('MEMBER')
const [maxAssignments, setMaxAssignments] = useState<string>('')
const [capMode, setCapMode] = useState<string>('')
const [role, setRole] = useState<string>('MEMBER')
const [startupRatio, setStartupRatio] = useState<number | null>(null)
const [availabilityNotes, setAvailabilityNotes] = useState('')
// Invite new user state
const [inviteName, setInviteName] = useState('')
const [inviteEmail, setInviteEmail] = useState('')
const [inviteMaxAssignments, setInviteMaxAssignments] = useState<string>('')
const [inviteCapMode, setInviteCapMode] = useState<string>('')
const [inviteRole, setInviteRole] = useState<string>('MEMBER')
const [inviteStartupRatio, setInviteStartupRatio] = useState<number | null>(null)
const [inviteAvailabilityNotes, setInviteAvailabilityNotes] = useState('')
const [inviteExpertise, setInviteExpertise] = useState('')
const utils = trpc.useUtils()
@@ -44,7 +63,7 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
const users = userResponse?.users || []
const { mutate: addMember, isPending } = trpc.juryGroup.addMember.useMutation({
const { mutate: addMember, isPending: isAdding } = trpc.juryGroup.addMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Member added successfully')
@@ -56,14 +75,56 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
},
})
const { mutate: createUser, isPending: isCreating } = trpc.user.create.useMutation({
onSuccess: (newUser) => {
// Immediately add the newly created user to the jury group
addMember({
juryGroupId,
userId: newUser.id,
role: inviteRole as 'CHAIR' | 'MEMBER' | 'OBSERVER',
maxAssignmentsOverride: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : null,
capModeOverride: inviteCapMode && inviteCapMode !== 'DEFAULT' ? (inviteCapMode as 'HARD' | 'SOFT' | 'NONE') : null,
preferredStartupRatio: inviteStartupRatio,
availabilityNotes: inviteAvailabilityNotes.trim() || null,
})
// Send invitation email
sendInvitation({ userId: newUser.id, juryGroupId })
},
onError: (err) => {
toast.error(err.message)
},
})
const { mutate: sendInvitation } = trpc.user.sendInvitation.useMutation({
onSuccess: (result) => {
toast.success(`Invitation sent to ${result.email}`)
utils.user.list.invalidate()
},
onError: (err) => {
// Don't block — user was created and added, just invitation failed
toast.error(`Member added but invitation email failed: ${err.message}`)
},
})
const resetForm = () => {
setSearchQuery('')
setSelectedUserId('')
setRole('MEMBER')
setMaxAssignments('')
setCapMode('')
setRole('MEMBER')
setStartupRatio(null)
setAvailabilityNotes('')
setInviteName('')
setInviteEmail('')
setInviteMaxAssignments('')
setInviteCapMode('')
setInviteRole('MEMBER')
setInviteStartupRatio(null)
setInviteAvailabilityNotes('')
setInviteExpertise('')
}
const handleSubmit = (e: React.FormEvent) => {
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!selectedUserId) {
@@ -74,22 +135,63 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
addMember({
juryGroupId,
userId: selectedUserId,
role,
role: role as 'CHAIR' | 'MEMBER' | 'OBSERVER',
maxAssignmentsOverride: maxAssignments ? parseInt(maxAssignments, 10) : null,
capModeOverride: capMode && capMode !== 'DEFAULT' ? (capMode as 'HARD' | 'SOFT' | 'NONE') : null,
preferredStartupRatio: startupRatio,
availabilityNotes: availabilityNotes.trim() || null,
})
}
const handleInviteSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!inviteEmail.trim()) {
toast.error('Please enter an email address')
return
}
const expertiseTags = inviteExpertise
.split(',')
.map((t) => t.trim())
.filter(Boolean)
createUser({
email: inviteEmail.trim(),
name: inviteName.trim() || undefined,
role: 'JURY_MEMBER',
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
maxAssignments: inviteMaxAssignments ? parseInt(inviteMaxAssignments, 10) : undefined,
})
}
const isPending = isAdding || isCreating
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add Member to Jury Group</DialogTitle>
<DialogDescription>
Search for a user and assign them to this jury group
Search for an existing user or invite a new juror to the platform
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<Tabs value={tab} onValueChange={(v) => setTab(v as 'search' | 'invite')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="search" className="flex items-center gap-2">
<Search className="h-3.5 w-3.5" />
Search Existing
</TabsTrigger>
<TabsTrigger value="invite" className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5" />
Invite New
</TabsTrigger>
</TabsList>
{/* Search existing user tab */}
<TabsContent value="search">
<form onSubmit={handleSearchSubmit} className="space-y-4 pt-2">
<div className="space-y-2">
<Label htmlFor="search">Search User</Label>
<div className="relative">
@@ -127,9 +229,10 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(val) => setRole(val as any)}>
<Select value={role} onValueChange={setRole}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
@@ -140,6 +243,21 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="capMode">Cap Mode</Label>
<Select value={capMode || 'DEFAULT'} onValueChange={setCapMode}>
<SelectTrigger id="capMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DEFAULT">Group Default</SelectItem>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments Override (optional)</Label>
@@ -153,15 +271,183 @@ export function AddMemberDialog({ juryGroupId, open, onOpenChange }: AddMemberDi
/>
</div>
<div className="space-y-2">
<Label>Category Preference</Label>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
<Slider
value={[startupRatio !== null ? startupRatio * 100 : 50]}
onValueChange={([v]) => setStartupRatio(v / 100)}
min={0}
max={100}
step={10}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
</div>
<p className="text-xs text-muted-foreground">
{startupRatio !== null
? `~${Math.round(startupRatio * 100)}% startups / ~${Math.round((1 - startupRatio) * 100)}% concepts`
: 'No preference set (balanced distribution)'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="availabilityNotes">Availability Notes (optional)</Label>
<Textarea
id="availabilityNotes"
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
rows={2}
value={availabilityNotes}
onChange={(e) => setAvailabilityNotes(e.target.value)}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !selectedUserId}>
{isPending ? 'Adding...' : 'Add Member'}
{isAdding ? 'Adding...' : 'Add Member'}
</Button>
</DialogFooter>
</form>
</TabsContent>
{/* Invite new user tab */}
<TabsContent value="invite">
<form onSubmit={handleInviteSubmit} className="space-y-4 pt-2">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteName">Full Name</Label>
<Input
id="inviteName"
placeholder="Jane Doe"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inviteEmail">
Email <span className="text-destructive">*</span>
</Label>
<Input
id="inviteEmail"
type="email"
placeholder="jane@example.com"
required
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="inviteRole">Role</Label>
<Select value={inviteRole} onValueChange={setInviteRole}>
<SelectTrigger id="inviteRole">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MEMBER">Member</SelectItem>
<SelectItem value="CHAIR">Chair</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="inviteCapMode">Cap Mode</Label>
<Select value={inviteCapMode || 'DEFAULT'} onValueChange={setInviteCapMode}>
<SelectTrigger id="inviteCapMode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DEFAULT">Group Default</SelectItem>
<SelectItem value="HARD">Hard Cap</SelectItem>
<SelectItem value="SOFT">Soft Cap</SelectItem>
<SelectItem value="NONE">No Cap</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="inviteMaxAssignments">Max Assignments Override (optional)</Label>
<Input
id="inviteMaxAssignments"
type="number"
min="1"
placeholder="Leave empty to use group default"
value={inviteMaxAssignments}
onChange={(e) => setInviteMaxAssignments(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Category Preference</Label>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground w-20 shrink-0">Startup</span>
<Slider
value={[inviteStartupRatio !== null ? inviteStartupRatio * 100 : 50]}
onValueChange={([v]) => setInviteStartupRatio(v / 100)}
min={0}
max={100}
step={10}
className="flex-1"
/>
<span className="text-xs text-muted-foreground w-20 shrink-0 text-right">Concept</span>
</div>
<p className="text-xs text-muted-foreground">
{inviteStartupRatio !== null
? `~${Math.round(inviteStartupRatio * 100)}% startups / ~${Math.round((1 - inviteStartupRatio) * 100)}% concepts`
: 'No preference set (balanced distribution)'}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="inviteAvailabilityNotes">Availability Notes (optional)</Label>
<Textarea
id="inviteAvailabilityNotes"
placeholder="e.g. Available only in March, limited to 5 reviews/week..."
rows={2}
value={inviteAvailabilityNotes}
onChange={(e) => setInviteAvailabilityNotes(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="inviteExpertise">Expertise Tags (optional)</Label>
<Input
id="inviteExpertise"
placeholder="marine biology, policy, finance"
value={inviteExpertise}
onChange={(e) => setInviteExpertise(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Comma-separated tags for smart assignment matching
</p>
</div>
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-700">
<UserPlus className="mr-1 inline h-3 w-3" />
This will create a new user account and send an invitation email to join the platform as a jury member.
</p>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isPending || !inviteEmail.trim()}>
{isCreating || isAdding ? 'Creating & Inviting...' : 'Create & Invite'}
</Button>
</DialogFooter>
</form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)

View File

@@ -1,10 +1,11 @@
'use client'
import { useState } from 'react'
import { Trash2, UserPlus } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { Trash2, UserPlus, Pencil } from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Table,
TableBody,
@@ -29,14 +30,15 @@ import { AddMemberDialog } from './add-member-dialog'
interface JuryMember {
id: string
userId: string
role: string
role?: string
user: {
id: string
name: string | null
email: string
}
maxAssignmentsOverride: number | null
preferredStartupRatio: number | null
maxAssignmentsOverride?: number | null
capModeOverride?: string | null
preferredStartupRatio?: number | null
}
interface JuryMembersTableProps {
@@ -44,6 +46,79 @@ interface JuryMembersTableProps {
members: JuryMember[]
}
function InlineCapEdit({
memberId,
currentValue,
juryGroupId,
}: {
memberId: string
currentValue: number | null | undefined
juryGroupId: string
}) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState(currentValue?.toString() ?? '')
const inputRef = useRef<HTMLInputElement>(null)
const utils = trpc.useUtils()
const { mutate: updateMember, isPending } = trpc.juryGroup.updateMember.useMutation({
onSuccess: () => {
utils.juryGroup.getById.invalidate({ id: juryGroupId })
toast.success('Cap updated')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
useEffect(() => {
if (editing) inputRef.current?.focus()
}, [editing])
const save = () => {
const trimmed = value.trim()
const newVal = trimmed === '' ? null : parseInt(trimmed, 10)
if (newVal !== null && (isNaN(newVal) || newVal < 1)) {
toast.error('Enter a positive number or leave empty for no cap')
return
}
if (newVal === (currentValue ?? null)) {
setEditing(false)
return
}
updateMember({ id: memberId, maxAssignmentsOverride: newVal })
}
if (editing) {
return (
<Input
ref={inputRef}
type="number"
min={1}
className="h-7 w-20 text-xs"
value={value}
placeholder="∞"
disabled={isPending}
onChange={(e) => setValue(e.target.value)}
onBlur={save}
onKeyDown={(e) => {
if (e.key === 'Enter') save()
if (e.key === 'Escape') { setValue(currentValue?.toString() ?? ''); setEditing(false) }
}}
/>
)
}
return (
<button
type="button"
className="inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-sm hover:bg-muted transition-colors group"
onClick={() => { setValue(currentValue?.toString() ?? ''); setEditing(true) }}
>
<span>{currentValue ?? '∞'}</span>
<Pencil className="h-3 w-3 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
)
}
export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps) {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [removingMemberId, setRemovingMemberId] = useState<string | null>(null)
@@ -79,16 +154,17 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="hidden sm:table-cell">Role</TableHead>
<TableHead>Email</TableHead>
<TableHead className="hidden md:table-cell">Role</TableHead>
<TableHead className="hidden sm:table-cell">Max Assignments</TableHead>
<TableHead className="hidden lg:table-cell">Cap Mode</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
<TableCell colSpan={6} className="text-center text-muted-foreground">
No members yet. Add members to get started.
</TableCell>
</TableRow>
@@ -98,16 +174,29 @@ export function JuryMembersTable({ juryGroupId, members }: JuryMembersTableProps
<TableCell className="font-medium">
{member.user.name || 'Unnamed User'}
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="outline" className="text-[10px] capitalize">
{member.role?.toLowerCase().replace('_', ' ') || 'member'}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{member.user.email}
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant={member.role === 'CHAIR' ? 'default' : 'secondary'}>
{member.role}
</Badge>
</TableCell>
<TableCell className="hidden sm:table-cell">
{member.maxAssignmentsOverride ?? '—'}
<InlineCapEdit
memberId={member.id}
currentValue={member.maxAssignmentsOverride}
juryGroupId={juryGroupId}
/>
</TableCell>
<TableCell className="hidden lg:table-cell">
{member.capModeOverride ? (
<Badge variant="outline" className="text-[10px]">
{member.capModeOverride}
</Badge>
) : (
<span className="text-muted-foreground text-xs">Group default</span>
)}
</TableCell>
<TableCell>
<Button

View File

@@ -5,7 +5,7 @@ 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 { ChevronLeft, ChevronRight, Play, Square, Timer } from 'lucide-react';
import { ChevronLeft, ChevronRight, Play, Square, Pause, Timer } from 'lucide-react';
import { toast } from 'sonner';
interface LiveControlPanelProps {
@@ -15,18 +15,36 @@ interface LiveControlPanelProps {
export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelProps) {
const utils = trpc.useUtils();
const [timerSeconds, setTimerSeconds] = useState(300); // 5 minutes default
const [timerSeconds, setTimerSeconds] = useState(300);
const [isTimerRunning, setIsTimerRunning] = useState(false);
const { data: cursor } = trpc.live.getCursor.useQuery({ roundId });
// TODO: Add getScores to live router
const scores: any[] = [];
const { data: cursor } = trpc.live.getCursor.useQuery(
{ roundId },
{ refetchInterval: 5000 }
);
// TODO: Implement cursor mutation
const moveCursorMutation = {
mutate: () => {},
isPending: false
};
const jumpMutation = trpc.live.jump.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
},
onError: (err) => toast.error(err.message),
});
const pauseMutation = trpc.live.pause.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session paused');
},
onError: (err) => toast.error(err.message),
});
const resumeMutation = trpc.live.resume.useMutation({
onSuccess: () => {
utils.live.getCursor.invalidate({ roundId });
toast.success('Live session resumed');
},
onError: (err) => toast.error(err.message),
});
useEffect(() => {
if (!isTimerRunning) return;
@@ -44,14 +62,24 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
return () => clearInterval(interval);
}, [isTimerRunning]);
const currentIndex = cursor?.activeOrderIndex ?? 0;
const totalProjects = cursor?.totalProjects ?? 0;
const isNavigating = jumpMutation.isPending;
const handlePrevious = () => {
// TODO: Implement previous navigation
toast.info('Previous navigation not yet implemented');
if (currentIndex <= 0) {
toast.info('Already at the first project');
return;
}
jumpMutation.mutate({ roundId, index: currentIndex - 1 });
};
const handleNext = () => {
// TODO: Implement next navigation
toast.info('Next navigation not yet implemented');
if (currentIndex >= totalProjects - 1) {
toast.info('Already at the last project');
return;
}
jumpMutation.mutate({ roundId, index: currentIndex + 1 });
};
const formatTime = (seconds: number) => {
@@ -67,12 +95,17 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Current Project</CardTitle>
<div className="flex gap-2">
<div className="flex items-center gap-2">
{cursor && (
<span className="text-sm text-muted-foreground tabular-nums">
{currentIndex + 1} / {totalProjects}
</span>
)}
<Button
variant="outline"
size="icon"
onClick={handlePrevious}
disabled={moveCursorMutation.isPending}
disabled={isNavigating || currentIndex <= 0}
>
<ChevronLeft className="h-4 w-4" />
</Button>
@@ -80,7 +113,7 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
variant="outline"
size="icon"
onClick={handleNext}
disabled={moveCursorMutation.isPending}
disabled={isNavigating || currentIndex >= totalProjects - 1}
>
<ChevronRight className="h-4 w-4" />
</Button>
@@ -92,13 +125,24 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
<div className="space-y-4">
<div>
<h3 className="text-2xl font-bold">{cursor.activeProject.title}</h3>
{cursor.activeProject.teamName && (
<p className="text-muted-foreground">{cursor.activeProject.teamName}</p>
)}
</div>
<div className="text-sm text-muted-foreground">
Total projects: {cursor.totalProjects}
{cursor.activeProject.tags && (cursor.activeProject.tags as string[]).length > 0 && (
<div className="flex flex-wrap gap-1">
{(cursor.activeProject.tags as string[]).map((tag: string) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
) : (
<p className="text-muted-foreground">No project selected</p>
<p className="text-muted-foreground">
{cursor ? 'No project selected' : 'No live session active for this round'}
</p>
)}
</CardContent>
</Card>
@@ -144,48 +188,48 @@ export function LiveControlPanel({ roundId, competitionId }: LiveControlPanelPro
</CardContent>
</Card>
{/* Voting Controls */}
{/* Session Controls */}
<Card>
<CardHeader>
<CardTitle>Voting Controls</CardTitle>
<CardDescription>Manage jury and audience voting</CardDescription>
<CardTitle>Session Controls</CardTitle>
<CardDescription>Pause or resume the live presentation</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button className="w-full" variant="default">
<Play className="mr-2 h-4 w-4" />
Open Jury Voting
</Button>
<Button className="w-full" variant="outline">
Close Voting
</Button>
</CardContent>
</Card>
{/* Scores Display */}
<Card>
<CardHeader>
<CardTitle>Live Scores</CardTitle>
</CardHeader>
<CardContent>
{scores && scores.length > 0 ? (
<div className="space-y-2">
{scores.map((score: any, index: number) => (
<div
key={score.projectId}
className="flex items-center justify-between rounded-lg border p-3"
{cursor?.isPaused ? (
<Button
className="w-full"
onClick={() => resumeMutation.mutate({ roundId })}
disabled={resumeMutation.isPending}
>
<div>
<p className="font-medium">
#{index + 1} {score.projectTitle}
</p>
<p className="text-sm text-muted-foreground">{score.votes} votes</p>
</div>
<Badge variant="outline">{score.totalScore.toFixed(1)}</Badge>
<Play className="mr-2 h-4 w-4" />
{resumeMutation.isPending ? 'Resuming...' : 'Resume Session'}
</Button>
) : (
<Button
className="w-full"
variant="outline"
onClick={() => pauseMutation.mutate({ roundId })}
disabled={pauseMutation.isPending || !cursor}
>
<Pause className="mr-2 h-4 w-4" />
{pauseMutation.isPending ? 'Pausing...' : 'Pause Session'}
</Button>
)}
{cursor?.isPaused && (
<Badge variant="destructive" className="w-full justify-center py-1">
Session Paused
</Badge>
)}
{cursor?.openCohorts && cursor.openCohorts.length > 0 && (
<div className="rounded-lg border p-3">
<p className="text-sm font-medium mb-2">Open Voting Windows</p>
{cursor.openCohorts.map((cohort: any) => (
<div key={cohort.id} className="flex items-center justify-between text-sm">
<span>{cohort.name}</span>
<Badge variant="outline">{cohort.votingMode}</Badge>
</div>
))}
</div>
) : (
<p className="text-center text-muted-foreground">No scores yet</p>
)}
</CardContent>
</Card>

View File

@@ -53,6 +53,9 @@ const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destru
const statusLabels: Record<string, string> = {
NONE: 'Not Invited',
INVITED: 'Invited',
ACTIVE: 'Active',
SUSPENDED: 'Suspended',
}
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {

View File

@@ -1,166 +0,0 @@
'use client'
import { useState, useCallback } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { FileDown, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import {
createReportDocument,
addCoverPage,
addPageBreak,
addHeader,
addSectionTitle,
addStatCards,
addTable,
addAllPageFooters,
savePdf,
} from '@/lib/pdf-generator'
interface PdfReportProps {
roundId: string
sections: string[]
}
export function PdfReportGenerator({ roundId, sections }: PdfReportProps) {
const [generating, setGenerating] = useState(false)
const { refetch } = trpc.export.getReportData.useQuery(
{ roundId, sections },
{ enabled: false }
)
const handleGenerate = useCallback(async () => {
setGenerating(true)
toast.info('Generating PDF report...')
try {
const result = await refetch()
if (!result.data) {
toast.error('Failed to fetch report data')
return
}
const data = result.data as Record<string, unknown>
const rName = String(data.roundName || 'Report')
const pName = String(data.programName || '')
// 1. Create document
const doc = await createReportDocument()
// 2. Cover page
await addCoverPage(doc, {
title: 'Round Report',
subtitle: `${pName} ${data.programYear ? `(${data.programYear})` : ''}`.trim(),
roundName: rName,
programName: pName,
})
// 3. Summary
const summary = data.summary as Record<string, unknown> | undefined
if (summary) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Summary', 28)
y = addStatCards(doc, [
{ label: 'Projects', value: String(summary.projectCount ?? 0) },
{ label: 'Evaluations', value: String(summary.evaluationCount ?? 0) },
{
label: 'Avg Score',
value: summary.averageScore != null
? Number(summary.averageScore).toFixed(1)
: '--',
},
{
label: 'Completion',
value: summary.completionRate != null
? `${Number(summary.completionRate).toFixed(0)}%`
: '--',
},
], y)
}
// 4. Rankings
const rankings = data.rankings as Array<Record<string, unknown>> | undefined
if (rankings && rankings.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Project Rankings', 28)
const headers = ['#', 'Project', 'Team', 'Avg Score', 'Evaluations', 'Yes %']
const rows = rankings.map((r, i) => [
i + 1,
String(r.title ?? ''),
String(r.teamName ?? ''),
r.averageScore != null ? Number(r.averageScore).toFixed(2) : '-',
String(r.evaluationCount ?? 0),
r.yesPercentage != null ? `${Number(r.yesPercentage).toFixed(0)}%` : '-',
])
y = addTable(doc, headers, rows, y)
}
// 5. Juror stats
const jurorStats = data.jurorStats as Array<Record<string, unknown>> | undefined
if (jurorStats && jurorStats.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Juror Statistics', 28)
const headers = ['Juror', 'Assigned', 'Completed', 'Completion %', 'Avg Score']
const rows = jurorStats.map((j) => [
String(j.name ?? ''),
String(j.assigned ?? 0),
String(j.completed ?? 0),
`${Number(j.completionRate ?? 0).toFixed(0)}%`,
j.averageScore != null ? Number(j.averageScore).toFixed(2) : '-',
])
y = addTable(doc, headers, rows, y)
}
// 6. Criteria breakdown
const criteriaBreakdown = data.criteriaBreakdown as Array<Record<string, unknown>> | undefined
if (criteriaBreakdown && criteriaBreakdown.length > 0) {
addPageBreak(doc)
await addHeader(doc, rName)
let y = addSectionTitle(doc, 'Criteria Breakdown', 28)
const headers = ['Criterion', 'Avg Score', 'Responses']
const rows = criteriaBreakdown.map((c) => [
String(c.label ?? ''),
c.averageScore != null ? Number(c.averageScore).toFixed(2) : '-',
String(c.count ?? 0),
])
y = addTable(doc, headers, rows, y)
}
// 7. Footers
addAllPageFooters(doc)
// 8. Save
const dateStr = new Date().toISOString().split('T')[0]
savePdf(doc, `MOPC-Report-${rName.replace(/\s+/g, '-')}-${dateStr}.pdf`)
toast.success('PDF report downloaded successfully')
} catch (err) {
console.error('PDF generation error:', err)
toast.error('Failed to generate PDF report')
} finally {
setGenerating(false)
}
}, [refetch])
return (
<Button variant="outline" onClick={handleGenerate} disabled={generating}>
{generating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
{generating ? 'Generating...' : 'Export PDF Report'}
</Button>
)
}

View File

@@ -31,15 +31,21 @@ export function ResultLockControls({ competitionId, roundId, category }: ResultL
const [unlockDialogOpen, setUnlockDialogOpen] = useState(false);
const [unlockReason, setUnlockReason] = useState('');
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery({
competitionId,
roundId,
category
});
const { data: lockStatus } = trpc.resultLock.isLocked.useQuery(
{ competitionId, roundId, category },
{ refetchInterval: 15_000 }
);
const { data: history } = trpc.resultLock.history.useQuery({
competitionId
});
const { data: history } = trpc.resultLock.history.useQuery(
{ competitionId },
{ refetchInterval: 15_000 }
);
// Fetch project rankings for the snapshot
const { data: projectRankings } = trpc.analytics.getProjectRankings.useQuery(
{ roundId, limit: 5000 },
{ enabled: !!roundId }
);
const lockMutation = trpc.resultLock.lock.useMutation({
onSuccess: () => {
@@ -67,11 +73,25 @@ export function ResultLockControls({ competitionId, roundId, category }: ResultL
});
const handleLock = () => {
const snapshot = {
lockedAt: new Date().toISOString(),
category,
roundId,
rankings: (projectRankings ?? []).map((p: any) => ({
projectId: p.id,
title: p.title,
teamName: p.teamName,
averageScore: p.averageScore,
evaluationCount: p.evaluationCount,
status: p.status,
})),
};
lockMutation.mutate({
competitionId,
roundId,
category,
resultSnapshot: {} // This would contain the actual results snapshot
resultSnapshot: snapshot,
});
};

View File

@@ -0,0 +1,375 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Progress } from '@/components/ui/progress'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
ChevronDown,
ChevronUp,
Loader2,
CheckCircle2,
Play,
Trophy,
AlertTriangle,
} from 'lucide-react'
type AwardShortlistProps = {
awardId: string
roundId: string
awardName: string
criteriaText?: string | null
eligibilityMode: string
shortlistSize: number
jobStatus?: string | null
jobTotal?: number | null
jobDone?: number | null
}
export function AwardShortlist({
awardId,
roundId,
awardName,
criteriaText,
eligibilityMode,
shortlistSize,
jobStatus,
jobTotal,
jobDone,
}: AwardShortlistProps) {
const [expanded, setExpanded] = useState(false)
const utils = trpc.useUtils()
const isRunning = jobStatus === 'PENDING' || jobStatus === 'PROCESSING'
const { data: shortlist, isLoading: isLoadingShortlist } = trpc.specialAward.listShortlist.useQuery(
{ awardId, perPage: 100 },
{ enabled: expanded && !isRunning }
)
const { data: jobPoll } = trpc.specialAward.getEligibilityJobStatus.useQuery(
{ awardId },
{ enabled: isRunning, refetchInterval: 3000 }
)
const runMutation = trpc.specialAward.runEligibilityForRound.useMutation({
onSuccess: () => {
toast.success('Eligibility evaluation started')
utils.specialAward.getEligibilityJobStatus.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const toggleMutation = trpc.specialAward.toggleShortlisted.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(data.shortlisted ? 'Added to shortlist' : 'Removed from shortlist')
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const bulkToggleMutation = trpc.specialAward.bulkToggleShortlisted.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(`${data.updated} projects ${data.shortlisted ? 'added to' : 'removed from'} shortlist`)
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const { data: awardRounds } = trpc.specialAward.listRounds.useQuery(
{ awardId },
{ enabled: expanded && eligibilityMode === 'SEPARATE_POOL' }
)
const hasAwardRounds = (awardRounds?.length ?? 0) > 0
const confirmMutation = trpc.specialAward.confirmShortlist.useMutation({
onSuccess: (data) => {
utils.specialAward.listShortlist.invalidate({ awardId })
utils.specialAward.listForRound.invalidate({ roundId })
toast.success(
`Confirmed ${data.confirmedCount} projects` +
(data.routedCount > 0 ? `${data.routedCount} routed to award track` : '')
)
},
onError: (err) => toast.error(`Failed: ${err.message}`),
})
const currentJobStatus = jobPoll?.eligibilityJobStatus ?? jobStatus
const currentJobDone = jobPoll?.eligibilityJobDone ?? jobDone
const currentJobTotal = jobPoll?.eligibilityJobTotal ?? jobTotal
const jobProgress = currentJobTotal && currentJobTotal > 0
? Math.round(((currentJobDone ?? 0) / currentJobTotal) * 100)
: 0
const shortlistedCount = shortlist?.eligibilities?.filter((e) => e.shortlisted).length ?? 0
const allShortlisted = shortlist && shortlist.eligibilities.length > 0 && shortlist.eligibilities.every((e) => e.shortlisted)
const someShortlisted = shortlistedCount > 0 && !allShortlisted
const handleBulkToggle = () => {
if (!shortlist) return
const projectIds = shortlist.eligibilities.map((e) => e.project.id)
const newValue = !allShortlisted
bulkToggleMutation.mutate({ awardId, projectIds, shortlisted: newValue })
}
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
<div className="border rounded-lg">
<CollapsibleTrigger asChild>
<button className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors text-left">
<div className="flex items-center gap-3">
<Trophy className="h-5 w-5 text-amber-600" />
<div>
<h4 className="font-semibold text-sm">{awardName}</h4>
{criteriaText && (
<p className="text-xs text-muted-foreground line-clamp-1 max-w-md">
{criteriaText}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant="outline" className={eligibilityMode === 'SEPARATE_POOL'
? 'bg-purple-50 text-purple-700 border-purple-200'
: 'bg-blue-50 text-blue-700 border-blue-200'
}>
{eligibilityMode === 'SEPARATE_POOL' ? 'Separate Pool' : 'Main Pool'}
</Badge>
{currentJobStatus === 'COMPLETED' && (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<CheckCircle2 className="h-3 w-3 mr-1" />
Evaluated
</Badge>
)}
{expanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border-t p-4 space-y-4">
{/* Job controls */}
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Evaluate PASSED projects against this award&apos;s criteria
</div>
<Button
size="sm"
onClick={() => runMutation.mutate({ awardId, roundId })}
disabled={runMutation.isPending || isRunning}
>
{isRunning ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Processing...</>
) : runMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Starting...</>
) : currentJobStatus === 'COMPLETED' ? (
<><Play className="h-4 w-4 mr-2" />Re-evaluate</>
) : (
<><Play className="h-4 w-4 mr-2" />Run Eligibility</>
)}
</Button>
</div>
{/* Progress bar */}
{isRunning && currentJobTotal && currentJobTotal > 0 && (
<div className="space-y-1">
<Progress value={jobProgress} className="h-2" />
<p className="text-xs text-muted-foreground text-right">
{currentJobDone ?? 0} / {currentJobTotal} projects
</p>
</div>
)}
{/* Shortlist table */}
{expanded && currentJobStatus === 'COMPLETED' && (
<>
{isLoadingShortlist ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : shortlist && shortlist.eligibilities.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{shortlist.total} eligible projects
{shortlistedCount > 0 && (
<span className="text-muted-foreground ml-1">
({shortlistedCount} shortlisted)
</span>
)}
</p>
{shortlistedCount > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="default">
<CheckCircle2 className="h-4 w-4 mr-2" />
Confirm Shortlist ({shortlistedCount})
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Shortlist</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
{eligibilityMode === 'SEPARATE_POOL'
? `This will confirm ${shortlistedCount} projects for the "${awardName}" award track. Projects will be routed to the award's rounds for separate evaluation.`
: `This will confirm ${shortlistedCount} projects as eligible for the "${awardName}" award. Projects remain in the main competition pool.`
}
</p>
{eligibilityMode === 'SEPARATE_POOL' && !hasAwardRounds && (
<div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-800 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300">
<AlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<p className="text-sm">
No award rounds have been created yet. Projects will be confirmed but <strong>not routed</strong> to an evaluation track. Create rounds on the award page first.
</p>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => confirmMutation.mutate({ awardId })}
disabled={confirmMutation.isPending}
>
{confirmMutation.isPending ? 'Confirming...' : 'Confirm'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left w-8">#</th>
<th className="px-3 py-2 text-left">Project</th>
<th className="px-3 py-2 text-left w-24">Score</th>
<th className="px-3 py-2 text-left min-w-[300px]">Reasoning</th>
<th className="px-3 py-2 text-center w-20">
<div className="flex items-center justify-center gap-1">
<Checkbox
checked={allShortlisted ? true : someShortlisted ? 'indeterminate' : false}
onCheckedChange={handleBulkToggle}
disabled={bulkToggleMutation.isPending}
aria-label="Select all"
/>
<span className="text-xs">All</span>
</div>
</th>
</tr>
</thead>
<tbody>
{shortlist.eligibilities.map((e, i) => {
const reasoning = (e.aiReasoningJson as Record<string, unknown>)?.reasoning as string | undefined
const isTop5 = i < shortlistSize
return (
<tr key={e.id} className={`border-t ${isTop5 ? 'bg-amber-50/50' : ''}`}>
<td className="px-3 py-2 text-muted-foreground font-mono">
{isTop5 ? (
<span className="text-amber-600 font-semibold">{i + 1}</span>
) : (
i + 1
)}
</td>
<td className="px-3 py-2">
<div>
<p className={`font-medium ${isTop5 ? 'text-amber-900' : ''}`}>
{e.project.title}
</p>
<p className="text-xs text-muted-foreground">
{[e.project.teamName, e.project.country, e.project.competitionCategory].filter(Boolean).join(', ') || '—'}
</p>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<Progress
value={e.qualityScore ?? 0}
className="h-2 w-16"
/>
<span className="text-xs font-mono font-medium">
{e.qualityScore ?? 0}
</span>
</div>
</td>
<td className="px-3 py-2">
{reasoning ? (
<p className="text-xs text-muted-foreground whitespace-pre-wrap leading-relaxed">
{reasoning}
</p>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2 text-center">
<Checkbox
checked={e.shortlisted}
onCheckedChange={() =>
toggleMutation.mutate({ awardId, projectId: e.project.id })
}
disabled={toggleMutation.isPending}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">
No eligible projects found
</p>
)}
</>
)}
{/* Not yet evaluated */}
{expanded && !currentJobStatus && (
<p className="text-sm text-muted-foreground text-center py-4">
Click &quot;Run Eligibility&quot; to evaluate projects against this award&apos;s criteria
</p>
)}
{/* Failed */}
{currentJobStatus === 'FAILED' && (
<p className="text-sm text-red-600 text-center py-2">
Eligibility evaluation failed. Try again.
</p>
)}
</div>
</CollapsibleContent>
</div>
</Collapsible>
)
}

View File

@@ -0,0 +1,403 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Loader2,
Plus,
Pencil,
Trash2,
FileText,
FileCheck,
FileQuestion,
} from 'lucide-react'
type FileRequirementsEditorProps = {
roundId: string
windowOpenAt?: Date | string | null
windowCloseAt?: Date | string | null
}
type FormState = {
name: string
description: string
acceptedMimeTypes: string[]
maxSizeMB: string
isRequired: boolean
}
const emptyForm: FormState = {
name: '',
description: '',
acceptedMimeTypes: [],
maxSizeMB: '',
isRequired: true,
}
const MIME_TYPE_OPTIONS: { label: string; value: string }[] = [
{ label: 'PDF', value: 'application/pdf' },
{ label: 'Images', value: 'image/*' },
{ label: 'Video', value: 'video/*' },
{ label: 'Word', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
{ label: 'Excel', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
{ label: 'PowerPoint', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
]
function getMimeLabel(mime: string): string {
const preset = MIME_TYPE_OPTIONS.find((p) => p.value === mime)
if (preset) return preset.label
if (mime.endsWith('/*')) return mime.replace('/*', '')
return mime
}
export function FileRequirementsEditor({ roundId, windowOpenAt, windowCloseAt }: FileRequirementsEditorProps) {
const [dialogOpen, setDialogOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const utils = trpc.useUtils()
const { data: requirements, isLoading } = trpc.file.listRequirements.useQuery({ roundId })
const createMutation = trpc.file.createRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId })
toast.success('Requirement added')
closeDialog()
},
onError: (err) => toast.error(err.message),
})
const updateMutation = trpc.file.updateRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId })
toast.success('Requirement updated')
closeDialog()
},
onError: (err) => toast.error(err.message),
})
const deleteMutation = trpc.file.deleteRequirement.useMutation({
onSuccess: () => {
utils.file.listRequirements.invalidate({ roundId })
toast.success('Requirement removed')
},
onError: (err) => toast.error(err.message),
})
const closeDialog = () => {
setDialogOpen(false)
setEditingId(null)
setForm(emptyForm)
}
const openCreateDialog = () => {
setForm(emptyForm)
setEditingId(null)
setDialogOpen(true)
}
const openEditDialog = (req: any) => {
setForm({
name: req.name,
description: req.description || '',
acceptedMimeTypes: req.acceptedMimeTypes || [],
maxSizeMB: req.maxSizeMB?.toString() || '',
isRequired: req.isRequired ?? true,
})
setEditingId(req.id)
setDialogOpen(true)
}
const toggleMimeType = (mime: string) => {
setForm((prev) => ({
...prev,
acceptedMimeTypes: prev.acceptedMimeTypes.includes(mime)
? prev.acceptedMimeTypes.filter((m) => m !== mime)
: [...prev.acceptedMimeTypes, mime],
}))
}
const handleSubmit = () => {
const maxSize = form.maxSizeMB ? parseInt(form.maxSizeMB, 10) : undefined
if (editingId) {
updateMutation.mutate({
id: editingId,
name: form.name,
description: form.description || null,
acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB: maxSize ?? null,
isRequired: form.isRequired,
})
} else {
createMutation.mutate({
roundId,
name: form.name,
description: form.description || undefined,
acceptedMimeTypes: form.acceptedMimeTypes,
maxSizeMB: maxSize,
isRequired: form.isRequired,
sortOrder: (requirements?.length ?? 0),
})
}
}
const isSaving = createMutation.isPending || updateMutation.isPending
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-20 w-full" />)}
</div>
)
}
return (
<div className="space-y-4">
{/* Submission period info */}
<Card>
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Submission Period</p>
<p className="text-xs text-muted-foreground mt-0.5">
Applicants can upload documents during the round&apos;s active window
</p>
</div>
<div className="text-right text-sm">
{windowOpenAt || windowCloseAt ? (
<>
<p className="font-medium">
{windowOpenAt ? new Date(windowOpenAt).toLocaleDateString() : 'No start'} &mdash;{' '}
{windowCloseAt ? new Date(windowCloseAt).toLocaleDateString() : 'No deadline'}
</p>
<p className="text-xs text-muted-foreground">
Set in the Config tab under round time windows
</p>
</>
) : (
<p className="text-muted-foreground">No dates configured &mdash; set in Config tab</p>
)}
</div>
</div>
</CardContent>
</Card>
{/* Requirements list */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Required Documents</CardTitle>
<CardDescription>
Define what files applicants must submit for this round
</CardDescription>
</div>
<Button size="sm" onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-1.5" />
Add Requirement
</Button>
</div>
</CardHeader>
<CardContent>
{!requirements || requirements.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<FileText className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Document Requirements</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Add requirements to specify what documents applicants must upload during this round.
</p>
<Button size="sm" variant="outline" className="mt-4" onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-1.5" />
Add First Requirement
</Button>
</div>
) : (
<div className="space-y-2">
{requirements.map((req: any) => (
<div
key={req.id}
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted/30 transition-colors"
>
<div className="mt-0.5 text-muted-foreground">
{req.isRequired ? (
<FileCheck className="h-4 w-4 text-blue-500" />
) : (
<FileQuestion className="h-4 w-4 text-gray-400" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">{req.name}</p>
<Badge variant={req.isRequired ? 'default' : 'secondary'} className="text-[10px]">
{req.isRequired ? 'Required' : 'Optional'}
</Badge>
</div>
{req.description && (
<p className="text-xs text-muted-foreground mt-0.5">{req.description}</p>
)}
<div className="flex flex-wrap gap-1 mt-1.5">
{req.acceptedMimeTypes?.length > 0 ? (
req.acceptedMimeTypes.map((t: string) => (
<Badge key={t} variant="outline" className="text-[10px]">
{getMimeLabel(t)}
</Badge>
))
) : (
<Badge variant="outline" className="text-[10px]">
Any file type
</Badge>
)}
{req.maxSizeMB && (
<Badge variant="outline" className="text-[10px]">
Max {req.maxSizeMB} MB
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEditDialog(req)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete requirement?</AlertDialogTitle>
<AlertDialogDescription>
This will remove &quot;{req.name}&quot; from the round. Previously uploaded files will not be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate({ id: req.id })}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Create / Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!open) closeDialog() }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingId ? 'Edit Requirement' : 'Add Document Requirement'}</DialogTitle>
<DialogDescription>
{editingId
? 'Update the document requirement details.'
: 'Define a new document that applicants must submit.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Name</label>
<Input
placeholder="e.g. Business Plan, Pitch Deck, Financial Projections"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Textarea
placeholder="Describe what this document should contain..."
rows={3}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Accepted File Types</label>
<div className="flex flex-wrap gap-2">
{MIME_TYPE_OPTIONS.map((opt) => (
<Badge
key={opt.value}
variant={form.acceptedMimeTypes.includes(opt.value) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleMimeType(opt.value)}
>
{opt.label}
</Badge>
))}
</div>
<p className="text-xs text-muted-foreground">
Select one or more file types. Leave empty to accept any file type.
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Max File Size (MB)</label>
<Input
type="number"
placeholder="e.g. 50"
value={form.maxSizeMB}
onChange={(e) => setForm((f) => ({ ...f, maxSizeMB: e.target.value }))}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="isRequired"
checked={form.isRequired}
onCheckedChange={(checked) => setForm((f) => ({ ...f, isRequired: !!checked }))}
/>
<label htmlFor="isRequired" className="text-sm">
Required document (applicant must upload to proceed)
</label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>Cancel</Button>
<Button onClick={handleSubmit} disabled={isSaving || !form.name.trim()}>
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{editingId ? 'Update' : 'Add Requirement'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,78 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Layers } from 'lucide-react'
import { useState, useCallback, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Loader2,
MoreHorizontal,
ArrowRight,
XCircle,
CheckCircle2,
Clock,
Play,
LogOut,
Layers,
Trash2,
Plus,
Search,
ExternalLink,
} from 'lucide-react'
import Link from 'next/link'
import type { Route } from 'next'
const PROJECT_STATES = ['PENDING', 'IN_PROGRESS', 'PASSED', 'REJECTED', 'COMPLETED', 'WITHDRAWN'] as const
type ProjectState = (typeof PROJECT_STATES)[number]
const stateConfig: Record<ProjectState, { label: string; color: string; icon: React.ElementType }> = {
PENDING: { label: 'Pending', color: 'bg-gray-100 text-gray-700 border-gray-200', icon: Clock },
IN_PROGRESS: { label: 'In Progress', color: 'bg-blue-100 text-blue-700 border-blue-200', icon: Play },
PASSED: { label: 'Passed', color: 'bg-green-100 text-green-700 border-green-200', icon: CheckCircle2 },
REJECTED: { label: 'Rejected', color: 'bg-red-100 text-red-700 border-red-200', icon: XCircle },
COMPLETED: { label: 'Completed', color: 'bg-emerald-100 text-emerald-700 border-emerald-200', icon: CheckCircle2 },
WITHDRAWN: { label: 'Withdrawn', color: 'bg-orange-100 text-orange-700 border-orange-200', icon: LogOut },
}
type ProjectStatesTableProps = {
competitionId: string
@@ -9,25 +80,889 @@ type ProjectStatesTableProps = {
}
export function ProjectStatesTable({ competitionId, roundId }: ProjectStatesTableProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [stateFilter, setStateFilter] = useState<string>('ALL')
const [searchQuery, setSearchQuery] = useState('')
const [batchDialogOpen, setBatchDialogOpen] = useState(false)
const [batchNewState, setBatchNewState] = useState<ProjectState>('PASSED')
const [removeConfirmId, setRemoveConfirmId] = useState<string | null>(null)
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false)
const [quickAddOpen, setQuickAddOpen] = useState(false)
const [addProjectOpen, setAddProjectOpen] = useState(false)
const utils = trpc.useUtils()
const poolLink = `/admin/projects/pool?roundId=${roundId}&competitionId=${competitionId}` as Route
const { data: projectStates, isLoading } = trpc.roundEngine.getProjectStates.useQuery(
{ roundId },
{ refetchInterval: 15_000 },
)
const transitionMutation = trpc.roundEngine.transitionProject.useMutation({
onSuccess: () => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
toast.success('Project state updated')
},
onError: (err) => toast.error(err.message),
})
const batchTransitionMutation = trpc.roundEngine.batchTransition.useMutation({
onSuccess: (data) => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
setSelectedIds(new Set())
setBatchDialogOpen(false)
toast.success(`${data.succeeded.length} projects updated${data.failed.length > 0 ? `, ${data.failed.length} failed` : ''}`)
},
onError: (err) => toast.error(err.message),
})
const removeMutation = trpc.roundEngine.removeFromRound.useMutation({
onSuccess: (data) => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
setRemoveConfirmId(null)
toast.success(`Removed from ${data.removedFromRounds} round(s)`)
},
onError: (err) => toast.error(err.message),
})
const batchRemoveMutation = trpc.roundEngine.batchRemoveFromRound.useMutation({
onSuccess: (data) => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
setSelectedIds(new Set())
setBatchRemoveOpen(false)
toast.success(`${data.removedCount} project(s) removed from this round and later rounds`)
},
onError: (err) => toast.error(err.message),
})
const handleTransition = (projectId: string, newState: ProjectState) => {
transitionMutation.mutate({ projectId, roundId, newState })
}
const handleBatchTransition = () => {
batchTransitionMutation.mutate({
projectIds: Array.from(selectedIds),
roundId,
newState: batchNewState,
})
}
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
// Apply state filter first, then search filter
const filtered = useMemo(() => {
let result = projectStates ?? []
if (stateFilter !== 'ALL') {
result = result.filter((ps: any) => ps.state === stateFilter)
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase()
result = result.filter((ps: any) =>
(ps.project?.title || '').toLowerCase().includes(q) ||
(ps.project?.teamName || '').toLowerCase().includes(q)
)
}
return result
}, [projectStates, stateFilter, searchQuery])
const toggleSelectAll = useCallback(() => {
const ids = filtered.map((ps: any) => ps.projectId)
const allSelected = ids.length > 0 && ids.every((id: string) => selectedIds.has(id))
if (allSelected) {
setSelectedIds((prev) => {
const next = new Set(prev)
ids.forEach((id: string) => next.delete(id))
return next
})
} else {
setSelectedIds((prev) => {
const next = new Set(prev)
ids.forEach((id: string) => next.add(id))
return next
})
}
}, [filtered, selectedIds])
// State counts
const counts = projectStates?.reduce((acc: Record<string, number>, ps: any) => {
acc[ps.state] = (acc[ps.state] || 0) + 1
return acc
}, {} as Record<string, number>) ?? {}
if (isLoading) {
return (
<div className="space-y-3">
<Skeleton className="h-10 w-full" />
{[1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)
}
if (!projectStates || projectStates.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Project States</CardTitle>
<p className="text-sm text-muted-foreground">
Projects participating in this round
</p>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12 text-center">
<CardContent className="py-12">
<div className="flex flex-col items-center justify-center text-center">
<div className="rounded-full bg-muted p-4 mb-4">
<Layers className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm font-medium">No Active Projects</p>
<p className="text-xs text-muted-foreground mt-1">
Project states will appear here when the round is active
<p className="text-sm font-medium">No Projects in This Round</p>
<p className="text-xs text-muted-foreground mt-1 max-w-sm">
Assign projects from the Project Pool to this round to get started.
</p>
<Link href={poolLink}>
<Button size="sm" className="mt-4">
<Plus className="h-4 w-4 mr-1.5" />
Go to Project Pool
</Button>
</Link>
</div>
</CardContent>
</Card>
)
}
return (
<div className="space-y-4">
{/* Top bar: search + filters + add buttons */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="relative w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-8 h-8 text-sm"
/>
</div>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => { setStateFilter('ALL'); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === 'ALL'
? 'bg-foreground text-background border-foreground'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
All ({projectStates.length})
</button>
{PROJECT_STATES.map((state) => {
const count = counts[state] || 0
if (count === 0) return null
const cfg = stateConfig[state]
return (
<button
key={state}
onClick={() => { setStateFilter(state); setSelectedIds(new Set()) }}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
stateFilter === state
? cfg.color + ' border-current'
: 'bg-muted text-muted-foreground border-transparent hover:border-border'
}`}
>
{cfg.label} ({count})
</button>
)
})}
</div>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => { setAddProjectOpen(true) }}>
<Plus className="h-4 w-4 mr-1.5" />
Add Project
</Button>
</div>
</div>
{/* Search results count */}
{searchQuery.trim() && (
<p className="text-xs text-muted-foreground">
Showing {filtered.length} of {projectStates.length} projects matching &quot;{searchQuery}&quot;
</p>
)}
{/* Bulk actions bar */}
{selectedIds.size > 0 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50 border">
<span className="text-sm font-medium">{selectedIds.size} selected</span>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => setBatchDialogOpen(true)}
>
<ArrowRight className="h-3.5 w-3.5 mr-1.5" />
Change State
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/30 hover:bg-destructive/10"
onClick={() => setBatchRemoveOpen(true)}
>
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
Remove from Round
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedIds(new Set())}
>
Clear
</Button>
</div>
</div>
)}
{/* Table */}
<div className="border rounded-lg overflow-hidden">
{/* Header */}
<div className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-2.5 bg-muted/40 text-xs font-medium text-muted-foreground border-b">
<div>
<Checkbox
checked={filtered.length > 0 && filtered.every((ps: any) => selectedIds.has(ps.projectId))}
onCheckedChange={toggleSelectAll}
/>
</div>
<div>Project</div>
<div>Category</div>
<div>Country</div>
<div>State</div>
<div>Entered</div>
<div />
</div>
{/* Rows */}
{filtered.map((ps: any) => {
const cfg = stateConfig[ps.state as ProjectState] || stateConfig.PENDING
const StateIcon = cfg.icon
return (
<div
key={ps.id}
className="grid grid-cols-[40px_1fr_140px_160px_120px_100px_48px] gap-2 px-4 py-3 items-center border-b last:border-b-0 hover:bg-muted/30 text-sm"
>
<div>
<Checkbox
checked={selectedIds.has(ps.projectId)}
onCheckedChange={() => toggleSelect(ps.projectId)}
/>
</div>
<div className="min-w-0">
<Link
href={`/admin/projects/${ps.projectId}` as Route}
className="font-medium truncate block hover:underline text-foreground"
>
{ps.project?.title || 'Unknown'}
</Link>
<p className="text-xs text-muted-foreground truncate">{ps.project?.teamName}</p>
</div>
<div>
<Badge variant="outline" className="text-xs">
{ps.project?.competitionCategory || '—'}
</Badge>
</div>
<div className="text-xs text-muted-foreground truncate">
{ps.project?.country || '—'}
</div>
<div>
<Badge variant="outline" className={`text-xs ${cfg.color}`}>
<StateIcon className="h-3 w-3 mr-1" />
{cfg.label}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
{ps.enteredAt ? new Date(ps.enteredAt).toLocaleDateString() : '—'}
</div>
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${ps.projectId}` as Route}>
<ExternalLink className="h-3.5 w-3.5 mr-2" />
View Project
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{PROJECT_STATES.filter((s) => s !== ps.state).map((state) => {
const sCfg = stateConfig[state]
return (
<DropdownMenuItem
key={state}
onClick={() => handleTransition(ps.projectId, state)}
disabled={transitionMutation.isPending}
>
<sCfg.icon className="h-3.5 w-3.5 mr-2" />
Move to {sCfg.label}
</DropdownMenuItem>
)
})}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setRemoveConfirmId(ps.projectId)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5 mr-2" />
Remove from Round
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
})}
{filtered.length === 0 && searchQuery.trim() && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No projects match &quot;{searchQuery}&quot;
</div>
)}
</div>
{/* Quick Add Dialog (legacy, kept for empty state) */}
<QuickAddDialog
open={quickAddOpen}
onOpenChange={setQuickAddOpen}
roundId={roundId}
competitionId={competitionId}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
{/* Add Project Dialog (Create New + From Pool) */}
<AddProjectDialog
open={addProjectOpen}
onOpenChange={setAddProjectOpen}
roundId={roundId}
competitionId={competitionId}
onAssigned={() => {
utils.roundEngine.getProjectStates.invalidate({ roundId })
}}
/>
{/* Single Remove Confirmation */}
<AlertDialog open={!!removeConfirmId} onOpenChange={(open) => { if (!open) setRemoveConfirmId(null) }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove project from this round?</AlertDialogTitle>
<AlertDialogDescription>
The project will be removed from this round and all subsequent rounds.
It will remain in any prior rounds it was already assigned to.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (removeConfirmId) {
removeMutation.mutate({ projectId: removeConfirmId, roundId })
}
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={removeMutation.isPending}
>
{removeMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Batch Remove Confirmation */}
<AlertDialog open={batchRemoveOpen} onOpenChange={setBatchRemoveOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove {selectedIds.size} projects from this round?</AlertDialogTitle>
<AlertDialogDescription>
These projects will be removed from this round and all subsequent rounds in the competition.
They will remain in any prior rounds they were already assigned to.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
batchRemoveMutation.mutate({
projectIds: Array.from(selectedIds),
roundId,
})
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={batchRemoveMutation.isPending}
>
{batchRemoveMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Remove {selectedIds.size} Projects
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Batch Transition Dialog */}
<Dialog open={batchDialogOpen} onOpenChange={setBatchDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Change State for {selectedIds.size} Projects</DialogTitle>
<DialogDescription>
All selected projects will be moved to the new state.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<label className="text-sm font-medium">New State</label>
<Select value={batchNewState} onValueChange={(v) => setBatchNewState(v as ProjectState)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROJECT_STATES.map((state) => (
<SelectItem key={state} value={state}>
{stateConfig[state].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBatchDialogOpen(false)}>Cancel</Button>
<Button
onClick={handleBatchTransition}
disabled={batchTransitionMutation.isPending}
>
{batchTransitionMutation.isPending && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Update {selectedIds.size} Projects
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
/**
* Quick Add Dialog — inline search + assign projects to this round without leaving the page.
*/
function QuickAddDialog({
open,
onOpenChange,
roundId,
competitionId,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
onAssigned: () => void
}) {
const [search, setSearch] = useState('')
const [addingIds, setAddingIds] = useState<Set<string>>(new Set())
// Get the competition to find programId
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: open && !!competitionId },
)
const programId = (competition as any)?.programId || ''
const { data: poolResults, isLoading } = trpc.projectPool.listUnassigned.useQuery(
{
programId,
excludeRoundId: roundId,
search: search.trim() || undefined,
perPage: 10,
},
{ enabled: open && !!programId },
)
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (data) => {
toast.success(`Added to round`)
onAssigned()
// Remove from addingIds
setAddingIds(new Set())
},
onError: (err) => toast.error(err.message),
})
const handleQuickAssign = (projectId: string) => {
setAddingIds((prev) => new Set(prev).add(projectId))
assignMutation.mutate({ projectIds: [projectId], roundId })
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Quick Add Projects</DialogTitle>
<DialogDescription>
Search and assign projects to this round without leaving the page.
</DialogDescription>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by project title or team..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
autoFocus
/>
</div>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && poolResults?.projects.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{search.trim() ? `No projects found matching "${search}"` : 'No unassigned projects available'}
</p>
)}
{poolResults?.projects.map((project: any) => (
<div
key={project.id}
className="flex items-center justify-between gap-3 p-2.5 rounded-md hover:bg-muted/50 border border-transparent hover:border-border"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.competitionCategory && (
<> &middot; {project.competitionCategory}</>
)}
</p>
</div>
<Button
size="sm"
variant="outline"
className="shrink-0"
disabled={assignMutation.isPending && addingIds.has(project.id)}
onClick={() => handleQuickAssign(project.id)}
>
{assignMutation.isPending && addingIds.has(project.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<>
<Plus className="h-3.5 w-3.5 mr-1" />
Add
</>
)}
</Button>
</div>
))}
</div>
{poolResults && poolResults.total > 10 && (
<p className="text-xs text-muted-foreground text-center">
Showing 10 of {poolResults.total} &mdash; refine your search for more specific results
</p>
)}
</DialogContent>
</Dialog>
)
}
/**
* Add Project Dialog — two tabs: "Create New" and "From Pool".
* Create New: form to create a project and assign it directly to the round.
* From Pool: search existing projects not yet in this round and assign them.
*/
function AddProjectDialog({
open,
onOpenChange,
roundId,
competitionId,
onAssigned,
}: {
open: boolean
onOpenChange: (open: boolean) => void
roundId: string
competitionId: string
onAssigned: () => void
}) {
const [activeTab, setActiveTab] = useState<'create' | 'pool'>('create')
// ── Create New tab state ──
const [title, setTitle] = useState('')
const [teamName, setTeamName] = useState('')
const [description, setDescription] = useState('')
const [country, setCountry] = useState('')
const [category, setCategory] = useState<string>('')
// ── From Pool tab state ──
const [poolSearch, setPoolSearch] = useState('')
const [selectedPoolIds, setSelectedPoolIds] = useState<Set<string>>(new Set())
const utils = trpc.useUtils()
// Get the competition to find programId (for pool search)
const { data: competition } = trpc.competition.getById.useQuery(
{ id: competitionId },
{ enabled: open && !!competitionId },
)
const programId = (competition as any)?.programId || ''
// Pool query
const { data: poolResults, isLoading: poolLoading } = trpc.projectPool.listUnassigned.useQuery(
{
programId,
excludeRoundId: roundId,
search: poolSearch.trim() || undefined,
perPage: 50,
},
{ enabled: open && activeTab === 'pool' && !!programId },
)
// Create mutation
const createMutation = trpc.project.createAndAssignToRound.useMutation({
onSuccess: () => {
toast.success('Project created and added to round')
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
// Assign from pool mutation
const assignMutation = trpc.projectPool.assignToRound.useMutation({
onSuccess: (data) => {
toast.success(`${data.assignedCount} project(s) added to round`)
utils.roundEngine.getProjectStates.invalidate({ roundId })
onAssigned()
resetAndClose()
},
onError: (err) => toast.error(err.message),
})
const resetAndClose = () => {
setTitle('')
setTeamName('')
setDescription('')
setCountry('')
setCategory('')
setPoolSearch('')
setSelectedPoolIds(new Set())
onOpenChange(false)
}
const handleCreate = () => {
if (!title.trim()) return
createMutation.mutate({
title: title.trim(),
teamName: teamName.trim() || undefined,
description: description.trim() || undefined,
country: country.trim() || undefined,
competitionCategory: category === 'STARTUP' || category === 'BUSINESS_CONCEPT' ? category : undefined,
roundId,
})
}
const handleAssignFromPool = () => {
if (selectedPoolIds.size === 0) return
assignMutation.mutate({
projectIds: Array.from(selectedPoolIds),
roundId,
})
}
const togglePoolProject = (id: string) => {
setSelectedPoolIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const isMutating = createMutation.isPending || assignMutation.isPending
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) resetAndClose()
else onOpenChange(true)
}}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Project to Round</DialogTitle>
<DialogDescription>
Create a new project or select existing ones to add to this round.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'create' | 'pool')}>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="create">Create New</TabsTrigger>
<TabsTrigger value="pool">From Pool</TabsTrigger>
</TabsList>
{/* ── Create New Tab ── */}
<TabsContent value="create" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="add-project-title">Title *</Label>
<Input
id="add-project-title"
placeholder="Project title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="add-project-team">Team Name</Label>
<Input
id="add-project-team"
placeholder="Team or organization name"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="add-project-country">Country</Label>
<Input
id="add-project-country"
placeholder="e.g. France"
value={country}
onChange={(e) => setCountry(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="STARTUP">Startup</SelectItem>
<SelectItem value="BUSINESS_CONCEPT">Business Concept</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="add-project-desc">Description</Label>
<Input
id="add-project-desc"
placeholder="Brief description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleCreate}
disabled={!title.trim() || isMutating}
>
{createMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
Create & Add to Round
</Button>
</DialogFooter>
</TabsContent>
{/* ── From Pool Tab ── */}
<TabsContent value="pool" className="space-y-4 mt-4">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by project title or team..."
value={poolSearch}
onChange={(e) => setPoolSearch(e.target.value)}
className="pl-8"
/>
</div>
<ScrollArea className="h-[320px] rounded-md border">
<div className="p-2 space-y-0.5">
{poolLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!poolLoading && poolResults?.projects.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{poolSearch.trim() ? `No projects found matching "${poolSearch}"` : 'No projects available to add'}
</p>
)}
{poolResults?.projects.map((project: any) => {
const isSelected = selectedPoolIds.has(project.id)
return (
<label
key={project.id}
className={`flex items-center gap-3 rounded-md px-2.5 py-2 text-sm cursor-pointer transition-colors ${
isSelected ? 'bg-accent' : 'hover:bg-muted/50'
}`}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePoolProject(project.id)}
/>
<div className="flex flex-1 items-center justify-between min-w-0">
<div className="min-w-0">
<p className="text-sm font-medium truncate">{project.title}</p>
<p className="text-xs text-muted-foreground truncate">
{project.teamName}
{project.country && <> &middot; {project.country}</>}
</p>
</div>
{project.competitionCategory && (
<Badge variant="outline" className="text-[10px] ml-2 shrink-0">
{project.competitionCategory === 'STARTUP' ? 'Startup' : 'Concept'}
</Badge>
)}
</div>
</label>
)
})}
</div>
</ScrollArea>
{poolResults && poolResults.total > 50 && (
<p className="text-xs text-muted-foreground text-center">
Showing 50 of {poolResults.total} &mdash; refine your search for more specific results
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={resetAndClose}>Cancel</Button>
<Button
onClick={handleAssignFromPool}
disabled={selectedPoolIds.size === 0 || isMutating}
>
{assignMutation.isPending && <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />}
{selectedPoolIds.size <= 1
? 'Add to Round'
: `Add ${selectedPoolIds.size} Projects to Round`
}
</Button>
</DialogFooter>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,291 +0,0 @@
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Plus, Lock, Unlock, LockKeyhole, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { formatDistanceToNow } from 'date-fns'
type SubmissionWindowManagerProps = {
competitionId: string
roundId: string
}
export function SubmissionWindowManager({ competitionId, roundId }: SubmissionWindowManagerProps) {
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const [roundNumber, setRoundNumber] = useState(1)
const utils = trpc.useUtils()
// For now, we'll query all windows for the competition
// In a real implementation, we'd filter by round or have a dedicated endpoint
const { data: competition, isLoading } = trpc.competition.getById.useQuery({
id: competitionId,
})
const createWindowMutation = trpc.round.createSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Submission window created')
setIsCreateOpen(false)
setName('')
setSlug('')
setRoundNumber(1)
},
onError: (err) => toast.error(err.message),
})
const openWindowMutation = trpc.round.openSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window opened')
},
onError: (err) => toast.error(err.message),
})
const closeWindowMutation = trpc.round.closeSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window closed')
},
onError: (err) => toast.error(err.message),
})
const lockWindowMutation = trpc.round.lockSubmissionWindow.useMutation({
onSuccess: () => {
utils.competition.getById.invalidate({ id: competitionId })
toast.success('Window locked')
},
onError: (err) => toast.error(err.message),
})
const handleCreate = () => {
if (!name || !slug) {
toast.error('Name and slug are required')
return
}
createWindowMutation.mutate({
competitionId,
name,
slug,
roundNumber,
})
}
const handleNameChange = (value: string) => {
setName(value)
const autoSlug = value.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
setSlug(autoSlug)
}
const windows = competition?.submissionWindows ?? []
return (
<div className="space-y-4">
<Card>
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-base">Submission Windows</CardTitle>
<p className="text-sm text-muted-foreground">
File upload windows for this round
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="w-full sm:w-auto">
<Plus className="h-4 w-4 mr-1" />
Create Window
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Submission Window</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="windowName">Window Name</Label>
<Input
id="windowName"
placeholder="e.g., Round 1 Submissions"
value={name}
onChange={(e) => handleNameChange(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="windowSlug">Slug</Label>
<Input
id="windowSlug"
placeholder="e.g., round-1-submissions"
value={slug}
onChange={(e) => setSlug(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="roundNumber">Round Number</Label>
<Input
id="roundNumber"
type="number"
min={1}
value={roundNumber}
onChange={(e) => setRoundNumber(parseInt(e.target.value, 10))}
/>
</div>
<div className="flex gap-2 pt-4">
<Button
variant="outline"
className="flex-1"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
className="flex-1"
onClick={handleCreate}
disabled={createWindowMutation.isPending}
>
{createWindowMutation.isPending && (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
)}
Create
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
Loading windows...
</div>
) : windows.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
No submission windows yet. Create one to enable file uploads.
</div>
) : (
<div className="space-y-2">
{windows.map((window) => {
const isPending = !window.windowOpenAt
const isOpen = window.windowOpenAt && !window.windowCloseAt
const isClosed = window.windowCloseAt && !window.isLocked
const isLocked = window.isLocked
return (
<div
key={window.id}
className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between border rounded-lg p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">{window.name}</p>
{isPending && (
<Badge variant="secondary" className="text-[10px] bg-gray-100 text-gray-700">
Pending
</Badge>
)}
{isOpen && (
<Badge variant="secondary" className="text-[10px] bg-emerald-100 text-emerald-700">
Open
</Badge>
)}
{isClosed && (
<Badge variant="secondary" className="text-[10px] bg-blue-100 text-blue-700">
Closed
</Badge>
)}
{isLocked && (
<Badge variant="secondary" className="text-[10px] bg-red-100 text-red-700">
<LockKeyhole className="h-2.5 w-2.5 mr-1" />
Locked
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono mt-0.5">{window.slug}</p>
<div className="flex flex-wrap gap-2 mt-1 text-xs text-muted-foreground">
<span>Round {window.roundNumber}</span>
<span></span>
<span>{window._count.fileRequirements} requirements</span>
<span></span>
<span>{window._count.projectFiles} files</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{isPending && (
<Button
size="sm"
variant="outline"
onClick={() => openWindowMutation.mutate({ windowId: window.id })}
disabled={openWindowMutation.isPending}
>
{openWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Unlock className="h-3 w-3 mr-1" />
)}
Open
</Button>
)}
{isOpen && (
<Button
size="sm"
variant="outline"
onClick={() => closeWindowMutation.mutate({ windowId: window.id })}
disabled={closeWindowMutation.isPending}
>
{closeWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<Lock className="h-3 w-3 mr-1" />
)}
Close
</Button>
)}
{isClosed && (
<Button
size="sm"
variant="outline"
onClick={() => lockWindowMutation.mutate({ windowId: window.id })}
disabled={lockWindowMutation.isPending}
>
{lockWindowMutation.isPending ? (
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
) : (
<LockKeyhole className="h-3 w-3 mr-1" />
)}
Lock
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -46,12 +46,9 @@ export function DeliberationConfig({ config, onChange, juryGroups }: Deliberatio
</SelectContent>
</Select>
) : (
<Input
id="juryGroupId"
placeholder="Jury group ID"
value={(config.juryGroupId as string) ?? ''}
onChange={(e) => update('juryGroupId', e.target.value)}
/>
<p className="text-sm text-muted-foreground italic">
No jury groups available. Create one in the Juries section first.
</p>
)}
</div>
</CardContent>

View File

@@ -143,6 +143,18 @@ export function EvaluationConfig({ config, onChange }: EvaluationConfigProps) {
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="requireDocumentUpload">Require Document Upload</Label>
<p className="text-xs text-muted-foreground">Applicants must upload documents for this evaluation round (disable if documents were uploaded in a previous round)</p>
</div>
<Switch
id="requireDocumentUpload"
checked={(config.requireDocumentUpload as boolean) ?? false}
onCheckedChange={(v) => update('requireDocumentUpload', v)}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="peerReviewEnabled">Peer Review</Label>

View File

@@ -36,23 +36,11 @@ export function IntakeConfig({ config, onChange }: IntakeConfigProps) {
onChange({ ...config, [key]: value })
}
const acceptedCategories = (config.acceptedCategories as string[]) ?? ['STARTUP', 'BUSINESS_CONCEPT']
const allowedMimeTypes = (config.allowedMimeTypes as string[]) ?? ['application/pdf']
const customFields = (config.customFields as Array<{
id: string; label: string; type: string; required: boolean; options?: string[]
}>) ?? []
const toggleCategory = (cat: string) => {
const current = [...acceptedCategories]
const idx = current.indexOf(cat)
if (idx >= 0) {
current.splice(idx, 1)
} else {
current.push(cat)
}
update('acceptedCategories', current)
}
const toggleMime = (mime: string) => {
const current = [...allowedMimeTypes]
const idx = current.indexOf(mime)
@@ -141,28 +129,6 @@ export function IntakeConfig({ config, onChange }: IntakeConfigProps) {
</CardContent>
</Card>
{/* Categories */}
<Card>
<CardHeader>
<CardTitle className="text-base">Accepted Categories</CardTitle>
<CardDescription>Which project categories can submit in this round</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{['STARTUP', 'BUSINESS_CONCEPT'].map((cat) => (
<Badge
key={cat}
variant={acceptedCategories.includes(cat) ? 'default' : 'outline'}
className="cursor-pointer select-none"
onClick={() => toggleCategory(cat)}
>
{cat === 'STARTUP' ? 'Startup' : 'Business Concept'}
</Badge>
))}
</div>
</CardContent>
</Card>
{/* File Settings */}
<Card>
<CardHeader>

View File

@@ -250,6 +250,7 @@ export function UserMobileActions({
try {
await sendInvitation.mutateAsync({ userId })
toast.success(`Invitation sent to ${userEmail}`)
utils.user.list.invalidate()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
} finally {

View File

@@ -0,0 +1,140 @@
// Brand colors from CLAUDE.md
export const BRAND_DARK_BLUE = '#053d57'
export const BRAND_RED = '#de0f1e'
export const BRAND_TEAL = '#557f8c'
export const BRAND_WHITE = '#fefefe'
// Extended palette derived from brand
export const BRAND_COLORS = [
'#053d57', // Dark Blue
'#de0f1e', // Red
'#557f8c', // Teal
'#1e7a8a', // Deep Teal
'#c4453a', // Coral
'#3a6f7f', // Mid Teal
'#8b1a24', // Dark Red
'#2d8659', // Sea Green
'#7c9aa6', // Light Teal
'#a83240', // Rose
] as const
// Tremor named colors for chart components
// These are the official Tremor palette names that render correctly
export const TREMOR_BRAND = 'blue' as const
export const TREMOR_ACCENT = 'indigo' as const
export const TREMOR_CHART_COLORS = [
'blue',
'emerald',
'amber',
'violet',
'rose',
'indigo',
'sky',
'fuchsia',
'lime',
'orange',
] as const
// Donut / status chart colors (mapped to Tremor names)
// Covers both global ProjectStatus and round-level ProjectRoundState values
export const TREMOR_STATUS_COLORS: Record<string, string> = {
// Global project statuses
SUBMITTED: 'sky',
ELIGIBLE: 'blue',
ASSIGNED: 'violet',
SEMIFINALIST: 'amber',
FINALIST: 'emerald',
REJECTED: 'rose',
DRAFT: 'gray',
WITHDRAWN: 'slate',
// Round-level states (ProjectRoundState)
PENDING: 'sky',
IN_PROGRESS: 'blue',
PASSED: 'emerald',
COMPLETED: 'indigo',
// Evaluation review states
FULLY_REVIEWED: 'emerald',
PARTIALLY_REVIEWED: 'amber',
NOT_REVIEWED: 'rose',
}
// Project status colors — mapped to actual ProjectStatus enum values
export const STATUS_COLORS: Record<string, string> = {
SUBMITTED: '#557f8c', // Teal
ELIGIBLE: '#053d57', // Dark Blue
ASSIGNED: '#1e7a8a', // Deep Teal
SEMIFINALIST: '#c4453a', // Coral
FINALIST: '#2d8659', // Sea Green
REJECTED: '#de0f1e', // Red
DRAFT: '#9ca3af', // Gray
WITHDRAWN: '#6b7280', // Dark Gray
// Evaluation review states
FULLY_REVIEWED: '#2d8659', // Sea Green
PARTIALLY_REVIEWED: '#d97706', // Amber
NOT_REVIEWED: '#de0f1e', // Red
}
// Human-readable status labels
export const STATUS_LABELS: Record<string, string> = {
SUBMITTED: 'Submitted',
ELIGIBLE: 'In-Competition',
ASSIGNED: 'Special Award',
SEMIFINALIST: 'Semi-finalist',
FINALIST: 'Finalist',
REJECTED: 'Rejected',
DRAFT: 'Draft',
WITHDRAWN: 'Withdrawn',
// Round-level states
PENDING: 'Pending',
IN_PROGRESS: 'In Progress',
PASSED: 'Passed',
COMPLETED: 'Completed',
// Evaluation review states
FULLY_REVIEWED: 'Fully Reviewed',
PARTIALLY_REVIEWED: 'Partially Reviewed',
NOT_REVIEWED: 'Not Reviewed',
}
/**
* Score gradient: Red (low) → Teal (mid) → Dark Blue (high)
* for scores on a 1-10 scale
*/
export function scoreGradient(score: number): string {
const t = Math.max(0, Math.min(1, (score - 1) / 9))
if (t < 0.5) {
// Red → Teal (0 → 0.5)
const p = t * 2
return lerpColor(BRAND_RED, BRAND_TEAL, p)
}
// Teal → Dark Blue (0.5 → 1)
const p = (t - 0.5) * 2
return lerpColor(BRAND_TEAL, BRAND_DARK_BLUE, p)
}
function lerpColor(a: string, b: string, t: number): string {
const ar = parseInt(a.slice(1, 3), 16)
const ag = parseInt(a.slice(3, 5), 16)
const ab = parseInt(a.slice(5, 7), 16)
const br = parseInt(b.slice(1, 3), 16)
const bg = parseInt(b.slice(3, 5), 16)
const bb = parseInt(b.slice(5, 7), 16)
const r = Math.round(ar + (br - ar) * t)
const g = Math.round(ag + (bg - ag) * t)
const bl = Math.round(ab + (bb - ab) * t)
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`
}
/**
* Helper: get color for a status value from STATUS_COLORS
* Falls back to a neutral gray
*/
export function getStatusColor(status: string): string {
return TREMOR_STATUS_COLORS[status] || 'gray'
}
/**
* Helper: format a status value for display
*/
export function formatStatus(status: string): string {
return STATUS_LABELS[status] || status.charAt(0) + status.slice(1).toLowerCase().replace(/_/g, ' ')
}

View File

@@ -1,15 +1,6 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from 'recharts'
import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface CriteriaScoreData {
@@ -23,31 +14,24 @@ interface CriteriaScoresProps {
data: CriteriaScoreData[]
}
// Color scale from red to green based on score
const getScoreColor = (score: number): string => {
if (score >= 8) return '#0bd90f' // Excellent - green
if (score >= 6) return '#82ca9d' // Good - light green
if (score >= 4) return '#ffc658' // Average - yellow
if (score >= 2) return '#ff7300' // Poor - orange
return '#de0f1e' // Very poor - red
}
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
const formattedData = data.map((d) => ({
...d,
displayName:
d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name,
}))
if (!data?.length) return null
const overallAverage =
data.length > 0
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
: 0
const chartData = data.map((d) => ({
criterion:
d.name.length > 40 ? d.name.substring(0, 40) + '...' : d.name,
'Avg Score': parseFloat(d.averageScore.toFixed(2)),
}))
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<CardTitle className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<span>Score by Evaluation Criteria</span>
<span className="text-sm font-normal text-muted-foreground">
Overall Avg: {overallAverage.toFixed(2)}
@@ -55,51 +39,17 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={formattedData}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="displayName"
tick={{ fontSize: 11 }}
angle={-45}
textAnchor="end"
interval={0}
height={60}
data={chartData}
index="criterion"
categories={['Avg Score']}
colors={['indigo']}
maxValue={10}
layout="vertical"
yAxisWidth={160}
showLegend={false}
className="h-[300px]"
/>
<YAxis domain={[0, 10]} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
formatter={(value: number | undefined) => [
(value ?? 0).toFixed(2),
'Average Score',
]}
labelFormatter={(_, payload) => {
if (payload && payload[0]) {
const item = payload[0].payload as CriteriaScoreData
return `${item.name} (${item.count} ratings)`
}
return ''
}}
/>
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
{formattedData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={getScoreColor(entry.averageScore)}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
)

View File

@@ -1,15 +1,6 @@
'use client'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts'
import { BarChart } from '@tremor/react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface StageComparison {
@@ -26,128 +17,114 @@ interface CrossStageComparisonProps {
data: StageComparison[]
}
const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
export function CrossStageComparisonChart({
data,
}: CrossStageComparisonProps) {
if (!data?.length) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">No comparison data available</p>
</CardContent>
</Card>
)
}
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) {
// Prepare comparison data
const comparisonData = data.map((stage, i) => ({
name: stage.roundName.length > 20 ? stage.roundName.slice(0, 20) + '...' : stage.roundName,
projects: stage.projectCount,
evaluations: stage.evaluationCount,
completionRate: stage.completionRate,
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0,
color: STAGE_COLORS[i % STAGE_COLORS.length],
const baseData = data.map((round) => ({
name: round.roundName,
Projects: round.projectCount,
Evaluations: round.evaluationCount,
'Completion Rate': round.completionRate,
'Avg Score': round.averageScore
? parseFloat(round.averageScore.toFixed(2))
: 0,
}))
return (
<div className="space-y-6">
{/* Metrics Comparison */}
<Card>
<CardHeader>
<CardTitle>Stage Metrics Comparison</CardTitle>
<CardTitle>Round Metrics Comparison</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[350px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
/>
<YAxis />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Legend />
<Bar dataKey="projects" name="Projects" fill="#053d57" radius={[4, 4, 0, 0]} />
<Bar dataKey="evaluations" name="Evaluations" fill="#557f8c" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Completion & Score Comparison */}
<div className="grid gap-6 lg:grid-cols-2">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Completion Rate by Stage</CardTitle>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Projects</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<CardContent className="pt-0">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
data={baseData}
index="name"
categories={['Projects']}
colors={['blue']}
showLegend={false}
yAxisWidth={40}
className="h-[200px]"
/>
<YAxis domain={[0, 100]} unit="%" />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
/>
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Average Score by Stage</CardTitle>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Evaluations
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<CardContent className="pt-0">
<BarChart
data={comparisonData}
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="name"
angle={-25}
textAnchor="end"
height={60}
tick={{ fontSize: 12 }}
data={baseData}
index="name"
categories={['Evaluations']}
colors={['violet']}
showLegend={false}
yAxisWidth={40}
className="h-[200px]"
/>
<YAxis domain={[0, 10]} />
<Tooltip
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
}}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Completion Rate
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Completion Rate']}
colors={['emerald']}
showLegend={false}
maxValue={100}
yAxisWidth={40}
valueFormatter={(v) => `${v}%`}
className="h-[200px]"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">
Average Score
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<BarChart
data={baseData}
index="name"
categories={['Avg Score']}
colors={['amber']}
showLegend={false}
maxValue={10}
yAxisWidth={40}
className="h-[200px]"
/>
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
</div>
</div>
</CardContent>
</Card>
)
}

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