Initial commit: MOPC platform with Docker deployment setup
Build and Push Docker Image / build (push) Failing after 10s Details

Full Next.js 15 platform with tRPC, Prisma, PostgreSQL, NextAuth.
Includes production Dockerfile (multi-stage, port 7600), docker-compose
with registry-based image pull, Gitea Actions CI workflow, nginx config
for portal.monaco-opc.com, deployment scripts, and DEPLOYMENT.md guide.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matt 2026-01-30 13:41:32 +01:00
commit a606292aaa
290 changed files with 70691 additions and 0 deletions

44
.dockerignore Normal file
View File

@ -0,0 +1,44 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Testing
coverage
# Next.js
.next
out
# Production
build
dist
# Misc
.DS_Store
*.pem
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.idea
.vscode
*.swp
*.swo
# Git
.git
.gitignore
# Docker
docker-output.txt

70
.env.example Normal file
View File

@ -0,0 +1,70 @@
# =============================================================================
# MOPC Platform Environment Variables
# =============================================================================
# Copy this file to .env.local for local development
# Copy to .env for production
# =============================================================================
# DATABASE
# =============================================================================
DATABASE_URL="postgresql://mopc:password@localhost:5432/mopc"
# Docker Compose database credentials
POSTGRES_USER="mopc"
POSTGRES_PASSWORD="devpassword"
POSTGRES_DB="mopc"
# =============================================================================
# AUTHENTICATION (NextAuth.js)
# =============================================================================
# Production URL (no trailing slash)
NEXTAUTH_URL="https://monaco-opc.com"
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET="your-secret-key-here"
# =============================================================================
# FILE STORAGE (MinIO)
# =============================================================================
# Internal endpoint for server-to-server communication
MINIO_ENDPOINT="http://localhost:9000"
# Public endpoint for browser-accessible URLs (pre-signed URLs)
# Set this when MinIO is behind a reverse proxy or external to Docker network
# If not set, falls back to MINIO_ENDPOINT
# MINIO_PUBLIC_ENDPOINT="https://storage.monaco-opc.com"
MINIO_ACCESS_KEY="minioadmin"
MINIO_SECRET_KEY="minioadmin"
MINIO_BUCKET="mopc-files"
# =============================================================================
# EMAIL (SMTP via Poste.io)
# =============================================================================
SMTP_HOST="localhost"
SMTP_PORT="587"
SMTP_USER="noreply@monaco-opc.com"
SMTP_PASS="your-smtp-password"
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
# =============================================================================
# AI (OpenAI for Smart Assignment)
# =============================================================================
# Optional: Enable AI-powered jury assignment suggestions
OPENAI_API_KEY=""
OPENAI_MODEL="gpt-4o"
# =============================================================================
# APPLICATION SETTINGS
# =============================================================================
# Node environment
NODE_ENV="development"
# Maximum file upload size in bytes (500MB for videos)
MAX_FILE_SIZE="524288000"
# Session duration in seconds (24 hours)
SESSION_MAX_AGE="86400"
# Magic link expiry in seconds (15 minutes)
MAGIC_LINK_EXPIRY="900"

View File

@ -0,0 +1,39 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
env:
REGISTRY: ${{ vars.REGISTRY_URL }}
IMAGE_NAME: mopc-app
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max

60
.gitignore vendored Normal file
View File

@ -0,0 +1,60 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Next.js
.next/
out/
# Build
dist/
build/
# Environment
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Docker env (contains secrets on server - never commit)
docker/.env
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# TypeScript
*.tsbuildinfo
next-env.d.ts
# Prisma
prisma/*.db
prisma/*.db-journal
# Testing
coverage/
# Playwright MCP screenshots
.playwright-mcp/
# Output files
*-output.txt
build-output.txt
# Misc
*.log
.vercel

340
CLAUDE.md Normal file
View File

@ -0,0 +1,340 @@
# MOPC Platform - Claude Code Context
## Project Overview
**MOPC (Monaco Ocean Protection Challenge)** is a secure jury online voting platform for managing project selection rounds. The platform enables jury members to evaluate submitted ocean conservation projects, with Phase 1 supporting two selection rounds:
- **Round 1**: ~130 projects → ~60 semi-finalists
- **Round 2**: ~60 projects → 6 finalists
**Domain**: `monaco-opc.com`
The platform is designed for future expansion into a comprehensive program management system including learning hub, communication workflows, and partner modules.
## Key Decisions
| Decision | Choice |
|----------|--------|
| Evaluation Criteria | Fully configurable per round (admin defines) |
| CSV Import | Flexible column mapping (admin maps columns) |
| Max File Size | 500MB (for videos) |
| Observer Role | Included in Phase 1 |
| First Admin | Database seed script |
| Past Evaluations | Visible read-only after submit |
| Grace Period | Admin-configurable per juror/project |
| Smart Assignment | AI-powered (GPT) + Smart Algorithm fallback |
| AI Data Privacy | All data anonymized before sending to GPT |
## Brand Identity
| Name | Hex | Usage |
|------|-----|-------|
| Primary Red | `#de0f1e` | CTAs, alerts |
| Dark Blue | `#053d57` | Headers, sidebar |
| White | `#fefefe` | Backgrounds |
| Teal | `#557f8c` | Links, secondary |
**Typography**: Montserrat (600/700 for headings, 300/400 for body)
## Tech Stack
| Layer | Technology | Version |
|-------|-----------|---------|
| **Framework** | Next.js (App Router) | 15.x |
| **Language** | TypeScript | 5.x |
| **UI Components** | shadcn/ui | latest |
| **Styling** | Tailwind CSS | 3.x |
| **API Layer** | tRPC | 11.x |
| **Database** | PostgreSQL | 16.x |
| **ORM** | Prisma | 6.x |
| **Authentication** | NextAuth.js (Auth.js) | 5.x |
| **AI** | OpenAI GPT | 4.x SDK |
| **Animation** | Motion (Framer Motion) | 11.x |
| **Notifications** | Sonner | 1.x |
| **Command Palette** | cmdk | 1.x |
| **File Storage** | MinIO (S3-compatible) | External |
| **Email** | Nodemailer + Poste.io | External |
| **Containerization** | Docker Compose | 2.x |
| **Reverse Proxy** | Nginx | External |
## Architecture Principles
1. **Type Safety First**: End-to-end TypeScript from database to UI via Prisma → tRPC → React
2. **Mobile-First Responsive**: All components designed for mobile, enhanced for desktop
3. **Full Control**: No black-box services; every component is understood and maintainable
4. **Extensible Data Model**: JSON fields for future attributes without schema migrations
5. **Security by Default**: RBAC, audit logging, secure file access with pre-signed URLs
## File Structure
```
mopc-platform/
├── CLAUDE.md # This file - project context
├── docs/
│ └── architecture/ # Architecture documentation
│ ├── README.md # System overview
│ ├── database.md # Database design
│ ├── api.md # API design
│ ├── infrastructure.md # Deployment docs
│ └── ui.md # UI/UX patterns
├── src/
│ ├── app/ # Next.js App Router pages
│ │ ├── (auth)/ # Public auth routes (login, verify)
│ │ ├── (admin)/ # Admin dashboard (protected)
│ │ ├── (jury)/ # Jury interface (protected)
│ │ ├── api/ # API routes
│ │ │ └── trpc/ # tRPC endpoint
│ │ ├── layout.tsx # Root layout
│ │ └── page.tsx # Home/landing
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ ├── forms/ # Form components (evaluation, etc.)
│ │ ├── layouts/ # Layout components (sidebar, nav)
│ │ └── shared/ # Shared components
│ ├── lib/
│ │ ├── auth.ts # NextAuth configuration
│ │ ├── prisma.ts # Prisma client singleton
│ │ ├── trpc/ # tRPC client & server setup
│ │ ├── minio.ts # MinIO client
│ │ └── email.ts # Email utilities
│ ├── server/
│ │ ├── routers/ # tRPC routers by domain
│ │ │ ├── program.ts
│ │ │ ├── round.ts
│ │ │ ├── project.ts
│ │ │ ├── user.ts
│ │ │ ├── assignment.ts
│ │ │ ├── evaluation.ts
│ │ │ ├── audit.ts
│ │ │ ├── settings.ts
│ │ │ └── gracePeriod.ts
│ │ ├── services/ # Business logic services
│ │ └── middleware/ # RBAC & auth middleware
│ ├── hooks/ # React hooks
│ ├── types/ # Shared TypeScript types
│ └── utils/ # Utility functions
├── prisma/
│ ├── schema.prisma # Database schema
│ ├── migrations/ # Migration files
│ └── seed.ts # Seed data
├── public/ # Static assets
├── docker/
│ ├── Dockerfile # Production build
│ ├── docker-compose.yml # Production stack
│ └── docker-compose.dev.yml # Development stack
├── tests/
│ ├── unit/ # Unit tests
│ └── e2e/ # End-to-end tests
└── config files... # package.json, tsconfig, etc.
```
## Coding Standards
### TypeScript
- Strict mode enabled
- Explicit return types for functions
- Use `type` over `interface` for consistency (unless extending)
- Prefer `unknown` over `any`
### React/Next.js
- Use Server Components by default
- `'use client'` only when needed (interactivity, hooks)
- Collocate components with their routes when specific to that route
- Use React Query (via tRPC) for server state
### Naming Conventions
- **Files**: kebab-case (`user-profile.tsx`)
- **Components**: PascalCase (`UserProfile`)
- **Functions/Variables**: camelCase (`getUserById`)
- **Constants**: SCREAMING_SNAKE_CASE (`MAX_FILE_SIZE`)
- **Database Tables**: PascalCase in Prisma (`User`, `Project`)
- **Database Columns**: camelCase in Prisma (`createdAt`)
### Styling
- Tailwind CSS utility classes
- Mobile-first: base styles for mobile, `md:` for tablet, `lg:` for desktop
- Use shadcn/ui components as base, customize via CSS variables
- No inline styles; no separate CSS files unless absolutely necessary
### API Design (tRPC)
- Group by domain: `trpc.program.create()`, `trpc.round.list()`
- Use Zod for input validation
- Return consistent response shapes
- Throw `TRPCError` with appropriate codes
## Common Commands
```bash
# Development
npm run dev # Start Next.js dev server
npm run db:studio # Open Prisma Studio
npm run db:push # Push schema changes (dev only)
npm run db:migrate # Run migrations
npm run db:seed # Seed database
# Testing
npm run test # Run unit tests
npm run test:e2e # Run E2E tests
npm run test:coverage # Test with coverage
# Build & Deploy
npm run build # Production build
npm run start # Start production server
docker compose up -d # Start Docker stack
docker compose logs -f app # View app logs
# Code Quality
npm run lint # ESLint
npm run format # Prettier
npm run typecheck # TypeScript check
```
## Windows Development Notes
**IMPORTANT**: On Windows, all Docker commands must be run using PowerShell (`powershell -Command "..."`), not bash/cmd. This is required for proper Docker Desktop integration.
**IMPORTANT**: When invoking PowerShell from bash, always use `-ExecutionPolicy Bypass` to skip the user profile script which is blocked by execution policy:
```bash
powershell -ExecutionPolicy Bypass -Command "..."
```
```powershell
# Docker commands on Windows (use PowerShell)
docker compose -f docker/docker-compose.dev.yml up -d
docker compose -f docker/docker-compose.dev.yml build --no-cache app
docker compose -f docker/docker-compose.dev.yml logs -f app
docker compose -f docker/docker-compose.dev.yml down
```
## Environment Variables
```env
# Database
DATABASE_URL="postgresql://user:pass@localhost:5432/mopc"
# NextAuth
NEXTAUTH_URL="https://monaco-opc.com"
NEXTAUTH_SECRET="your-secret-key"
# MinIO (existing separate stack)
MINIO_ENDPOINT="http://localhost:9000"
MINIO_ACCESS_KEY="your-access-key"
MINIO_SECRET_KEY="your-secret-key"
MINIO_BUCKET="mopc-files"
# Email (Poste.io - existing)
SMTP_HOST="localhost"
SMTP_PORT="587"
SMTP_USER="noreply@monaco-opc.com"
SMTP_PASS="your-smtp-password"
EMAIL_FROM="MOPC Platform <noreply@monaco-opc.com>"
# OpenAI (for smart assignment)
OPENAI_API_KEY="your-openai-api-key"
```
## Key Architectural Decisions
### 1. Next.js App Router over Pages Router
**Rationale**: Server Components reduce client bundle, better data fetching patterns, layouts system
### 2. tRPC over REST
**Rationale**: End-to-end type safety without code generation, excellent DX with autocomplete
### 3. Prisma over raw SQL
**Rationale**: Type-safe queries, migration system, works seamlessly with TypeScript
### 4. NextAuth.js over custom auth
**Rationale**: Battle-tested, supports magic links, session management built-in
### 5. MinIO (external) over local file storage
**Rationale**: S3-compatible, pre-signed URLs for security, scalable, already deployed
### 6. JSON fields for extensibility
**Rationale**: `metadata_json`, `settings_json` allow adding attributes without migrations
### 7. Soft deletes with status fields
**Rationale**: Audit trail preservation, recovery capability, referential integrity
## User Roles (RBAC)
| Role | Permissions |
|------|------------|
| **SUPER_ADMIN** | Full system access, all programs, user management |
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
| **JURY_MEMBER** | View assigned projects only, submit evaluations |
| **OBSERVER** | Read-only access to dashboards (optional) |
## Important Constraints
1. **Jury can only see assigned projects** - enforced at query level
2. **Voting windows are strict** - submissions blocked outside active window
3. **Evaluations are versioned** - edits create new versions
4. **All admin actions are audited** - immutable audit log
5. **Files accessed via pre-signed URLs** - no public bucket access
6. **Mobile responsiveness is mandatory** - every view must work on phones
7. **File downloads require project authorization** - jury/mentor must be assigned to the project
8. **Mentor endpoints require MENTOR role** - uses `mentorProcedure` middleware
## Security Notes
### CSRF Protection
tRPC mutations are protected against CSRF attacks because:
- tRPC uses `application/json` content type, which triggers CORS preflight on cross-origin requests
- Browsers block cross-origin JSON POSTs by default (Same-Origin Policy)
- NextAuth's own routes (`/api/auth/*`) have built-in CSRF token protection
- No custom CORS headers are configured to allow external origins
**Do NOT add permissive CORS headers** (e.g., `Access-Control-Allow-Origin: *`) without also implementing explicit CSRF token validation on all mutation endpoints.
### Rate Limiting
- tRPC API: 100 requests/minute per IP
- Auth endpoints: 10 POST requests/minute per IP
- Account lockout: 5 failed password attempts triggers 15-minute lockout
## External Services (Pre-existing)
These services are already running on the VPS in separate Docker Compose stacks:
- **MinIO**: `http://localhost:9000` - S3-compatible storage
- **Poste.io**: `localhost:587` - SMTP server for emails
- **Nginx**: Host-level reverse proxy with SSL (certbot)
The MOPC platform connects to these via environment variables.
## Phase 1 Scope
### In Scope
- Round management (create, configure, activate/close)
- Project import (CSV) and file uploads
- Jury invitation (magic link)
- Manual project assignment (single + bulk)
- Evaluation form (configurable criteria)
- Autosave + final submit
- Voting window enforcement
- Progress dashboards
- CSV export
- Audit logging
### Out of Scope (Phase 2+)
- Auto-assignment algorithm
- Typeform/Notion integrations
- WhatsApp notifications
- Learning hub
- Partner modules
- Public website
## Testing Strategy
- **Unit Tests**: Business logic, utilities, validators
- **Integration Tests**: tRPC routers with test database
- **E2E Tests**: Critical user flows (Playwright)
- **Manual Testing**: Responsive design on real devices
## Documentation Links
- [Architecture Overview](./docs/architecture/README.md)
- [Database Design](./docs/architecture/database.md)
- [API Design](./docs/architecture/api.md)
- [Infrastructure](./docs/architecture/infrastructure.md)
- [UI/UX Patterns](./docs/architecture/ui.md)

318
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,318 @@
# MOPC Platform - Server Deployment Guide
Deployment guide for the MOPC platform on a Linux VPS with Docker.
**Domain**: `portal.monaco-opc.com`
**App Port**: 7600 (behind Nginx reverse proxy)
**CI/CD**: Gitea Actions (Ubuntu runner) builds and pushes Docker images
## CI/CD Pipeline
The app is built automatically by a Gitea runner on every push to `main`:
1. Gitea Actions workflow builds the Docker image on Ubuntu
2. Image is pushed to the Gitea container registry
3. On the server, you pull the latest image and restart
### Gitea Setup
Configure the following in your Gitea repository settings:
**Repository Variables** (Settings > Actions > Variables):
| Variable | Value |
|----------|-------|
| `REGISTRY_URL` | Your Gitea registry URL (e.g. `gitea.example.com/your-org`) |
**Repository Secrets** (Settings > Actions > Secrets):
| Secret | Value |
|--------|-------|
| `REGISTRY_USER` | Gitea username with registry access |
| `REGISTRY_PASSWORD` | Gitea access token or password |
The workflow file is at `.gitea/workflows/build.yml`.
## Prerequisites
- Linux VPS (Ubuntu 22.04+ recommended)
- Docker Engine 24+ with Compose v2
- Nginx installed on the host
- Certbot for SSL certificates
### Install Docker (if needed)
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Log out and back in
```
### Install Nginx & Certbot (if needed)
```bash
sudo apt update
sudo apt install nginx certbot python3-certbot-nginx
```
## First-Time Deployment
### 1. Clone the repository
```bash
git clone <your-repo-url> /opt/mopc
cd /opt/mopc
```
### 2. Configure environment variables
```bash
cp docker/.env.production docker/.env
nano docker/.env
```
Fill in all `CHANGE_ME` values. Generate secrets with:
```bash
openssl rand -base64 32
```
Required variables:
| Variable | Description |
|----------|-------------|
| `REGISTRY_URL` | Gitea registry URL (e.g. `gitea.example.com/your-org`) |
| `DB_PASSWORD` | PostgreSQL password |
| `NEXTAUTH_SECRET` | Auth session secret (openssl rand) |
| `NEXTAUTH_URL` | `https://portal.monaco-opc.com` |
| `MINIO_ENDPOINT` | MinIO internal URL (e.g. `http://localhost:9000`) |
| `MINIO_ACCESS_KEY` | MinIO access key |
| `MINIO_SECRET_KEY` | MinIO secret key |
| `MINIO_BUCKET` | MinIO bucket name (`mopc-files`) |
| `SMTP_HOST` | SMTP server host |
| `SMTP_PORT` | SMTP port (587) |
| `SMTP_USER` | SMTP username |
| `SMTP_PASS` | SMTP password |
| `EMAIL_FROM` | Sender address |
### 3. Run the deploy script
```bash
chmod +x scripts/deploy.sh scripts/seed.sh scripts/update.sh
./scripts/deploy.sh
```
This will:
- Log in to the container registry
- Pull the latest app image
- Start PostgreSQL + the app
- Run database migrations automatically on startup
- Wait for the health check
### 4. Seed the database (one time only)
```bash
./scripts/seed.sh
```
This seeds:
- Super admin user (`matt.ciaccio@gmail.com`)
- System settings
- Program & Round 1 configuration
- Evaluation form
- All candidature data from CSV
### 5. Set up Nginx
```bash
sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### 6. Set up SSL
```bash
sudo certbot --nginx -d portal.monaco-opc.com
```
Auto-renewal is configured by default. Test with:
```bash
sudo certbot renew --dry-run
```
### 7. Verify
```bash
curl https://portal.monaco-opc.com/api/health
```
Expected response:
```json
{"status":"healthy","timestamp":"...","services":{"database":"connected"}}
```
## Updating the Platform
After Gitea CI builds a new image (push to `main`):
```bash
cd /opt/mopc
./scripts/update.sh
```
This pulls the latest image from the registry, restarts only the app container (PostgreSQL stays running), runs migrations via the entrypoint, and waits for the health check.
## Manual Operations
### View logs
```bash
cd /opt/mopc/docker
docker compose logs -f app # App logs
docker compose logs -f postgres # Database logs
```
### Run migrations manually
```bash
cd /opt/mopc/docker
docker compose exec app npx prisma migrate deploy
```
### Open a shell in the app container
```bash
cd /opt/mopc/docker
docker compose exec app sh
```
### Restart services
```bash
cd /opt/mopc/docker
docker compose restart app # App only
docker compose restart # All services
```
### Stop everything
```bash
cd /opt/mopc/docker
docker compose down # Stop containers (data preserved)
docker compose down -v # Stop AND delete volumes (data lost!)
```
## Database Backups
### Create a backup
```bash
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
```
### Restore a backup
```bash
gunzip < backup_20260130_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc
```
### Set up daily backups (cron)
```bash
sudo mkdir -p /data/backups/mopc
cat > /opt/mopc/scripts/backup-db.sh << 'SCRIPT'
#!/bin/bash
BACKUP_DIR=/data/backups/mopc
DATE=$(date +%Y%m%d_%H%M%S)
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > $BACKUP_DIR/mopc_$DATE.sql.gz
find $BACKUP_DIR -name "mopc_*.sql.gz" -mtime +30 -delete
SCRIPT
chmod +x /opt/mopc/scripts/backup-db.sh
echo "0 2 * * * /opt/mopc/scripts/backup-db.sh >> /var/log/mopc-backup.log 2>&1" | sudo tee /etc/cron.d/mopc-backup
```
## Architecture
```
Gitea CI (Ubuntu runner)
|
v (docker push)
Container Registry
|
v (docker pull)
Linux VPS
|
v
Nginx (host, port 443) -- SSL termination
|
v
mopc-app (Docker, port 7600) -- Next.js standalone
|
v
mopc-postgres (Docker, port 5432) -- PostgreSQL 16
External services (separate Docker stacks):
- MinIO (port 9000) -- S3-compatible file storage
- Poste.io (port 587) -- SMTP email
```
## Troubleshooting
### App won't start
```bash
cd /opt/mopc/docker
docker compose logs app
docker compose exec postgres pg_isready -U mopc
```
### Can't pull image
```bash
# Re-authenticate with registry
docker login <your-registry-url>
# Check image exists
docker pull <your-registry-url>/mopc-app:latest
```
### Migration fails
```bash
# Check migration status
docker compose exec app npx prisma migrate status
# Reset (DESTROYS DATA):
docker compose exec app npx prisma migrate reset
```
### SSL certificate issues
```bash
sudo certbot certificates
sudo certbot renew --force-renewal
```
### Port conflict
The app runs on port 7600. If something else uses it:
```bash
sudo ss -tlnp | grep 7600
```
## Security Checklist
- [ ] SSL certificate active and auto-renewing
- [ ] `docker/.env` has strong, unique passwords
- [ ] `NEXTAUTH_SECRET` is randomly generated
- [ ] Gitea registry credentials secured
- [ ] Firewall allows only ports 80, 443, 22
- [ ] Docker daemon not exposed to network
- [ ] Daily backups configured
- [ ] Nginx security headers active

402
Notes.md Normal file
View File

@ -0,0 +1,402 @@
Below is a “technical requirements” rendering (not an architecture diagram), structured so you can hand it to a dev team and derive system architecture + backlog from it. Phase 1 is the only critical deliverable; Phase 2+ are explicitly extendable.
---
## 0) Product scope and phasing
### Phase 1 (critical, delivery in ~2 weeks)
**Secure Jury Online Voting Module** to run two selection rounds:
* Round 1: ~130 projects → ~60 semi-finalists (Feb 1823 voting window)
* Round 2: ~60 projects → 6 finalists (~April 13 week voting window)
* Voting is asynchronous, online, with assigned project access, scoring + feedback capture, and reporting dashboards.
### Phase 2+ (mid-term)
Centralized MOPC platform:
* Applications/projects database
* Document management (MinIO S3)
* Jury spaces (history, comments, scoring)
* Learning hub / resources
* Communication workflows (email + possibly WhatsApp)
* Partner/sponsor visibility modules
* Potential website integration / shared back office
---
## 1) Users, roles, permissions (RBAC)
### Core roles
1. **Platform Super Admin**
* Full system configuration, security policies, integrations, user/role management, data export, audit access.
2. **Program Admin (MOPC Admin)**
* Manages cycles/rounds, projects, jury members, assignments, voting windows, criteria forms, dashboards, exports.
3. **Jury Member**
* Can access only assigned projects for active rounds; submit evaluations; view own submitted evaluations; optionally view aggregated results only if permitted.
4. **Read-only Observer (optional)**
* Internal meeting viewer: can see dashboards/aggregates but cannot edit votes.
### Permission model requirements
* **Least privilege by default**
* **Round-scoped permissions**: access can be constrained per selection round/cycle.
* **Project-scoped access control**: jury sees only assigned projects (unless admin toggles “all projects visible”).
* **Admin override controls**: reassign projects, revoke access, reopen/lock evaluations, extend voting windows, invalidate votes with reason logging.
---
## 2) Core domain objects (data model concepts)
### Entities
* **Program** (e.g., “MOPC 2026”)
* **Selection Cycle / Round**
* Attributes: name, start/end of voting window, status (draft/active/closed/archived), required reviews per project (default ≥3), scoring form version, jury cohort.
* **Project**
* Attributes: title, team name, description, tags, status (submitted/eligible/assigned/semi-finalist/finalist/etc.), submission metadata, external IDs (Typeform/Notion), files (exec summary, PDF deck, intro video).
* **File Asset**
* Stored in MinIO (S3-compatible): object key, bucket, version/etag, mime type, size, upload timestamp, retention policy, access policy.
* **Jury Member**
* Profile: name, email, organization (optional), role, expertise tags, status (invited/active/suspended).
* **Expertise Tag**
* Managed vocabulary or free-form with admin approval.
* **Assignment**
* Connects Jury Member ↔ Project ↔ Round
* Attributes: assignment method (manual/auto), created by, created timestamp, required review flag, completion status.
* **Evaluation (Vote)**
* Per assignment: criterion scores + global score + binary decision + qualitative feedback
* Metadata: submitted_at, last_edited_at, finalization flag, versioning, IP/user-agent logging (optional), conflict handling.
* **Audit Log**
* Immutable events: login, permission changes, voting window changes, assignments, overrides, exports, vote invalidations.
---
## 3) Phase 1 functional requirements
### 3.1 Jury authentication & access
* Invite flow:
* Admin imports jury list (CSV) or adds manually.
* System sends invitation email with secure link + account activation.
* Authentication options (choose one for Phase 1, keep others pluggable):
* Email magic link (recommended for speed)
* Password + MFA optional
* Session requirements:
* Configurable session duration
* Forced logout on role revocation
* Access gating:
* Jury can only view projects for **active** rounds and only those assigned.
### 3.2 Project ingestion & management
Phase 1 can support either:
* **Option A (fastest): Manual import**
* Admin uploads CSV with project metadata + file links or uploads.
* **Option B (semi-integrated): Sync from Notion/Typeform**
* Read projects from existing Notion DB and/or Typeform export.
Minimum capabilities:
* Admin CRUD on projects (create/update/archive)
* Project tagging (from “Which issue does your project address?” + additional admin tags)
* Attach required assets:
* Executive summary (PDF/doc)
* PDF presentation
* 30s intro video (mp4)
* File storage via MinIO (see Section 6)
### 3.3 Assignment system (≥3 reviews/project)
Admin can:
* Manually assign projects to jury members (bulk assign supported)
* Auto-assign (optional but strongly recommended):
* Input: jury expertise tags + project tags + constraints
* Constraints:
* Each project assigned to at least N jurors (N configurable; default 3)
* Load balancing across jurors (minimize variance)
* Avoid conflicts (optional): disallow assignment if juror marked conflict with project
* Output: assignment set + summary metrics (coverage, per-juror load, unmatched tags)
* Reassignment rules:
* Admin can reassign at any time
* If an evaluation exists, admin can:
* keep existing evaluation tied to original juror
* or invalidate/lock it (requires reason + audit event)
### 3.4 Evaluation form & scoring logic
Per project evaluation must capture:
* **Criterion scores** (scale-based, define exact scale as configurable; e.g., 15 or 110)
1. Need clarity
2. Solution relevance
3. Gap analysis (market/competitors)
4. Target customers clarity
5. Ocean impact
* **Global score**: 110
* **Binary decision**: “Select as semi-finalist?” (Yes/No)
* **Qualitative feedback**: long text
Form requirements:
* Admin-configurable criteria text, ordering, scales, and whether fields are mandatory
* Autosave drafts
* Final submit locks evaluation by default (admin can allow edits until window closes)
* Support multiple rounds with potentially different forms (versioned forms per round)
### 3.5 Voting windows and enforcement (must-have)
Admins must be able to configure and enforce:
* Voting window start/end **per round** (date-time, timezone-aware)
* States:
* Draft (admins only)
* Active (jury can submit)
* Closed (jury read-only)
* Archived (admin/export only)
* Enforcement rules:
* Jury cannot submit outside the active window
* Admin “grace period” toggle to accept late submissions for specific jurors/projects
* Admin can extend the window (global or subset) with audit logging
* Dashboard countdown + clear messaging for jurors
### 3.6 Dashboards & outputs
Must produce:
* **Jury member view**
* Assigned projects list, completion status, quick access to files, evaluation status (not started/draft/submitted)
* **Admin dashboards**
* Coverage: projects with <N evaluations
* Progress: submission rates by juror
* Aggregates per project:
* Average per criterion
* Average global score
* Distribution (min/max, std dev optional)
* Count of “Yes” votes
* Qualitative comments list (with juror identity visible only to admins, configurable)
* Shortlisting tools:
* Filter/sort by aggregate score, yes-vote ratio, tag, missing reviews
* Export shortlist (e.g., top 60 / top 6) with manual override controls
* Exports (Phase 1):
* CSV/Excel export for:
* Evaluations (row per evaluation)
* Aggregates (row per project)
* Assignment matrix
* PDF export (optional) for meeting packs
---
## 4) Admin console requirements (robust)
### 4.1 Governance & configuration
* Create/manage Programs and Rounds
* Set:
* Required reviews per project (N)
* Voting windows (start/end) + grace rules
* Evaluation form version
* Visibility rules (whether jurors can see aggregates, whether jurors can see their past submissions after close)
* Manage tags:
* Tag taxonomy, synonyms/merging, locked tags
### 4.2 User management & security controls
* Bulk invite/import
* Role assignment & revocation
* Force password reset / disable account
* View user activity logs
* Configure:
* Allowed email domains (optional)
* MFA requirement (optional)
* Session lifetime (optional)
### 4.3 Assignment controls
* Manual assignment UI (single + bulk)
* Auto-assignment wizard:
* select round
* choose balancing strategy (e.g., “maximize tag match”, “balance load first”)
* preview results
* apply
* Conflict of interest handling:
* Admin can mark conflicts (juror ↔ project)
* Auto-assign must respect conflicts
### 4.4 Data integrity controls
* Vote invalidation (requires reason)
* Reopen evaluation (admin-only, logged)
* Freeze round (hard lock)
* Immutable audit log export
### 4.5 Integrations management
* Connectors toggles (Typeform/Notion/email provider/WhatsApp) with credentials stored securely
* MinIO bucket configuration + retention policies
* Webhook management (optional)
---
## 5) Non-functional requirements (Phase 1)
### Security
* TLS everywhere
* RBAC + project-level access control
* Secure file access (pre-signed URLs with short TTL; no public buckets)
* Audit logging for admin actions + exports
* Basic anti-abuse:
* rate limiting login endpoints
* brute-force protection if password auth used
### Reliability & performance
* Support:
* Round 1: 15 jurors, 130 projects, min 390 evaluations
* Round 2: ~30 jurors, 60 projects
* Fast page load for dashboards and project pages
* File streaming for PDFs/videos (avoid timeouts)
### Compliance & privacy (baseline)
* Store only necessary personal data for jurors/candidates
* Retention policies configurable (especially for candidate files)
* Access logs available for security review
---
## 6) File storage requirements (MinIO S3)
### Storage design (requirements-level)
* Use MinIO as S3-compatible object store for:
* project documents (exec summary, deck)
* video files
* optional assets (logos, exports packs)
* Buckets:
* Separate buckets or prefixes by Program/Round to simplify retention + permissions
* Access pattern:
* Upload: direct-to-S3 (preferred) or via backend proxy
* Download/view: **pre-signed URLs** generated by backend per authorized user
* Optional features:
* Object versioning enabled
* Antivirus scanning hook (Phase 2)
* Lifecycle rules (auto-expire after X months)
---
## 7) “Current process” integration mapping (future-proof)
### Existing flow
* Typeform application → confirmation email → Tally upload → Notion tracking → Google Drive manual upload
### Platform integration targets
Phase 1 (minimal):
* Allow admin to ingest projects and upload assets (replace Drive for jury-facing access)
Phase 2 options:
* Typeform: pull submissions via API/webhooks
* Tally: capture uploads directly to MinIO (or via platform upload portal)
* Notion: sync project status + metadata (one-way or two-way)
* Email automation: reminder workflows for incomplete applications
---
## 8) Additional ideas as “technical backlog candidates”
### Automated follow-ups for incomplete applications (Phase 2)
* State machine for applications: registered → awaiting docs → complete → expired
* Scheduler:
* send reminders at configurable intervals (e.g., +2d, +5d, +7d)
* stop on completion
* Channels:
* Email must-have
* WhatsApp optional (requires compliance + provider; store consent + opt-out)
### Learning hub access (semi-finalists only)
* Resource library stored in MinIO + metadata in DB
* Access controlled by cohort + passwordless login or access tokens
* Expiring invite links
### Website integration
* Shared identity/back office (SSO-ready) OR separate admin domains
* Public-facing site remains content-only; platform is operational hub
* Requirement: clear separation between “public content” and “private jury/applicant data”
---
## 9) Acceptance criteria checklist (Phase 1)
1. Admin can create a round, set voting window (start/end), and activate it.
2. Admin can import projects + upload/attach required files to MinIO.
3. Admin can import jurors, invite them, and jurors can log in securely.
4. Admin can assign projects (manual + bulk). Auto-assign is optional but if included must guarantee ≥3 reviews/project.
5. Juror sees only assigned projects, can view files, and submit evaluation form.
6. System blocks submissions outside the voting window (unless admin-granted exception).
7. Admin dashboard shows progress + aggregates per project; admin can export results.
8. All critical admin actions are audit-logged.
9. File access is protected (no public links; pre-signed URLs with TTL).
---
If you want, I can turn this into:
* a clean PRD-style document (Dev-ready) **plus**
* a ticket breakdown (Epics → user stories → acceptance tests) for Phase 1 delivery.

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

60
docker/.env.production Normal file
View File

@ -0,0 +1,60 @@
# =============================================================================
# MOPC Platform - Production Environment Variables
# =============================================================================
# Copy this file to docker/.env and fill in real values:
# cp docker/.env.production docker/.env
#
# Generate secrets with:
# openssl rand -base64 32
# =============================================================================
# DATABASE
# =============================================================================
DB_PASSWORD=CHANGE_ME_use_openssl_rand
# =============================================================================
# AUTHENTICATION (NextAuth.js / Auth.js)
# =============================================================================
NEXTAUTH_URL=https://portal.monaco-opc.com
NEXTAUTH_SECRET=CHANGE_ME_use_openssl_rand
# =============================================================================
# FILE STORAGE (MinIO - external stack)
# =============================================================================
# Internal endpoint (server-to-server, within Docker host)
MINIO_ENDPOINT=http://localhost:9000
# Public endpoint for browser-accessible pre-signed URLs
# Set this when MinIO is behind a reverse proxy
# MINIO_PUBLIC_ENDPOINT=https://storage.monaco-opc.com
MINIO_ACCESS_KEY=CHANGE_ME
MINIO_SECRET_KEY=CHANGE_ME
MINIO_BUCKET=mopc-files
# =============================================================================
# EMAIL (SMTP via Poste.io - external stack)
# =============================================================================
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=noreply@monaco-opc.com
SMTP_PASS=CHANGE_ME
EMAIL_FROM=MOPC Platform <noreply@monaco-opc.com>
# =============================================================================
# AI (OpenAI - optional)
# =============================================================================
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
# =============================================================================
# DOCKER REGISTRY (Gitea container registry)
# =============================================================================
# The Gitea registry URL where the CI pushes built images
# Example: gitea.example.com/your-org
REGISTRY_URL=code.letsbe.solutions/letsbe
# =============================================================================
# APPLICATION
# =============================================================================
MAX_FILE_SIZE=524288000

71
docker/Dockerfile Normal file
View File

@ -0,0 +1,71 @@
# =============================================================================
# MOPC Platform - Production Dockerfile
# =============================================================================
# Multi-stage build for optimized production image
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Install runtime dependencies for migrations and seeding
RUN apk add --no-cache libc6-compat
# Copy built files
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
# Copy CSV data file for manual seeding
COPY --from=builder /app/docs/candidatures_2026.csv ./docs/candidatures_2026.csv
# Copy entrypoint script
COPY docker/docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
# Set correct permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 7600
ENV PORT=7600
ENV HOSTNAME="0.0.0.0"
# Run via entrypoint (migrate then start)
CMD ["/app/docker-entrypoint.sh"]

34
docker/Dockerfile.dev Normal file
View File

@ -0,0 +1,34 @@
# =============================================================================
# MOPC Platform - Development Dockerfile (Pre-built)
# =============================================================================
FROM node:22-alpine
WORKDIR /app
# Install dependencies for Prisma and development
RUN apk add --no-cache libc6-compat openssl
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm install && npm install tailwindcss-animate
# Copy prisma schema for generation
COPY prisma ./prisma
# Generate Prisma client
RUN npx prisma generate
# Copy the rest of the application
COPY . .
# Build the application at build time (pre-compile everything)
RUN npm run build
# Expose port
EXPOSE 3000
# Start production server (uses pre-built files)
CMD ["npm", "start"]

View File

@ -0,0 +1,97 @@
# =============================================================================
# MOPC Platform - Development Docker Compose
# =============================================================================
# Use this for local development. Includes PostgreSQL, MinIO, and the app.
services:
postgres:
image: postgres:16-alpine
container_name: mopc-postgres-dev
ports:
- "5432:5432"
environment:
- POSTGRES_USER=${POSTGRES_USER:-mopc}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-devpassword}
- POSTGRES_DB=${POSTGRES_DB:-mopc}
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mopc"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio
container_name: mopc-minio-dev
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=${MINIO_ACCESS_KEY:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_SECRET_KEY:-minioadmin}
volumes:
- minio_dev_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# MinIO client to create default bucket on startup
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
environment:
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
- MINIO_BUCKET=${MINIO_BUCKET:-mopc-files}
entrypoint: >
/bin/sh -c "
mc alias set myminio http://minio:9000 $${MINIO_ACCESS_KEY} $${MINIO_SECRET_KEY};
mc mb --ignore-existing myminio/$${MINIO_BUCKET};
mc anonymous set download myminio/$${MINIO_BUCKET};
echo 'Bucket created successfully';
"
# Next.js application
app:
build:
context: ..
dockerfile: docker/Dockerfile.dev
container_name: mopc-app-dev
ports:
- "3000:3000"
env_file:
- ../.env
environment:
- DATABASE_URL=postgresql://${POSTGRES_USER:-mopc}:${POSTGRES_PASSWORD:-devpassword}@postgres:5432/${POSTGRES_DB:-mopc}
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-dev-secret-key-for-local-development-only}
- AUTH_SECRET=${AUTH_SECRET:-dev-secret-key-for-local-development-only}
- MINIO_ENDPOINT=http://minio:9000
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY:-minioadmin}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY:-minioadmin}
- MINIO_BUCKET=${MINIO_BUCKET:-mopc-files}
- NODE_ENV=development
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
volumes:
- ../src:/app/src
- ../public:/app/public
- ../prisma:/app/prisma
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
volumes:
postgres_dev_data:
minio_dev_data:

75
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,75 @@
# =============================================================================
# MOPC Platform - Production Docker Compose
# =============================================================================
# This stack contains only the Next.js app and PostgreSQL.
# MinIO and Poste.io are external services connected via environment variables.
#
# The app image is built by Gitea CI and pushed to the container registry.
# To pull the latest image: docker compose pull app
# To deploy: docker compose up -d
services:
app:
image: ${REGISTRY_URL}/mopc-app:latest
container_name: mopc-app
restart: unless-stopped
ports:
- "127.0.0.1:7600:7600"
env_file:
- .env
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTH_SECRET=${NEXTAUTH_SECRET}
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_PUBLIC_ENDPOINT=${MINIO_PUBLIC_ENDPOINT:-}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET=${MINIO_BUCKET}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o}
- MAX_FILE_SIZE=${MAX_FILE_SIZE:-524288000}
depends_on:
postgres:
condition: service_healthy
networks:
- mopc-network
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:7600/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
postgres:
image: postgres:16-alpine
container_name: mopc-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=mopc
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=mopc
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mopc"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mopc-network
volumes:
postgres_data:
driver: local
networks:
mopc-network:
driver: bridge

View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "==> Running database migrations..."
npx prisma migrate deploy
echo "==> Starting application..."
exec node server.js

View File

@ -0,0 +1,78 @@
# =============================================================================
# MOPC Platform - Nginx Reverse Proxy Configuration
# =============================================================================
# Install: sudo ln -s /opt/mopc/docker/nginx/mopc-platform.conf /etc/nginx/sites-enabled/
# Test: sudo nginx -t
# Reload: sudo systemctl reload nginx
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=mopc_limit:10m rate=10r/s;
# MOPC Platform - HTTPS
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name portal.monaco-opc.com;
# SSL certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/portal.monaco-opc.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/portal.monaco-opc.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';" always;
# File upload size (500MB for videos)
client_max_body_size 500M;
# Rate limiting
limit_req zone=mopc_limit burst=20 nodelay;
# Logging
access_log /var/log/nginx/mopc-access.log;
error_log /var/log/nginx/mopc-error.log;
# Next.js application
location / {
proxy_pass http://127.0.0.1:7600;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts for large file uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Static files caching
location /_next/static {
proxy_pass http://127.0.0.1:7600;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Health check endpoint (no access log noise)
location /api/health {
proxy_pass http://127.0.0.1:7600;
access_log off;
}
}
# HTTP to HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name portal.monaco-opc.com;
return 301 https://$host$request_uri;
}

View File

374
docs/architecture/README.md Normal file
View File

@ -0,0 +1,374 @@
# MOPC Platform - Architecture Overview
## System Overview
The MOPC Platform is a secure jury voting system built as a modern full-stack TypeScript application for the **Monaco Ocean Protection Challenge**. It follows a layered architecture with clear separation of concerns.
**Phase 1 Focus**: Jury selection rounds (130→60→6 projects)
**Domain**: `monaco-opc.com`
## Finalized Decisions
| Decision | Choice |
|----------|--------|
| **Domain** | `monaco-opc.com` |
| **Evaluation Criteria** | Fully configurable per round (admin defines) |
| **CSV Import** | Flexible column mapping (admin maps columns) |
| **Max File Size** | 500MB (for videos) |
| **Observer Role** | Included in Phase 1 |
| **First Admin** | Database seed script |
| **Past Evaluations** | Visible read-only after submit |
| **Grace Period** | Admin-configurable per juror/project |
| **Smart Assignment** | AI-powered (GPT) + Smart Algorithm fallback |
| **AI Provider** | Admin-configurable (OpenAI GPT) |
| **AI Data Privacy** | All data anonymized/encoded before sending to GPT |
## Tech Stack
| Layer | Technology | Version |
|-------|-----------|---------|
| **Framework** | Next.js (App Router) | 15.x |
| **Language** | TypeScript | 5.x |
| **UI Components** | shadcn/ui | latest |
| **Styling** | Tailwind CSS | 3.x |
| **API Layer** | tRPC | 11.x |
| **Database** | PostgreSQL | 16.x |
| **ORM** | Prisma | 6.x |
| **Authentication** | NextAuth.js (Auth.js) | 5.x |
| **AI** | OpenAI GPT | 4.x SDK |
| **File Storage** | MinIO (S3-compatible) | External |
| **Email** | Nodemailer + Poste.io | External |
| **Animation** | Motion (Framer Motion) | 11.x |
| **Notifications** | Sonner | 1.x |
| **Command Palette** | cmdk | 1.x |
| **Containerization** | Docker Compose | 2.x |
| **Reverse Proxy** | Nginx | External |
## Brand Identity
### Colors
| Name | Hex | Usage |
|------|-----|-------|
| Primary Red | `#de0f1e` | Accents, CTAs, alerts |
| Dark Blue | `#053d57` | Headers, sidebar, primary text |
| White | `#fefefe` | Backgrounds |
| Teal | `#557f8c` | Secondary elements, links |
### Typography
- **Headings**: Montserrat (600/700 weight)
- **Body**: Montserrat Light (300/400 weight)
## High-Level Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Admin Views │ │ Jury Views │ │ Auth Views │ │
│ │ │ │ │ │ │ │
│ │ - Dashboard │ │ - Project List │ │ - Login │ │
│ │ - Rounds │ │ - Project View │ │ - Magic Link │ │
│ │ - Projects │ │ - Evaluation │ │ - Verify │ │
│ │ - Jury Mgmt │ │ - My Progress │ │ │ │
│ │ - Assignments │ │ │ │ │ │
│ │ - Reports │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ Built with: Next.js App Router + React Server Components + shadcn/ui │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ API LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ tRPC Router │ │
│ │ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ │
│ │ │program │ │ round │ │project │ │ user │ │assign- │ │evalua- │ │ │
│ │ │Router │ │Router │ │Router │ │Router │ │ment │ │tion │ │ │
│ │ │ │ │ │ │ │ │ │ │Router │ │Router │ │ │
│ │ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Auth Middleware │ │ RBAC Middleware │ │ Audit Logger │ │
│ │ (NextAuth.js) │ │ (role checks) │ │ (all actions) │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ SERVICE LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Program │ │ Round │ │ Assignment │ │ Evaluation │ │
│ │ Service │ │ Service │ │ Service │ │ Service │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ File │ │ Email │ │ Export │ │ Audit │ │
│ │ Service │ │ Service │ │ Service │ │ Service │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATA LAYER │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ Prisma ORM │ │
│ │ │ │
│ │ Type-safe database access, migrations, query building │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ PostgreSQL │ │ MinIO │ │
│ │ (Database) │ │ (File Store) │ │
│ │ │ │ │ │
│ │ - Users │ │ - PDFs │ │
│ │ - Programs │ │ - Videos │ │
│ │ - Rounds │ │ - Exports │ │
│ │ - Projects │ │ │ │
│ │ - Evaluations │ │ │ │
│ │ - Audit Logs │ │ │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### Presentation Layer
| Component | Responsibility |
|-----------|----------------|
| **Admin Views** | Program/round management, project import, jury management, assignments, dashboards |
| **Jury Views** | View assigned projects, evaluate projects, track progress |
| **Auth Views** | Login, magic link verification, session management |
| **Layouts** | Responsive navigation, sidebar, mobile adaptations |
| **UI Components** | shadcn/ui based, reusable, accessible |
### API Layer
| Component | Responsibility |
|-----------|----------------|
| **tRPC Routers** | Type-safe API endpoints grouped by domain |
| **Auth Middleware** | Session validation via NextAuth.js |
| **RBAC Middleware** | Role-based access control enforcement |
| **Audit Logger** | Record all significant actions |
| **Validators** | Zod schemas for input validation |
### Service Layer
| Service | Responsibility |
|---------|----------------|
| **ProgramService** | CRUD for programs (e.g., "MOPC 2026") |
| **RoundService** | Round lifecycle, voting windows, form versions |
| **AssignmentService** | Jury-project assignments, load balancing |
| **EvaluationService** | Form submission, autosave, scoring |
| **FileService** | MinIO uploads, pre-signed URLs |
| **EmailService** | Magic links, notifications via Nodemailer |
| **ExportService** | CSV/Excel generation |
| **AuditService** | Immutable event logging |
### Data Layer
| Component | Responsibility |
|-----------|----------------|
| **Prisma Client** | Type-safe database queries |
| **PostgreSQL** | Primary data store (relational) |
| **MinIO** | S3-compatible file storage |
| **Migrations** | Schema versioning and evolution |
## Data Flow Examples
### 1. Jury Login Flow
```
User Next.js NextAuth PostgreSQL
│ │ │ │
│ 1. Enter email │ │ │
│───────────────────────>│ │ │
│ │ 2. Request magic link │ │
│ │───────────────────────>│ │
│ │ │ 3. Store token │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ │ 4. Send email │ │
│ │<───────────────────────│ │
│ 5. Email with link │ │ │
<───────────────────────│ │ │
│ │ │ │
│ 6. Click link │ │ │
│───────────────────────>│ │ │
│ │ 7. Verify token │ │
│ │───────────────────────>│ │
│ │ │ 8. Validate │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ │ 9. Create session │ │
│ │<───────────────────────│ │
│ 10. Redirect to dash │ │ │
<───────────────────────│ │ │
```
### 2. Jury Evaluation Flow
```
Jury Member Next.js/tRPC Service Layer PostgreSQL/MinIO
│ │ │ │
│ 1. View project list │ │ │
│───────────────────────>│ 2. project.listAssigned() │
│ │───────────────────────>│ 3. Query assignments │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ 4. Show assigned projects │ │
<───────────────────────│ │ │
│ │ │ │
│ 5. Open project │ │ │
│───────────────────────>│ 6. project.getDetails() │
│ │───────────────────────>│ 7. Get project + files│
│ │ │───────────────────────>│
│ │ │ 8. Generate pre-signed URLs
│ │ │<───────────────────────│
│ 9. Show project with file links │ │
<───────────────────────│ │ │
│ │ │ │
│ 10. Fill evaluation │ │ │
│ (typing...) │ 11. evaluation.autosave() (debounced) │
│───────────────────────>│───────────────────────>│ 12. Save draft │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ 13. Autosaved indicator │ │
<───────────────────────│ │ │
│ │ │ │
│ 14. Submit final │ │ │
│───────────────────────>│ 15. evaluation.submit() │
│ │───────────────────────>│ 16. Validate window │
│ │ │ 17. Lock evaluation │
│ │ │ 18. Log audit event │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ 19. Success, mark complete │ │
<───────────────────────│ │ │
```
### 3. Admin Export Flow
```
Admin Next.js/tRPC Service Layer PostgreSQL/MinIO
│ │ │ │
│ 1. Request CSV export │ │ │
│───────────────────────>│ 2. export.generateCSV() │
│ │───────────────────────>│ 3. Query all evals │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ │ │ 4. Build CSV │
│ │ │ 5. Upload to MinIO │
│ │ │───────────────────────>│
│ │ │<───────────────────────│
│ │ │ 6. Log audit event │
│ │ │───────────────────────>│
│ │ 7. Return download URL│ │
│ │<───────────────────────│ │
│ 8. Download file │ │ │
<───────────────────────│ │ │
```
## Security Architecture
### Authentication
- **Method**: Email magic links (passwordless)
- **Sessions**: JWT stored in HTTP-only cookies
- **Provider**: NextAuth.js with custom email provider
### Authorization (RBAC)
- **Enforcement**: tRPC middleware checks role before procedure
- **Granularity**: Role + resource-level (jury sees only assigned projects)
- **Storage**: User.role field in database
### Data Security
- **File Access**: Pre-signed URLs with short TTL (15 min)
- **SQL Injection**: Prevented by Prisma parameterized queries
- **XSS**: React's built-in escaping + CSP headers
- **CSRF**: NextAuth.js built-in protection
### Audit Trail
- **Coverage**: All admin actions, all state changes
- **Immutability**: Append-only audit_logs table
- **Fields**: user, action, entity, details, timestamp, IP
## Scalability Considerations
### Current Design (Phase 1)
- Single PostgreSQL instance (sufficient for ~200 concurrent users)
- Single Next.js instance behind Nginx
- MinIO for file storage (horizontally scalable)
### Future Scale Path
1. **Database**: Read replicas for dashboards, connection pooling (PgBouncer)
2. **Application**: Multiple Next.js instances behind load balancer
3. **Caching**: Redis for session storage and query caching
4. **CDN**: Static assets via CDN
5. **Background Jobs**: BullMQ for email queues, exports
## Smart Assignment System
The platform includes two assignment modes:
### 1. AI-Powered Assignment (GPT)
- Analyzes juror expertise and project tags
- Optimizes for balanced workload
- Respects organizational conflicts
- **Privacy**: All data is anonymized before sending to GPT
- Names → `JUROR_A`, `JUROR_B`
- Projects → `PROJECT_1`, `PROJECT_2`
- Organizations → `ORG_X`, `ORG_Y`
- Emails and personal details are never sent
### 2. Smart Algorithm (Rule-Based Fallback)
- Fully featured scoring algorithm
- No external API required
- Handles 200 projects × 50 jurors in < 1 second
- Deterministic results
**Scoring Formula**:
```
Score = (expertise_match × 40) + (load_balance × 25) +
(specialty_match × 20) + (diversity × 10) - (conflict × 100)
```
## Admin Settings Panel
Centralized configuration for:
- **AI Configuration**: Provider, API key, model, budget limits
- **Platform Branding**: Logo, colors, name
- **Email/SMTP**: Server, credentials, templates
- **File Storage**: MinIO endpoint, bucket, limits
- **Security**: Session duration, rate limits
- **Defaults**: Timezone, pagination, autosave interval
## User Roles (RBAC)
| Role | Permissions |
|------|------------|
| **SUPER_ADMIN** | Full system access, all programs, user management |
| **PROGRAM_ADMIN** | Manage specific programs, rounds, projects, jury |
| **JURY_MEMBER** | View assigned projects only, submit evaluations |
| **OBSERVER** | Read-only access to dashboards |
## Related Documentation
- [Database Design](./database.md) - Schema, relationships, indexes
- [API Design](./api.md) - tRPC routers, endpoints, auth flow
- [Infrastructure](./infrastructure.md) - Docker, Nginx, deployment
- [UI/UX Patterns](./ui.md) - Components, responsive design

1138
docs/architecture/api.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,701 @@
# MOPC Platform - Database Design
## Overview
The MOPC platform uses PostgreSQL as its primary database, accessed via Prisma ORM. The schema is designed for:
1. **Type Safety**: Prisma generates TypeScript types from the schema
2. **Extensibility**: JSON fields allow future attributes without migrations
3. **Auditability**: All significant changes are logged
4. **Performance**: Strategic indexes for common query patterns
## Entity Relationship Diagram
```
┌─────────────┐
│ Program │
│─────────────│
│ id │
│ name │
│ year │
│ status │
└──────┬──────┘
│ 1:N
┌─────────────┐ ┌─────────────────┐
│ Round │ │ EvaluationForm │
│─────────────│ │─────────────────│
│ id │◄──────►│ id │
│ programId │ 1:N │ roundId │
│ name │ │ version │
│ status │ │ criteriaJson │
│ votingStart │ │ scalesJson │
│ votingEnd │ └─────────────────┘
│ settings │
└──────┬──────┘
│ 1:N
┌─────────────┐ ┌─────────────────┐
│ Project │ │ ProjectFile │
│─────────────│ │─────────────────│
│ id │◄──────►│ id │
│ roundId │ 1:N │ projectId │
│ title │ │ fileType │
│ teamName │ │ bucket │
│ description │ │ objectKey │
│ status │ │ mimeType │
│ tags[] │ │ size │
│ metadata │ └─────────────────┘
└──────┬──────┘
│ N:M (via Assignment)
┌──────┴──────┐
│ Assignment │
│─────────────│
│ id │
│ userId │◄─────────┐
│ projectId │ │
│ roundId │ │
│ method │ │
│ completed │ │
└──────┬──────┘ │
│ 1:1 │
▼ │
┌─────────────┐ ┌─────┴───────┐
│ Evaluation │ │ User │
│─────────────│ │─────────────│
│ id │ │ id │
│ assignmentId│ │ email │
│ status │ │ name │
│ scores │ │ role │
│ globalScore │ │ status │
│ decision │ │ expertise[] │
│ feedback │ │ metadata │
└─────────────┘ └─────────────┘
┌─────────────┐
│ AuditLog │
│─────────────│
│ id │
│ userId │
│ action │
│ entityType │
│ entityId │
│ details │
│ ipAddress │
│ timestamp │
└─────────────┘
┌─────────────────────┐
│ SystemSettings │
│─────────────────────│
│ id │
│ key │
│ value │
│ type │
│ category │
│ description │
│ updatedAt │
│ updatedBy │
└─────────────────────┘
┌─────────────────────┐
│ GracePeriod │
│─────────────────────│
│ id │
│ roundId │
│ userId │
│ projectId (opt) │
│ extendedUntil │
│ reason │
│ grantedBy │
│ createdAt │
└─────────────────────┘
```
## Prisma Schema
```prisma
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =============================================================================
// ENUMS
// =============================================================================
enum UserRole {
SUPER_ADMIN
PROGRAM_ADMIN
JURY_MEMBER
OBSERVER
}
enum UserStatus {
INVITED
ACTIVE
SUSPENDED
}
enum ProgramStatus {
DRAFT
ACTIVE
ARCHIVED
}
enum RoundStatus {
DRAFT
ACTIVE
CLOSED
ARCHIVED
}
enum ProjectStatus {
SUBMITTED
ELIGIBLE
ASSIGNED
SEMIFINALIST
FINALIST
REJECTED
}
enum EvaluationStatus {
NOT_STARTED
DRAFT
SUBMITTED
LOCKED
}
enum AssignmentMethod {
MANUAL
AUTO
BULK
}
enum FileType {
EXEC_SUMMARY
PRESENTATION
VIDEO
OTHER
}
// =============================================================================
// USERS & AUTHENTICATION
// =============================================================================
model User {
id String @id @default(cuid())
email String @unique
name String?
role UserRole @default(JURY_MEMBER)
status UserStatus @default(INVITED)
expertiseTags String[] @default([])
metadataJson Json? @db.JsonB
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
// Relations
assignments Assignment[]
auditLogs AuditLog[]
// Indexes
@@index([email])
@@index([role])
@@index([status])
}
// NextAuth.js required models
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// =============================================================================
// PROGRAMS & ROUNDS
// =============================================================================
model Program {
id String @id @default(cuid())
name String // e.g., "Monaco Ocean Protection Challenge"
year Int // e.g., 2026
status ProgramStatus @default(DRAFT)
description String?
settingsJson Json? @db.JsonB
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
rounds Round[]
// Indexes
@@unique([name, year])
@@index([status])
}
model Round {
id String @id @default(cuid())
programId String
name String // e.g., "Round 1 - Semi-Finalists"
status RoundStatus @default(DRAFT)
// Voting window
votingStartAt DateTime?
votingEndAt DateTime?
// Configuration
requiredReviews Int @default(3) // Min evaluations per project
settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
projects Project[]
assignments Assignment[]
evaluationForms EvaluationForm[]
// Indexes
@@index([programId])
@@index([status])
@@index([votingStartAt, votingEndAt])
}
model EvaluationForm {
id String @id @default(cuid())
roundId String
version Int @default(1)
// Form configuration
criteriaJson Json @db.JsonB // Array of criteria with labels, scales
scalesJson Json? @db.JsonB // Scale definitions (1-5, 1-10, etc.)
isActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
evaluations Evaluation[]
// Indexes
@@unique([roundId, version])
@@index([roundId, isActive])
}
// =============================================================================
// PROJECTS
// =============================================================================
model Project {
id String @id @default(cuid())
roundId String
// Core fields
title String
teamName String?
description String? @db.Text
status ProjectStatus @default(SUBMITTED)
// Flexible fields
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
files ProjectFile[]
assignments Assignment[]
// Indexes
@@index([roundId])
@@index([status])
@@index([tags])
}
model ProjectFile {
id String @id @default(cuid())
projectId String
// File info
fileType FileType
fileName String
mimeType String
size Int // bytes
// MinIO location
bucket String
objectKey String
createdAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
// Indexes
@@index([projectId])
@@index([fileType])
@@unique([bucket, objectKey])
}
// =============================================================================
// ASSIGNMENTS & EVALUATIONS
// =============================================================================
model Assignment {
id String @id @default(cuid())
userId String
projectId String
roundId String
// Assignment info
method AssignmentMethod @default(MANUAL)
isRequired Boolean @default(true)
isCompleted Boolean @default(false)
createdAt DateTime @default(now())
createdBy String? // Admin who created the assignment
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
evaluation Evaluation?
// Constraints
@@unique([userId, projectId, roundId])
// Indexes
@@index([userId])
@@index([projectId])
@@index([roundId])
@@index([isCompleted])
}
model Evaluation {
id String @id @default(cuid())
assignmentId String @unique
formId String
// Status
status EvaluationStatus @default(NOT_STARTED)
// Scores
criterionScoresJson Json? @db.JsonB // { "criterion1": 4, "criterion2": 5 }
globalScore Int? // 1-10
binaryDecision Boolean? // Yes/No for semi-finalist
feedbackText String? @db.Text
// Versioning
version Int @default(1)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submittedAt DateTime?
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
form EvaluationForm @relation(fields: [formId], references: [id])
// Indexes
@@index([status])
@@index([submittedAt])
}
// =============================================================================
// AUDIT LOGGING
// =============================================================================
model AuditLog {
id String @id @default(cuid())
userId String?
// Event info
action String // "CREATE", "UPDATE", "DELETE", "LOGIN", "EXPORT", etc.
entityType String // "Round", "Project", "Evaluation", etc.
entityId String?
// Details
detailsJson Json? @db.JsonB // Before/after values, additional context
// Request info
ipAddress String?
userAgent String?
timestamp DateTime @default(now())
// Relations
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
// Indexes
@@index([userId])
@@index([action])
@@index([entityType, entityId])
@@index([timestamp])
}
```
## Indexing Strategy
### Primary Indexes (Automatic)
- All `@id` fields have primary key indexes
- All `@unique` constraints have unique indexes
### Query-Optimized Indexes
| Table | Index | Purpose |
|-------|-------|---------|
| User | `email` | Login lookup |
| User | `role` | Filter by role |
| User | `status` | Filter active users |
| Program | `status` | List active programs |
| Round | `programId` | Get rounds for program |
| Round | `status` | Filter active rounds |
| Round | `votingStartAt, votingEndAt` | Check voting window |
| Project | `roundId` | Get projects in round |
| Project | `status` | Filter by status |
| Project | `tags` | Filter by tag (GIN index) |
| Assignment | `userId` | Get user's assignments |
| Assignment | `projectId` | Get project's reviewers |
| Assignment | `roundId` | Get all assignments for round |
| Assignment | `isCompleted` | Track progress |
| Evaluation | `status` | Filter by completion |
| Evaluation | `submittedAt` | Sort by submission time |
| AuditLog | `timestamp` | Time-based queries |
| AuditLog | `entityType, entityId` | Entity history |
### JSON Field Indexes
For PostgreSQL JSONB fields, we can add GIN indexes for complex queries:
```sql
-- Add via migration if needed
CREATE INDEX idx_project_metadata ON "Project" USING GIN ("metadataJson");
CREATE INDEX idx_evaluation_scores ON "Evaluation" USING GIN ("criterionScoresJson");
```
## Migration Strategy
### Development
```bash
# Push schema changes directly (no migration files)
npx prisma db push
# Generate client after schema changes
npx prisma generate
```
### Production
```bash
# Create migration from schema changes
npx prisma migrate dev --name description_of_change
# Apply migrations in production
npx prisma migrate deploy
```
### Migration Best Practices
1. **Never** use `db push` in production
2. **Always** backup before migrations
3. **Test** migrations on staging first
4. **Review** generated SQL before applying
5. **Keep** migrations small and focused
## Data Seeding
```typescript
// prisma/seed.ts
import { PrismaClient, UserRole, ProgramStatus, RoundStatus } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
// Create super admin
const admin = await prisma.user.upsert({
where: { email: 'admin@mopc.org' },
update: {},
create: {
email: 'admin@mopc.org',
name: 'System Admin',
role: UserRole.SUPER_ADMIN,
status: 'ACTIVE',
},
})
// Create sample program
const program = await prisma.program.upsert({
where: { name_year: { name: 'Monaco Ocean Protection Challenge', year: 2026 } },
update: {},
create: {
name: 'Monaco Ocean Protection Challenge',
year: 2026,
status: ProgramStatus.ACTIVE,
description: 'Annual ocean conservation startup competition',
},
})
// Create Round 1
const round1 = await prisma.round.create({
data: {
programId: program.id,
name: 'Round 1 - Semi-Finalists Selection',
status: RoundStatus.DRAFT,
requiredReviews: 3,
votingStartAt: new Date('2026-02-18'),
votingEndAt: new Date('2026-02-23'),
},
})
// Create evaluation form for Round 1
await prisma.evaluationForm.create({
data: {
roundId: round1.id,
version: 1,
isActive: true,
criteriaJson: [
{ id: 'need_clarity', label: 'Need clarity', scale: '1-5', weight: 1 },
{ id: 'solution_relevance', label: 'Solution relevance', scale: '1-5', weight: 1 },
{ id: 'gap_analysis', label: 'Gap analysis', scale: '1-5', weight: 1 },
{ id: 'target_customers', label: 'Target customers clarity', scale: '1-5', weight: 1 },
{ id: 'ocean_impact', label: 'Ocean impact', scale: '1-5', weight: 1 },
],
scalesJson: {
'1-5': { min: 1, max: 5, labels: { 1: 'Poor', 3: 'Average', 5: 'Excellent' } },
'1-10': { min: 1, max: 10, labels: { 1: 'Poor', 5: 'Average', 10: 'Excellent' } },
},
},
})
console.log('Seed completed')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
```
Run seed:
```bash
npx prisma db seed
```
## Query Patterns
### Get Jury's Assigned Projects
```typescript
const assignments = await prisma.assignment.findMany({
where: {
userId: currentUser.id,
round: {
status: 'ACTIVE',
},
},
include: {
project: {
include: {
files: true,
},
},
evaluation: true,
},
})
```
### Get Project Evaluation Stats
```typescript
const stats = await prisma.evaluation.groupBy({
by: ['status'],
where: {
assignment: {
projectId: projectId,
},
},
_count: true,
})
```
### Check Voting Window
```typescript
const isVotingOpen = await prisma.round.findFirst({
where: {
id: roundId,
status: 'ACTIVE',
votingStartAt: { lte: new Date() },
votingEndAt: { gte: new Date() },
},
})
```
## Backup Strategy
### Automated Backups
```bash
# Daily backup cron job
0 2 * * * pg_dump -h localhost -U mopc -d mopc -F c -f /backups/mopc_$(date +\%Y\%m\%d).dump
# Keep last 30 days
find /backups -name "mopc_*.dump" -mtime +30 -delete
```
### Manual Backup
```bash
docker exec -t mopc-postgres pg_dump -U mopc mopc > backup.sql
```
### Restore
```bash
docker exec -i mopc-postgres psql -U mopc mopc < backup.sql
```
## Related Documentation
- [API Design](./api.md) - How the database is accessed via tRPC
- [Infrastructure](./infrastructure.md) - PostgreSQL deployment

View File

@ -0,0 +1,651 @@
# MOPC Platform - Infrastructure
## Overview
The MOPC platform is self-hosted on a Linux VPS at **monaco-opc.com** with the following architecture:
- **Nginx** (host-level) - Reverse proxy with SSL termination
- **Docker Compose** (MOPC stack) - Next.js + PostgreSQL
- **MinIO** (separate stack) - S3-compatible file storage
- **Poste.io** (separate stack) - Self-hosted email server
**Key Configurations:**
- Max file size: 500MB (for video uploads)
- SSL via Certbot (Let's Encrypt)
## Architecture Diagram
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ INTERNET │
└─────────────────────────────────┬────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ LINUX VPS │
├──────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ NGINX (Host Level) │ │
│ │ │ │
│ │ - SSL termination via Certbot │ │
│ │ - Reverse proxy to Docker services │ │
│ │ - Rate limiting │ │
│ │ - Security headers │ │
│ │ │ │
│ │ Ports: 80 (HTTP → HTTPS redirect), 443 (HTTPS) │ │
│ └─────────────────────────────────┬──────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────┼───────────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ MOPC Stack │ │ MinIO Stack │ │ Poste.io Stack │ │
│ │ (Docker Compose)│ │ (Docker Compose)│ │ (Docker Compose)│ │
│ │ │ │ │ │ │ │
│ │ ┌────────────┐ │ │ ┌────────────┐ │ │ ┌────────────┐ │ │
│ │ │ Next.js │ │ │ │ MinIO │ │ │ │ Poste.io │ │ │
│ │ │ :3000 │ │ │ │ :9000 │ │ │ │ :25,587 │ │ │
│ │ └────────────┘ │ │ │ :9001 │ │ │ └────────────┘ │ │
│ │ │ │ └────────────┘ │ │ │ │
│ │ ┌────────────┐ │ │ │ │ │ │
│ │ │ PostgreSQL │ │ │ │ │ │ │
│ │ │ :5432 │ │ │ │ │ │ │
│ │ └────────────┘ │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │
│ Data Volumes: │
│ /data/mopc/postgres /data/minio /data/poste │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
```
## Docker Compose Configuration
### MOPC Stack
```yaml
# docker/docker-compose.yml
version: '3.8'
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
container_name: mopc-app
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET=${MINIO_BUCKET}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
depends_on:
postgres:
condition: service_healthy
networks:
- mopc-network
postgres:
image: postgres:16-alpine
container_name: mopc-postgres
restart: unless-stopped
environment:
- POSTGRES_USER=mopc
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=mopc
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mopc"]
interval: 10s
timeout: 5s
retries: 5
networks:
- mopc-network
volumes:
postgres_data:
driver: local
driver_opts:
type: none
o: bind
device: /data/mopc/postgres
networks:
mopc-network:
driver: bridge
```
### Development Stack
The development stack includes PostgreSQL, MinIO, and the Next.js app running in Docker containers.
```yaml
# docker/docker-compose.dev.yml
services:
postgres:
image: postgres:16-alpine
container_name: mopc-postgres-dev
ports:
- "5432:5432"
environment:
- POSTGRES_USER=mopc
- POSTGRES_PASSWORD=devpassword
- POSTGRES_DB=mopc
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mopc"]
interval: 5s
timeout: 5s
retries: 5
minio:
image: minio/minio
container_name: mopc-minio-dev
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
volumes:
- minio_dev_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
# MinIO client to create default bucket on startup
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set myminio http://minio:9000 minioadmin minioadmin;
mc mb --ignore-existing myminio/mopc-files;
mc anonymous set download myminio/mopc-files;
echo 'Bucket created successfully';
"
# Next.js application
app:
build:
context: ..
dockerfile: docker/Dockerfile.dev
container_name: mopc-app-dev
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://mopc:devpassword@postgres:5432/mopc
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=dev-secret-key-for-local-development-only
- MINIO_ENDPOINT=http://minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_BUCKET=mopc-files
- NODE_ENV=development
volumes:
- ../src:/app/src
- ../public:/app/public
- ../prisma:/app/prisma
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
volumes:
postgres_dev_data:
minio_dev_data:
```
### Quick Start (Development)
```bash
# 1. Start all services (PostgreSQL, MinIO, Next.js)
docker compose -f docker/docker-compose.dev.yml up --build -d
# 2. Push database schema
docker exec mopc-app-dev npx prisma db push
# 3. Seed test data
docker exec mopc-app-dev npx tsx prisma/seed.ts
# 4. Open http://localhost:3000
# Login with: admin@monaco-opc.com (magic link)
```
### Development URLs
| Service | URL | Credentials |
|---------|-----|-------------|
| Next.js App | http://localhost:3000 | See seed data |
| MinIO Console | http://localhost:9001 | minioadmin / minioadmin |
| PostgreSQL | localhost:5432 | mopc / devpassword |
### Test Accounts (after seeding)
| Role | Email |
|------|-------|
| Super Admin | admin@monaco-opc.com |
| Jury Member | jury1@example.com |
| Jury Member | jury2@example.com |
| Jury Member | jury3@example.com |
## Dockerfile
```dockerfile
# docker/Dockerfile
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build Next.js
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Production image
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
```
## Nginx Configuration
```nginx
# /etc/nginx/sites-available/mopc-platform
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=mopc_limit:10m rate=10r/s;
# MOPC Platform
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name monaco-opc.com;
# SSL certificates (managed by Certbot)
ssl_certificate /etc/letsencrypt/live/monaco-opc.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/monaco-opc.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self';" always;
# File upload size (500MB for videos)
client_max_body_size 500M;
# Rate limiting
limit_req zone=mopc_limit burst=20 nodelay;
# Logging
access_log /var/log/nginx/mopc-access.log;
error_log /var/log/nginx/mopc-error.log;
# Next.js application
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
# Timeouts for large file uploads
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
# Static files caching
location /_next/static {
proxy_pass http://127.0.0.1:3000;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Health check endpoint
location /api/health {
proxy_pass http://127.0.0.1:3000;
access_log off;
}
}
# HTTP to HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name monaco-opc.com;
return 301 https://$host$request_uri;
}
```
## SSL Setup with Certbot
```bash
# Install Certbot
sudo apt update
sudo apt install certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d monaco-opc.com
# Auto-renewal is configured automatically
# Test renewal
sudo certbot renew --dry-run
```
## Environment Variables
### Production (.env)
```env
# Application
NODE_ENV=production
NEXTAUTH_URL=https://monaco-opc.com
NEXTAUTH_SECRET=generate-a-secure-random-string-here
# Database
DB_PASSWORD=your-secure-database-password
DATABASE_URL=postgresql://mopc:${DB_PASSWORD}@postgres:5432/mopc
# MinIO (external stack)
MINIO_ENDPOINT=http://localhost:9000
MINIO_ACCESS_KEY=your-minio-access-key
MINIO_SECRET_KEY=your-minio-secret-key
MINIO_BUCKET=mopc-files
# Email (Poste.io)
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=noreply@monaco-opc.com
SMTP_PASS=your-smtp-password
EMAIL_FROM=MOPC Platform <noreply@monaco-opc.com>
```
### Generate Secrets
```bash
# Generate NEXTAUTH_SECRET
openssl rand -base64 32
# Generate DB_PASSWORD
openssl rand -base64 24
```
## Deployment Commands
### Initial Deployment
```bash
# 1. Clone repository
git clone https://github.com/your-org/mopc-platform.git /opt/mopc
cd /opt/mopc
# 2. Create environment file
cp .env.example .env
nano .env # Edit with production values
# 3. Create data directories
sudo mkdir -p /data/mopc/postgres
sudo chown -R 1000:1000 /data/mopc
# 4. Start the stack
cd docker
docker compose up -d
# 5. Run database migrations
docker compose exec app npx prisma migrate deploy
# 6. Seed initial data (optional)
docker compose exec app npx prisma db seed
# 7. Enable Nginx site
sudo ln -s /etc/nginx/sites-available/mopc-platform /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
# 8. Set up SSL
sudo certbot --nginx -d monaco-opc.com
```
### Updates
```bash
cd /opt/mopc
# 1. Pull latest code
git pull origin main
# 2. Rebuild and restart
cd docker
docker compose build app
docker compose up -d app
# 3. Run any new migrations
docker compose exec app npx prisma migrate deploy
```
### Rollback
```bash
# Revert to previous image
docker compose down
git checkout HEAD~1
docker compose build app
docker compose up -d
# Or restore from specific tag
git checkout v1.0.0
docker compose build app
docker compose up -d
```
## Backup Strategy
### Database Backups
```bash
# Create backup script
cat > /opt/mopc/scripts/backup-db.sh << 'EOF'
#!/bin/bash
BACKUP_DIR=/data/backups/mopc
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE=$BACKUP_DIR/mopc_$DATE.sql.gz
mkdir -p $BACKUP_DIR
docker exec mopc-postgres pg_dump -U mopc mopc | gzip > $BACKUP_FILE
# Keep last 30 days
find $BACKUP_DIR -name "mopc_*.sql.gz" -mtime +30 -delete
echo "Backup completed: $BACKUP_FILE"
EOF
chmod +x /opt/mopc/scripts/backup-db.sh
# Add to crontab (daily at 2 AM)
echo "0 2 * * * /opt/mopc/scripts/backup-db.sh >> /var/log/mopc-backup.log 2>&1" | sudo tee -a /etc/cron.d/mopc-backup
```
### Restore Database
```bash
# Restore from backup
gunzip < /data/backups/mopc/mopc_20260115_020000.sql.gz | docker exec -i mopc-postgres psql -U mopc mopc
```
## Monitoring
### Health Check Endpoint
```typescript
// src/app/api/health/route.ts
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
// Check database connection
await prisma.$queryRaw`SELECT 1`
return Response.json({
status: 'healthy',
timestamp: new Date().toISOString(),
})
} catch (error) {
return Response.json(
{
status: 'unhealthy',
error: 'Database connection failed',
},
{ status: 503 }
)
}
}
```
### Log Viewing
```bash
# Application logs
docker compose logs -f app
# Nginx access logs
tail -f /var/log/nginx/mopc-access.log
# Nginx error logs
tail -f /var/log/nginx/mopc-error.log
# PostgreSQL logs
docker compose logs -f postgres
```
### Resource Monitoring
```bash
# Docker stats
docker stats mopc-app mopc-postgres
# System resources
htop
```
## Security Checklist
- [ ] SSL certificate active and auto-renewing
- [ ] Database password is strong and unique
- [ ] NEXTAUTH_SECRET is randomly generated
- [ ] MinIO credentials are secure
- [ ] SMTP credentials are secure
- [ ] Firewall allows only ports 80, 443, 22
- [ ] Docker daemon not exposed to network
- [ ] Regular backups configured
- [ ] Log rotation configured
- [ ] Security headers enabled in Nginx
## Troubleshooting
### Application Won't Start
```bash
# Check logs
docker compose logs app
# Check if database is ready
docker compose exec postgres pg_isready -U mopc
# Restart stack
docker compose restart
```
### Database Connection Issues
```bash
# Test connection from app container
docker compose exec app sh
nc -zv postgres 5432
# Check PostgreSQL logs
docker compose logs postgres
```
### SSL Certificate Issues
```bash
# Test certificate
sudo certbot certificates
# Force renewal
sudo certbot renew --force-renewal
# Check Nginx configuration
sudo nginx -t
```
## Related Documentation
- [Database Design](./database.md) - Schema and migrations
- [API Design](./api.md) - tRPC endpoints

749
docs/architecture/ui.md Normal file
View File

@ -0,0 +1,749 @@
# MOPC Platform - UI/UX Architecture
## Overview
The MOPC platform uses a mobile-first responsive design built with:
- **Next.js App Router** - Server Components by default
- **shadcn/ui** - Accessible, customizable component library
- **Tailwind CSS** - Utility-first styling
- **Radix UI** - Headless accessible primitives
- **Motion** (Framer Motion) - Buttery smooth animations
- **Vaul** - Native-feeling mobile drawers
- **Sonner** - Beautiful toast notifications
- **cmdk** - Command palette (⌘K)
## Design Philosophy
**CRITICAL: Avoid "AI-built" aesthetic. Platform must look professionally designed.**
### What to AVOID (typical AI-generated look)
| Don't | Why | Instead |
|-------|-----|---------|
| Cards everywhere | Generic, lazy layout | Use varied layouts: tables, lists, grids, hero sections |
| Same border-radius on everything | Monotonous | Vary: sharp corners for data, rounded for actions |
| Identical padding/spacing | Robotic feel | Use intentional rhythm: tight for data, generous for CTAs |
| Blue/purple gradients | Screams "AI template" | Use brand colors with restraint |
| Stock icons everywhere | Impersonal | Custom icons or carefully curated set |
| Centered everything | No visual hierarchy | Left-align content, strategic centering |
| Gray backgrounds | Dull, corporate | Subtle off-white textures, strategic white space |
| "Dashboard" with 6 equal cards | The #1 AI cliché | Prioritize: hero metric, then supporting data |
### What TO DO (professional design)
| Do | Why | Example |
|----|-----|---------|
| Visual hierarchy | Guides the eye | Large numbers for KPIs, smaller for details |
| Intentional white space | Breathability | 32-48px between sections, not uniform 16px |
| Typography scale | Professional rhythm | 12/14/16/20/24/32/48px - skip sizes intentionally |
| Micro-interactions | Delight users | Button hover states, loading skeletons |
| Consistent but varied | Not monotonous | Same colors, different layouts per page |
| Data density where needed | Efficient | Tables for lists, not cards |
| Strategic color accents | Draw attention | Red only for primary CTAs, not decoration |
| Real content sizes | Accommodate reality | Long project names, international characters |
## Brand Colors
```css
:root {
/* Brand Colors */
--color-primary: #de0f1e; /* Primary Red - CTAs, alerts */
--color-primary-hover: #c00d1a;
--color-secondary: #053d57; /* Dark Blue - headers, sidebar */
--color-accent: #557f8c; /* Teal - links, secondary elements */
--color-background: #fefefe; /* White - backgrounds */
/* Semantic */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
/* Neutrals (warm, not cold gray) */
--color-gray-50: #fafaf9;
--color-gray-100: #f5f5f4;
--color-gray-200: #e7e5e4;
--color-gray-500: #78716c;
--color-gray-900: #1c1917;
}
```
## Typography
- **Font Family**: Montserrat
- **Headings**: 600/700 weight
- **Body**: 300/400 weight (Montserrat Light)
```css
:root {
--font-family: 'Montserrat', system-ui, sans-serif;
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-base: 1rem; /* 16px */
--font-size-lg: 1.25rem; /* 20px */
--font-size-xl: 1.5rem; /* 24px */
--font-size-2xl: 2rem; /* 32px */
--font-size-3xl: 3rem; /* 48px */
}
```
## Design Principles
1. **Mobile First**: Base styles for mobile, enhanced for larger screens
2. **Accessibility**: WCAG 2.1 AA compliance, keyboard navigation, screen reader support
3. **Performance**: Server Components, minimal client JavaScript
4. **Consistency**: Design tokens, component library, consistent patterns
5. **Feedback**: Loading states, error messages, success confirmations
## Responsive Breakpoints
```css
/* Tailwind CSS default breakpoints */
sm: 640px /* Small tablets */
md: 768px /* Tablets */
lg: 1024px /* Laptops */
xl: 1280px /* Desktops */
2xl: 1536px /* Large monitors */
```
## Layout Architecture
### Application Shell
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ DESKTOP LAYOUT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌──────────────────────────────────────────────────────┐ │
│ │ │ │ HEADER │ │
│ │ │ │ Logo Search (optional) User Menu │ │
│ │ │ └──────────────────────────────────────────────────────┘ │
│ │ │ ┌──────────────────────────────────────────────────────┐ │
│ │ SIDEBAR │ │ │ │
│ │ │ │ │ │
│ │ - Dashboard│ │ MAIN CONTENT │ │
│ │ - Rounds │ │ │ │
│ │ - Projects │ │ │ │
│ │ - Jury │ │ │ │
│ │ - Reports │ │ │ │
│ │ │ │ │ │
│ │ │ │ │ │
│ └─────────────┘ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ MOBILE LAYOUT │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ ☰ Logo User Avatar │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ MAIN CONTENT │ │
│ │ (full width) │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 🏠 📋 📊 👤 │ │
│ │ Home Projects Reports Profile │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Layout Components
```typescript
// src/components/layouts/app-layout.tsx
import { Sidebar } from './sidebar'
import { Header } from './header'
import { MobileNav } from './mobile-nav'
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-background">
{/* Desktop Sidebar */}
<Sidebar className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64" />
{/* Main Content Area */}
<div className="lg:pl-64">
<Header />
<main className="p-4 lg:p-8">{children}</main>
</div>
{/* Mobile Bottom Navigation */}
<MobileNav className="fixed bottom-0 left-0 right-0 lg:hidden" />
</div>
)
}
```
## Component Hierarchy
```
src/components/
├── ui/ # shadcn/ui base components
│ ├── button.tsx
│ ├── input.tsx
│ ├── card.tsx
│ ├── table.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── form.tsx
│ ├── select.tsx
│ ├── textarea.tsx
│ ├── badge.tsx
│ ├── progress.tsx
│ ├── skeleton.tsx
│ ├── toast.tsx
│ └── ...
├── layouts/ # Layout components
│ ├── app-layout.tsx
│ ├── auth-layout.tsx
│ ├── sidebar.tsx
│ ├── header.tsx
│ └── mobile-nav.tsx
├── forms/ # Form components
│ ├── evaluation-form.tsx
│ ├── project-import-form.tsx
│ ├── round-settings-form.tsx
│ └── user-invite-form.tsx
├── data-display/ # Data display components
│ ├── project-card.tsx
│ ├── project-list.tsx
│ ├── project-table.tsx
│ ├── evaluation-summary.tsx
│ ├── progress-tracker.tsx
│ └── stats-card.tsx
└── shared/ # Shared utility components
├── file-viewer.tsx
├── loading-state.tsx
├── error-state.tsx
├── empty-state.tsx
└── confirm-dialog.tsx
```
## Page Layouts by View
### Admin Dashboard
```
┌─────────────────────────────────────────────────────────────────┐
│ ADMIN DASHBOARD │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
│ │ Projects │ │ Evaluations │ │ Jury Active │ │ Time Left │ │
│ │ 130 │ │ 234/390 │ │ 12/15 │ │ 5 days │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └───────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ PROGRESS BY PROJECT │ │
│ │ ████████████████████████████░░░░░░░░░░░░ 60% Complete │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ ┌────────────────────────────┐ │
│ │ JURY PROGRESS │ │ RECENT ACTIVITY │ │
│ │ │ │ │ │
│ │ Alice ████████░░ 80% │ │ • John submitted eval... │ │
│ │ Bob ██████░░░░ 60% │ │ • Sarah started eval... │ │
│ │ Carol ████░░░░░░ 40% │ │ • Admin extended window │ │
│ │ ... │ │ • ... │ │
│ └─────────────────────────────┘ └────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Mobile: Stats stack vertically, Progress & Activity in tabs
```
### Jury Project List
```
DESKTOP:
┌─────────────────────────────────────────────────────────────────┐
│ MY ASSIGNED PROJECTS Filter ▼ Search │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Title │ Team │ Status │ Actions │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Ocean Cleanup AI │ BlueWave │ ✅ Done │ View │ │
│ │ Coral Restoration │ ReefGuard │ 📝 Draft │ Continue │ │
│ │ Plastic Tracker │ CleanSeas │ ⏳ Pending│ Start │ │
│ │ ... │ │ │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ Showing 1-10 of 15 < 1 2 >
└─────────────────────────────────────────────────────────────────┘
MOBILE (Card View):
┌─────────────────────────────────────┐
│ MY PROJECTS (15) 🔍 Filter │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐│
│ │ Ocean Cleanup AI ││
│ │ Team: BlueWave ││
│ │ ✅ Completed ││
│ │ [View →] ││
│ └─────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────┐│
│ │ Coral Restoration ││
│ │ Team: ReefGuard ││
│ │ 📝 Draft saved ││
│ │ [Continue →] ││
│ └─────────────────────────────────┘│
│ │
│ ┌─────────────────────────────────┐│
│ │ Plastic Tracker ││
│ │ Team: CleanSeas ││
│ │ ⏳ Not started ││
│ │ [Start →] ││
│ └─────────────────────────────────┘│
│ │
└─────────────────────────────────────┘
```
### Evaluation Form
```
DESKTOP (Side Panel):
┌─────────────────────────────────────────────────────────────────┐
│ PROJECT DETAILS │ EVALUATION FORM │
├─────────────────────────────────────────────────────────────────┤
│ │ │
│ Ocean Cleanup AI │ Need Clarity │
│ Team: BlueWave Tech │ ○ 1 ○ 2 ○ 3 ● 4 ○ 5 │
│ │ │
│ [📄 Exec Summary] [📊 Deck] │ Solution Relevance │
│ [🎬 Video] │ ○ 1 ○ 2 ● 3 ○ 4 ○ 5 │
│ │ │
│ Description: │ Gap Analysis │
│ Our AI-powered system uses │ ○ 1 ○ 2 ○ 3 ○ 4 ● 5 │
│ machine learning to identify │ │
│ ocean plastic concentrations... │ Target Customers │
│ │ ○ 1 ○ 2 ○ 3 ● 4 ○ 5 │
│ Tags: AI, Plastic, Monitoring │ │
│ │ Ocean Impact │
│ │ ○ 1 ○ 2 ○ 3 ○ 4 ● 5 │
│ │ │
│ │ Global Score (1-10) │
│ │ [ 8 ] │
│ │ │
│ │ Semi-finalist? │
│ │ (●) Yes ( ) No │
│ │ │
│ │ Feedback │
│ │ ┌────────────────────┐ │
│ │ │ Strong technical │ │
│ │ │ approach with... │ │
│ │ └────────────────────┘ │
│ │ │
│ │ Autosaved 2s ago │
│ │ [Submit Evaluation] │
│ │ │
└─────────────────────────────────────────────────────────────────┘
MOBILE (Full Screen Wizard):
┌─────────────────────────────────────┐
│ ← Ocean Cleanup AI Step 3/7 │
├─────────────────────────────────────┤
│ │
│ Gap Analysis │
│ │
│ How well does the project │
│ analyze market gaps? │
│ │
│ ┌─────────────────────────────────┐│
│ │ ││
│ │ 1 2 3 4 5 ││
│ │ (○) (○) (○) (○) (●) ││
│ │ Poor Excellent ││
│ │ ││
│ └─────────────────────────────────┘│
│ │
│ │
│ │
│ │
│ │
│ ┌─────────────────────────────────┐│
│ │ ○ ○ ● ○ ○ ○ ○ ││
│ └─────────────────────────────────┘│
│ │
│ [← Previous] [Next →] │
│ │
└─────────────────────────────────────┘
```
## Design System
### Color Palette (MOPC Brand)
```css
/* CSS Variables in tailwind.config.ts - MOPC Brand Colors */
:root {
/* Brand Colors */
--color-primary: 354 90% 47%; /* #de0f1e - Primary Red */
--color-secondary: 198 85% 18%; /* #053d57 - Dark Blue */
--color-accent: 194 25% 44%; /* #557f8c - Teal */
/* shadcn/ui mapped to MOPC brand */
--background: 0 0% 100%; /* #fefefe */
--foreground: 198 85% 18%; /* Dark Blue for text */
--card: 0 0% 100%;
--card-foreground: 198 85% 18%;
--popover: 0 0% 100%;
--popover-foreground: 198 85% 18%;
--primary: 354 90% 47%; /* Primary Red - main actions */
--primary-foreground: 0 0% 100%;
--secondary: 30 6% 96%; /* Warm gray */
--secondary-foreground: 198 85% 18%;
--muted: 30 6% 96%;
--muted-foreground: 30 8% 45%;
--accent: 194 25% 44%; /* Teal */
--accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 100%;
--border: 30 6% 91%;
--input: 30 6% 91%;
--ring: 354 90% 47%; /* Primary Red for focus */
--radius: 0.5rem;
/* Semantic colors */
--success: 142.1 76.2% 36.3%;
--warning: 38 92% 50%;
--info: 194 25% 44%; /* Teal */
}
```
### Typography (Montserrat)
```typescript
// tailwind.config.ts
const config = {
theme: {
extend: {
fontFamily: {
sans: ['Montserrat', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
fontWeight: {
light: '300',
normal: '400',
semibold: '600',
bold: '700',
},
fontSize: {
'display-lg': ['3rem', { lineHeight: '1.1', fontWeight: '700' }],
'display': ['2.25rem', { lineHeight: '1.2', fontWeight: '700' }],
'heading': ['1.5rem', { lineHeight: '1.3', fontWeight: '600' }],
'subheading': ['1.125rem', { lineHeight: '1.4', fontWeight: '600' }],
'body': ['1rem', { lineHeight: '1.5', fontWeight: '400' }],
'small': ['0.875rem', { lineHeight: '1.5', fontWeight: '400' }],
'tiny': ['0.75rem', { lineHeight: '1.5', fontWeight: '400' }],
},
},
},
}
```
### Spacing System
```
Base unit: 4px
0 = 0px
1 = 4px
2 = 8px
3 = 12px
4 = 16px
5 = 20px
6 = 24px
8 = 32px
10 = 40px
12 = 48px
16 = 64px
20 = 80px
24 = 96px
```
## Component Patterns
### Loading States
```typescript
// Always show skeleton while loading
function ProjectList() {
const { data, isLoading } = trpc.project.list.useQuery({ roundId })
if (isLoading) {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
)
}
return <ProjectTable projects={data.projects} />
}
```
### Error States
```typescript
// Consistent error display
function ErrorState({
title = 'Something went wrong',
message,
onRetry,
}: {
title?: string
message: string
onRetry?: () => void
}) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<AlertCircle className="h-12 w-12 text-destructive" />
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
<p className="mt-2 text-muted-foreground">{message}</p>
{onRetry && (
<Button onClick={onRetry} className="mt-4">
Try Again
</Button>
)}
</div>
)
}
```
### Empty States
```typescript
function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: React.ComponentType<{ className?: string }>
title: string
description: string
action?: React.ReactNode
}) {
return (
<div className="flex flex-col items-center justify-center p-8 text-center">
<Icon className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
<p className="mt-2 text-muted-foreground">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
)
}
```
### Responsive Patterns
```typescript
// Table on desktop, cards on mobile
function ProjectDisplay({ projects }: { projects: Project[] }) {
return (
<>
{/* Desktop: Table */}
<div className="hidden md:block">
<ProjectTable projects={projects} />
</div>
{/* Mobile: Cards */}
<div className="md:hidden space-y-4">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</>
)
}
```
## Touch Targets
All interactive elements must have a minimum touch target of 44x44px on mobile:
```typescript
// Good: Large touch target
<Button className="min-h-[44px] min-w-[44px] px-4">
Click me
</Button>
// Good: Icon button with padding
<Button variant="ghost" size="icon" className="h-11 w-11">
<Menu className="h-5 w-5" />
</Button>
```
## Form Patterns
### Autosave with Debounce
```typescript
function EvaluationForm({ evaluation }: { evaluation: Evaluation }) {
const utils = trpc.useUtils()
const autosave = trpc.evaluation.autosave.useMutation({
onSuccess: () => {
utils.evaluation.get.invalidate({ assignmentId: evaluation.assignmentId })
},
})
const debouncedSave = useMemo(
() => debounce((data: FormData) => autosave.mutate(data), 1000),
[autosave]
)
return (
<Form
onChange={(data) => {
debouncedSave(data)
}}
>
{/* Form fields */}
<div className="text-sm text-muted-foreground">
{autosave.isPending ? 'Saving...' : 'Autosaved'}
</div>
</Form>
)
}
```
### Form Validation
```typescript
const evaluationSchema = z.object({
criterionScores: z.record(z.number().min(1).max(5)),
globalScore: z.number().min(1).max(10),
binaryDecision: z.boolean(),
feedbackText: z.string().min(10, 'Please provide at least 10 characters'),
})
function EvaluationForm() {
const form = useForm<z.infer<typeof evaluationSchema>>({
resolver: zodResolver(evaluationSchema),
})
return (
<Form {...form}>
<FormField
control={form.control}
name="feedbackText"
render={({ field }) => (
<FormItem>
<FormLabel>Feedback</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormMessage /> {/* Shows validation error */}
</FormItem>
)}
/>
</Form>
)
}
```
## Accessibility Checklist
- [ ] All images have alt text
- [ ] Form inputs have labels
- [ ] Color is not the only indicator (icons + text)
- [ ] Focus states are visible
- [ ] Skip links for main content
- [ ] Keyboard navigation works
- [ ] Screen reader tested
- [ ] Reduced motion respected
- [ ] Sufficient color contrast (4.5:1 for text)
## Animation Patterns
### Page Transitions (Motion)
```typescript
const pageVariants = {
initial: { opacity: 0, y: 20 },
enter: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.25, 0.1, 0.25, 1] } },
exit: { opacity: 0, y: -10, transition: { duration: 0.2 } }
}
```
### List Stagger (Items enter one by one)
```typescript
const listVariants = {
visible: { transition: { staggerChildren: 0.05 } }
}
```
### Spring Physics (Natural movement)
```typescript
const springConfig = { type: "spring", stiffness: 400, damping: 30 }
```
## Mobile-Specific UX Patterns
| Pattern | Implementation |
|---------|----------------|
| Bottom sheets instead of modals | Vaul drawer, thumb-reachable |
| Swipe gestures | Motion drag handlers |
| Pull-to-refresh | Custom spring animation |
| Haptic feedback hints | Visual bounce on limits |
| Large touch targets | Min 44x44px, generous spacing |
| Thumb-zone navigation | Bottom nav, not hamburger menu |
| Native-feeling scrolls | CSS scroll-snap, momentum |
## Performance Targets
| Metric | Target | How |
|--------|--------|-----|
| First Contentful Paint | < 1.5s | SSR, optimized fonts |
| Largest Contentful Paint | < 2.5s | Image optimization, lazy loading |
| Time to Interactive | < 3.5s | Code splitting, minimal JS |
| Cumulative Layout Shift | < 0.1 | Reserved space, skeleton loaders |
| Touch response | < 100ms | Optimistic UI, spring animations |
| Scroll performance | 60fps | CSS transforms, will-change |
## Component Design Rules
### Buttons
- Primary: Solid brand red (#de0f1e), 12px radius, subtle shadow
- Secondary: Ghost/outline, same radius
- Hover: Scale 1.02, slight lift shadow
- Active: Scale 0.98, pressed feel
- Loading: Spinner replaces text, same width
### Tables (for data density)
- Zebra striping: Subtle, not harsh
- Row hover: Slight highlight, not full color change
- Sortable headers: Subtle indicator, not loud
- Mobile: Horizontal scroll with sticky first column
### Forms
- Labels above inputs (not placeholder-as-label)
- Clear focus states (brand color ring)
- Inline validation (not modal alerts)
- Autosave indicator: Subtle, top-right
### Empty States
- Illustration + helpful text
- Clear CTA to fix the empty state
- Not just "No data found"
## Related Documentation
- [Architecture Overview](./README.md) - System design
- [API Design](./api.md) - tRPC endpoints

594
docs/candidatures_2026.csv Normal file
View File

@ -0,0 +1,594 @@
Full name,Application status,Category,"Comment ",Country,Date of creation,E-mail,How did you hear about MOPC?,Issue,Jury 1 attribués,MOPC team comments,Mentorship,PHASE 1 - Submission,PHASE 2 - Submission,Project's name,Team members,Tri par zone,Téléphone,University
Chaima BEN GRIRA,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,,TN,2023-01-19,cbengrira@blueeconomy.ogs.it,,Reduction of pollution (plastics chemicals noise light...),,,false,,,Bluepsol,"Eskander ALAYA, Chaima BEN GRIRA, Nabil FOGHRI, Ahmed BACCOUCHE, Adel JELJLI","Africa, Tunisia",+393508394071,
James Carter-Johnson,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"To Farm Giant Kelp at Scale, on special racks, capturing 30x more C02 than forrest per hecare. Harvest 4 times a year for oganic fertilizer, algin and materials for bio-plastics. All these replace highly polluting oil based products on land.",GB,2024-06-06,james@bigkelp.com,You contacted me I think.,Mitigation of climate change and sea-level rise,,,false,https://drive.google.com/drive/folders/1R5-IfGbETFri6ZX0RnJY8W6wan7cLoz-?usp=drive_link,,Big Kelp,James Carter-Johnson MA MBA; Prof. Carole Llewelyn MSc PhD; Vincent Doumeizel; Carlos Vanegas MSc PhD; James Sainty BA MBA; Akhthar Swaebe BT MSc MBA; Peter Rivera MSc PhD; Alessio Massironi MSc PhD; Johannes van der Merwe ME CE PhD; Oliver Parker BSc MSc,UK,+447899791166,
Silvia Ruiz-Berdejo,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"We are a biofoodtech startup specializing in microalgae and plant-based functional ingredients within the blue economy. Our R&D targets sectors like Functional Food Formulation, Precision Food Nutrition, and Nutricosmetics.
We develop new ingredients that replace fats, sugars, and additives in ultra-processed foods while replicating traditional textures, colors, and flavors to ease consumer transitions to healthier diets.
Our clean-label formulations support easy industrial integration and rapid scale-up for B2B clients in the food industry, health and wellness groups, innovative food brands, and sports teams. This advances sustainable functional nutrition aligned with blue economy principles",ES,2024-01-11,silvia@omnivorus.com,Linkedlin,Other,,,true,https://drive.google.com/drive/folders/1A8jzY7h4pfebbQKvCtg0Fc0AKzUE1F_q?usp=drive_link,,Omnivorus Smartfood,"Silvia rui-Berdejo CEO -Cofounder , Toni Gonzalez CPO - Cofounder, Luis Pascual CFO , Jose Tornero R&D Funtional Food , Carlota Villanueva-Tobaldo R&D Nutro cosmetic","Europe, Spain",+34622381855,
Achyut Karn,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"OceanGuardian AI: Predictive Ocean Protection Through Autonomous Intelligence
The Problem We're Solving
Ocean conservation today operates in crisis mode. We discover dead zones after they form, find pollution after it spreads, and detect coral bleaching after ecosystems collapse. Current monitoring methods are expensive, sporadic, and reactive—providing data only after irreversible damage occurs. The ocean needs an early warning system, not an autopsy report.
Critical gaps in current approaches:
- Monitoring covers less than 5% of critical marine zones
- Research-grade equipment costs $50,000+ per unit, limiting deployment
- Data collection happens quarterly or annually—far too slow for dynamic threats
- No predictive capability to prevent ecosystem collapse before it happens
- Communities lack real-time information to protect their local waters
Our Innovation: The World's First Predictive Ocean Protection Network
OceanGuardian AI deploys networks of affordable, solar-powered autonomous underwater drones that create continuous, real-time monitoring of marine ecosystems. But we don't just collect data—our AI predicts threats 2-8 weeks before critical damage occurs, enabling intervention while ecosystems can still be saved.
Core Technology Components:
1. Affordable Autonomous Drones ($800/unit)
- Solar and wave-energy powered for perpetual operation
- Multi-sensor array monitors 15+ parameters simultaneously
- Computer vision and acoustic sensors for marine life tracking
- Swarm intelligence enables coordinated monitoring
- Modular design adapts for different missions
2. Predictive AI Engine
- Machine learning models trained on oceanographic data
- Predicts coral bleaching events, harmful algal blooms, oxygen depletion
- Identifies microplastic accumulation hotspots
- Detects illegal fishing and pollution incidents in real-time
- Creates digital twin models of monitored ecosystems
3. Real-Time Intervention System
- Automated alerts to authorities, NGOs, and c",IN,,achyut.karn.2025@sse.ac.in,Linkedin,Technology & innovations,,,true,,,OceanGuardian AI,"Rishan Narula, Saanvi Mahajan",Asia,+916204778589,Symbiosis School of Economics
Laurent BUOB,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Whisper 360, a foiling boat with the performance of a thermal boat, powered by electricity: 45 knots, 100 nautical miles, zero emissions.",FR,2024-09-30,l.buob@whisper-ef.com,We were incubated at Monaco Tech,Sustainable shipping & yachting,,,false,,,Whisper eF,Vincent Lebeault,"Europe, France",+33675090543,
Adrien BARRAU,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Seavium is an AI platform that reduces the environmental footprint of offshore operations by eliminating unnecessary vessel movements.
Fragmented data and inefficient sourcing lead to avoidable transits, excess fuel use and emissions across the sector.
Seavium matches each offshore need with the closest, most suitable vessel in real time, using technical data and AIS availability. This optimisation cuts transit miles and fuel consumption at scale.
Early results show 1825% fewer miles sailed and 512% fuel savings per operation.
With 20 000+ vessels mapped and 118 companies already engaged, the model is globally scalable.
Seavium combines a SaaS subscription with performance-based fees, ensuring that environmental impact increases with platform adoption.",FR,2024-04-01,adrien@seavium.com,via GreenwaterFoundation,Technology & innovations,,,true,https://drive.google.com/drive/folders/1fUCrWCyXQHWEcacseTa338-RPn53KnZy?usp=drive_link,,SEAVIUM,Adrien BARRAU / Samuel DRAI,"Europe, France",+33646221977,
Nitya Gunturu,Received,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"We aim to create innovative, sustainable mycelium-based packaging materials designed to replace single-use Styrofoam and plastic in transportation and e-commerce sectors.
Problem and Solution Statements
Problem 1: Plastic Pollution (Land, Water, Air) and Non-Biodegradability
The Problem:
Over 300 million tons of plastic are produced globally each year, with around 45% being single-use packaging. Styrofoam and plastic foams take up to 500 years or more to decompose, causing persistent pollution.
Our Solution:
We develop 100% biodegradable mycelium packaging that decomposes naturally in 30 to 90 days, enabling a circular economy.
Problem 2: High Carbon Footprint of Production
The Problem:
Plastic production contributes about 3.4% of global greenhouse gas emissions, heavily reliant on fossil fuels.
Our Solution:
Our process uses renewable agricultural waste and fungal growth, reducing carbon emissions by up to 7090% compared to plastics.
Problem 3: Less Use of Plants and Other Natural Resources
The Problem:
Conventional bio-packaging often requires dedicated crops, which leads to over-exploitation of valuable land and water resources.
Our Solution:
We convert locally sourced agricultural waste into packaging, requiring significantly less land or water resources.
Problem 4: Agricultural Waste Mismanagement
The Problem:
India produces over 500 million tons of crop residue annually, much of which is burned, causing severe air pollution impacting millions.
Our Solution:
We utilize this waste as raw material, reducing harmful burning and creating economic value for rural producers.",IN,,nityagunturu95@gmail.com,University,Reduction of pollution (plastics chemicals noise light...),,,true,https://drive.google.com/drive/folders/1322p0iOzB-d66xlZV85oBEOf9gWOsNkq?usp=sharing,,MycoWrap,Nitya Gunturu and Avni Mishra,Asia,+917680093169,"Ashoka University, India"
Hasan Noor Ahmed,Received,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.
The project targets urgent threats in the region, including plastic pollution, illegal fishing, coastal erosion, and the loss of marine biodiversity.",SO,,biland.awdal.org@gmail.com,Fund for NGO,Capacity building for coastal communities,,,false,https://drive.google.com/drive/folders/1Oz9lQCfhQqw818QegNj9S_SvArSQwZFw?usp=drive_link,,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","Africa, Somalia",+491737752964,Bilan Awdal Organization Training & Capacity Development Unit
ssentubiro billy,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,allow needy children access quality education,UG,2016-08-13,lemanfoundation16@gmail.com,via social media,Capacity building for coastal communities,,,true,,,schoolarships,Nakayulu Grace and ssentubiro billy,"Africa, Ouganda",+256708630034,
Ramsay Bader,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"PosidoniaGuard is a turnkey service that helps Mediterranean marinas and coastal authorities stop anchor damage to Posidonia oceanica seagrass meadows by installing seagrass-safe “eco-moorings”, managing no-anchoring zones via a simple booking app, and quantifying the blue-carbon and biodiversity benefits for funders and regulators. Posidonia meadows are critical “blue forests” that store large amounts of carbon, support fisheries and protect coasts, but up to about 34% have already been lost, with tens of thousands of hectares damaged annually by anchoring.
Objectives:
1. Protect and restore Posidonia meadows by replacing destructive chain moorings and ad-hoc anchoring with certified eco-moorings in high-pressure bays.
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.",US,,Ramsay.Bader@gmail.com,Through my University.,Blue Carbon,,,true,,,PosidoniaGuard,Ramsay Bader. Caroline Hulbert.,US,+16468972588,University of St Andrews. United Kingdom.
Adrian Colline Odira,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Our project aims to build a fully circular, climate smart aquaculture model that reduces pressure on overfished natural water bodies while empowering coastal and lakeside communities. By integrating sustainable fish production, renewable energy systems (biogas and solar), digital traceability, and community led cage farming, we create an alternative source of affordable, high quality protein that eases exploitation of lake and ocean ecosystems.
Objectives
Reduce dependence on open water fishing by scaling sustainable cage and pond aquaculture systems.
Empower women and youth with ownership of production units, fair market access, and technical training.
Increase ocean and freshwater protection by promoting regenerative practices, responsible feed use, and cold-chain efficiency to minimise post harvest loss.
Deploy digital tools to track origin, ensure transparency, and support ecosystem friendly decision making.
This approach strengthens food security, grows blue economy incomes, and protects aquatic ecosystems through a scalable, community-centered model.",KE,2018-08-20,adrian@riofish.co.ke,LinkedIn,Sustainable fishing and aquaculture & blue food,,,true,,,Rio Fish Limited,"Adrian Colline Odira, Loren Edwina Odira","Africa, Kenya",+254742838455,
Mohammad Badran,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Vision
Our vision is to be a global supporter to marine and coastal ecosystems stewardship, fostering a future where tropical and subtropical marine environments thrive in harmony with human activities. We envision vibrant resilient marine ecosystems that support biodiversity, enhance climate stability, and contribute to viable sustainable development with diversified livelihoods for the local communities.
Mission
Our mission is to deliver innovative and sustainable management solutions that advance development in tropical and subtropical marine and coastal areas maintaining ecosystems health and resilience. We endeavor to harness broad stakeholders involvement, community engagement, scientific research, local knowledge, and cutting-edge technology for supporting development in tropical seas to protect and restore ecosystems biodiversity and functionality while achieving stakeholders interests and local communities contentment.
Approach
Our approach is to harness the local knowledge and expertise in all our projects. We will do consultancy work and target nationally, regionally and internationally supported initiatives. We will keep a small team for coordination and management, but our heavy weight will be the local performers in the field. Implementing multiple local projects, we will build an effective Platform for Global Dialogue and Exchange of Experience
Objectives
Our objectives are highly ambitious and divers. We realize the hard work ample time they need to be achieved. But we trust that our approach that counts on the local knowledge and expertise will make our mission achievable. Our objectives include:
 Conservation and Restoration
o Develop and implement science-based and local knowledge strategies for conservation and restoration of critical marine habitats and the biodiversity they support, including coral reefs, mangroves, and seagrass beds.
o Monitor and assess coastal and marine ecosystems health and the stressors they face to gu",JO,2024-08-30,ceo@martropic.com,From Canada's Ocean Supercluster,Restoration of marine habitats & ecosystems,,,true,,,MarTropic Canada Inc.,Mohammad Badran and Hala Marouf,Asia,+18733557575,
Danail Marinov,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",BG,2024-12-01,dmarinov@redget.io,A friend of mine shared this opportunity to me,Technology & innovations,,,false,,,RedGet.io,"Danail Marinov, Dobromir Balabanov, Alexander Valchev","Bulgaria, Europe",+359895497694,
Shelby Thomas,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",US,2019-12-01,admin@oceanrescuealliance.org,via Email Newsletter,Restoration of marine habitats & ecosystems,,,true,,,Coastal Resilience Solutions: WeRestore,"Dr. Shelby Thomas, Dr. David Weinstein, Lindsay Humbles,",US,+13866897675,
Maaire Gyengne Francis,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Problem and Solution
Urban cities across Africa face a severe plastic waste crisis driven by rapid population growth, heavy consumption of plastic-packaged products, and inadequate formal waste management infrastructure. Most households lack convenient, reliable, and affordable waste disposal options, forcing them to depend on informal collectors with limited capacity and inconsistent schedules - or resort to harmful practices such as burning, burying, or illegally dumping plastic waste in gutters, waterways, and open spaces. This results in widespread pollution, health hazards, clogged drainage systems, flooding, and the loss of valuable recyclable material that could support local and global circular economy markets. Also, recycling companies lack consistent, traceable, and high-quality access to plastic feedstock.
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",GH,2025-01-01,gyengnefrancis90@gmail.com,Google search,Reduction of pollution (plastics chemicals noise light...),,,true,https://drive.google.com/drive/folders/1Rv9W6h5zQESX7A68bQio5JWy5TML86rH?usp=drive_link,,WasteTrack,"Frank Faarkuu, Prosper Dorfiah","Africa, Ghana",+233208397960,
Vincent Kneefel,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,,NL,2024-04-16,vincent@vitalocean.io,Linkedin,Technology & innovations,,,true,,,Vital Ocean,Joi Danielson,"Europe, Netherland",+31622514465,
Raismin Kotta,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,Sustainability fisheries and Aquaculture,ID,,raisminkotta88@gmail.com,I hear and read MOPC in website and interested to apply,Sustainable fishing and aquaculture & blue food,,,true,,,The Pearls cultuvation & Pearls jewelry,"Raismin Kotta, aya sophia, Lalu harianza,asril junaidy",Asia,+6281342018565,"45 University, Mataram Indonesia"
Anastasiia,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,Take technology onto another level,UA,,grozdova.anastasiia@gmail.com,Social media marketing,Technology & innovations,,,true,,,Innovations in ocean environment,Darina Mitina,"Europe, Ukraine",+380680650309,
Raphaëlle Guénard,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",FR,2025-03-21,contact@filae.eu,"from Marine Jacq-Pietri, Coordinatrice du Monaco Ocean Protection Challenge",Reduction of pollution (plastics chemicals noise light...),,,true,,,Filae,Raphaëlle Guénard & Killian Bossé,"Europe, France",+33663688277,
Pavel Kartashov,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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",MK,2025-03-05,pavel.k@wavespark.co,Social media post,Technology & innovations,,,false,https://drive.google.com/drive/folders/1vdcWHlPUURdN69T-Ek7wsqOTrLNaODq0?usp=drive_link,,WaveSpark Green Marine Energies,"Pavel Kartashov, Rodrigo Caba, Francisco Perez, Glib Ivanov","Europe, Macedonia",+38975588771,
Coral Bisson,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,- Reduction of ocean plastics through development of swimwear using recycled ocean plastics,JE,,coralbisson@icloud.com,University,Reduction of pollution (plastics chemicals noise light...),,,true,,,Corali,Coral Bisson,"Europe, Jersey",+377643915342,International University of Monaco
Carol Nkawaga Moonga,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,,ZM,2024-07-11,moongacaroln@gmail.com,I saw an advertisement on LinkedIn,Sustainable fishing and aquaculture & blue food,,,true,https://drive.google.com/drive/folders/1wEWiGREhq-dWPkFqGqmSK89PcuOjhsXX?usp=drive_link,,Kacachi General Dealers,Cathrine Kapesha,"Africa, Zambia",+260979164462,
Peter Teye Busumprah,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"This initiative aims to bridge the gap in ocean data across Africa by establishing a standardized platform for accessing African Ocean Biodiversity information. The project involves developing an African Ocean Biodiversity Atlas that provides detailed data on Blue Carbon and Fisheries ecosystems, including GPS coordinates, high-resolution images, and videos illustrating the state of coastal environments throughout Africa. To ensure accessibility, we are utilizing affordable, locally developed technologies and multifunctional ocean applications to map key ecosystems such as fisheries, seaweeds, seagrasses, mangroves, and other ocean biodiversity ecosystems along the continents coastlines.
Our team has grown significantly from 8 to 40 members, representing 20 African nations. Currently, over 800 users are engaged, and a pilot map encompasses ten African countries. We anticipate generating approximately $240,000 annually from app downloads and technology sales, with projected monthly revenues of about $20,000. This includes $7,000 from subscriptions, $7,000 from data sales, $3,000 from licensing, and $3,000 from consulting services.
The database is designed for policymakers and academic institutions, offering precise data crucial for policy formulation, research, and publication activities. Additionally, we aim to involve private sector stakeholders who depend on reliable data to inform their investments in a sustainable blue economy.
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",GH,2024-01-01,petervegan1223@gmail.com,MOPC Linkedin.,Technology & innovations,,,true,,,African Ocean Biodiversity Atlas,Mavis Essilfie,"Africa, Ghana",+233544671951,
Nilas Neuhauser,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"The NAUTILUS team is developing the latest generation, and most advanced Autonomous Underwater Glider with the goal of flexibly facilitating the collection of crucial data for aquatic research. By doing so, we seek to create a cost-effective and minimally invasive aquatic research robot.
After conducting first successful tests this year, we seek to continue testing our glider in Swiss lakes until summer and then, in September, set off for a 2 week mission to test in the Norwegian Ocean.
Find our website here: https://aris-space.ch/our-projects/nautilus/",CH,,nilas.neuhauser@aris-space.ch,from the 1000 Ocean Startups LinkedIn,Technology & innovations,,,true,,,Nautilus,45+ members (Management -> PM: Phillip Zenger ; DPM: Nilas Neuhauser ; SE: Matias Betschen),"Europe, Switzerland",+41792977194,"ETH Zurich, Zurich"
Aki Allahgholi,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"We will solve the extreme coral restoration bottleneck when it comes to outplanting. The logistical limitations of farming, transporting and outplanting cannot be overcome through the classical methods as of now. Our patented coral paint and spraying mechanism will solve that hurdle.",CH,2025-08-13,aki@corall.eco,LinkedIn,Restoration of marine habitats & ecosystems,,,false,https://drive.google.com/drive/folders/1M8KGN87ZSTEqFP8T2eUccYOE7K7DZNrV?usp=drive_link,,CORAlliance,"Chris Glaser, Peach Zwyssig, Tamaki Bieri, Dave Gulko","Europe, Switzerland",+41763879261,
Irina Kharitonova,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"EcoPlaton Tracker is a digital educational and action-oriented platform aimed at protecting oceans by addressing the root causes of pollution on land. The project helps children and families understand how everyday habits—plastic use, chemical products, water consumption, and carbon footprint—affect rivers, lakes, seas, and ultimately the oceans.
The platform combines carbon and water impact tracking, eco-challenges, audio guides, and storytelling, including stories about lakes, oceans, and industrial water pollution. It guides users from awareness to action and delivers real environmental impact: part of the projects revenue supports reforestation and environmental initiatives, with over 1,300 trees already planted in industrial regions of Kazakhstan.
EcoPlaton Tracker integrates a Water & Ocean Impact Tracker module that visualizes the “landwaterocean” pollution pathway and encourages measurable behavior change.",KZ,2025-07-07,irinakharitonova0201@gmail.com,We learned about the Monaco Ocean Protection Challenge last year through Instagram and have been preparing our application since then.,Consumer awareness and education,,,true,,,EcoPlaton Tracker: From Land to Ocean,"Irina Kharitonova, Alexandra Kharitonova, Platon Nechayev",Asia,+77012141077,
Fritz Noel Bayong Momha,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"GeoCosta : Application of Geodesign to understand and Innovate in Coastal Protection Planning / Balaz Studio
Objectives :
-Understand the development of coastal protection in order to contribute to a concerted management focused on adaptation and coastal resilience
- Use the concepts of Geodesign and coastal resilience, landscape approach, and consultation in our diagnosis of the protective planning process
- Mapping of infrastructures and different actors will illustrate the actions and scenarios of the future vision of this site.",CM,2021-02-07,fbayong@balazstudio.com,LinkedIn,Technology & innovations,,,true,,,GeoCosta : Application of Geodesign to understand and Innovate in Coastal Protection Planning /Balaz Studio,Fritz Bayong,"Africa, Cameroun",+32467868495,
Rasmus Borgstrøm,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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",DK,2023-09-24,rasmus@blueplanetinnovators.com,LinkedIn,Mitigation of ocean acidification,,,true,,,FlowMinerals,"Rasmus Borgstrøm, Esben Jessen","Denmark, Europe",+4527117113,
Amelia Martin,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,We manufacture an eco-friendly alternative to marine foam (marine grade styrofoam).,US,2023-06-13,amelia@mudratsurf.com,Google!,Reduction of pollution (plastics chemicals noise light...),,,true,,,Mud Rat,"Jack Tarka, Patricio Acevedo, Brian Lassy",US,+18606824426,
James Kalo Malau,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,,VU,2026-01-01,malau_jk@hotmail.com,Funds for NGOs Premium,Sustainable fishing and aquaculture & blue food,,,true,,,Coral Reforestation,"John Maliu, Josue Jimmy, Nalo Samuel, Manu Roy, James Sulu",Oceania,+6787774965,
Jonas Wüst,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",CH,2024-08-15,jonas@tethys-robotics.ch,BRIDGE by Innosuisse forward us.,Technology & innovations,,,false,,,Tethys Robotics,Pragash Sivananthaguru,"Europe, Switzerland",+41766307924,
João Manuel de Gouveia Firmino,Received,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"Project idea: Convert local fish discards on Madeira into a hygienic, fermented fish sauce (small-batch artisanal → scalable).
Objectives: Reduce waste; add value for fishers; create local jobs; supply restaurants/retail; position as circular blue-economy premium product.
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.",PT,,9822@novalaw.unl.pt,Through Fondation Prince Albert II de Monaco.,Other,,,false,https://drive.google.com/drive/folders/1Pbf4FwTfAfqklel_a94CYA7dZsmvPfGH?usp=drive_link,,Atlantic Fish Sauce,João Firmino / Duarte Fernandes,"Europe, Portugal",+351969136436,NOVA University Lisbon (Nova School of Business & Economics) / University of Madeira (Faculty of Sciences and Engineering)
Francesco Ruscio,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,Enhance monitoring of benthic habitats using robotics and artificial intelligence.,IT,,francesco.ruscio@ing.unipi.it,linkedin,Technology & innovations,,,false,,,PerSEAve,"Francesco Ruscio, Simone Tani, Alessandro Gentili","Europe, Italia",+393756436501,"University of Pisa, Pisa, Italy"
Lorna Mudegu,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"WAVU is a market and aggregation platform that connects verified aquaculture producers to buyers through organised, predictable supply chains.
In many coastal and inland markets, fish buyers source from informal channels where farmed fish and wild-caught fish are indistinguishable. This lack of separation sustains demand for capture fisheries and contributes to overfishing in already stressed marine and freshwater ecosystems. By aggregating aquaculture producers, forecasting demand, and directing buyers toward farm-based supply, WAVU helps shift market demand away from unregulated wild catch.
As more buyers rely on planned aquaculture sourcing, pressure on wild fisheries is reduced while livelihoods are supported through sustainable fish production. Each tonne of farmed fish absorbed into formal markets represents demand that would otherwise be met through extraction from natural fish stocks.
WAVU builds on ongoing operations in East Africa and offers a scalable, market-driven pathway to reducing pressure on wild fisheries in regions facing overfishing and informal fish trade.",KE,2024-07-30,lornaafwandi@gmail.com,LinkedIn,Sustainable fishing and aquaculture & blue food,,,true,https://drive.google.com/drive/folders/1_Y9YW-Y_kd2Tpz80fH5juc9Ol1TR7DKq?usp=drive_link,,WAVU,Don Okoth | Vincent Oduor | Chris Munialo | Loise Mudegu,"Africa, Kenya",+254718059337,
Shamim Wasii Nyanda,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",TZ,2024-03-01,shamim@sunwaveltd.com,It was shared by SUNWAVE's Advisory Board member.,Sustainable fishing and aquaculture & blue food,,,true,,,SUNWAVE,Ridhiwan Mseya,"Africa, Tanzania",+255764190074,
Olaleye Rofiat Olayinka,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",NG,2021-11-08,olaleyerofiatyinka@gmail.com,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.,Reduction of pollution (plastics chemicals noise light...),,,true,,,Eco Heroes Nigeria limited,Olaleye Rofiat Olayinka Salaam Lateef Oladimeji Akinsanya Dorcas Olaleye Hassan Ogundairo Ganiyat,"Africa, Nigeria",+2348038877293,
Christian Mwijage,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",TZ,2022-12-21,chrissmwijage@gmail.com,Social Media,Reduction of pollution (plastics chemicals noise light...),,,true,,,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.","Africa, Tanzania",+255711457346,
Rasheed Aliu,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Land-based pollution is the largest contributor to ocean degradation, yet sanitation failures in coastal communities remain overlooked. In flood-prone coastal regions of Africa, fragile septic systems collapse during flooding, releasing untreated human waste into groundwater, rivers, lagoons, and ultimately the ocean.
I witnessed this firsthand in coastal Lagos, Nigeria, when flooding destroyed local sanitation systems and a neighbours 4-year-old daughter died from cholera, a preventable waterborne disease. This tragedy reflects a systemic failure. Over 90% of Nigerian households rely on sanitation systems that leak sewage, contributing to 117,000 annual child deaths from waterborne diseases, according to UNICEF.
In the absence of centralized wastewater infrastructure, outdated septic tanks costly to build and maintain are frequently evacuated or overflow during floods, with waste discharged into coastal waters. This drives marine pollution, eutrophication, biodiversity loss, and degradation of near-shore ecosystems critical to food security.
At Pod we design and manufacture LoopBox, LoopBox is a solar-powered, IoT-enabled, self-contained sanitation system designed for coastal and flood-prone communities. Unlike traditional soakaway pits that leak, our tech uses embedded sensors and microbial treatment to track, treat, and recycle human waste into reusable water. Through our cloud dashboard, users and local authorities can monitor sanitation performance and water quality remotely. We also provide nearby borehole treatment as a service. LoopBox is 5x more cost-effective than conventional systems, eliminates 100 dollars/year in waste evacuation costs, and requires minimal space. Built with scalable hardware and software, it is designed to be deployed across low-income, climate-vulnerable communities bringing safety, sustainability, and data-driven decision-making to sanitation in Nigeria and Africa.
The project delivers a flood-resilient, decentralized sanitation s",NG,2025-05-06,rasheedofpod@gmail.com,BFA Global TECA Alumni Group( Tyler),Reduction of pollution (plastics chemicals noise light...),,,true,https://drive.google.com/drive/folders/1Ur6FAveOOAtS77TOGXuLGbVfqTF8mG9p?usp=drive_link,,Pod,"Rasheed Aliu, Gabriel Simon , Habeeb Lasisi and MaryJudith Chiamaka","Africa, Nigeria",+2348160238021,
Chelsey Karbowski,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Mikjikj Mniku, Mikmaq for “Turtle Island”, is an Indigenous-led consulting firm working at the intersection of ocean protection, community governance, and workforce development.
We help governments, philanthropies, and conservation organizations design ocean and climate initiatives that last beyond funding cycles by embedding Indigenous knowledge, ethical engagement, and local stewardship from the start.
Our work focuses on strengthening Indigenous and coastal governance, building inclusive workforce pathways in fisheries, marine monitoring, and ocean-adjacent clean energy, and making social impact measurable and defensible through socio-economic and SROI frameworks.
In a global push to protect more ocean faster, we ensure protection efforts are community-supported, socially resilient, and future-proofed, because conservation only succeeds when the people closest to the ocean are empowered to carry it forward.",CA,2025-03-01,chelsey.m.karbowski@gmail.com,Linkedin,Other,,,true,,,Mikjikj Mniku Consulting Ltd.,Chelsey Karbowski,Canada,+19026314362,
OLUTOKI FEYISHAYO FUNMI,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"To develop and inplement a sustainable food production (crops and animals) that specifically reduces a known threat to the Ocean (e. go, pollution, overfishing pressure, habitat destruction).
We will take measures using circular economy, by developing a system where waste products from our farm are treated and used in a way that prevent them from entering marine ecosystem
We will work on water management and pollution reduction and sustainable sourcing /supply chain",NG,2024-12-12,rebugssolutions@gmail.com,,Consumer awareness and education,,,true,https://drive.google.com/drive/folders/1xCJ_8EpTEdBORiJHYwIRZO22z8e54fbx?usp=drive_link,,Operation feed the children,"Adewuyi Feranmi, Olutoki sewafunmi victor, Ayodele joy, Babalola gbenga","Africa, Nigeria",+2348038226106,
Anshika Sarraf,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.
Objectives:
1) Measure and Map noise pollution
2) Marine life protection
3) Encourage better and sustainable practices
4) Support policy, investment & encourage systemic change in blue economy",IN,,anshika.sarraf_ug2024@ashoka.edu.in,LinkedIn,Reduction of pollution (plastics chemicals noise light...),,,true,,,Auralis Blue,Anshika Sarraf,Asia,+917897130506,Ashoka University + Sonipat
Neville Agesa,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",KE,2023-02-01,agesanevil@gmail.com,Gensea opportunities,Sustainable fishing and aquaculture & blue food,,,true,,,Sustainable Blue Food & Livelihoods Innovation,"Robert Meya,Hannah Mathenge,JohnChaka","Africa, Kenya",+254796438122,
Veronica Nzuu,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",KE,2023-05-29,veramichael2000@gmail.com,Social Media linked in,Consumer awareness and education,,,true,,,Furies,Angelo Mulu,"Africa, Kenya",+254748488312,
Fiona McOmish,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,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.,IT,2024-12-16,fiona.mcomish@algae-scope.com,LinkedIn,Technology & innovations,,,true,,,Algae Scope,Natasha Yamamura; Alejandra Noren; Farshid Pahlevani,"Europe, Italia",+447722083419,
Nesphory Mwambai,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",KE,2024-08-22,mwambai@seamo.earth,email news letter,Restoration of marine habitats & ecosystems,,,true,https://drive.google.com/drive/folders/1eOyDGZwwlNNAzbwwC-CUVmJi4gM3kDLI?usp=drive_link,,seamo.earth,"Nesphory Mwambai, Lewis Kimaru","Africa, Kenya",+254714520023,
Yahuza Sani Hudu,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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",NG,2024-02-26,ysanihudu@gmail.com,"The Commissioner for Environment and Natural Resources of Kaduna State Government, Nigeria Share's the link with my startup",Reduction of pollution (plastics chemicals noise light...),,,true,,,CleanUp MDC,"Abner Ayuba Atuga, Ameer Saeed","Africa, Nigeria",+2348146036089,
Emeka Nwachinemere,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"Pelagos is developing autonomous, bio-hybrid ocean regeneration machines that restore marine ecosystems while capturing atmospheric carbon. These AI-guided ocean drones re-mineralize seawater to combat acidification, stimulate safe plankton growth to enhance blue carbon sequestration, and support coral regeneration in degraded reefs.
Objectives:
Restore ocean health and biodiversity at scale
Enhance natural blue carbon capture and climate resilience
Provide real-time ocean intelligence data
Build a commercially viable, globally scalable blue-economy solution
Pelagos aims to transform oceans into self-healing climate engines while creating measurable environmental, social, and economic value.",NG,,nwachinemere.emeka@gmail.com,Linkedin,Restoration of marine habitats & ecosystems,,,true,,,Pelagos,"Nwachinemere Emeka, Nduka Miracle","Africa, Nigeria",+2348062148183,"University of Nigeria, Nsukka"
Rodrick Nyendwa,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,Mitigation about climate change and its impact and issue that community are aware .,ZM,2023-12-12,rodricknyendwa2016@gmail.com,Through social media on funds for NGOs,Mitigation of climate change and sea-level rise,,,true,https://drive.google.com/drive/folders/1RlybRQMKzhAdcU9vqg8XDZHbtpSzLOCN?usp=drive_link,,"Complehensive HIV prevention ,Treatment care support","Rodrick Nyendwa,Executive Director, Mumbi Micheal - Finace Manager, Ementy Mweemba- Programme Manager, Winter Musonda - Human Resource Mnager, Josiah Ndjovu -Community Liason Officer, Simata Mate - Monitoring and Evaluation Manager , Edith Bwalya -Data Entry Officer, Brona Kapindo - Office Assistant , Sylvester Chisanga - Front office Assistant","Africa, Zambia",+260977339071,
Nasibu Mtambo,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Blue EcoponicX is a climate-tech initiative that transforms marine plastic waste into 3D printed hydroponic towers for urban farming. The project addresses two interconnected challenges; ocean plastic pollution and urban food insecurity by converting waste into smart, productive food-growing systems designed for cities.
Project Objectives
1. Reduce Marine Plastic Pollution
Collect and recycle marine plastic waste, preventing it from entering landfills or degrading in the ocean.
2. Improve Urban Food Security
Enable affordable, space-efficient food production for households, youth groups, and small-scale urban farmers.
3. Lower Urban Carbon Emissions
Reduce food miles, optimize resource use, and promote localized production using energy-efficient systems.
4. Promote Climate-Smart Agriculture
Use IoT technology to minimize water, nutrient, and energy waste while maximizing crop yields.
5. Empower Communities Through Technology
Make modern farming accessible through easy-to-use smart systems, training, and data insights.
Key Features
1. Circular Economy Design: Hydroponic towers made from recycled marine plastics
2. IoT Integration: Real-time monitoring of water, nutrients, and system health
3. Low Resource Use: Up to 90% less water than traditional farming
4. Urban-Friendly: Suitable for rooftops, balconies, schools, and community spaces
5. Scalable & Modular: Easy to expand from household to community-scale deployment
Target Beneficiaries
1. Urban small-scale farmers
2. Youth and women-led agribusinesses
3. Schools and training institutions
4. Cities seeking climate-resilient food systems
Expected Impact
1. Reduced plastic pollution in coastal and marine ecosystems
2. Increased access to fresh, nutritious food in urban areas
3. Lower carbon emissions from food transport and waste
4. Creation of green jobs in recycling, manufacturing, and urban agriculture
5. Stronger climate resilience for cities",KE,2025-05-20,mtamboduke@gmail.com,Through LinkendIn,Reduction of pollution (plastics chemicals noise light...),,,true,,,Blue EcoponicX,"Tabitha Shali, Mohammed Athman, Terry Okwanyo","Africa, Kenya",+254742051141,
Faith Mutisya,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Tumbe sea weed farmers is a community based organization in Msambweni kwale Kenya. We focus on empowering coastal communities especially young women and youth with skills in sustainable sea weed farming. This is because as women in kwale county we face alot of challenges such as early marriages and pregnancies most especially because women are not given the same schooling privilege as men. Therefore so many young mothers don't have any skills to provide for their young ones. Therefore Tumbe sea weed farmers has taken the initiative to empower them , and through the farming they are able to support themselves financially and at the same time contribute to global efforts in fighting climate change because weed plays an important role as a carbon sink . And we also contribute to increase in biodiversity by provide nursery and nurturing bay for fish and other aquatic organisms",KE,2023-03-02,faithmutisya56@gmail.com,Through linked in,Capacity building for coastal communities,,,true,,,Tumbe sea weed farmers,Faith Mutisya - founder and Trainer 2. Hanifa wendo- secretary/field manager 3. Mwanamisi Mwadzumba - Treasurer,"Africa, Kenya",+254711627836,
李涵凝,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"The 10cm transparent eco-jellyfish robot carries ocean-beneficial materials, moving by mechanical legs and drifting with waves to reduce marine pollution and repair ecosystems, quietly improving ocean health",CN,2026-01-01,xbm_0201@qq.com,I found out about MOPC through an online search.,Reduction of pollution (plastics chemicals noise light...),,,,https://drive.google.com/drive/folders/1duMty6mbpLCOoataogbZEShA6keuK2fy?usp=drive_link,,Environmentally Friendly Jellyfish,李涵凝,Asia,+8618618164803,
Kabir Olaosebikan,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",NG,2023-04-17,kabir@craftplanet.org,Through online sustainability platforms and ocean innovation networks.,Reduction of pollution (plastics chemicals noise light...),,,true,https://drive.google.com/drive/folders/1jUFqGLk1zZ6afP4BysRPpp_jRXsw_9Kz?usp=drive_link,,Craft Planet - Blue Guard,"Kabir Olaosebikan, Aminat Abdulazeez, Promise Dalero, Hanatu Abdulakeem","Africa, Nigeria",+2348142123656,
Karl Mihhels,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",FI,,karl.mihhels@aalto.fi,2nd EU Algae Awareness Summit held in Berlin on October 17th 2025,Blue Carbon,,,true,,,Shaving the Seas,Karl Mihhels,"Europe, Finland",+358447627444,"Aalto University School of Chemical Engineering, Finland"
SENI Abd-Ramane,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"_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.",BJ,2025-12-29,seniramane@gmail.com,"I heard about the Monaco Ocean Protection Challenge on LinkedIn, it immediately caught my attention!",Reduction of pollution (plastics chemicals noise light...),,,true,,,OceanClean Tech,"SENI Abd-Ramane, DJIBRIL Samir, SOULÉ SEIDOU Mansoura","Africa, Bénin",+2290161149564,
Omoding Olinga Simon,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",UG,2024-01-05,simonomoding.ace@gmail.com,Through Linkedinn social media,Sustainable fishing and aquaculture & blue food,,,true,,,Dagim Fisheries (U) Ltd,"Omoding Simon, Ilukat Musa, Omiel Peter, Omongole Richard, Fellista Nakatabirwa","Africa, Ouganda",+256773351242,
Mutave Nelly,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,Hrkb,KE,1997-01-24,mutavenelly.mn@gmail.com,Friend shared link,Technology & innovations,,,true,,,Revamp Flips,Nthatisi Lesala,"Africa, Kenya",+254704458380,
Ketty Shamakamba,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Lake Farms is establishing an academy dedicated to preserving Lake Kariba and its communities. It is a center for training, innovation, and direct action.
Core Mission: Halt fish stock depletion and foster a sustainable blue economy.
Key Initiatives:
Training Hub: Equip local fishers with skills in sustainable aquaculture, ecosystem management, and cooperative business.
Innovation & Deployment: Design and deploy ethical, lake-friendly cage systems and restorative practices to rebuild wild stocks.
Community Enterprises: Launch community-owned ""Aqua-Hubs"" that provide food security, create livelihoods, and empower women.
This academy would create a lasting legacy of ecological restoration, poverty reduction, and resilience for Lake Kariba's people, directly honoring a commitment to ocean and freshwater preservation.",ZM,2021-11-30,ilovesolarfreezers@gmail.com,"We learned about the Monaco Ocean Protection Challenge through the communication channels of the Prince Albert II of Monaco Foundation and its associated networks, which highlight pioneering solutions for ocean and freshwater conservation.",Capacity building for coastal communities,,,true,,,LAKE FARMS AND FISHING LODGE LIMITED,"Board and Management Team Chisanga Mambwe Board Chairperson (Strategic oversight) Provides governance leadership, investor relations support, and high-level oversight of the executive team. Ketty Shamakamba Chief Executive Officer (CEO) Leads overall strategy, fundraising, partnerships, gender lens work, and company growth. Oversees business development, climate initiatives, and solar cold-chain expansion. Chiozya Mwanza Chief Operations Officer (COO) Responsible for day-to-day operations, cage management, production planning, logistics coordination, and community engagement with fishers and women traders. Hamando Hamalabbi Chief Financial Officer (CFO) (Accountant) Manages finance, accounting, compliance, investment reporting, budgeting, and financial controls. Muzalema Zimba Chief Marketing Officer (CMO) (Sales & Marketing Manager) Oversees sales strategy, distribution channels, branding, customer acquisition, and premium market relationships (hotels, restaurants, wholesalers). Joshua Mwanza Chief Operations Manager / Deputy COO (Operations Manager) Supports operations, distribution logistics, procurement, cold-chain coordination, and team supervision. Micheck Chulaula Chief Farm Manager (CFM) (Farm Manager) Oversees cage management, feeding regimes, harvesting, processing coordination, and ensuring biosecurity and aquaculture standards. Mabel Kaunda Chief Human Resources Officer (CHRO) (HR Manager/Secretary) Manages staff welfare, recruitment, training, compliance, and gender-inclusive workforce policies. Our operations team includes experts in farm management, logistics, and business development, ensuring efficient production, processing, and distribution. The finance and technology staff oversee solar freezer leasing, mobile payments, and digital monitoring systems, enabling scalable, sustainable impact. Many team members, including the founders, have personal connections to the communities we serve, which drives our ","Africa, Zambia",+260971094443,
Torrigiani Aurore,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Sea Blocks develops modular, low-tech artificial reefs designed to restore marine habitats in port environments.
Each reef is co-designed and assembled through participatory workshops involving companies, citizens and local stakeholders, then installed in partnership with ports. The modules are made from low-carbon materials and locally sourced shell waste, enhancing ecological functionality and accelerating colonisation by marine species.
The project combines ecological restoration, circular economy and awareness-raising, with scientific monitoring conducted by marine biology experts to assess biodiversity recovery and long-term impact.",FR,2021-02-03,seablocksrecif@gmail.com,Through professional networks and partners involved in ocean and coastal innovation.,Restoration of marine habitats & ecosystems,,,true,,,Sea Blocks,Olivier Meynard,"Europe, France",+33647780342,
Godfrey Noel,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Project Title: Bamboo Stewardship for Mangrove Protection: Building Sustainable Livelihoods Along the East African Coast
The Problem
East African coastal mangrove ecosystems spanning from Somalia to Mozambique face catastrophic degradation, with communities harvesting mangroves for fuel and construction because alternative income sources remain unavailable. This extraction destroys critical carbon sinks, eliminates natural storm surge barriers, and collapses fish nursery habitats that sustain coastal food security. Traditional conservation approaches exclude communities from protected areas without providing viable economic alternatives, guaranteeing enforcement failure and continued ecosystem loss.
Our Solution
Kilimora, in strategic partnership with EarthLungs, is implementing a bamboo based mangrove protection system that transforms coastal communities from ecosystem exploiters into paid ecosystem stewards. We employ community members to cultivate and harvest fast growing bamboo (Bambusa species with 3 to 5 year harvest cycles and continuous regrowth capacity) in designated buffer zones adjacent to mangrove forests. This bamboo provides sustainable construction materials and biomass fuel alternatives that eliminate economic pressure on mangrove stands while generating verifiable income for participating households.
Technical Innovation
The initiative integrates drone based mangrove health monitoring with ground truth verification by community stewards, creating high resolution ecosystem data that supports both conservation management and carbon credit generation. Kilimora provides the artificial intelligence powered verification infrastructure and blockchain based transparent payment systems ensuring stewards receive direct compensation tied to measurable mangrove protection outcomes. EarthLungs contributes marine ecosystem expertise, coastal community organizing capacity, and connections to corporate blue carbon credit buyers.
Scale and Impact
The program cu",KE,2024-01-04,gnoel@kilimora.africa,LinkedIn network,Capacity building for coastal communities,,,,https://drive.google.com/drive/folders/1Ouz8-deBPfgUl7VYIwofxUwEYG4D9iMw?usp=drive_link,,Kilimora CLG,"Godfrey Noel, Zuhra Nagib, Matthew Muange, Hildah Gichuru, Ezra Maruti, Hildah Gichuru","Africa, Kenya",+254795647634,
Hellen flavine akinyi,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,Plastic pollution from urban markets and streets flows into rivers ad ultimately into the ocean. single-use plastic paper bags are among the most common sources of marine debris.preventing plastic waste at the source is the most effective ad affordable solution than ocean multi-million clean up efforts,KE,2021-02-09,artworkspace1@gmail.com,thro. funds- for- Ngo newsletters,Consumer awareness and education,,,true,https://drive.google.com/drive/folders/1bFnRFeWaxyD2g52MG0l_Y6q_rQOCYbnc?usp=drive_link,,"STOPING OCEAN PLASTICS AT THE SOURCE;DIGITAL ECO-PACKAGING SOLUTION LED BY A YOUNG AFRICA WOMAN,KENYA 2026026","KIMBERLY ADHIAMBO CONIE, MAISON JOHN &PETER WAMBURA","Africa, Kenya",+27631484516,
Tochukwu Uwakeme,Received,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"Coastal Blue-Skills Hubs by Pikia is a scalable, community-led training and microenterprise program that equips coastal youth and women with practical skills, tools, and starter microgrants to reduce ocean pollution and strengthen climate-resilient livelihoods through waste-to-value (plastic collection/sorting), sustainable fishing practices, and mangrove/coastal restoration. The program will run through local “Hub” partners (NGOs/co-ops/schools), a lightweight mobile curriculum, and a train-the-trainer model, paired with verified community monitoring (simple metrics + photo evidence) to prove impact and unlock blue-economy buyers and sponsors.
Objectives:
• Cut land-to-ocean leakage by organizing community collection, sorting, and resale of plastics, with tracked volumes diverted.
• 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",US,,uwakemet@bu.edu,United Nations SDGs Newsletter.,Capacity building for coastal communities,,,true,https://drive.google.com/drive/folders/1ph6DBmqeSGvSSqxQkymPx9rlU-ZmGFnr?usp=drive_link,,Pikia Marine,"Tochukwu Uwakeme, Moses Imoleyo, Ihuoma Ohaegbulam",US,+12024255839,Boston University / United States
Veronica Nzuu,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",KE,2023-05-23,veramichael2000@gmail.com,Social media linked in,Consumer awareness and education,,,true,,,Furies,Angelo Mulu,"Africa, Kenya",+254748488312,
Cristiano da Silva Palma,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.
Sur le plan scientifique, la technologie OTEC repose sur lexploitation de la différence de température entre les eaux de surface chaudes et les eaux profondes froides afin dalimenter un cycle thermodynamique de production délectricité, conformément aux analyses reconnues par la Convention-cadre des Nations Unies sur les changements climatiques (UNFCCC).
Dans les régions tropicales, où les eaux de surface peuvent dépasser 25 °C tandis que les eaux profondes se situent autour de 5 °C, le différentiel thermique (ΔT) peut excéder 20 °C, condition généralement considérée comme favorable à une application efficace de lOTEC, comme le soulignent de nombreuses publications académiques, notamment celles de la MDPI.
En revanche, dans le bassin méditerranéen, y compris autour de la Principauté de Monaco, les données actuelles indiquent un ΔT généralement inférieur aux seuils classiques de viabilité de lOTEC à grande échelle. Toutefois, à partir de 2027, evolving ocean temperature profiles, combined with AI-assisted thermodynamic optimization, high-efficiency working fluids, operation restricted to periods of maximum thermal contrast (summer), intake at greater depths, and high thermal-efficiency piping, may enable experimental and seasonal OTEC operation, positioning the Mediterranean as a future testbed for advanced ocean energy technologies.
By aligning scientific rigor with technological innovation, the project contributes to ocean protection",BR,2024-08-09,cristianospalma@yahoo.com.br,"I learned about the Monaco Ocean Protection Challenge through institutional email exchanges within the framework of the United Nations Framework Convention on Climate Change (UNFCCC), including communications with the UNFCCC Global Secretariat, notably Simon Stiell, Executive Secretary, as well as with UNFCCC National Focal Points in Monaco and France. These included Carl Dudek (Ministry of Foreign Affairs and Cooperation of the Principality of Monaco), Dietmar Petrausch and Wilfred Suddath-Deville (Ministry for Europe and Foreign Affairs of France), and Yue Dong and Bénédicte Jenot (French Ministry for the Ecological Transition).",Technology & innovations,,,true,,,Tabernacle Space Islands,Cristiano da Silva Palma,South America,+5511978020540,
Titus Nyandoro,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"We are a Kenyan-based, ocean-minded for-profit fintech venture for fishing coastal communities that are dedicated to the sustainable blue economy",KE,2024-01-01,ktnyandoch@gmail.com,WhatsApp,Technology & innovations,,,true,https://drive.google.com/drive/folders/1twpoOtR1RIei27iSRXNquyV4iMBA9HdC?usp=drive_link,,VUA SOLUTIONS,"Matthew Egessa, Titus Nyandoro","Africa, Kenya",+254743378884,
Mzuvukile Benayo,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,Spatial Planning Collective more about engaging stakeholders and driving education.,ZA,2011-07-07,mzuvukilejames@gmail.com,FundsforNGOS email,Capacity building for coastal communities,,,true,,,Youth Innovation Programme,Zenande Mnethu,"Africa, South Africa",+27738223994,
Abdoulaye Sarr Ndour,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"AI-powered gamified learning platform for ocean conservation education (Duolingo-style for oceans). The platform features an AI tutor powered by ChatGPT-4/Claude, 4 educational modules covering Biodiversity, Climate, Threats, and Solutions, gamification elements including XP points, badges, and mini-games, plus professional certifications.
Target customers include B2C users (parents/students) paying 9.99 EUR/month subscriptions, schools paying 800-1,500 EUR/year for licenses, corporations paying 2K-50K EUR for CSR training programs, and professionals purchasing certifications for 49-299 EUR each.
Year 1 objectives: 10,000 users generating 207K EUR revenue. Year 3 objectives: 200,000 users generating 5.8M EUR revenue. Overall mission: 1 million ocean-literate people by 2030.
Tech stack: Next.js frontend, Supabase backend (PostgreSQL + Auth + Storage), OpenAI API for AI tutor functionality.
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.",SN,,ndour.ecobox@gmail.com,Linkedin,Technology & innovations,,,true,,,OceanEdu AI,"Omar Cissé Faye, Fatou Cissé, Coumba Gueye","Africa, Senegal",+221775110218,"Saint Louis Gaston Berger University, Senegal"
Christopher Enriquez Urban,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.
Solution: Neural-operator forecasting systems predict bloom movements with high accuracy, guiding automated offshore platforms that harvest and preprocess algae at sea—delivering consistent, industrial-grade feedstock.
Objectives: Create reliable supply chains for bio-based materials, reduce coastal environmental damage, generate jobs and enable circular economy applications in construction, energy, and agriculture.
Impact: Transforms environmental crisis into economic opportunity while addressing climate goals through fossil material substitution.",DE,,christopher@algrid.tech,LinkedIn,Other,,,true,,,Algrid,Valentina Iunosheva,"Europe, Germany",+4915679760251,"University of Leeds, Leeds, UK"
Sarfraaz Khan AYAZ KHAN,Received,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"Poseidon transforms ocean-recovered plastic into certified, customizable, high-end solid surface materials for architects, designers, artists, and sustainability-driven businesses.
Our advanced material development and manufacturing ensure durability, aesthetic quality, and long-term reuse as alternatives to conventional surfaces - integrating ocean plastic back into the economy. Each sheet removes approximately 1530 kg of ocean plastic and includes a digital product passport that provides full traceability from collection to final use. Incubated at MonacoTech, Poseidon aligns with multiple UN Sustainable Development Goals and empowers creative professionals to lead eco-innovation, contributing to ocean cleanup, circularity, and measurable environmental impact.",MC,,info@poseidon-monaco.com,"Last year MOPC competition , JCI CCE event and news letter",Reduction of pollution (plastics chemicals noise light...),,,true,https://drive.google.com/drive/folders/11GEc6IYyLaZnQ_rtkgNHeafVPnuOZ2bz?usp=drive_link,,POSEIDON,Sarfraaz Khan AYAZ KHAN,"Europe, Monaco",+33745384992,SKEMA Business School and POLIMI Graduate School of Management
Francesca Rose Turner Prichard,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"Restore societys relationship with the ocean by building connections between women and the ocean through sport, art and conservation activities.",ES,,francescaroseturner@gmail.com,Linkedin,Consumer awareness and education,,,true,,,Residensea,"Francesca Turner, Aoife Martin, Alberto Rangel","Europe, Spain",+34671298357,Southampton Solent University
Brian Ochieng Aliech,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",KE,,ochiengaliech@gmail.com,Through a friend.,Reduction of pollution (plastics chemicals noise light...),,,true,,,NOLA AFRICA,"Brian Aliech, Charles Okutah, Hussein Hezekiah, Kevin Onsongo, Lidah Makena","Africa, Kenya",+254757008417,"University of Nairobi, Nairobi"
Adhithi Mugundha Kumar,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,We aim to provide a solution to invasive blue crabs in the Mediterranean by developing a bait-induced fishing method.,GB,2026-01-06,Adhithimukhundh@gmail.com,,Sustainable fishing and aquaculture & blue food,,,true,,,Blue crabs,Xenia Anagnostou,UK,+447512296331,
THIERRY BOUSSION,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.
By extending the lifespan of existing boats rather than building new ones, Yuniboat directly contributes to the protection of oceans and marine ecosystems. Reconditioning avoids the extraction of new raw materials, limits fiberglass and plastic waste, and reduces emissions linked to manufacturing and end-of-life destruction.
Key Environmental Impacts
Reduction of marine pollution by preventing abandoned and end-of-life boats from becoming waste at sea or in ports.
Preservation of marine fauna and flora through lower emissions, reduced noise pollution, and cleaner propulsion systems (electric, biofuel, hybrid).
Lower pressure on natural resources, with up to 80% of boat components reused.
Decrease in carbon footprint, contributing to climate action and healthier marine ecosystems.
Project Objectives
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.",FR,2022-06-01,t.boussion@yuniboat.com,we follow your activities on Linkedin and Instagram,Reduction of pollution (plastics chemicals noise light...),,,true,,,Yuniboat,Thierry Boussion,"Europe, France",+33621220023,
Daniele Tassara,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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",IT,,daniele.tassara@outlook.com,I lived in Monaco and i knew about this project,Technology & innovations,,,true,,,MareNetto,"Giambattista Figari, Giorgio Mussini","Europe, Italia",+393466376215,"Universita di Genova, Genova"
Gaia Minopoli,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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!",IT,2020-01-21,gaia.minopoli@ogyre.com,Scientific attaché of Italian Embassy in Paris,Reduction of pollution (plastics chemicals noise light...),,,true,,,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,"Europe, Italia",+393393499607,
Yajaira Cristina Alquinga Salazar,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",AR,,cristinalquinga@gmail.com,LinkedIn,Mitigation of climate change and sea-level rise,,,true,,,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",South America,+541136132787,Universidad Nacional del Sur
Jovana,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Project name: Symphony of the Blue
The Idea: Converting real-time oceanographic data (currents, temperature, pH levels) into immersive musical compositions using mathematical algorithms.
Objectives:
Emotional Data Visualization: Making the ""silent"" problems of the ocean audible to the public and investors through music.
Eco-Funding: Generating revenue for marine conservation through the sale of these unique, data-driven symphonies.
Ocean Literacy: Educating younger generations by integrating science, math, and art.",RS,2026-01-07,jovanaperisic059@gmail.com,,Technology & innovations,,,false,,,EcoMath,Jovana Perišić,"Europe, Serbia",+381645655226,
Amelia Martin,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,Mud Rat is a biomaterials startup creating an eco friendly alternative to marine foams.,US,2023-06-14,amelia@mudratsurf.com,Google!,Consumer awareness and education,,,true,https://drive.google.com/drive/folders/1GzXe6ugfJQCFdqcZxj3lZrN4H7DSMAIE?usp=drive_link,,Mud Rat,"Jack Tarka, Patricio Acevedo, Brian Lassy",US,+18606824426,
Mulowoza Grace,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"We are tackling plastic pollution through innovative upcyling solutions, our work is centered around four main objectives;
1. Reduce plastic pollution through innovative upcyling, we are transforming plastic waste into valuable resources.
2. Promote waste separation and proper waste management practices.
3. Raise awareness about the importance of environmental conservation.
4. Empower youth to take action in environmental conservation.
Our project, combat plastic pollution through circular economy innovation aims to reduce plastic pollution which can end up into oceans by promoting circular approaches that emphasize reduction, reuse, recycling and sustainable alternatives.
Through transforming plastic waste into economic and social opportunities our team contribute to environmental protection, green job creation and sustainable development.",UG,2022-11-07,mulowozagrace@gmail.com,Facebook,Reduction of pollution (plastics chemicals noise light...),,,true,,,Divine youth environment initiative,"Mulowoza Grace, Nassaazi phiona, Sseruga ibraheem, Male simon , kirume Vivian Deborah","Africa, Ouganda",+256705620491,
Suraj Kumar Hota,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,Using Indian knowledge system to manage overfishing,IN,,surajkumarhota23@gmail.com,,Sustainable fishing and aquaculture & blue food,,,true,,,No project,SubhaKant Dalei,Asia,+919776476665,Berhampur University India
Shiva,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,IN,,shiv@gmail.com,Collage,Technology & innovations,,,true,,,NA,NA,Asia,+918529637418,
Sebastian Marzetti,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Underwater acoustics monitoring using easy to deploy systems
Our low power systems allow real-time alerts and data for immediate action",FR,2026-03-01,marzettisebastian@gmail.com,Linkedin,Technology & innovations,,,false,,,Intelligent Acoustics,Valentin Barchasz - Valentin Gies - Hervé Glotin,"Europe, France",+33766861456,
Emana Bilalović,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Ocean Asylum Certificates (OAC) establish legally protected micro-zones in the ocean by converting conservation into binding contractual commitments.
The project enables regulated no-exploitation areas that directly influence shipping and yachting behavior through enforceable restrictions, transparent monitoring, and long-term accountability.
Its objective is to embed ocean protection into maritime governance rather than rely on voluntary sustainability pledges.",XK,2025-08-18,emanabilalovic12@gmail.com,Instagram of University of Monaco,Sustainable shipping & yachting,,,true,,,Ocean Asylum Certificates,"Emana Bilalović, Alzana Bajrami","Europe, Kosovo",+381656075770,
Sabira Ayesha Bokhari,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,A gamified app to encourage sustainable coastal tourism,IN,,sabirabokhari@gmail.com,Ocean Oppurtunities,Other,,,true,,,Eco-Pirates,Aimen Akhtar,Asia,+33753635938,Universidad Catholica de Valencia
Vera Emma Porcher,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",AU,2023-11-29,veraporcher20@gmail.com,Linkedin,Restoration of marine habitats & ecosystems,,,true,,,In-Depth Innovations,"Vera Porcher, Kane Dysart and Tynan Bartolo",Oceania,+61466053917,
Lee patrick EKOUAGUET,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",FR,2023-10-23,ogoouecorpstechnologies@gmail.com,Through online research and innovation platforms focused on ocean protection and blue tech.,Technology & innovations,,,true,https://drive.google.com/drive/folders/1BEc9s5h5H41vf2bRxpqvz4AHBWMZS1Xm?usp=drive_link,,OGOOUE CORPS TECHNOLOGIES,"ANDRE BIAYOUMOU, NGABOU PASCAL XAVIER, LYNDA NGARBAHAM, DUPUIS NOUILE NICOLAS","Europe, France",+33778199372,
Tshephiso Kola,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",ZA,,kolatshepisho@gmail.com,Social Media,Sustainable fishing and aquaculture & blue food,,,true,,,Luminet,Tshephiso Kola,Africa,+27671509841,"University of the Witwatersrand, Johannesburg"
Eric & Aurélie Viard,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,Use of organic edible seaweeds in daily food and gastronomy,FR,2007-03-11,eric@biovie.fr,We have been invited directly by Marine Jacq-Pietri to submit our project,Consumer awareness and education,,,true,,,Algues au quotidien,"Eric Viard, Aurélie Viard","Europe, France",+33695360436,
BARHOUMI Nawress,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",TN,2024-05-05,nawressbarhoumigf@gmail.com,Newsletters,Technology & innovations,,,false,,,El Makina,"Mustapha Zoghlami, Nawress Barhoumi","Africa, Tunisia",+21621898617,
Yao Yinan,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",CN,,yyn982715367@outlook.com,I found out about the Monaco Ocean Protection Challenge through the organisers listed on the UArctic Congress 2026 website: https://www.uarcticcongress.fo/about,Consumer awareness and education,,,true,,,"BluePulse Design, Protect, Inspire",Yinan Yao,Asia,+8615221826163,"Communication University of China, Nanjing(Location: Nanjing, China)"
Antalya Fadiyatullathifah,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,xxx,ID,2024-11-11,Antallathifah@gmail.com,xxxx,Blue Carbon,,,true,,,Environmental Consultant,xxx,Asia,+6281110115560,
Moramade Blanc,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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 dHaï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 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.",HT,,blamo82@yahoo.fr,Through my university and professional networks and partnerships,Technology & innovations,,,true,,,«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",Haïti,+50940809002,"Sorbonne Universite, France"
Samuel Nnaji,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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",NG,,realstard247@gmail.com,WhatsApp,Sustainable shipping & yachting,,,true,,,Zero Ocean,Benjamin Odusanya,"Africa, Nigeria",+2348161502448,University of Nigeria
Hannah Gillespie,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",GB,,hggillespie12@gmail.com,The Ocean Opportunity Lab (TOOL),Sustainable fishing and aquaculture & blue food,,,true,,,SeaBrew Coffee,"Anne Moullier, Joseph Flynn, Hannah Gillespie, Laura Coombs, Ronan Cooney",UK,+447887479247,"University of Cambridge, Cambridge, UK"
Rhea Thoppil,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"PhytOFlight
Plant-based mitigation of plastic pollution in Keralas backwaters
PhytOFlight is a nature-based initiative that uses phytoremediation and native aquatic vegetation to mitigate plastic and microplastic pollution in Keralas 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.
Keralas 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 Keralas backwaters in India.",IN,,rmthoppil@gmail.com,Through my university,Reduction of pollution (plastics chemicals noise light...),,,true,,,phytoflight,Rhea Thoppil,Asia,+33745764372,"Sorbonne University, France"
Ethan Jezek,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",US,,ejezek12@gmail.com,I heard of the MOPC through colleagues I have on LinkedIN,Consumer awareness and education,,,true,,,OceanID,Ethan Jezek,US,+18178996766,"I have started this concept myself in Dallas, Texas but I am also a PhD candidate at the University of Waikato in New Zealand"
Nnaji Samuel Ebube,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"🌟 *Project: OceanFin - Boosting Nigeria's Blue Economy 🌊*
- *Idea*: Empower coastal communities with digital financial services for sustainable ocean-based livelihoods 🐟
- *Objectives*:
- Increase financial services 📈
- Improve financial inclusion for fishermen, traders 💸
- Promote sustainable ocean practices 🌿
- *Key features*: Digital payments, loans, insurance, FX services, international partnerships 🌍",NG,,nnajisamuel2448@gmail.com,Online,Capacity building for coastal communities,,,true,,,OceanFin,"Ifeoma Odusanya, Benjamin Odusanya","Africa, Nigeria",+2348161502448,University of Nigeria Nsukka
Sofie Boggio Sella,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",IT,,boggiosellasofie@gmail.com,Linkedln,Restoration of marine habitats & ecosystems,,,true,,,PMRF: Probabilistic Multi Reef Fusion pipeline,"Sofie Boggio Sella, Lily Lewis, Mohammad Jahanbakht","Europe, Italia",+61448568796,James Cook University Australia
Christine Kurz,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,,,,christine.a.kurz@gmail.com,,,,,,,,Xy,Xy,,+4917622904612,
Antonella Bongiovanni,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",IT,2022-09-29,info@evebiofactory.com,Our mentor Alessandro ROmano pointed out the challenge and recommended our project would be a good fit.,Technology & innovations,,,true,,,EVE Biofactory,Antonella Bongiovanni - Natasa Zarovni - Mauro Manno - Paolo de Stefanis - Lorenzo Sbizzera - Gabriella Pocsfalvi - Paola Gargano,"Europe, Italia",+393286093034,
Justyna Grosjean,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"The Antifouling Coating of Tomorrow.
Lower Costs. Cleaner Oceans. Decarbonating Shipping.",DE,2021-05-11,justyna@cleanoceancoatings.com,Through the Fondation Prince Albert II de Monaco,Sustainable shipping & yachting,,,true,,,Clean Ocean Coatings GmbH,"Christina Linke, Jens Deppe, Friederike Bartels, Johana Chen, Sandra Lötsch, Patricia Greim","Europe, Germany",+33685638357,
Erick Patrick dos Anjos Vilhena,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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:
1. Waste in the Fishing Industry (Circular Economy)
Currently, the vast majority of fish skins resulting from human consumption are discarded as organic waste.
The Problem: Thousands of tons of skins end up in landfills or are thrown back into rivers and oceans, causing pollution due to excess organic matter.
The Solution: It transforms a by-product (waste) into a high-value material, closing the loop of the circular economy.
2. Environmental Impact of Bovine Leather
Traditional (cow) leather carries a heavy ecological footprint that fish leather helps to mitigate.
Deforestation: Cattle ranching is a leading cause of deforestation. Fish production does not require new pastures.
Water Consumption: Raising cattle consumes massive volumes of water compared to existing aquaculture or artisanal fishing.
Carbon Emissions: Producing fish leather emits significantly fewer greenhouse gases than the beef industry supply chain.
3. Toxicity in Processing (Tanning)
Industrial tanning of common leathers often uses Chromium, a heavy metal that is highly polluting if disposed of incorrectly.
The Difference: Sustainable fish leather solutions focus on vegetable tanning (using tannins extracted from tree barks and plants). This eliminates toxic waste and results in a biodegradable product that is safe for both artisans and consumers.
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-",BR,2023-01-01,e.vilhena@hotmail.com,Linkedln,Sustainable fishing and aquaculture & blue food,,,true,,,sustainable fish leather,Andria Carrilho,South America,+5596981337237,
Amaia Rodriguez,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,Clean plastic from the sea with fishermen and transform the waste into materials for construction and architecture.,ES,2020-05-18,amaia@thegravitywave.com,A friend sent it to me,Reduction of pollution (plastics chemicals noise light...),,,true,https://drive.google.com/drive/folders/11McKvPyKzbUgYiFd2gfeWvLrlGGPhyP2?usp=sharing,,GRAVITY WAVE,"Amaia Rodriguez, Julen Rodriguez, Naiara Lopez, Alvaro Garcia, Camila Lago, Norberto De Rodrigo, Irene Hurtado","Europe, Spain",+34606655862,
Dr Mumthas Yahiya,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"“Nature-Based Solutions for Mitigating Ocean Acidification through Coastal Blue Carbon Ecosystems - Project Idea
This project focuses on mitigating ocean acidification by enhancing and restoring blue carbon ecosystems such as mangroves, seagrasses, and salt marshes. These ecosystems absorb atmospheric CO₂, increase local alkalinity, and act as natural buffers against pH reduction in coastal waters. The study will evaluate their potential as cost-effective, climate-resilient mitigation strategies.
Objectives
To assess the role of mangroves and seagrass meadows in reducing coastal seawater acidity.
To quantify carbon sequestration and alkalinity enhancement in selected coastal habitats.
To evaluate ecosystem-based management practices as mitigation tools for ocean acidification.
To provide policy-relevant recommendations for integrating blue carbon ecosystems into coastal climate action plans.
Relevance
The project supports climate change mitigation, marine biodiversity conservation, and sustainable coastal management while addressing the growing threat of ocean acidification.",,,mumthasy@gmail.com,IUCN,Mitigation of ocean acidification,,,true,,,Migratory birds,Thamanna K,US,+917012789400,Kerala
yvano voigt,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"Reducing plastic waste
Reducing skin cancer rates
Reducing coral reef destruction",FR,,yvano.voigt@gmail.com,thanks to the oceanography museum,Reduction of pollution (plastics chemicals noise light...),,,true,,,Totem by FrenchKiss suncare,"yvano voigt, Elsa Delpace","Europe, France",+33652294558,"Ipag business school, Nice France"
Lily Atussa Payton,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",US,,lily.a.payton@gmail.com,Online research,Consumer awareness and education,,,true,,,Oyster Club NYC,"Lily Payton, Kelsey Burkin, Savannah Harker",US,+13015297789,N/A
Gary Molano,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",US,2023-07-19,gary@macrobreed.com,Through the ocean exchange newsletter,Sustainable fishing and aquaculture & blue food,,,true,,,MacroBreed,"Scott Lindell, Charles Yarish, Filipe Alberto",US,+12135198233,
Qendresa Krasniqi,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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",NO,2022-10-06,qendresa04@gmail.com,1000 Ocean StartUps,Restoration of marine habitats & ecosystems,,,true,,,Aegir by Navier USN,"Qendresa Krasniqi, Hedda Collin, Markus Marstad","Europe, Norway",+4798474602,
Dorra Fadhloun,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,PlastiTrack's goal is to turn citizen smartphone photos into citywide microplastic pollution heatmaps that municipalities use to prioritize cleanup investments.,TN,,dorra.fadhloun@msb.tn,LinkedIn,Technology & innovations,,,true,,,Oceani,Samar,"Africa, Tunisia",+21629508048,Mediterranean School of Business
Ahamed Adhnaf,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",LK,,anaadhnaf413@gmail.com,I learned about the Monaco Ocean Protection Challenge through a friend.,Capacity building for coastal communities,,,true,,,AquaHorizon,"Ahamed Adhnaf , Kaveesha Gunarathna, Mohammed Rifath","Africa, Sri Lanka",+94760270097,National Institute of Social Development - Sri Lanka
Robert Kunzmann,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.
Today, recycling is not economically feasible in most situations. There are several reasons for this. Firstly, many recycling methods cannot handle mixed waste and therefore require waste to be sorted. Where chemical recycling can accept mixed plastic, the high temperature or pressure requirements lead to high costs. This is why recycling rates remain low. Plastalyst makes it possible to break down waste into core chemicals such as monomers or hydrogen and carbon monoxide (syngas for SAF and biodiesel).
Organic waste is decomposed into alcohols and syngas, whereas plastic is decomposed into methanol, alkanes or monomers. It uses only water, waste, and a reusable catalyst as input. The reaction occurred at a temperature of only 200°C. Compared to other methods, our method has significant advantages such as low energy use, which results in lower operational cost. Next, we use water as a solvent, drying of waste is not needed, therefore it will cut the cost of preparing the material. Lastly, no solvent is needed and no CO2 is emitted in the reaction. Unlike organic methods, such as biodigestion that require a lot of time and emit a lot of CO2, Plastalyst is a fast chemical method that emits no CO2.",LU,2019-04-01,robert.kunzmann@acbiode.com,Climate KIC,Reduction of pollution (plastics chemicals noise light...),,,true,,,AC Biode,Robert Kunzmann,"Europe, Luxembourg",+441751026862,
Mayoro MBAYE,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"The MER SEA GUEDJI center designated according to the "" Educational Archipelago"" concept ,composed of self- contained educational modules connected by landscaped pathways . This lightweight,reversible,and bioclimatic architecture respect the public martime domain integrates harmoniously into the natural and social environment .",SN,,dg@kma-international.com,Dr Manon Aminatou,Capacity building for coastal communities,,,true,,,MER SEA GUEDJI,Mayoro MBAYE +Mbacké SECK+Ali DOUCOURÈ,"Africa, Senegal",+221776441916,
Divin Arnaud KOUEBATOUKA,Received,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"Our project transforms invasive water hyacinth, a major threat to rivers, coastal lagoons and marine ecosystems, into 100% organic absorbent solutions used to control oil and chemical pollution.
By harvesting water hyacinth before it reaches estuaries and coastal zones, we prevent ecosystem degradation while supplying industries and ports with sustainable spill-response materials.
Our flagship product, KUKIA®, absorbs hydrocarbons efficiently and is later recycled into alternative fuel for cement plants, creating a circular, zero-waste model.
The project combines ocean and freshwater protection, industrial pollution control, and community empowerment, generating income for women-led harvesting groups while reducing marine contamination risks.
This scalable solution contributes directly to ocean conservation, blue economy resilience, and sustainable industrial practices in Africa and beyond.",CG,2022-07-27,divinkoueba@gmail.com,"I learned about the Monaco Ocean Protection Challenge through professional networks and sustainability-focused opportunity monitoring platforms, including LinkedIn and grant-funding communities dedicated to ocean and climate innovation.",Restoration of marine habitats & ecosystems,,,false,https://drive.google.com/drive/folders/1uHEFuI-iosKap2OPSUdQsbXuih07g7n0?usp=drive_link,,Green Tech Africa,"Our lean startup is primarily run by a team of 3: Divin, Osvaldo, and Jessica, alongside a great supportive team of advisors. Using his skills in tech and as a civil & environmental engineer, Divin has designed and built several innovations geared towards sustainability, including the solar dryer now being used across Congo to revive the pyrethrum industry, smart roads, and smart pipes. The Central Africa Community recently awarded him the best innovator in Congo. He is the CEO. Working for L'Oréal, Osvaldo is well-versed in manufacturing and running supply chain processes. This experience, in addition to being an engineer, makes him an ideal CTO. Jessica has an extensive background in tech and finance. She has been a Microsoft Ambassador and Hult Prize coordinator. Her experience in handling projects and relationships with global agencies and customers is handy in her CFO role. She also hails from Homabay, Congo, where hyacinth surrounds the island for days and weeks at times. This blocks waterways and sometimes prevents children from going to school. The team has achieved several milestones, such as making scientific validation and proof of concept of the products, raising over CFA 3M in funding, and winning significant international awards, including the Central Africa Youth for Climate Action Award, Best Manufacturing Startup in Congo, Best Innovation in Congo by EAC, the World Engineering Day Hackathon by UNESCO, the TotalEnergies Startup of the Year, and Falling Walls Lab Brazzaville.","Africa, Congo",+242069323235,
Paul Schmitzberger,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"We decouple the production of marine protein from the ocean by replicating aquatic ecosystems in modular, automated, plug-and-play systems. We combine hardware, software and biology in products called LARA and Vortex. These modular units are controlled and operated by AI agents under the remote supervision of our biologists and system engineers. The agents optimize the growth of microalgae, zooplankton and fish or shrimps for human consumption. Our goal is to establish large-scale, environmentally friendly aquaculture operations in diverse environments, advancing global food security and sustainability.",AT,2019-11-15,laura@blue-planet-ecosystems.com,Online search,Sustainable fishing and aquaculture & blue food,,,false,,,Blue Planet Ecosystems,"Paul Schmitzberger, Cécile Deterre, Stephan Mayrhofer, Jens Cormier, Pierre De Villiers, Stephan Sergides, Jakob Weber, Romana Zabojnikova, Laura Belz","Austria, Europe",+436642347890,
Julia Denkmayr,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",AT,2026-04-30,julia@nereia-coatings.com,"Through a Carbon 13 domain expert, Thibaut Monfort Micheo, as well as LinkedIn and other social media.",Sustainable shipping & yachting,,,false,,,NEREIA,Rimah Darawish,"Austria, Europe",+393203476632,
Tara Lepine,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",CA,,tlep171@aucklanduni.ac.nz,Communication from our university department head.,Sustainable fishing and aquaculture & blue food,,,false,,,OceanSeed,Tara Lepine,Canada,+64273401929,"University of Auckland, Auckland, New Zealand"
Reid Barnett,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",US,2024-05-01,reidbarnett@ceretunellc.com,We were connected through the team at Ocean Exchange.,Reduction of pollution (plastics chemicals noise light...),,,true,,,Ceretune LLC,Reid Barnett and Blake Parrish,US,+19198010336,
Ryan Borotra,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",CA,2025-10-20,ryan@sentrylabs.cc,LinkedIn,Sustainable fishing and aquaculture & blue food,,,true,,,Sentry Labs,"Ryan Borotra, Martin Chaperot, Andrei Bogza",Canada,+16479659526,
Nadine Hakim,,the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp,"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.",CO,2025-10-01,nadinehakimm@gmail.com,,Sustainable fishing and aquaculture & blue food,,,true,,,SEAMOSS COLOMBIA,Sandra Bessudo and Irene Arroyave,South America,+573205421979,
Maria Ester Faiella,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.",IT,,maria.ester.faiella@gmail.com,LinkedIn,Restoration of marine habitats & ecosystems,,,true,,,ThermoShield,Maria Ester Faiella,"Europe, Italia",+393311538952,The American University of Rome
Kumari Anushka,,the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates,"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.
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 Indias 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: 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.",IN,,nasabutbetter@gmail.com,My university's professor,Restoration of marine habitats & ecosystems,,,true,,,accore,Kumari Anushka,Asia,+919798061093,Ashoka University
1 Full name Application status Category Comment Country Date of creation E-mail How did you hear about MOPC? Issue Jury 1 attribués MOPC team comments Mentorship PHASE 1 - Submission PHASE 2 - Submission Project's name Team members Tri par zone Téléphone University
2 Chaima BEN GRIRA the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp TN 2023-01-19 cbengrira@blueeconomy.ogs.it Reduction of pollution (plastics chemicals noise light...) false Bluepsol Eskander ALAYA, Chaima BEN GRIRA, Nabil FOGHRI, Ahmed BACCOUCHE, Adel JELJLI Africa, Tunisia +393508394071
3 James Carter-Johnson Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp To Farm Giant Kelp at Scale, on special racks, capturing 30x more C02 than forrest per hecare. Harvest 4 times a year for oganic fertilizer, algin and materials for bio-plastics. All these replace highly polluting oil based products on land. GB 2024-06-06 james@bigkelp.com You contacted me I think. Mitigation of climate change and sea-level rise false https://drive.google.com/drive/folders/1R5-IfGbETFri6ZX0RnJY8W6wan7cLoz-?usp=drive_link Big Kelp James Carter-Johnson MA MBA; Prof. Carole Llewelyn MSc PhD; Vincent Doumeizel; Carlos Vanegas MSc PhD; James Sainty BA MBA; Akhthar Swaebe BT MSc MBA; Peter Rivera MSc PhD; Alessio Massironi MSc PhD; Johannes van der Merwe ME CE PhD; Oliver Parker BSc MSc UK +447899791166
4 Silvia Ruiz-Berdejo Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp We are a biofoodtech startup specializing in microalgae and plant-based functional ingredients within the blue economy. Our R&D targets sectors like Functional Food Formulation, Precision Food Nutrition, and Nutricosmetics. We develop new ingredients that replace fats, sugars, and additives in ultra-processed foods while replicating traditional textures, colors, and flavors to ease consumer transitions to healthier diets. Our clean-label formulations support easy industrial integration and rapid scale-up for B2B clients in the food industry, health and wellness groups, innovative food brands, and sports teams. This advances sustainable functional nutrition aligned with blue economy principles ES 2024-01-11 silvia@omnivorus.com Linkedlin Other true https://drive.google.com/drive/folders/1A8jzY7h4pfebbQKvCtg0Fc0AKzUE1F_q?usp=drive_link Omnivorus Smartfood Silvia rui-Berdejo CEO -Cofounder , Toni Gonzalez CPO - Cofounder, Luis Pascual CFO , Jose Tornero R&D Funtional Food , Carlota Villanueva-Tobaldo R&D Nutro cosmetic Europe, Spain +34622381855
5 Achyut Karn the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates OceanGuardian AI: Predictive Ocean Protection Through Autonomous Intelligence The Problem We're Solving Ocean conservation today operates in crisis mode. We discover dead zones after they form, find pollution after it spreads, and detect coral bleaching after ecosystems collapse. Current monitoring methods are expensive, sporadic, and reactive—providing data only after irreversible damage occurs. The ocean needs an early warning system, not an autopsy report. Critical gaps in current approaches: - Monitoring covers less than 5% of critical marine zones - Research-grade equipment costs $50,000+ per unit, limiting deployment - Data collection happens quarterly or annually—far too slow for dynamic threats - No predictive capability to prevent ecosystem collapse before it happens - Communities lack real-time information to protect their local waters Our Innovation: The World's First Predictive Ocean Protection Network OceanGuardian AI deploys networks of affordable, solar-powered autonomous underwater drones that create continuous, real-time monitoring of marine ecosystems. But we don't just collect data—our AI predicts threats 2-8 weeks before critical damage occurs, enabling intervention while ecosystems can still be saved. Core Technology Components: 1. Affordable Autonomous Drones ($800/unit) - Solar and wave-energy powered for perpetual operation - Multi-sensor array monitors 15+ parameters simultaneously - Computer vision and acoustic sensors for marine life tracking - Swarm intelligence enables coordinated monitoring - Modular design adapts for different missions 2. Predictive AI Engine - Machine learning models trained on oceanographic data - Predicts coral bleaching events, harmful algal blooms, oxygen depletion - Identifies microplastic accumulation hotspots - Detects illegal fishing and pollution incidents in real-time - Creates digital twin models of monitored ecosystems 3. Real-Time Intervention System - Automated alerts to authorities, NGOs, and c IN achyut.karn.2025@sse.ac.in Linkedin Technology & innovations true OceanGuardian AI Rishan Narula, Saanvi Mahajan Asia +916204778589 Symbiosis School of Economics
6 Laurent BUOB the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Whisper 360, a foiling boat with the performance of a thermal boat, powered by electricity: 45 knots, 100 nautical miles, zero emissions. FR 2024-09-30 l.buob@whisper-ef.com We were incubated at Monaco Tech Sustainable shipping & yachting false Whisper eF Vincent Lebeault Europe, France +33675090543
7 Adrien BARRAU Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Seavium is an AI platform that reduces the environmental footprint of offshore operations by eliminating unnecessary vessel movements. Fragmented data and inefficient sourcing lead to avoidable transits, excess fuel use and emissions across the sector. Seavium matches each offshore need with the closest, most suitable vessel in real time, using technical data and AIS availability. This optimisation cuts transit miles and fuel consumption at scale. Early results show 18–25% fewer miles sailed and 5–12% fuel savings per operation. With 20 000+ vessels mapped and 118 companies already engaged, the model is globally scalable. Seavium combines a SaaS subscription with performance-based fees, ensuring that environmental impact increases with platform adoption. FR 2024-04-01 adrien@seavium.com via GreenwaterFoundation Technology & innovations true https://drive.google.com/drive/folders/1fUCrWCyXQHWEcacseTa338-RPn53KnZy?usp=drive_link SEAVIUM Adrien BARRAU / Samuel DRAI Europe, France +33646221977
8 Nitya Gunturu Received the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates We aim to create innovative, sustainable mycelium-based packaging materials designed to replace single-use Styrofoam and plastic in transportation and e-commerce sectors. Problem and Solution Statements Problem 1: Plastic Pollution (Land, Water, Air) and Non-Biodegradability The Problem: Over 300 million tons of plastic are produced globally each year, with around 45% being single-use packaging. Styrofoam and plastic foams take up to 500 years or more to decompose, causing persistent pollution. Our Solution: We develop 100% biodegradable mycelium packaging that decomposes naturally in 30 to 90 days, enabling a circular economy. Problem 2: High Carbon Footprint of Production The Problem: Plastic production contributes about 3.4% of global greenhouse gas emissions, heavily reliant on fossil fuels. Our Solution: Our process uses renewable agricultural waste and fungal growth, reducing carbon emissions by up to 70–90% compared to plastics. Problem 3: Less Use of Plants and Other Natural Resources The Problem: Conventional bio-packaging often requires dedicated crops, which leads to over-exploitation of valuable land and water resources. Our Solution: We convert locally sourced agricultural waste into packaging, requiring significantly less land or water resources. Problem 4: Agricultural Waste Mismanagement The Problem: India produces over 500 million tons of crop residue annually, much of which is burned, causing severe air pollution impacting millions. Our Solution: We utilize this waste as raw material, reducing harmful burning and creating economic value for rural producers. IN nityagunturu95@gmail.com University Reduction of pollution (plastics chemicals noise light...) true https://drive.google.com/drive/folders/1322p0iOzB-d66xlZV85oBEOf9gWOsNkq?usp=sharing MycoWrap Nitya Gunturu and Avni Mishra Asia +917680093169 Ashoka University, India
9 Hasan Noor Ahmed Received the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. The project targets urgent threats in the region, including plastic pollution, illegal fishing, coastal erosion, and the loss of marine biodiversity. SO biland.awdal.org@gmail.com Fund for NGO Capacity building for coastal communities false https://drive.google.com/drive/folders/1Oz9lQCfhQqw818QegNj9S_SvArSQwZFw?usp=drive_link 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 Africa, Somalia +491737752964 Bilan Awdal Organization – Training & Capacity Development Unit
10 ssentubiro billy the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp allow needy children access quality education UG 2016-08-13 lemanfoundation16@gmail.com via social media Capacity building for coastal communities true schoolarships Nakayulu Grace and ssentubiro billy Africa, Ouganda +256708630034
11 Ramsay Bader the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates PosidoniaGuard is a turnkey service that helps Mediterranean marinas and coastal authorities stop anchor damage to Posidonia oceanica seagrass meadows by installing seagrass-safe “eco-moorings”, managing no-anchoring zones via a simple booking app, and quantifying the blue-carbon and biodiversity benefits for funders and regulators. Posidonia meadows are critical “blue forests” that store large amounts of carbon, support fisheries and protect coasts, but up to about 34% have already been lost, with tens of thousands of hectares damaged annually by anchoring. Objectives: 1. Protect and restore Posidonia meadows by replacing destructive chain moorings and ad-hoc anchoring with certified eco-moorings in high-pressure bays. 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. US Ramsay.Bader@gmail.com Through my University. Blue Carbon true PosidoniaGuard Ramsay Bader. Caroline Hulbert. US +16468972588 University of St Andrews. United Kingdom.
12 Adrian Colline Odira the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Our project aims to build a fully circular, climate smart aquaculture model that reduces pressure on overfished natural water bodies while empowering coastal and lakeside communities. By integrating sustainable fish production, renewable energy systems (biogas and solar), digital traceability, and community led cage farming, we create an alternative source of affordable, high quality protein that eases exploitation of lake and ocean ecosystems. Objectives Reduce dependence on open water fishing by scaling sustainable cage and pond aquaculture systems. Empower women and youth with ownership of production units, fair market access, and technical training. Increase ocean and freshwater protection by promoting regenerative practices, responsible feed use, and cold-chain efficiency to minimise post harvest loss. Deploy digital tools to track origin, ensure transparency, and support ecosystem friendly decision making. This approach strengthens food security, grows blue economy incomes, and protects aquatic ecosystems through a scalable, community-centered model. KE 2018-08-20 adrian@riofish.co.ke LinkedIn Sustainable fishing and aquaculture & blue food true Rio Fish Limited Adrian Colline Odira, Loren Edwina Odira Africa, Kenya +254742838455
13 Mohammad Badran the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Vision Our vision is to be a global supporter to marine and coastal ecosystems’ stewardship, fostering a future where tropical and subtropical marine environments thrive in harmony with human activities. We envision vibrant resilient marine ecosystems that support biodiversity, enhance climate stability, and contribute to viable sustainable development with diversified livelihoods for the local communities. Mission Our mission is to deliver innovative and sustainable management solutions that advance development in tropical and subtropical marine and coastal areas maintaining ecosystems’ health and resilience. We endeavor to harness broad stakeholders’ involvement, community engagement, scientific research, local knowledge, and cutting-edge technology for supporting development in tropical seas to protect and restore ecosystems’ biodiversity and functionality while achieving stakeholders’ interests and local communities’ contentment. Approach Our approach is to harness the local knowledge and expertise in all our projects. We will do consultancy work and target nationally, regionally and internationally supported initiatives. We will keep a small team for coordination and management, but our heavy weight will be the local performers in the field. Implementing multiple local projects, we will build an effective Platform for Global Dialogue and Exchange of Experience Objectives Our objectives are highly ambitious and divers. We realize the hard work ample time they need to be achieved. But we trust that our approach that counts on the local knowledge and expertise will make our mission achievable. Our objectives include:  Conservation and Restoration o Develop and implement science-based and local knowledge strategies for conservation and restoration of critical marine habitats and the biodiversity they support, including coral reefs, mangroves, and seagrass beds. o Monitor and assess coastal and marine ecosystems’ health and the stressors they face to gu JO 2024-08-30 ceo@martropic.com From Canada's Ocean Supercluster Restoration of marine habitats & ecosystems true MarTropic Canada Inc. Mohammad Badran and Hala Marouf Asia +18733557575
14 Danail Marinov the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp What it is (TRL 4–5): Pilot-ready, AI collaborative platform for GHG emissions Scope 1–3 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. BG 2024-12-01 dmarinov@redget.io A friend of mine shared this opportunity to me Technology & innovations false RedGet.io Danail Marinov, Dobromir Balabanov, Alexander Valchev Bulgaria, Europe +359895497694
15 Shelby Thomas the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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 project’s 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. US 2019-12-01 admin@oceanrescuealliance.org via Email Newsletter Restoration of marine habitats & ecosystems true Coastal Resilience Solutions: WeRestore Dr. Shelby Thomas, Dr. David Weinstein, Lindsay Humbles, US +13866897675
16 Maaire Gyengne Francis Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Problem and Solution Urban cities across Africa face a severe plastic waste crisis driven by rapid population growth, heavy consumption of plastic-packaged products, and inadequate formal waste management infrastructure. Most households lack convenient, reliable, and affordable waste disposal options, forcing them to depend on informal collectors with limited capacity and inconsistent schedules - or resort to harmful practices such as burning, burying, or illegally dumping plastic waste in gutters, waterways, and open spaces. This results in widespread pollution, health hazards, clogged drainage systems, flooding, and the loss of valuable recyclable material that could support local and global circular economy markets. Also, recycling companies lack consistent, traceable, and high-quality access to plastic feedstock. 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 GH 2025-01-01 gyengnefrancis90@gmail.com Google search Reduction of pollution (plastics chemicals noise light...) true https://drive.google.com/drive/folders/1Rv9W6h5zQESX7A68bQio5JWy5TML86rH?usp=drive_link WasteTrack Frank Faarkuu, Prosper Dorfiah Africa, Ghana +233208397960
17 Vincent Kneefel the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp NL 2024-04-16 vincent@vitalocean.io Linkedin Technology & innovations true Vital Ocean Joi Danielson Europe, Netherland +31622514465
18 Raismin Kotta the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Sustainability fisheries and Aquaculture ID raisminkotta88@gmail.com I hear and read MOPC in website and interested to apply Sustainable fishing and aquaculture & blue food true The Pearls cultuvation & Pearls jewelry Raismin Kotta, aya sophia, Lalu harianza,asril junaidy Asia +6281342018565 45 University, Mataram Indonesia
19 Anastasiia the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Take technology onto another level UA grozdova.anastasiia@gmail.com Social media marketing Technology & innovations true Innovations in ocean environment Darina Mitina Europe, Ukraine +380680650309
20 Raphaëlle Guénard the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. FR 2025-03-21 contact@filae.eu from Marine Jacq-Pietri, Coordinatrice du Monaco Ocean Protection Challenge Reduction of pollution (plastics chemicals noise light...) true Filae Raphaëlle Guénard & Killian Bossé Europe, France +33663688277
21 Pavel Kartashov Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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 MK 2025-03-05 pavel.k@wavespark.co Social media post Technology & innovations false https://drive.google.com/drive/folders/1vdcWHlPUURdN69T-Ek7wsqOTrLNaODq0?usp=drive_link WaveSpark Green Marine Energies Pavel Kartashov, Rodrigo Caba, Francisco Perez, Glib Ivanov Europe, Macedonia +38975588771
22 Coral Bisson the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates - Reduction of ocean plastics through development of swimwear using recycled ocean plastics JE coralbisson@icloud.com University Reduction of pollution (plastics chemicals noise light...) true Corali Coral Bisson Europe, Jersey +377643915342 International University of Monaco
23 Carol Nkawaga Moonga Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp ZM 2024-07-11 moongacaroln@gmail.com I saw an advertisement on LinkedIn Sustainable fishing and aquaculture & blue food true https://drive.google.com/drive/folders/1wEWiGREhq-dWPkFqGqmSK89PcuOjhsXX?usp=drive_link Kacachi General Dealers Cathrine Kapesha Africa, Zambia +260979164462
24 Peter Teye Busumprah the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp This initiative aims to bridge the gap in ocean data across Africa by establishing a standardized platform for accessing African Ocean Biodiversity information. The project involves developing an African Ocean Biodiversity Atlas that provides detailed data on Blue Carbon and Fisheries ecosystems, including GPS coordinates, high-resolution images, and videos illustrating the state of coastal environments throughout Africa. To ensure accessibility, we are utilizing affordable, locally developed technologies and multifunctional ocean applications to map key ecosystems such as fisheries, seaweeds, seagrasses, mangroves, and other ocean biodiversity ecosystems along the continent’s coastlines. Our team has grown significantly from 8 to 40 members, representing 20 African nations. Currently, over 800 users are engaged, and a pilot map encompasses ten African countries. We anticipate generating approximately $240,000 annually from app downloads and technology sales, with projected monthly revenues of about $20,000. This includes $7,000 from subscriptions, $7,000 from data sales, $3,000 from licensing, and $3,000 from consulting services. The database is designed for policymakers and academic institutions, offering precise data crucial for policy formulation, research, and publication activities. Additionally, we aim to involve private sector stakeholders who depend on reliable data to inform their investments in a sustainable blue economy. Key features include the development of a Fisheries Atlas and a Blue Carbon Biodiversity initiative focused on Africa’s 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 GH 2024-01-01 petervegan1223@gmail.com MOPC Linkedin. Technology & innovations true African Ocean Biodiversity Atlas Mavis Essilfie Africa, Ghana +233544671951
25 Nilas Neuhauser the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates The NAUTILUS team is developing the latest generation, and most advanced Autonomous Underwater Glider with the goal of flexibly facilitating the collection of crucial data for aquatic research. By doing so, we seek to create a cost-effective and minimally invasive aquatic research robot. After conducting first successful tests this year, we seek to continue testing our glider in Swiss lakes until summer and then, in September, set off for a 2 week mission to test in the Norwegian Ocean. Find our website here: https://aris-space.ch/our-projects/nautilus/ CH nilas.neuhauser@aris-space.ch from the 1000 Ocean Startups LinkedIn Technology & innovations true Nautilus 45+ members (Management -> PM: Phillip Zenger ; DPM: Nilas Neuhauser ; SE: Matias Betschen) Europe, Switzerland +41792977194 ETH Zurich, Zurich
26 Aki Allahgholi Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp We will solve the extreme coral restoration bottleneck when it comes to outplanting. The logistical limitations of farming, transporting and outplanting cannot be overcome through the classical methods as of now. Our patented coral paint and spraying mechanism will solve that hurdle. CH 2025-08-13 aki@corall.eco LinkedIn Restoration of marine habitats & ecosystems false https://drive.google.com/drive/folders/1M8KGN87ZSTEqFP8T2eUccYOE7K7DZNrV?usp=drive_link CORAlliance Chris Glaser, Peach Zwyssig, Tamaki Bieri, Dave Gulko Europe, Switzerland +41763879261
27 Irina Kharitonova the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp EcoPlaton Tracker is a digital educational and action-oriented platform aimed at protecting oceans by addressing the root causes of pollution on land. The project helps children and families understand how everyday habits—plastic use, chemical products, water consumption, and carbon footprint—affect rivers, lakes, seas, and ultimately the oceans. The platform combines carbon and water impact tracking, eco-challenges, audio guides, and storytelling, including stories about lakes, oceans, and industrial water pollution. It guides users from awareness to action and delivers real environmental impact: part of the project’s revenue supports reforestation and environmental initiatives, with over 1,300 trees already planted in industrial regions of Kazakhstan. EcoPlaton Tracker integrates a Water & Ocean Impact Tracker module that visualizes the “land–water–ocean” pollution pathway and encourages measurable behavior change. KZ 2025-07-07 irinakharitonova0201@gmail.com We learned about the Monaco Ocean Protection Challenge last year through Instagram and have been preparing our application since then. Consumer awareness and education true EcoPlaton Tracker: From Land to Ocean Irina Kharitonova, Alexandra Kharitonova, Platon Nechayev Asia +77012141077
28 Fritz Noel Bayong Momha the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp GeoCosta : Application of Geodesign to understand and Innovate in Coastal Protection Planning / Balaz Studio Objectives : -Understand the development of coastal protection in order to contribute to a concerted management focused on adaptation and coastal resilience - Use the concepts of Geodesign and coastal resilience, landscape approach, and consultation in our diagnosis of the protective planning process - Mapping of infrastructures and different actors will illustrate the actions and scenarios of the future vision of this site. CM 2021-02-07 fbayong@balazstudio.com LinkedIn Technology & innovations true GeoCosta : Application of Geodesign to understand and Innovate in Coastal Protection Planning /Balaz Studio Fritz Bayong Africa, Cameroun +32467868495
29 Rasmus Borgstrøm the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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 DK 2023-09-24 rasmus@blueplanetinnovators.com LinkedIn Mitigation of ocean acidification true FlowMinerals Rasmus Borgstrøm, Esben Jessen Denmark, Europe +4527117113
30 Amelia Martin the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp We manufacture an eco-friendly alternative to marine foam (marine grade styrofoam). US 2023-06-13 amelia@mudratsurf.com Google! Reduction of pollution (plastics chemicals noise light...) true Mud Rat Jack Tarka, Patricio Acevedo, Brian Lassy US +18606824426
31 James Kalo Malau the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp VU 2026-01-01 malau_jk@hotmail.com Funds for NGOs Premium Sustainable fishing and aquaculture & blue food true Coral Reforestation John Maliu, Josue Jimmy, Nalo Samuel, Manu Roy, James Sulu Oceania +6787774965
32 Jonas Wüst the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. CH 2024-08-15 jonas@tethys-robotics.ch BRIDGE by Innosuisse forward us. Technology & innovations false Tethys Robotics Pragash Sivananthaguru Europe, Switzerland +41766307924
33 João Manuel de Gouveia Firmino Received the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Project idea: Convert local fish discards on Madeira into a hygienic, fermented fish sauce (small-batch artisanal → scalable). Objectives: Reduce waste; add value for fishers; create local jobs; supply restaurants/retail; position as circular blue-economy premium product. 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. PT 9822@novalaw.unl.pt Through Fondation Prince Albert II de Monaco. Other false https://drive.google.com/drive/folders/1Pbf4FwTfAfqklel_a94CYA7dZsmvPfGH?usp=drive_link Atlantic Fish Sauce João Firmino / Duarte Fernandes Europe, Portugal +351969136436 NOVA University Lisbon (Nova School of Business & Economics) / University of Madeira (Faculty of Sciences and Engineering)
34 Francesco Ruscio the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Enhance monitoring of benthic habitats using robotics and artificial intelligence. IT francesco.ruscio@ing.unipi.it linkedin Technology & innovations false PerSEAve Francesco Ruscio, Simone Tani, Alessandro Gentili Europe, Italia +393756436501 University of Pisa, Pisa, Italy
35 Lorna Mudegu Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp WAVU is a market and aggregation platform that connects verified aquaculture producers to buyers through organised, predictable supply chains. In many coastal and inland markets, fish buyers source from informal channels where farmed fish and wild-caught fish are indistinguishable. This lack of separation sustains demand for capture fisheries and contributes to overfishing in already stressed marine and freshwater ecosystems. By aggregating aquaculture producers, forecasting demand, and directing buyers toward farm-based supply, WAVU helps shift market demand away from unregulated wild catch. As more buyers rely on planned aquaculture sourcing, pressure on wild fisheries is reduced while livelihoods are supported through sustainable fish production. Each tonne of farmed fish absorbed into formal markets represents demand that would otherwise be met through extraction from natural fish stocks. WAVU builds on ongoing operations in East Africa and offers a scalable, market-driven pathway to reducing pressure on wild fisheries in regions facing overfishing and informal fish trade. KE 2024-07-30 lornaafwandi@gmail.com LinkedIn Sustainable fishing and aquaculture & blue food true https://drive.google.com/drive/folders/1_Y9YW-Y_kd2Tpz80fH5juc9Ol1TR7DKq?usp=drive_link WAVU Don Okoth | Vincent Oduor | Chris Munialo | Loise Mudegu Africa, Kenya +254718059337
36 Shamim Wasii Nyanda the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. TZ 2024-03-01 shamim@sunwaveltd.com It was shared by SUNWAVE's Advisory Board member. Sustainable fishing and aquaculture & blue food true SUNWAVE Ridhiwan Mseya Africa, Tanzania +255764190074
37 Olaleye Rofiat Olayinka the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. NG 2021-11-08 olaleyerofiatyinka@gmail.com 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. Reduction of pollution (plastics chemicals noise light...) true Eco Heroes Nigeria limited Olaleye Rofiat Olayinka Salaam Lateef Oladimeji Akinsanya Dorcas Olaleye Hassan Ogundairo Ganiyat Africa, Nigeria +2348038877293
38 Christian Mwijage the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. TZ 2022-12-21 chrissmwijage@gmail.com Social Media Reduction of pollution (plastics chemicals noise light...) true 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 Bachelor’s degree in Business Administration and Marketing. Ms. Elineca Ndowo, Chief Finance Officer, holds a Master’s degree in Project Management and Financing from the University of Dar es Salaam. Africa, Tanzania +255711457346
39 Rasheed Aliu Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Land-based pollution is the largest contributor to ocean degradation, yet sanitation failures in coastal communities remain overlooked. In flood-prone coastal regions of Africa, fragile septic systems collapse during flooding, releasing untreated human waste into groundwater, rivers, lagoons, and ultimately the ocean. I witnessed this firsthand in coastal Lagos, Nigeria, when flooding destroyed local sanitation systems and a neighbour’s 4-year-old daughter died from cholera, a preventable waterborne disease. This tragedy reflects a systemic failure. Over 90% of Nigerian households rely on sanitation systems that leak sewage, contributing to 117,000 annual child deaths from waterborne diseases, according to UNICEF. In the absence of centralized wastewater infrastructure, outdated septic tanks costly to build and maintain are frequently evacuated or overflow during floods, with waste discharged into coastal waters. This drives marine pollution, eutrophication, biodiversity loss, and degradation of near-shore ecosystems critical to food security. At Pod we design and manufacture LoopBox, LoopBox is a solar-powered, IoT-enabled, self-contained sanitation system designed for coastal and flood-prone communities. Unlike traditional soakaway pits that leak, our tech uses embedded sensors and microbial treatment to track, treat, and recycle human waste into reusable water. Through our cloud dashboard, users and local authorities can monitor sanitation performance and water quality remotely. We also provide nearby borehole treatment as a service. LoopBox is 5x more cost-effective than conventional systems, eliminates 100 dollars/year in waste evacuation costs, and requires minimal space. Built with scalable hardware and software, it is designed to be deployed across low-income, climate-vulnerable communities bringing safety, sustainability, and data-driven decision-making to sanitation in Nigeria and Africa. The project delivers a flood-resilient, decentralized sanitation s NG 2025-05-06 rasheedofpod@gmail.com BFA Global TECA Alumni Group( Tyler) Reduction of pollution (plastics chemicals noise light...) true https://drive.google.com/drive/folders/1Ur6FAveOOAtS77TOGXuLGbVfqTF8mG9p?usp=drive_link Pod Rasheed Aliu, Gabriel Simon , Habeeb Lasisi and MaryJudith Chiamaka Africa, Nigeria +2348160238021
40 Chelsey Karbowski the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Mikjikj Mniku, Mi’kmaq for “Turtle Island”, is an Indigenous-led consulting firm working at the intersection of ocean protection, community governance, and workforce development. We help governments, philanthropies, and conservation organizations design ocean and climate initiatives that last beyond funding cycles by embedding Indigenous knowledge, ethical engagement, and local stewardship from the start. Our work focuses on strengthening Indigenous and coastal governance, building inclusive workforce pathways in fisheries, marine monitoring, and ocean-adjacent clean energy, and making social impact measurable and defensible through socio-economic and SROI frameworks. In a global push to protect more ocean faster, we ensure protection efforts are community-supported, socially resilient, and future-proofed, because conservation only succeeds when the people closest to the ocean are empowered to carry it forward. CA 2025-03-01 chelsey.m.karbowski@gmail.com Linkedin Other true Mikjikj Mniku Consulting Ltd. Chelsey Karbowski Canada +19026314362
41 OLUTOKI FEYISHAYO FUNMI Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp To develop and inplement a sustainable food production (crops and animals) that specifically reduces a known threat to the Ocean (e. go, pollution, overfishing pressure, habitat destruction). We will take measures using circular economy, by developing a system where waste products from our farm are treated and used in a way that prevent them from entering marine ecosystem We will work on water management and pollution reduction and sustainable sourcing /supply chain NG 2024-12-12 rebugssolutions@gmail.com Consumer awareness and education true https://drive.google.com/drive/folders/1xCJ_8EpTEdBORiJHYwIRZO22z8e54fbx?usp=drive_link Operation feed the children Adewuyi Feranmi, Olutoki sewafunmi victor, Ayodele joy, Babalola gbenga Africa, Nigeria +2348038226106
42 Anshika Sarraf the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. Objectives: 1) Measure and Map noise pollution 2) Marine life protection 3) Encourage better and sustainable practices 4) Support policy, investment & encourage systemic change in blue economy IN anshika.sarraf_ug2024@ashoka.edu.in LinkedIn Reduction of pollution (plastics chemicals noise light...) true Auralis Blue Anshika Sarraf Asia +917897130506 Ashoka University + Sonipat
43 Neville Agesa the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp The Tsunza Community, located on Kenya’s 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. KE 2023-02-01 agesanevil@gmail.com Gensea opportunities Sustainable fishing and aquaculture & blue food true Sustainable Blue Food & Livelihoods Innovation Robert Meya,Hannah Mathenge,JohnChaka Africa, Kenya +254796438122
44 Veronica Nzuu the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. KE 2023-05-29 veramichael2000@gmail.com Social Media linked in Consumer awareness and education true Furies Angelo Mulu Africa, Kenya +254748488312
45 Fiona McOmish the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. IT 2024-12-16 fiona.mcomish@algae-scope.com LinkedIn Technology & innovations true Algae Scope Natasha Yamamura; Alejandra Noren; Farshid Pahlevani Europe, Italia +447722083419
46 Nesphory Mwambai Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. KE 2024-08-22 mwambai@seamo.earth email news letter Restoration of marine habitats & ecosystems true https://drive.google.com/drive/folders/1eOyDGZwwlNNAzbwwC-CUVmJi4gM3kDLI?usp=drive_link seamo.earth Nesphory Mwambai, Lewis Kimaru Africa, Kenya +254714520023
47 Yahuza Sani Hudu the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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 NG 2024-02-26 ysanihudu@gmail.com The Commissioner for Environment and Natural Resources of Kaduna State Government, Nigeria Share's the link with my startup Reduction of pollution (plastics chemicals noise light...) true CleanUp MDC Abner Ayuba Atuga, Ameer Saeed Africa, Nigeria +2348146036089
48 Emeka Nwachinemere the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Pelagos is developing autonomous, bio-hybrid ocean regeneration machines that restore marine ecosystems while capturing atmospheric carbon. These AI-guided ocean drones re-mineralize seawater to combat acidification, stimulate safe plankton growth to enhance blue carbon sequestration, and support coral regeneration in degraded reefs. Objectives: Restore ocean health and biodiversity at scale Enhance natural blue carbon capture and climate resilience Provide real-time ocean intelligence data Build a commercially viable, globally scalable blue-economy solution Pelagos aims to transform oceans into self-healing climate engines while creating measurable environmental, social, and economic value. NG nwachinemere.emeka@gmail.com Linkedin Restoration of marine habitats & ecosystems true Pelagos Nwachinemere Emeka, Nduka Miracle Africa, Nigeria +2348062148183 University of Nigeria, Nsukka
49 Rodrick Nyendwa Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Mitigation about climate change and its impact and issue that community are aware . ZM 2023-12-12 rodricknyendwa2016@gmail.com Through social media on funds for NGOs Mitigation of climate change and sea-level rise true https://drive.google.com/drive/folders/1RlybRQMKzhAdcU9vqg8XDZHbtpSzLOCN?usp=drive_link Complehensive HIV prevention ,Treatment care support Rodrick Nyendwa,Executive Director, Mumbi Micheal - Finace Manager, Ementy Mweemba- Programme Manager, Winter Musonda - Human Resource Mnager, Josiah Ndjovu -Community Liason Officer, Simata Mate - Monitoring and Evaluation Manager , Edith Bwalya -Data Entry Officer, Brona Kapindo - Office Assistant , Sylvester Chisanga - Front office Assistant Africa, Zambia +260977339071
50 Nasibu Mtambo the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Blue EcoponicX is a climate-tech initiative that transforms marine plastic waste into 3D printed hydroponic towers for urban farming. The project addresses two interconnected challenges; ocean plastic pollution and urban food insecurity by converting waste into smart, productive food-growing systems designed for cities. Project Objectives 1. Reduce Marine Plastic Pollution Collect and recycle marine plastic waste, preventing it from entering landfills or degrading in the ocean. 2. Improve Urban Food Security Enable affordable, space-efficient food production for households, youth groups, and small-scale urban farmers. 3. Lower Urban Carbon Emissions Reduce food miles, optimize resource use, and promote localized production using energy-efficient systems. 4. Promote Climate-Smart Agriculture Use IoT technology to minimize water, nutrient, and energy waste while maximizing crop yields. 5. Empower Communities Through Technology Make modern farming accessible through easy-to-use smart systems, training, and data insights. Key Features 1. Circular Economy Design: Hydroponic towers made from recycled marine plastics 2. IoT Integration: Real-time monitoring of water, nutrients, and system health 3. Low Resource Use: Up to 90% less water than traditional farming 4. Urban-Friendly: Suitable for rooftops, balconies, schools, and community spaces 5. Scalable & Modular: Easy to expand from household to community-scale deployment Target Beneficiaries 1. Urban small-scale farmers 2. Youth and women-led agribusinesses 3. Schools and training institutions 4. Cities seeking climate-resilient food systems Expected Impact 1. Reduced plastic pollution in coastal and marine ecosystems 2. Increased access to fresh, nutritious food in urban areas 3. Lower carbon emissions from food transport and waste 4. Creation of green jobs in recycling, manufacturing, and urban agriculture 5. Stronger climate resilience for cities KE 2025-05-20 mtamboduke@gmail.com Through LinkendIn Reduction of pollution (plastics chemicals noise light...) true Blue EcoponicX Tabitha Shali, Mohammed Athman, Terry Okwanyo Africa, Kenya +254742051141
51 Faith Mutisya the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Tumbe sea weed farmers is a community based organization in Msambweni kwale Kenya. We focus on empowering coastal communities especially young women and youth with skills in sustainable sea weed farming. This is because as women in kwale county we face alot of challenges such as early marriages and pregnancies most especially because women are not given the same schooling privilege as men. Therefore so many young mothers don't have any skills to provide for their young ones. Therefore Tumbe sea weed farmers has taken the initiative to empower them , and through the farming they are able to support themselves financially and at the same time contribute to global efforts in fighting climate change because weed plays an important role as a carbon sink . And we also contribute to increase in biodiversity by provide nursery and nurturing bay for fish and other aquatic organisms KE 2023-03-02 faithmutisya56@gmail.com Through linked in Capacity building for coastal communities true Tumbe sea weed farmers Faith Mutisya - founder and Trainer 2. Hanifa wendo- secretary/field manager 3. Mwanamisi Mwadzumba - Treasurer Africa, Kenya +254711627836
52 李涵凝 Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp The 10cm transparent eco-jellyfish robot carries ocean-beneficial materials, moving by mechanical legs and drifting with waves to reduce marine pollution and repair ecosystems, quietly improving ocean health CN 2026-01-01 xbm_0201@qq.com I found out about MOPC through an online search. Reduction of pollution (plastics chemicals noise light...) https://drive.google.com/drive/folders/1duMty6mbpLCOoataogbZEShA6keuK2fy?usp=drive_link Environmentally Friendly Jellyfish 李涵凝 Asia +8618618164803
53 Kabir Olaosebikan Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. NG 2023-04-17 kabir@craftplanet.org Through online sustainability platforms and ocean innovation networks. Reduction of pollution (plastics chemicals noise light...) true https://drive.google.com/drive/folders/1jUFqGLk1zZ6afP4BysRPpp_jRXsw_9Kz?usp=drive_link Craft Planet - Blue Guard Kabir Olaosebikan, Aminat Abdulazeez, Promise Dalero, Hanatu Abdulakeem Africa, Nigeria +2348142123656
54 Karl Mihhels the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. FI karl.mihhels@aalto.fi 2nd EU Algae Awareness Summit held in Berlin on October 17th 2025 Blue Carbon true Shaving the Seas Karl Mihhels Europe, Finland +358447627444 Aalto University School of Chemical Engineering, Finland
55 SENI Abd-Ramane the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp _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. BJ 2025-12-29 seniramane@gmail.com I heard about the Monaco Ocean Protection Challenge on LinkedIn, it immediately caught my attention! Reduction of pollution (plastics chemicals noise light...) true OceanClean Tech SENI Abd-Ramane, DJIBRIL Samir, SOULÉ SEIDOU Mansoura Africa, Bénin +2290161149564
56 Omoding Olinga Simon the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. UG 2024-01-05 simonomoding.ace@gmail.com Through Linkedinn social media Sustainable fishing and aquaculture & blue food true Dagim Fisheries (U) Ltd Omoding Simon, Ilukat Musa, Omiel Peter, Omongole Richard, Fellista Nakatabirwa Africa, Ouganda +256773351242
57 Mutave Nelly the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Hrkb KE 1997-01-24 mutavenelly.mn@gmail.com Friend shared link Technology & innovations true Revamp Flips Nthatisi Lesala Africa, Kenya +254704458380
58 Ketty Shamakamba the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Lake Farms is establishing an academy dedicated to preserving Lake Kariba and its communities. It is a center for training, innovation, and direct action. Core Mission: Halt fish stock depletion and foster a sustainable blue economy. Key Initiatives: Training Hub: Equip local fishers with skills in sustainable aquaculture, ecosystem management, and cooperative business. Innovation & Deployment: Design and deploy ethical, lake-friendly cage systems and restorative practices to rebuild wild stocks. Community Enterprises: Launch community-owned "Aqua-Hubs" that provide food security, create livelihoods, and empower women. This academy would create a lasting legacy of ecological restoration, poverty reduction, and resilience for Lake Kariba's people, directly honoring a commitment to ocean and freshwater preservation. ZM 2021-11-30 ilovesolarfreezers@gmail.com We learned about the Monaco Ocean Protection Challenge through the communication channels of the Prince Albert II of Monaco Foundation and its associated networks, which highlight pioneering solutions for ocean and freshwater conservation. Capacity building for coastal communities true LAKE FARMS AND FISHING LODGE LIMITED Board and Management Team Chisanga Mambwe – Board Chairperson (Strategic oversight) Provides governance leadership, investor relations support, and high-level oversight of the executive team. Ketty Shamakamba – Chief Executive Officer (CEO) Leads overall strategy, fundraising, partnerships, gender lens work, and company growth. Oversees business development, climate initiatives, and solar cold-chain expansion. Chiozya Mwanza – Chief Operations Officer (COO) Responsible for day-to-day operations, cage management, production planning, logistics coordination, and community engagement with fishers and women traders. Hamando Hamalabbi – Chief Financial Officer (CFO) (Accountant) Manages finance, accounting, compliance, investment reporting, budgeting, and financial controls. Muzalema Zimba – Chief Marketing Officer (CMO) (Sales & Marketing Manager) Oversees sales strategy, distribution channels, branding, customer acquisition, and premium market relationships (hotels, restaurants, wholesalers). Joshua Mwanza – Chief Operations Manager / Deputy COO (Operations Manager) Supports operations, distribution logistics, procurement, cold-chain coordination, and team supervision. Micheck Chulaula – Chief Farm Manager (CFM) (Farm Manager) Oversees cage management, feeding regimes, harvesting, processing coordination, and ensuring biosecurity and aquaculture standards. Mabel Kaunda – Chief Human Resources Officer (CHRO) (HR Manager/Secretary) Manages staff welfare, recruitment, training, compliance, and gender-inclusive workforce policies. Our operations team includes experts in farm management, logistics, and business development, ensuring efficient production, processing, and distribution. The finance and technology staff oversee solar freezer leasing, mobile payments, and digital monitoring systems, enabling scalable, sustainable impact. Many team members, including the founders, have personal connections to the communities we serve, which drives our Africa, Zambia +260971094443
59 Torrigiani Aurore the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Sea Blocks develops modular, low-tech artificial reefs designed to restore marine habitats in port environments. Each reef is co-designed and assembled through participatory workshops involving companies, citizens and local stakeholders, then installed in partnership with ports. The modules are made from low-carbon materials and locally sourced shell waste, enhancing ecological functionality and accelerating colonisation by marine species. The project combines ecological restoration, circular economy and awareness-raising, with scientific monitoring conducted by marine biology experts to assess biodiversity recovery and long-term impact. FR 2021-02-03 seablocksrecif@gmail.com Through professional networks and partners involved in ocean and coastal innovation. Restoration of marine habitats & ecosystems true Sea Blocks Olivier Meynard Europe, France +33647780342
60 Godfrey Noel Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Project Title: Bamboo Stewardship for Mangrove Protection: Building Sustainable Livelihoods Along the East African Coast The Problem East African coastal mangrove ecosystems spanning from Somalia to Mozambique face catastrophic degradation, with communities harvesting mangroves for fuel and construction because alternative income sources remain unavailable. This extraction destroys critical carbon sinks, eliminates natural storm surge barriers, and collapses fish nursery habitats that sustain coastal food security. Traditional conservation approaches exclude communities from protected areas without providing viable economic alternatives, guaranteeing enforcement failure and continued ecosystem loss. Our Solution Kilimora, in strategic partnership with EarthLungs, is implementing a bamboo based mangrove protection system that transforms coastal communities from ecosystem exploiters into paid ecosystem stewards. We employ community members to cultivate and harvest fast growing bamboo (Bambusa species with 3 to 5 year harvest cycles and continuous regrowth capacity) in designated buffer zones adjacent to mangrove forests. This bamboo provides sustainable construction materials and biomass fuel alternatives that eliminate economic pressure on mangrove stands while generating verifiable income for participating households. Technical Innovation The initiative integrates drone based mangrove health monitoring with ground truth verification by community stewards, creating high resolution ecosystem data that supports both conservation management and carbon credit generation. Kilimora provides the artificial intelligence powered verification infrastructure and blockchain based transparent payment systems ensuring stewards receive direct compensation tied to measurable mangrove protection outcomes. EarthLungs contributes marine ecosystem expertise, coastal community organizing capacity, and connections to corporate blue carbon credit buyers. Scale and Impact The program cu KE 2024-01-04 gnoel@kilimora.africa LinkedIn network Capacity building for coastal communities https://drive.google.com/drive/folders/1Ouz8-deBPfgUl7VYIwofxUwEYG4D9iMw?usp=drive_link Kilimora CLG Godfrey Noel, Zuhra Nagib, Matthew Muange, Hildah Gichuru, Ezra Maruti, Hildah Gichuru Africa, Kenya +254795647634
61 Hellen flavine akinyi Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Plastic pollution from urban markets and streets flows into rivers ad ultimately into the ocean. single-use plastic paper bags are among the most common sources of marine debris.preventing plastic waste at the source is the most effective ad affordable solution than ocean multi-million clean up efforts KE 2021-02-09 artworkspace1@gmail.com thro. funds- for- Ngo newsletters Consumer awareness and education true https://drive.google.com/drive/folders/1bFnRFeWaxyD2g52MG0l_Y6q_rQOCYbnc?usp=drive_link STOPING OCEAN PLASTICS AT THE SOURCE;DIGITAL ECO-PACKAGING SOLUTION LED BY A YOUNG AFRICA WOMAN,KENYA 2026026 KIMBERLY ADHIAMBO CONIE, MAISON JOHN &PETER WAMBURA Africa, Kenya +27631484516
62 Tochukwu Uwakeme Received the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Coastal Blue-Skills Hubs by Pikia is a scalable, community-led training and microenterprise program that equips coastal youth and women with practical skills, tools, and starter microgrants to reduce ocean pollution and strengthen climate-resilient livelihoods through waste-to-value (plastic collection/sorting), sustainable fishing practices, and mangrove/coastal restoration. The program will run through local “Hub” partners (NGOs/co-ops/schools), a lightweight mobile curriculum, and a train-the-trainer model, paired with verified community monitoring (simple metrics + photo evidence) to prove impact and unlock blue-economy buyers and sponsors. Objectives: • Cut land-to-ocean leakage by organizing community collection, sorting, and resale of plastics, with tracked volumes diverted. • 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 MOPC’s focus on ocean-positive business concepts US uwakemet@bu.edu United Nations SDGs Newsletter. Capacity building for coastal communities true https://drive.google.com/drive/folders/1ph6DBmqeSGvSSqxQkymPx9rlU-ZmGFnr?usp=drive_link Pikia Marine Tochukwu Uwakeme, Moses Imoleyo, Ihuoma Ohaegbulam US +12024255839 Boston University / United States
63 Veronica Nzuu the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. KE 2023-05-23 veramichael2000@gmail.com Social media linked in Consumer awareness and education true Furies Angelo Mulu Africa, Kenya +254748488312
64 Cristiano da Silva Palma the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. Sur le plan scientifique, la technologie OTEC repose sur l’exploitation de la différence de température entre les eaux de surface chaudes et les eaux profondes froides afin d’alimenter un cycle thermodynamique de production d’électricité, conformément aux analyses reconnues par la Convention-cadre des Nations Unies sur les changements climatiques (UNFCCC). Dans les régions tropicales, où les eaux de surface peuvent dépasser 25 °C tandis que les eaux profondes se situent autour de 5 °C, le différentiel thermique (ΔT) peut excéder 20 °C, condition généralement considérée comme favorable à une application efficace de l’OTEC, comme le soulignent de nombreuses publications académiques, notamment celles de la MDPI. En revanche, dans le bassin méditerranéen, y compris autour de la Principauté de Monaco, les données actuelles indiquent un ΔT généralement inférieur aux seuils classiques de viabilité de l’OTEC à grande échelle. Toutefois, à partir de 2027, evolving ocean temperature profiles, combined with AI-assisted thermodynamic optimization, high-efficiency working fluids, operation restricted to periods of maximum thermal contrast (summer), intake at greater depths, and high thermal-efficiency piping, may enable experimental and seasonal OTEC operation, positioning the Mediterranean as a future testbed for advanced ocean energy technologies. By aligning scientific rigor with technological innovation, the project contributes to ocean protection BR 2024-08-09 cristianospalma@yahoo.com.br I learned about the Monaco Ocean Protection Challenge through institutional email exchanges within the framework of the United Nations Framework Convention on Climate Change (UNFCCC), including communications with the UNFCCC Global Secretariat, notably Simon Stiell, Executive Secretary, as well as with UNFCCC National Focal Points in Monaco and France. These included Carl Dudek (Ministry of Foreign Affairs and Cooperation of the Principality of Monaco), Dietmar Petrausch and Wilfred Suddath-Deville (Ministry for Europe and Foreign Affairs of France), and Yue Dong and Bénédicte Jenot (French Ministry for the Ecological Transition). Technology & innovations true Tabernacle Space Islands Cristiano da Silva Palma South America +5511978020540
65 Titus Nyandoro Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp We are a Kenyan-based, ocean-minded for-profit fintech venture for fishing coastal communities that are dedicated to the sustainable blue economy KE 2024-01-01 ktnyandoch@gmail.com WhatsApp Technology & innovations true https://drive.google.com/drive/folders/1twpoOtR1RIei27iSRXNquyV4iMBA9HdC?usp=drive_link VUA SOLUTIONS Matthew Egessa, Titus Nyandoro Africa, Kenya +254743378884
66 Mzuvukile Benayo the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Spatial Planning Collective more about engaging stakeholders and driving education. ZA 2011-07-07 mzuvukilejames@gmail.com FundsforNGOS email Capacity building for coastal communities true Youth Innovation Programme Zenande Mnethu Africa, South Africa +27738223994
67 Abdoulaye Sarr Ndour the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates AI-powered gamified learning platform for ocean conservation education (Duolingo-style for oceans). The platform features an AI tutor powered by ChatGPT-4/Claude, 4 educational modules covering Biodiversity, Climate, Threats, and Solutions, gamification elements including XP points, badges, and mini-games, plus professional certifications. Target customers include B2C users (parents/students) paying 9.99 EUR/month subscriptions, schools paying 800-1,500 EUR/year for licenses, corporations paying 2K-50K EUR for CSR training programs, and professionals purchasing certifications for 49-299 EUR each. Year 1 objectives: 10,000 users generating 207K EUR revenue. Year 3 objectives: 200,000 users generating 5.8M EUR revenue. Overall mission: 1 million ocean-literate people by 2030. Tech stack: Next.js frontend, Supabase backend (PostgreSQL + Auth + Storage), OpenAI API for AI tutor functionality. 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. SN ndour.ecobox@gmail.com Linkedin Technology & innovations true OceanEdu AI Omar Cissé Faye, Fatou Cissé, Coumba Gueye Africa, Senegal +221775110218 Saint Louis Gaston Berger University, Senegal
68 Christopher Enriquez Urban the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. Solution: Neural-operator forecasting systems predict bloom movements with high accuracy, guiding automated offshore platforms that harvest and preprocess algae at sea—delivering consistent, industrial-grade feedstock. Objectives: Create reliable supply chains for bio-based materials, reduce coastal environmental damage, generate jobs and enable circular economy applications in construction, energy, and agriculture. Impact: Transforms environmental crisis into economic opportunity while addressing climate goals through fossil material substitution. DE christopher@algrid.tech LinkedIn Other true Algrid Valentina Iunosheva Europe, Germany +4915679760251 University of Leeds, Leeds, UK
69 Sarfraaz Khan AYAZ KHAN Received the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Poseidon transforms ocean-recovered plastic into certified, customizable, high-end solid surface materials for architects, designers, artists, and sustainability-driven businesses. Our advanced material development and manufacturing ensure durability, aesthetic quality, and long-term reuse as alternatives to conventional surfaces - integrating ocean plastic back into the economy. Each sheet removes approximately 15–30 kg of ocean plastic and includes a digital product passport that provides full traceability from collection to final use. Incubated at MonacoTech, Poseidon aligns with multiple UN Sustainable Development Goals and empowers creative professionals to lead eco-innovation, contributing to ocean cleanup, circularity, and measurable environmental impact. MC info@poseidon-monaco.com Last year MOPC competition , JCI CCE event and news letter Reduction of pollution (plastics chemicals noise light...) true https://drive.google.com/drive/folders/11GEc6IYyLaZnQ_rtkgNHeafVPnuOZ2bz?usp=drive_link POSEIDON Sarfraaz Khan AYAZ KHAN Europe, Monaco +33745384992 SKEMA Business School and POLIMI Graduate School of Management
70 Francesca Rose Turner Prichard the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Restore society’s relationship with the ocean by building connections between women and the ocean through sport, art and conservation activities. ES francescaroseturner@gmail.com Linkedin Consumer awareness and education true Residensea Francesca Turner, Aoife Martin, Alberto Rangel Europe, Spain +34671298357 Southampton Solent University
71 Brian Ochieng Aliech the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. KE ochiengaliech@gmail.com Through a friend. Reduction of pollution (plastics chemicals noise light...) true NOLA AFRICA Brian Aliech, Charles Okutah, Hussein Hezekiah, Kevin Onsongo, Lidah Makena Africa, Kenya +254757008417 University of Nairobi, Nairobi
72 Adhithi Mugundha Kumar the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp We aim to provide a solution to invasive blue crabs in the Mediterranean by developing a bait-induced fishing method. GB 2026-01-06 Adhithimukhundh@gmail.com Sustainable fishing and aquaculture & blue food true Blue crabs Xenia Anagnostou UK +447512296331
73 THIERRY BOUSSION the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. By extending the lifespan of existing boats rather than building new ones, Yuniboat directly contributes to the protection of oceans and marine ecosystems. Reconditioning avoids the extraction of new raw materials, limits fiberglass and plastic waste, and reduces emissions linked to manufacturing and end-of-life destruction. Key Environmental Impacts Reduction of marine pollution by preventing abandoned and end-of-life boats from becoming waste at sea or in ports. Preservation of marine fauna and flora through lower emissions, reduced noise pollution, and cleaner propulsion systems (electric, biofuel, hybrid). Lower pressure on natural resources, with up to 80% of boat components reused. Decrease in carbon footprint, contributing to climate action and healthier marine ecosystems. Project Objectives 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. Yuniboat’s ambition is to position eco-reconditioning as a key lever for ocean protection, combining circular economy, innovation, and long-term impact on marine biodiversity. FR 2022-06-01 t.boussion@yuniboat.com we follow your activities on Linkedin and Instagram Reduction of pollution (plastics chemicals noise light...) true Yuniboat Thierry Boussion Europe, France +33621220023
74 Daniele Tassara the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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 IT daniele.tassara@outlook.com I lived in Monaco and i knew about this project Technology & innovations true MareNetto Giambattista Figari, Giorgio Mussini Europe, Italia +393466376215 Universita di Genova, Genova
75 Gaia Minopoli the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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! IT 2020-01-21 gaia.minopoli@ogyre.com Scientific attaché of Italian Embassy in Paris Reduction of pollution (plastics chemicals noise light...) true 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 Europe, Italia +393393499607
76 Yajaira Cristina Alquinga Salazar the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. AR cristinalquinga@gmail.com LinkedIn Mitigation of climate change and sea-level rise true 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 South America +541136132787 Universidad Nacional del Sur
77 Jovana the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Project name: Symphony of the Blue The Idea: Converting real-time oceanographic data (currents, temperature, pH levels) into immersive musical compositions using mathematical algorithms. Objectives: Emotional Data Visualization: Making the "silent" problems of the ocean audible to the public and investors through music. Eco-Funding: Generating revenue for marine conservation through the sale of these unique, data-driven symphonies. Ocean Literacy: Educating younger generations by integrating science, math, and art. RS 2026-01-07 jovanaperisic059@gmail.com Technology & innovations false EcoMath Jovana Perišić Europe, Serbia +381645655226
78 Amelia Martin Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Mud Rat is a biomaterials startup creating an eco friendly alternative to marine foams. US 2023-06-14 amelia@mudratsurf.com Google! Consumer awareness and education true https://drive.google.com/drive/folders/1GzXe6ugfJQCFdqcZxj3lZrN4H7DSMAIE?usp=drive_link Mud Rat Jack Tarka, Patricio Acevedo, Brian Lassy US +18606824426
79 Mulowoza Grace the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp We are tackling plastic pollution through innovative upcyling solutions, our work is centered around four main objectives; 1. Reduce plastic pollution through innovative upcyling, we are transforming plastic waste into valuable resources. 2. Promote waste separation and proper waste management practices. 3. Raise awareness about the importance of environmental conservation. 4. Empower youth to take action in environmental conservation. Our project, combat plastic pollution through circular economy innovation aims to reduce plastic pollution which can end up into oceans by promoting circular approaches that emphasize reduction, reuse, recycling and sustainable alternatives. Through transforming plastic waste into economic and social opportunities our team contribute to environmental protection, green job creation and sustainable development. UG 2022-11-07 mulowozagrace@gmail.com Facebook Reduction of pollution (plastics chemicals noise light...) true Divine youth environment initiative Mulowoza Grace, Nassaazi phiona, Sseruga ibraheem, Male simon , kirume Vivian Deborah Africa, Ouganda +256705620491
80 Suraj Kumar Hota the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Using Indian knowledge system to manage overfishing IN surajkumarhota23@gmail.com Sustainable fishing and aquaculture & blue food true No project SubhaKant Dalei Asia +919776476665 Berhampur University India
81 Shiva the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates IN shiv@gmail.com Collage Technology & innovations true NA NA Asia +918529637418
82 Sebastian Marzetti the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Underwater acoustics monitoring using easy to deploy systems Our low power systems allow real-time alerts and data for immediate action FR 2026-03-01 marzettisebastian@gmail.com Linkedin Technology & innovations false Intelligent Acoustics Valentin Barchasz - Valentin Gies - Hervé Glotin Europe, France +33766861456
83 Emana Bilalović the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Ocean Asylum Certificates (OAC) establish legally protected micro-zones in the ocean by converting conservation into binding contractual commitments. The project enables regulated no-exploitation areas that directly influence shipping and yachting behavior through enforceable restrictions, transparent monitoring, and long-term accountability. Its objective is to embed ocean protection into maritime governance rather than rely on voluntary sustainability pledges. XK 2025-08-18 emanabilalovic12@gmail.com Instagram of University of Monaco Sustainable shipping & yachting true Ocean Asylum Certificates Emana Bilalović, Alzana Bajrami Europe, Kosovo +381656075770
84 Sabira Ayesha Bokhari the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates A gamified app to encourage sustainable coastal tourism IN sabirabokhari@gmail.com Ocean Oppurtunities Other true Eco-Pirates Aimen Akhtar Asia +33753635938 Universidad Catholica de Valencia
85 Vera Emma Porcher the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. AU 2023-11-29 veraporcher20@gmail.com Linkedin Restoration of marine habitats & ecosystems true In-Depth Innovations Vera Porcher, Kane Dysart and Tynan Bartolo Oceania +61466053917
86 Lee patrick EKOUAGUET Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. FR 2023-10-23 ogoouecorpstechnologies@gmail.com Through online research and innovation platforms focused on ocean protection and blue tech. Technology & innovations true https://drive.google.com/drive/folders/1BEc9s5h5H41vf2bRxpqvz4AHBWMZS1Xm?usp=drive_link OGOOUE CORPS TECHNOLOGIES ANDRE BIAYOUMOU, NGABOU PASCAL XAVIER, LYNDA NGARBAHAM, DUPUIS NOUILE NICOLAS Europe, France +33778199372
87 Tshephiso Kola the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. ZA kolatshepisho@gmail.com Social Media Sustainable fishing and aquaculture & blue food true Luminet Tshephiso Kola Africa +27671509841 University of the Witwatersrand, Johannesburg
88 Eric & Aurélie Viard the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Use of organic edible seaweeds in daily food and gastronomy FR 2007-03-11 eric@biovie.fr We have been invited directly by Marine Jacq-Pietri to submit our project Consumer awareness and education true Algues au quotidien Eric Viard, Aurélie Viard Europe, France +33695360436
89 BARHOUMI Nawress the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. TN 2024-05-05 nawressbarhoumigf@gmail.com Newsletters Technology & innovations false El Makina Mustapha Zoghlami, Nawress Barhoumi Africa, Tunisia +21621898617
90 Yao Yinan the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. CN yyn982715367@outlook.com I found out about the Monaco Ocean Protection Challenge through the organisers listed on the UArctic Congress 2026 website: https://www.uarcticcongress.fo/about Consumer awareness and education true BluePulse – Design, Protect, Inspire Yinan Yao Asia +8615221826163 Communication University of China, Nanjing(Location: Nanjing, China)
91 Antalya Fadiyatullathifah the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp xxx ID 2024-11-11 Antallathifah@gmail.com xxxx Blue Carbon true Environmental Consultant xxx Asia +6281110115560
92 Moramade Blanc the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. HT blamo82@yahoo.fr Through my university and professional networks and partnerships Technology & innovations true « 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 Haïti +50940809002 Sorbonne Universite, France
93 Samuel Nnaji the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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 NG realstard247@gmail.com WhatsApp Sustainable shipping & yachting true Zero Ocean Benjamin Odusanya Africa, Nigeria +2348161502448 University of Nigeria
94 Hannah Gillespie the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. GB hggillespie12@gmail.com The Ocean Opportunity Lab (TOOL) Sustainable fishing and aquaculture & blue food true SeaBrew Coffee Anne Moullier, Joseph Flynn, Hannah Gillespie, Laura Coombs, Ronan Cooney UK +447887479247 University of Cambridge, Cambridge, UK
95 Rhea Thoppil the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. IN rmthoppil@gmail.com Through my university Reduction of pollution (plastics chemicals noise light...) true phytoflight Rhea Thoppil Asia +33745764372 Sorbonne University, France
96 Ethan Jezek the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. US ejezek12@gmail.com I heard of the MOPC through colleagues I have on LinkedIN Consumer awareness and education true OceanID Ethan Jezek US +18178996766 I have started this concept myself in Dallas, Texas but I am also a PhD candidate at the University of Waikato in New Zealand
97 Nnaji Samuel Ebube the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 🌟 *Project: OceanFin - Boosting Nigeria's Blue Economy 🌊* - *Idea*: Empower coastal communities with digital financial services for sustainable ocean-based livelihoods 🐟 - *Objectives*: - Increase financial services 📈 - Improve financial inclusion for fishermen, traders 💸 - Promote sustainable ocean practices 🌿 - *Key features*: Digital payments, loans, insurance, FX services, international partnerships 🌍 NG nnajisamuel2448@gmail.com Online Capacity building for coastal communities true OceanFin Ifeoma Odusanya, Benjamin Odusanya Africa, Nigeria +2348161502448 University of Nigeria Nsukka
98 Sofie Boggio Sella the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. IT boggiosellasofie@gmail.com Linkedln Restoration of marine habitats & ecosystems true PMRF: Probabilistic Multi Reef Fusion pipeline Sofie Boggio Sella, Lily Lewis, Mohammad Jahanbakht Europe, Italia +61448568796 James Cook University Australia
99 Christine Kurz the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates christine.a.kurz@gmail.com Xy Xy +4917622904612
100 Antonella Bongiovanni the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. IT 2022-09-29 info@evebiofactory.com Our mentor Alessandro ROmano pointed out the challenge and recommended our project would be a good fit. Technology & innovations true EVE Biofactory Antonella Bongiovanni - Natasa Zarovni - Mauro Manno - Paolo de Stefanis - Lorenzo Sbizzera - Gabriella Pocsfalvi - Paola Gargano Europe, Italia +393286093034
101 Justyna Grosjean the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp The Antifouling Coating of Tomorrow. Lower Costs. Cleaner Oceans. Decarbonating Shipping. DE 2021-05-11 justyna@cleanoceancoatings.com Through the Fondation Prince Albert II de Monaco Sustainable shipping & yachting true Clean Ocean Coatings GmbH Christina Linke, Jens Deppe, Friederike Bartels, Johana Chen, Sandra Lötsch, Patricia Greim Europe, Germany +33685638357
102 Erick Patrick dos Anjos Vilhena the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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: 1. Waste in the Fishing Industry (Circular Economy) Currently, the vast majority of fish skins resulting from human consumption are discarded as organic waste. The Problem: Thousands of tons of skins end up in landfills or are thrown back into rivers and oceans, causing pollution due to excess organic matter. The Solution: It transforms a by-product (waste) into a high-value material, closing the loop of the circular economy. 2. Environmental Impact of Bovine Leather Traditional (cow) leather carries a heavy ecological footprint that fish leather helps to mitigate. Deforestation: Cattle ranching is a leading cause of deforestation. Fish production does not require new pastures. Water Consumption: Raising cattle consumes massive volumes of water compared to existing aquaculture or artisanal fishing. Carbon Emissions: Producing fish leather emits significantly fewer greenhouse gases than the beef industry supply chain. 3. Toxicity in Processing (Tanning) Industrial tanning of common leathers often uses Chromium, a heavy metal that is highly polluting if disposed of incorrectly. The Difference: Sustainable fish leather solutions focus on vegetable tanning (using tannins extracted from tree barks and plants). This eliminates toxic waste and results in a biodegradable product that is safe for both artisans and consumers. 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- BR 2023-01-01 e.vilhena@hotmail.com Linkedln Sustainable fishing and aquaculture & blue food true sustainable fish leather Andria Carrilho South America +5596981337237
103 Amaia Rodriguez Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Clean plastic from the sea with fishermen and transform the waste into materials for construction and architecture. ES 2020-05-18 amaia@thegravitywave.com A friend sent it to me Reduction of pollution (plastics chemicals noise light...) true https://drive.google.com/drive/folders/11McKvPyKzbUgYiFd2gfeWvLrlGGPhyP2?usp=sharing GRAVITY WAVE Amaia Rodriguez, Julen Rodriguez, Naiara Lopez, Alvaro Garcia, Camila Lago, Norberto De Rodrigo, Irene Hurtado Europe, Spain +34606655862
104 Dr Mumthas Yahiya the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates “Nature-Based Solutions for Mitigating Ocean Acidification through Coastal Blue Carbon Ecosystems - Project Idea This project focuses on mitigating ocean acidification by enhancing and restoring blue carbon ecosystems such as mangroves, seagrasses, and salt marshes. These ecosystems absorb atmospheric CO₂, increase local alkalinity, and act as natural buffers against pH reduction in coastal waters. The study will evaluate their potential as cost-effective, climate-resilient mitigation strategies. Objectives To assess the role of mangroves and seagrass meadows in reducing coastal seawater acidity. To quantify carbon sequestration and alkalinity enhancement in selected coastal habitats. To evaluate ecosystem-based management practices as mitigation tools for ocean acidification. To provide policy-relevant recommendations for integrating blue carbon ecosystems into coastal climate action plans. Relevance The project supports climate change mitigation, marine biodiversity conservation, and sustainable coastal management while addressing the growing threat of ocean acidification. mumthasy@gmail.com IUCN Mitigation of ocean acidification true Migratory birds Thamanna K US +917012789400 Kerala
105 yvano voigt the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates Reducing plastic waste Reducing skin cancer rates Reducing coral reef destruction FR yvano.voigt@gmail.com thanks to the oceanography museum Reduction of pollution (plastics chemicals noise light...) true Totem by FrenchKiss suncare yvano voigt, Elsa Delpace Europe, France +33652294558 Ipag business school, Nice France
106 Lily Atussa Payton the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. US lily.a.payton@gmail.com Online research Consumer awareness and education true Oyster Club NYC Lily Payton, Kelsey Burkin, Savannah Harker US +13015297789 N/A
107 Gary Molano the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. US 2023-07-19 gary@macrobreed.com Through the ocean exchange newsletter Sustainable fishing and aquaculture & blue food true MacroBreed Scott Lindell, Charles Yarish, Filipe Alberto US +12135198233
108 Qendresa Krasniqi the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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 NO 2022-10-06 qendresa04@gmail.com 1000 Ocean StartUps Restoration of marine habitats & ecosystems true Aegir by Navier USN Qendresa Krasniqi, Hedda Collin, Markus Marstad Europe, Norway +4798474602
109 Dorra Fadhloun the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates PlastiTrack's goal is to turn citizen smartphone photos into citywide microplastic pollution heatmaps that municipalities use to prioritize cleanup investments. TN dorra.fadhloun@msb.tn LinkedIn Technology & innovations true Oceani Samar Africa, Tunisia +21629508048 Mediterranean School of Business
110 Ahamed Adhnaf the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. LK anaadhnaf413@gmail.com I learned about the Monaco Ocean Protection Challenge through a friend. Capacity building for coastal communities true AquaHorizon Ahamed Adhnaf , Kaveesha Gunarathna, Mohammed Rifath Africa, Sri Lanka +94760270097 National Institute of Social Development - Sri Lanka
111 Robert Kunzmann the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. Today, recycling is not economically feasible in most situations. There are several reasons for this. Firstly, many recycling methods cannot handle mixed waste and therefore require waste to be sorted. Where chemical recycling can accept mixed plastic, the high temperature or pressure requirements lead to high costs. This is why recycling rates remain low. Plastalyst makes it possible to break down waste into core chemicals such as monomers or hydrogen and carbon monoxide (syngas for SAF and biodiesel). Organic waste is decomposed into alcohols and syngas, whereas plastic is decomposed into methanol, alkanes or monomers. It uses only water, waste, and a reusable catalyst as input. The reaction occurred at a temperature of only 200°C. Compared to other methods, our method has significant advantages such as low energy use, which results in lower operational cost. Next, we use water as a solvent, drying of waste is not needed, therefore it will cut the cost of preparing the material. Lastly, no solvent is needed and no CO2 is emitted in the reaction. Unlike organic methods, such as biodigestion that require a lot of time and emit a lot of CO2, Plastalyst is a fast chemical method that emits no CO2. LU 2019-04-01 robert.kunzmann@acbiode.com Climate KIC Reduction of pollution (plastics chemicals noise light...) true AC Biode Robert Kunzmann Europe, Luxembourg +441751026862
112 Mayoro MBAYE the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates The MER SEA GUEDJI center designated according to the " Educational Archipelago" concept ,composed of self- contained educational modules connected by landscaped pathways . This lightweight,reversible,and bioclimatic architecture respect the public martime domain integrates harmoniously into the natural and social environment . SN dg@kma-international.com Dr Manon Aminatou Capacity building for coastal communities true MER SEA GUEDJI Mayoro MBAYE +Mbacké SECK+Ali DOUCOURÈ Africa, Senegal +221776441916
113 Divin Arnaud KOUEBATOUKA Received the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp Our project transforms invasive water hyacinth, a major threat to rivers, coastal lagoons and marine ecosystems, into 100% organic absorbent solutions used to control oil and chemical pollution. By harvesting water hyacinth before it reaches estuaries and coastal zones, we prevent ecosystem degradation while supplying industries and ports with sustainable spill-response materials. Our flagship product, KUKIA®, absorbs hydrocarbons efficiently and is later recycled into alternative fuel for cement plants, creating a circular, zero-waste model. The project combines ocean and freshwater protection, industrial pollution control, and community empowerment, generating income for women-led harvesting groups while reducing marine contamination risks. This scalable solution contributes directly to ocean conservation, blue economy resilience, and sustainable industrial practices in Africa and beyond. CG 2022-07-27 divinkoueba@gmail.com I learned about the Monaco Ocean Protection Challenge through professional networks and sustainability-focused opportunity monitoring platforms, including LinkedIn and grant-funding communities dedicated to ocean and climate innovation. Restoration of marine habitats & ecosystems false https://drive.google.com/drive/folders/1uHEFuI-iosKap2OPSUdQsbXuih07g7n0?usp=drive_link Green Tech Africa Our lean startup is primarily run by a team of 3: Divin, Osvaldo, and Jessica, alongside a great supportive team of advisors. Using his skills in tech and as a civil & environmental engineer, Divin has designed and built several innovations geared towards sustainability, including the solar dryer now being used across Congo to revive the pyrethrum industry, smart roads, and smart pipes. The Central Africa Community recently awarded him the best innovator in Congo. He is the CEO. Working for L'Oréal, Osvaldo is well-versed in manufacturing and running supply chain processes. This experience, in addition to being an engineer, makes him an ideal CTO. Jessica has an extensive background in tech and finance. She has been a Microsoft Ambassador and Hult Prize coordinator. Her experience in handling projects and relationships with global agencies and customers is handy in her CFO role. She also hails from Homabay, Congo, where hyacinth surrounds the island for days and weeks at times. This blocks waterways and sometimes prevents children from going to school. The team has achieved several milestones, such as making scientific validation and proof of concept of the products, raising over CFA 3M in funding, and winning significant international awards, including the Central Africa Youth for Climate Action Award, Best Manufacturing Startup in Congo, Best Innovation in Congo by EAC, the World Engineering Day Hackathon by UNESCO, the TotalEnergies Startup of the Year, and Falling Walls Lab Brazzaville. Africa, Congo +242069323235
114 Paul Schmitzberger the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp We decouple the production of marine protein from the ocean by replicating aquatic ecosystems in modular, automated, plug-and-play systems. We combine hardware, software and biology in products called LARA and Vortex. These modular units are controlled and operated by AI agents under the remote supervision of our biologists and system engineers. The agents optimize the growth of microalgae, zooplankton and fish or shrimps for human consumption. Our goal is to establish large-scale, environmentally friendly aquaculture operations in diverse environments, advancing global food security and sustainability. AT 2019-11-15 laura@blue-planet-ecosystems.com Online search Sustainable fishing and aquaculture & blue food false Blue Planet Ecosystems Paul Schmitzberger, Cécile Deterre, Stephan Mayrhofer, Jens Cormier, Pierre De Villiers, Stephan Sergides, Jakob Weber, Romana Zabojnikova, Laura Belz Austria, Europe +436642347890
115 Julia Denkmayr the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. AT 2026-04-30 julia@nereia-coatings.com Through a Carbon 13 domain expert, Thibaut Monfort Micheo, as well as LinkedIn and other social media. Sustainable shipping & yachting false NEREIA Rimah Darawish Austria, Europe +393203476632
116 Tara Lepine the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. CA tlep171@aucklanduni.ac.nz Communication from our university department head. Sustainable fishing and aquaculture & blue food false OceanSeed Tara Lepine Canada +64273401929 University of Auckland, Auckland, New Zealand
117 Reid Barnett the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. US 2024-05-01 reidbarnett@ceretunellc.com We were connected through the team at Ocean Exchange. Reduction of pollution (plastics chemicals noise light...) true Ceretune LLC Reid Barnett and Blake Parrish US +19198010336
118 Ryan Borotra the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. CA 2025-10-20 ryan@sentrylabs.cc LinkedIn Sustainable fishing and aquaculture & blue food true Sentry Labs Ryan Borotra, Martin Chaperot, Andrei Bogza Canada +16479659526
119 Nadine Hakim the « Start-ups » category: Open to students fresh graduates & entrepreneurs with an existing comp 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. CO 2025-10-01 nadinehakimm@gmail.com Sustainable fishing and aquaculture & blue food true SEAMOSS COLOMBIA Sandra Bessudo and Irene Arroyave South America +573205421979
120 Maria Ester Faiella the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. IT maria.ester.faiella@gmail.com LinkedIn Restoration of marine habitats & ecosystems true ThermoShield Maria Ester Faiella Europe, Italia +393311538952 The American University of Rome
121 Kumari Anushka the « Business concepts » category: open to students (Bachelor Master MBA & PhD) & fresh graduates 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. IN nasabutbetter@gmail.com My university's professor Restoration of marine habitats & ecosystems true accore Kumari Anushka Asia +919798061093 Ashoka University

BIN
images/MOPC-blue-long.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
images/MOPC-blue-small.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

17
next.config.ts Normal file
View File

@ -0,0 +1,17 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
typedRoutes: true,
serverExternalPackages: ['@prisma/client', 'minio'],
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.minio.local',
},
],
},
}
export default nextConfig

14088
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

117
package.json Normal file
View File

@ -0,0 +1,117 @@
{
"name": "mopc-platform",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write .",
"typecheck": "tsc --noEmit",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts",
"db:seed:candidatures": "tsx prisma/seed-candidatures.ts",
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@auth/prisma-adapter": "^2.7.4",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.9.1",
"@mantine/core": "^8.3.13",
"@mantine/hooks": "^8.3.13",
"@notionhq/client": "^2.3.0",
"@prisma/client": "^6.19.2",
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
"@radix-ui/react-collapsible": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slider": "^1.2.2",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-switch": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@radix-ui/react-tooltip": "^1.1.6",
"@tailwindcss/postcss": "^4.1.18",
"@tanstack/react-query": "^5.62.0",
"@trpc/client": "^11.0.0-rc.678",
"@trpc/react-query": "^11.0.0-rc.678",
"@trpc/server": "^11.0.0-rc.678",
"@types/leaflet": "^1.9.21",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.5.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.563.0",
"minio": "^8.0.2",
"motion": "^11.15.0",
"next": "^15.1.0",
"next-auth": "^5.0.0-beta.25",
"nodemailer": "^7.0.7",
"openai": "^6.16.0",
"papaparse": "^5.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"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",
"twilio": "^5.4.0",
"use-debounce": "^10.0.4",
"vaul": "^1.1.2",
"zod": "^3.24.1"
},
"devDependencies": {
"@playwright/test": "^1.49.1",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^25.0.10",
"@types/nodemailer": "^7.0.9",
"@types/papaparse": "^5.3.15",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-config-next": "^15.1.0",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.7.2",
"prisma": "^6.19.2",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20.0.0"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

36
prisma/check-data.ts Normal file
View File

@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function check() {
const projects = await prisma.project.count()
console.log('Total projects:', projects)
const rounds = await prisma.round.findMany({
include: {
_count: { select: { projects: true } }
}
})
for (const r of rounds) {
console.log(`Round: ${r.name} (id: ${r.id})`)
console.log(` Projects: ${r._count.projects}`)
}
// Check if projects have roundId set
const projectsWithRound = await prisma.project.findMany({
select: { id: true, title: true, roundId: true },
take: 5
})
console.log('\nSample projects:')
for (const p of projectsWithRound) {
console.log(` ${p.title}: roundId=${p.roundId}`)
}
}
check()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
})

View File

@ -0,0 +1,68 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function cleanup() {
console.log('Checking all rounds...\n')
const rounds = await prisma.round.findMany({
select: {
id: true,
name: true,
slug: true,
projects: { select: { id: true, title: true } },
_count: { select: { projects: true } }
}
})
console.log(`Found ${rounds.length} rounds:`)
for (const round of rounds) {
console.log(`- ${round.name} (slug: ${round.slug}): ${round._count.projects} projects`)
}
// Find rounds with 9 or fewer projects (dummy data)
const dummyRounds = rounds.filter(r => r._count.projects <= 9)
if (dummyRounds.length > 0) {
console.log(`\nDeleting ${dummyRounds.length} dummy round(s)...`)
for (const round of dummyRounds) {
console.log(`\nProcessing: ${round.name}`)
const projectIds = round.projects.map(p => p.id)
if (projectIds.length > 0) {
// Delete team members first
const teamDeleted = await prisma.teamMember.deleteMany({
where: { projectId: { in: projectIds } }
})
console.log(` Deleted ${teamDeleted.count} team members`)
// Delete projects
const projDeleted = await prisma.project.deleteMany({
where: { id: { in: projectIds } }
})
console.log(` Deleted ${projDeleted.count} projects`)
}
// Delete the round
await prisma.round.delete({ where: { id: round.id } })
console.log(` Deleted round: ${round.name}`)
}
}
// Summary
const remaining = await prisma.round.count()
const projects = await prisma.project.count()
console.log(`\n✅ Cleanup complete!`)
console.log(` Remaining rounds: ${remaining}`)
console.log(` Total projects: ${projects}`)
}
cleanup()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

57
prisma/cleanup-dummy.ts Normal file
View File

@ -0,0 +1,57 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function cleanup() {
console.log('Cleaning up dummy data...\n')
// Find and delete the dummy round
const dummyRound = await prisma.round.findFirst({
where: { slug: 'round-1-2026' },
include: { projects: true }
})
if (dummyRound) {
console.log(`Found dummy round: ${dummyRound.name}`)
console.log(`Projects in round: ${dummyRound.projects.length}`)
// Get project IDs to delete
const projectIds = dummyRound.projects.map(p => p.id)
// Delete team members for these projects
if (projectIds.length > 0) {
const teamDeleted = await prisma.teamMember.deleteMany({
where: { projectId: { in: projectIds } }
})
console.log(`Deleted ${teamDeleted.count} team members`)
// Disconnect projects from round first
await prisma.round.update({
where: { id: dummyRound.id },
data: { projects: { disconnect: projectIds.map(id => ({ id })) } }
})
// Delete the projects
const projDeleted = await prisma.project.deleteMany({
where: { id: { in: projectIds } }
})
console.log(`Deleted ${projDeleted.count} dummy projects`)
}
// Delete the round
await prisma.round.delete({ where: { id: dummyRound.id } })
console.log('Deleted dummy round')
} else {
console.log('No dummy round found')
}
console.log('\nCleanup complete!')
}
cleanup()
.then(() => prisma.$disconnect())
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
process.exit(1)
})

973
prisma/schema.prisma Normal file
View File

@ -0,0 +1,973 @@
// =============================================================================
// MOPC Platform - Prisma Schema
// =============================================================================
// This schema defines the database structure for the Monaco Ocean Protection
// Challenge jury voting platform.
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "windows", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =============================================================================
// ENUMS
// =============================================================================
enum UserRole {
SUPER_ADMIN
PROGRAM_ADMIN
JURY_MEMBER
MENTOR
OBSERVER
APPLICANT
}
enum UserStatus {
INVITED
ACTIVE
SUSPENDED
}
enum ProgramStatus {
DRAFT
ACTIVE
ARCHIVED
}
enum RoundStatus {
DRAFT
ACTIVE
CLOSED
ARCHIVED
}
enum ProjectStatus {
SUBMITTED
ELIGIBLE
ASSIGNED
SEMIFINALIST
FINALIST
REJECTED
}
enum EvaluationStatus {
NOT_STARTED
DRAFT
SUBMITTED
LOCKED
}
enum AssignmentMethod {
MANUAL
BULK
AI_SUGGESTED
AI_AUTO
ALGORITHM
}
enum FileType {
EXEC_SUMMARY
PRESENTATION
VIDEO
OTHER
BUSINESS_PLAN
VIDEO_PITCH
SUPPORTING_DOC
}
enum SubmissionSource {
MANUAL
CSV
NOTION
TYPEFORM
PUBLIC_FORM
}
enum RoundType {
FILTERING
EVALUATION
LIVE_EVENT
}
enum SettingType {
STRING
NUMBER
BOOLEAN
JSON
SECRET
}
enum SettingCategory {
AI
BRANDING
EMAIL
STORAGE
SECURITY
DEFAULTS
WHATSAPP
}
enum NotificationChannel {
EMAIL
WHATSAPP
BOTH
NONE
}
enum ResourceType {
PDF
VIDEO
DOCUMENT
LINK
OTHER
}
enum CohortLevel {
ALL
SEMIFINALIST
FINALIST
}
enum PartnerVisibility {
ADMIN_ONLY
JURY_VISIBLE
PUBLIC
}
enum PartnerType {
SPONSOR
PARTNER
SUPPORTER
MEDIA
OTHER
}
enum FormFieldType {
TEXT
TEXTAREA
NUMBER
EMAIL
PHONE
URL
DATE
DATETIME
SELECT
MULTI_SELECT
RADIO
CHECKBOX
CHECKBOX_GROUP
FILE
FILE_MULTIPLE
SECTION
INSTRUCTIONS
}
// =============================================================================
// APPLICANT SYSTEM ENUMS
// =============================================================================
enum CompetitionCategory {
STARTUP // Existing companies
BUSINESS_CONCEPT // Students/graduates
}
enum OceanIssue {
POLLUTION_REDUCTION
CLIMATE_MITIGATION
TECHNOLOGY_INNOVATION
SUSTAINABLE_SHIPPING
BLUE_CARBON
HABITAT_RESTORATION
COMMUNITY_CAPACITY
SUSTAINABLE_FISHING
CONSUMER_AWARENESS
OCEAN_ACIDIFICATION
OTHER
}
enum TeamMemberRole {
LEAD // Primary contact / team lead
MEMBER // Regular team member
ADVISOR // Advisor/mentor from team side
}
enum MentorAssignmentMethod {
MANUAL
AI_SUGGESTED
AI_AUTO
ALGORITHM
}
// =============================================================================
// USERS & AUTHENTICATION
// =============================================================================
model User {
id String @id @default(cuid())
email String @unique
name String?
emailVerified DateTime? // Required by NextAuth Prisma adapter
role UserRole @default(JURY_MEMBER)
status UserStatus @default(INVITED)
expertiseTags String[] @default([])
maxAssignments Int? // Per-round limit
metadataJson Json? @db.JsonB
// Profile image
profileImageKey String? // Storage key (e.g., "avatars/user123/1234567890.jpg")
profileImageProvider String? // Storage provider used: 's3' or 'local'
// Phone and notification preferences (Phase 2)
phoneNumber String?
phoneNumberVerified Boolean @default(false)
notificationPreference NotificationChannel @default(EMAIL)
whatsappOptIn Boolean @default(false)
// Onboarding (Phase 2B)
onboardingCompletedAt DateTime?
// Password authentication (hybrid auth)
passwordHash String? // bcrypt hashed password
passwordSetAt DateTime? // When password was set
mustSetPassword Boolean @default(true) // Force setup on first login
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastLoginAt DateTime?
// Relations
assignments Assignment[]
auditLogs AuditLog[]
gracePeriods GracePeriod[]
grantedGracePeriods GracePeriod[] @relation("GrantedBy")
notificationLogs NotificationLog[]
createdResources LearningResource[] @relation("ResourceCreatedBy")
resourceAccess ResourceAccess[]
submittedProjects Project[] @relation("ProjectSubmittedBy")
liveVotes LiveVote[]
// Team membership & mentorship
teamMemberships TeamMember[]
mentorAssignments MentorAssignment[] @relation("MentorAssignments")
// NextAuth relations
accounts Account[]
sessions Session[]
@@index([email])
@@index([role])
@@index([status])
}
// NextAuth.js required models
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// =============================================================================
// PROGRAMS & ROUNDS
// =============================================================================
model Program {
id String @id @default(cuid())
name String // e.g., "Monaco Ocean Protection Challenge"
year Int // e.g., 2026
status ProgramStatus @default(DRAFT)
description String?
settingsJson Json? @db.JsonB
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
rounds Round[]
learningResources LearningResource[]
partners Partner[]
applicationForms ApplicationForm[]
@@unique([name, year])
@@index([status])
}
model Round {
id String @id @default(cuid())
programId String
name String // e.g., "Round 1 - Semi-Finalists"
slug String? @unique // URL-friendly identifier for public submissions
status RoundStatus @default(DRAFT)
roundType RoundType @default(EVALUATION)
// Submission window (for applicant portal)
submissionDeadline DateTime? // Deadline for project submissions
submissionStartDate DateTime? // When submissions open
submissionEndDate DateTime? // When submissions close (replaces submissionDeadline if set)
lateSubmissionGrace Int? // Hours of grace period after deadline
// Phase-specific deadlines
phase1Deadline DateTime?
phase2Deadline DateTime?
// Voting window
votingStartAt DateTime?
votingEndAt DateTime?
// Configuration
requiredReviews Int @default(3) // Min evaluations per project
settingsJson Json? @db.JsonB // Grace periods, visibility rules, etc.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
projects Project[]
assignments Assignment[]
evaluationForms EvaluationForm[]
gracePeriods GracePeriod[]
liveVotingSession LiveVotingSession?
@@index([programId])
@@index([status])
@@index([roundType])
@@index([votingStartAt, votingEndAt])
@@index([submissionStartDate, submissionEndDate])
}
model EvaluationForm {
id String @id @default(cuid())
roundId String
version Int @default(1)
// Form configuration
// criteriaJson: Array of { id, label, description, scale, weight, required }
criteriaJson Json @db.JsonB
// scalesJson: { "1-5": { min, max, labels }, "1-10": { min, max, labels } }
scalesJson Json? @db.JsonB
isActive Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
evaluations Evaluation[]
@@unique([roundId, version])
@@index([roundId, isActive])
}
// =============================================================================
// PROJECTS
// =============================================================================
model Project {
id String @id @default(cuid())
roundId String
// Core fields
title String
teamName String?
description String? @db.Text
status ProjectStatus @default(SUBMITTED)
// Competition category
competitionCategory CompetitionCategory?
oceanIssue OceanIssue?
// Location
country String?
geographicZone String? // "Europe, France"
// Institution (for students/Business Concepts)
institution String?
// Mentorship
wantsMentorship Boolean @default(false)
// Submission links (external, from CSV)
phase1SubmissionUrl String?
phase2SubmissionUrl String?
// Referral tracking
referralSource String?
// Internal admin fields
internalComments String? @db.Text
applicationStatus String? // "Received", etc.
// Submission tracking
submissionSource SubmissionSource @default(MANUAL)
submittedByEmail String?
submittedAt DateTime?
submittedByUserId String?
// Project branding
logoKey String? // Storage key (e.g., "logos/project456/1234567890.png")
logoProvider String? // Storage provider used: 's3' or 'local'
// Flexible fields
tags String[] @default([]) // "Ocean Conservation", "Tech", etc.
metadataJson Json? @db.JsonB // Custom fields from Typeform, etc.
externalIdsJson Json? @db.JsonB // Typeform ID, Notion ID, etc.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
files ProjectFile[]
assignments Assignment[]
submittedBy User? @relation("ProjectSubmittedBy", fields: [submittedByUserId], references: [id], onDelete: SetNull)
teamMembers TeamMember[]
mentorAssignment MentorAssignment?
@@index([roundId])
@@index([status])
@@index([tags])
@@index([submissionSource])
@@index([submittedByUserId])
@@index([competitionCategory])
@@index([oceanIssue])
@@index([country])
}
model ProjectFile {
id String @id @default(cuid())
projectId String
// File info
fileType FileType
fileName String
mimeType String
size Int // bytes
// MinIO location
bucket String
objectKey String
createdAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([bucket, objectKey])
@@index([projectId])
@@index([fileType])
}
// =============================================================================
// ASSIGNMENTS & EVALUATIONS
// =============================================================================
model Assignment {
id String @id @default(cuid())
userId String
projectId String
roundId String
// Assignment info
method AssignmentMethod @default(MANUAL)
isRequired Boolean @default(true)
isCompleted Boolean @default(false)
// AI assignment metadata
aiConfidenceScore Float? // 0-1 confidence from AI
expertiseMatchScore Float? // 0-1 match score
aiReasoning String? @db.Text
createdAt DateTime @default(now())
createdBy String? // Admin who created the assignment
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
evaluation Evaluation?
@@unique([userId, projectId, roundId])
@@index([userId])
@@index([projectId])
@@index([roundId])
@@index([isCompleted])
}
model Evaluation {
id String @id @default(cuid())
assignmentId String @unique
formId String
// Status
status EvaluationStatus @default(NOT_STARTED)
// Scores
// criterionScoresJson: { "criterion_id": score, ... }
criterionScoresJson Json? @db.JsonB
globalScore Int? // 1-10
binaryDecision Boolean? // Yes/No for semi-finalist
feedbackText String? @db.Text
// Versioning
version Int @default(1)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submittedAt DateTime?
// Relations
assignment Assignment @relation(fields: [assignmentId], references: [id], onDelete: Cascade)
form EvaluationForm @relation(fields: [formId], references: [id])
@@index([status])
@@index([submittedAt])
@@index([formId])
}
// =============================================================================
// GRACE PERIODS
// =============================================================================
model GracePeriod {
id String @id @default(cuid())
roundId String
userId String
projectId String? // Optional: specific project or all projects in round
extendedUntil DateTime
reason String? @db.Text
grantedById String
createdAt DateTime @default(now())
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
grantedBy User @relation("GrantedBy", fields: [grantedById], references: [id])
@@index([roundId])
@@index([userId])
@@index([extendedUntil])
@@index([grantedById])
@@index([projectId])
}
// =============================================================================
// SYSTEM SETTINGS
// =============================================================================
model SystemSettings {
id String @id @default(cuid())
key String @unique
value String @db.Text
type SettingType @default(STRING)
category SettingCategory
description String?
isSecret Boolean @default(false) // If true, value is encrypted
updatedAt DateTime @updatedAt
updatedBy String?
@@index([category])
}
// =============================================================================
// AUDIT LOGGING
// =============================================================================
model AuditLog {
id String @id @default(cuid())
userId String?
// Event info
action String // "CREATE", "UPDATE", "DELETE", "LOGIN", "EXPORT", etc.
entityType String // "Round", "Project", "Evaluation", etc.
entityId String?
// Details
detailsJson Json? @db.JsonB // Before/after values, additional context
// Request info
ipAddress String?
userAgent String?
timestamp DateTime @default(now())
// Relations
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([action])
@@index([entityType, entityId])
@@index([timestamp])
}
// =============================================================================
// NOTIFICATION LOG (Phase 2)
// =============================================================================
model NotificationLog {
id String @id @default(cuid())
userId String
channel NotificationChannel
provider String? // META, TWILIO, SMTP
type String // MAGIC_LINK, REMINDER, ANNOUNCEMENT, JURY_INVITATION
status String // PENDING, SENT, DELIVERED, FAILED
externalId String? // Message ID from provider
errorMsg String? @db.Text
createdAt DateTime @default(now())
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([status])
@@index([createdAt])
}
// =============================================================================
// LEARNING HUB (Phase 2)
// =============================================================================
model LearningResource {
id String @id @default(cuid())
programId String? // null = global resource
title String
description String? @db.Text
contentJson Json? @db.JsonB // BlockNote document structure
resourceType ResourceType
cohortLevel CohortLevel @default(ALL)
// File storage (for uploaded resources)
fileName String?
mimeType String?
size Int?
bucket String?
objectKey String?
// External link
externalUrl String?
sortOrder Int @default(0)
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
// Relations
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
createdBy User @relation("ResourceCreatedBy", fields: [createdById], references: [id])
accessLogs ResourceAccess[]
@@index([programId])
@@index([cohortLevel])
@@index([isPublished])
@@index([sortOrder])
}
model ResourceAccess {
id String @id @default(cuid())
resourceId String
userId String
accessedAt DateTime @default(now())
ipAddress String?
// Relations
resource LearningResource @relation(fields: [resourceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([resourceId])
@@index([userId])
@@index([accessedAt])
}
// =============================================================================
// PARTNER MANAGEMENT (Phase 2)
// =============================================================================
model Partner {
id String @id @default(cuid())
programId String? // null = global partner
name String
description String? @db.Text
website String?
partnerType PartnerType @default(PARTNER)
visibility PartnerVisibility @default(ADMIN_ONLY)
// Logo file
logoFileName String?
logoBucket String?
logoObjectKey String?
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
@@index([programId])
@@index([partnerType])
@@index([visibility])
@@index([isActive])
@@index([sortOrder])
}
// =============================================================================
// APPLICATION FORMS (Phase 2)
// =============================================================================
model ApplicationForm {
id String @id @default(cuid())
programId String? // null = global form
name String
description String? @db.Text
status String @default("DRAFT") // DRAFT, PUBLISHED, CLOSED
isPublic Boolean @default(false)
publicSlug String? @unique // /apply/ocean-challenge-2026
submissionLimit Int?
opensAt DateTime?
closesAt DateTime?
confirmationMessage String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
program Program? @relation(fields: [programId], references: [id], onDelete: SetNull)
fields ApplicationFormField[]
submissions ApplicationFormSubmission[]
@@index([programId])
@@index([status])
@@index([isPublic])
}
model ApplicationFormField {
id String @id @default(cuid())
formId String
fieldType FormFieldType
name String // Internal name (e.g., "project_title")
label String // Display label (e.g., "Project Title")
description String? @db.Text
placeholder String?
required Boolean @default(false)
minLength Int?
maxLength Int?
minValue Float? // For NUMBER type
maxValue Float? // For NUMBER type
optionsJson Json? @db.JsonB // For select/radio: [{ value, label }]
conditionJson Json? @db.JsonB // Conditional logic: { fieldId, operator, value }
sortOrder Int @default(0)
width String @default("full") // full, half
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
@@index([formId])
@@index([sortOrder])
}
model ApplicationFormSubmission {
id String @id @default(cuid())
formId String
email String?
name String?
dataJson Json @db.JsonB // Field values: { fieldName: value, ... }
status String @default("SUBMITTED") // SUBMITTED, REVIEWED, APPROVED, REJECTED
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
form ApplicationForm @relation(fields: [formId], references: [id], onDelete: Cascade)
files SubmissionFile[]
@@index([formId])
@@index([status])
@@index([email])
@@index([createdAt])
}
model SubmissionFile {
id String @id @default(cuid())
submissionId String
fieldName String
fileName String
mimeType String?
size Int?
bucket String
objectKey String
createdAt DateTime @default(now())
// Relations
submission ApplicationFormSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
@@index([submissionId])
@@unique([bucket, objectKey])
}
// =============================================================================
// EXPERTISE TAGS (Phase 2B)
// =============================================================================
model ExpertiseTag {
id String @id @default(cuid())
name String @unique
description String?
category String? // "Marine Science", "Technology", "Policy"
color String? // Hex for badge
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([isActive])
@@index([sortOrder])
}
// =============================================================================
// LIVE VOTING (Phase 2B)
// =============================================================================
model LiveVotingSession {
id String @id @default(cuid())
roundId String @unique
status String @default("NOT_STARTED") // NOT_STARTED, IN_PROGRESS, PAUSED, COMPLETED
currentProjectIndex Int @default(0)
currentProjectId String?
votingStartedAt DateTime?
votingEndsAt DateTime?
projectOrderJson Json? @db.JsonB // Array of project IDs in presentation order
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
round Round @relation(fields: [roundId], references: [id], onDelete: Cascade)
votes LiveVote[]
@@index([status])
}
model LiveVote {
id String @id @default(cuid())
sessionId String
projectId String
userId String
score Int // 1-10
votedAt DateTime @default(now())
// Relations
session LiveVotingSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([sessionId, projectId, userId])
@@index([sessionId])
@@index([projectId])
@@index([userId])
}
// =============================================================================
// TEAM MEMBERSHIP
// =============================================================================
model TeamMember {
id String @id @default(cuid())
projectId String
userId String
role TeamMemberRole @default(MEMBER)
title String? // "CEO", "CTO", etc.
joinedAt DateTime @default(now())
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([projectId, userId])
@@index([projectId])
@@index([userId])
@@index([role])
}
// =============================================================================
// MENTOR ASSIGNMENT
// =============================================================================
model MentorAssignment {
id String @id @default(cuid())
projectId String @unique // One mentor per project
mentorId String // User with MENTOR role or expertise
// Assignment tracking
method MentorAssignmentMethod @default(MANUAL)
assignedAt DateTime @default(now())
assignedBy String? // Admin who assigned
// AI assignment metadata
aiConfidenceScore Float?
expertiseMatchScore Float?
aiReasoning String? @db.Text
// Relations
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
mentor User @relation("MentorAssignments", fields: [mentorId], references: [id])
@@index([mentorId])
@@index([method])
}

510
prisma/seed-candidatures.ts Normal file
View File

@ -0,0 +1,510 @@
import { PrismaClient, CompetitionCategory, OceanIssue, TeamMemberRole } from '@prisma/client'
import * as fs from 'fs'
import * as path from 'path'
import { fileURLToPath } from 'url'
import Papa from 'papaparse'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const prisma = new PrismaClient()
// CSV Column Mapping
interface CandidatureRow {
'Full name': string
'Application status': string
'Category': string
'Comment ': string // Note the space after 'Comment'
'Country': string
'Date of creation': string
'E-mail': string
'How did you hear about MOPC?': string
'Issue': string
'Jury 1 attribués': string
'MOPC team comments': string
'Mentorship': string
'PHASE 1 - Submission': string
'PHASE 2 - Submission': string
"Project's name": string
'Team members': string
'Tri par zone': string
'Téléphone': string
'University': string
}
// Map CSV category strings to enum values
function mapCategory(category: string): CompetitionCategory | null {
if (!category) return null
const lower = category.toLowerCase()
if (lower.includes('start-up') || lower.includes('startup')) {
return 'STARTUP'
}
if (lower.includes('business concept')) {
return 'BUSINESS_CONCEPT'
}
return null
}
// Map CSV issue strings to enum values
function mapOceanIssue(issue: string): OceanIssue | null {
if (!issue) return null
const lower = issue.toLowerCase()
if (lower.includes('pollution')) return 'POLLUTION_REDUCTION'
if (lower.includes('climate') || lower.includes('sea-level')) return 'CLIMATE_MITIGATION'
if (lower.includes('technology') || lower.includes('innovation')) return 'TECHNOLOGY_INNOVATION'
if (lower.includes('shipping') || lower.includes('yachting')) return 'SUSTAINABLE_SHIPPING'
if (lower.includes('blue carbon')) return 'BLUE_CARBON'
if (lower.includes('habitat') || lower.includes('restoration') || lower.includes('ecosystem')) return 'HABITAT_RESTORATION'
if (lower.includes('community') || lower.includes('capacity') || lower.includes('coastal')) return 'COMMUNITY_CAPACITY'
if (lower.includes('fishing') || lower.includes('aquaculture') || lower.includes('blue food')) return 'SUSTAINABLE_FISHING'
if (lower.includes('awareness') || lower.includes('education') || lower.includes('consumer')) return 'CONSUMER_AWARENESS'
if (lower.includes('acidification')) return 'OCEAN_ACIDIFICATION'
return 'OTHER'
}
// Parse team members string into array
function parseTeamMembers(teamMembersStr: string): { name: string; email?: string }[] {
if (!teamMembersStr) return []
// Split by comma or semicolon
const members = teamMembersStr.split(/[,;]/).map((m) => m.trim()).filter(Boolean)
return members.map((name) => ({
name: name.trim(),
// No emails in CSV, just names with titles
}))
}
// Extract country code from location string or return ISO code directly
function extractCountry(location: string): string | null {
if (!location) return null
// If already a 2-letter ISO code, return it directly
const trimmed = location.trim()
if (/^[A-Z]{2}$/.test(trimmed)) return trimmed
// Common country mappings from the CSV data
const countryMappings: Record<string, string> = {
'tunisie': 'TN',
'tunisia': 'TN',
'royaume-uni': 'GB',
'uk': 'GB',
'united kingdom': 'GB',
'angleterre': 'GB',
'england': 'GB',
'espagne': 'ES',
'spain': 'ES',
'inde': 'IN',
'india': 'IN',
'france': 'FR',
'états-unis': 'US',
'usa': 'US',
'united states': 'US',
'allemagne': 'DE',
'germany': 'DE',
'italie': 'IT',
'italy': 'IT',
'portugal': 'PT',
'monaco': 'MC',
'suisse': 'CH',
'switzerland': 'CH',
'belgique': 'BE',
'belgium': 'BE',
'pays-bas': 'NL',
'netherlands': 'NL',
'australia': 'AU',
'australie': 'AU',
'japon': 'JP',
'japan': 'JP',
'chine': 'CN',
'china': 'CN',
'brésil': 'BR',
'brazil': 'BR',
'mexique': 'MX',
'mexico': 'MX',
'canada': 'CA',
'maroc': 'MA',
'morocco': 'MA',
'egypte': 'EG',
'egypt': 'EG',
'afrique du sud': 'ZA',
'south africa': 'ZA',
'nigeria': 'NG',
'kenya': 'KE',
'ghana': 'GH',
'senegal': 'SN',
'sénégal': 'SN',
'côte d\'ivoire': 'CI',
'ivory coast': 'CI',
'indonesia': 'ID',
'indonésie': 'ID',
'philippines': 'PH',
'vietnam': 'VN',
'thaïlande': 'TH',
'thailand': 'TH',
'malaisie': 'MY',
'malaysia': 'MY',
'singapour': 'SG',
'singapore': 'SG',
'grèce': 'GR',
'greece': 'GR',
'turquie': 'TR',
'turkey': 'TR',
'pologne': 'PL',
'poland': 'PL',
'norvège': 'NO',
'norway': 'NO',
'suède': 'SE',
'sweden': 'SE',
'danemark': 'DK',
'denmark': 'DK',
'finlande': 'FI',
'finland': 'FI',
'irlande': 'IE',
'ireland': 'IE',
'autriche': 'AT',
'austria': 'AT',
// Additional mappings from CSV data (French names, accented variants)
'nigéria': 'NG',
'tanzanie': 'TZ',
'tanzania': 'TZ',
'ouganda': 'UG',
'uganda': 'UG',
'zambie': 'ZM',
'zambia': 'ZM',
'somalie': 'SO',
'somalia': 'SO',
'jordanie': 'JO',
'jordan': 'JO',
'bulgarie': 'BG',
'bulgaria': 'BG',
'indonesie': 'ID',
'macédoine du nord': 'MK',
'north macedonia': 'MK',
'jersey': 'JE',
'kazakhstan': 'KZ',
'cameroun': 'CM',
'cameroon': 'CM',
'vanuatu': 'VU',
'bénin': 'BJ',
'benin': 'BJ',
'argentine': 'AR',
'argentina': 'AR',
'srbija': 'RS',
'serbia': 'RS',
'kraljevo': 'RS',
'kosovo': 'XK',
'pristina': 'XK',
'xinjiang': 'CN',
'haïti': 'HT',
'haiti': 'HT',
'sri lanka': 'LK',
'luxembourg': 'LU',
'congo': 'CG',
'brazzaville': 'CG',
'colombie': 'CO',
'colombia': 'CO',
'bogota': 'CO',
'ukraine': 'UA',
}
const lower = location.toLowerCase()
for (const [key, code] of Object.entries(countryMappings)) {
if (lower.includes(key)) {
return code
}
}
return null
}
async function main() {
console.log('Starting candidatures import...\n')
// Read the CSV file
const csvPath = path.join(__dirname, '../docs/candidatures_2026.csv')
if (!fs.existsSync(csvPath)) {
console.error(`CSV file not found at ${csvPath}`)
process.exit(1)
}
const csvContent = fs.readFileSync(csvPath, 'utf-8')
// Parse CSV
const parseResult = Papa.parse<CandidatureRow>(csvContent, {
header: true,
skipEmptyLines: true,
})
if (parseResult.errors.length > 0) {
console.warn('CSV parsing warnings:', parseResult.errors)
}
const rows = parseResult.data
console.log(`Found ${rows.length} candidatures in CSV\n`)
// Get or create program
let program = await prisma.program.findFirst({
where: {
name: 'Monaco Ocean Protection Challenge',
year: 2026,
},
})
if (!program) {
program = await prisma.program.create({
data: {
name: 'Monaco Ocean Protection Challenge',
year: 2026,
status: 'ACTIVE',
description: 'The Monaco Ocean Protection Challenge is a flagship program promoting innovative solutions for ocean conservation.',
},
})
console.log('Created program:', program.name, program.year)
} else {
console.log('Using existing program:', program.name, program.year)
}
// Get or create Round 1
let round = await prisma.round.findFirst({
where: {
programId: program.id,
slug: 'mopc-2026-round-1',
},
})
if (!round) {
round = await prisma.round.create({
data: {
programId: program.id,
name: 'Round 1 - Semi-Finalists Selection',
slug: 'mopc-2026-round-1',
status: 'ACTIVE',
roundType: 'EVALUATION',
submissionStartDate: new Date('2025-09-01'),
submissionEndDate: new Date('2026-01-31'),
votingStartAt: new Date('2026-02-15'),
votingEndAt: new Date('2026-02-28'),
requiredReviews: 3,
settingsJson: {
gracePeriod: { hours: 24 },
allowLateSubmissions: true,
},
},
})
console.log('Created round:', round.name)
} else {
console.log('Using existing round:', round.name)
}
console.log('\nImporting candidatures...\n')
let imported = 0
let skipped = 0
let errors = 0
for (const row of rows) {
try {
const projectName = row["Project's name"]?.trim()
const email = row['E-mail']?.trim()
if (!projectName || !email) {
console.log(`Skipping row: missing project name or email`)
skipped++
continue
}
// Check if project already exists
const existingProject = await prisma.project.findFirst({
where: {
roundId: round.id,
OR: [
{ title: projectName },
{ submittedByEmail: email },
],
},
})
if (existingProject) {
console.log(`Skipping duplicate: ${projectName} (${email})`)
skipped++
continue
}
// Get or create user
let user = await prisma.user.findUnique({
where: { email },
})
if (!user) {
user = await prisma.user.create({
data: {
email,
name: row['Full name']?.trim() || 'Unknown',
role: 'APPLICANT',
status: 'ACTIVE',
phoneNumber: row['Téléphone']?.trim() || null,
},
})
}
// Parse date
let submittedAt: Date | null = null
if (row['Date of creation']) {
const dateStr = row['Date of creation'].trim()
const parsed = new Date(dateStr)
if (!isNaN(parsed.getTime())) {
submittedAt = parsed
}
}
// Create project
const project = await prisma.project.create({
data: {
roundId: round.id,
title: projectName,
description: row['Comment ']?.trim() || null,
status: 'SUBMITTED',
competitionCategory: mapCategory(row['Category']),
oceanIssue: mapOceanIssue(row['Issue']),
country: extractCountry(row['Country']),
geographicZone: row['Tri par zone']?.trim() || null,
institution: row['University']?.trim() || null,
wantsMentorship: row['Mentorship']?.toLowerCase() === 'true',
phase1SubmissionUrl: row['PHASE 1 - Submission']?.trim() || null,
phase2SubmissionUrl: row['PHASE 2 - Submission']?.trim() || null,
referralSource: row['How did you hear about MOPC?']?.trim() || null,
applicationStatus: row['Application status']?.trim() || 'Received',
internalComments: row['MOPC team comments']?.trim() || null,
submissionSource: 'CSV',
submittedByEmail: email,
submittedByUserId: user.id,
submittedAt: submittedAt || new Date(),
metadataJson: {
importedFrom: 'candidatures_2026.csv',
importedAt: new Date().toISOString(),
originalPhone: row['Téléphone']?.trim(),
},
},
})
// Create team lead membership
await prisma.teamMember.create({
data: {
projectId: project.id,
userId: user.id,
role: 'LEAD',
title: 'Team Lead',
},
})
// Parse and create team members
const teamMembers = parseTeamMembers(row['Team members'])
const leadName = row['Full name']?.trim().toLowerCase()
for (const member of teamMembers) {
// Skip if it's the lead (already added)
if (member.name.toLowerCase() === leadName) continue
// Since we don't have emails for team members, we create placeholder accounts
// They can claim their accounts later
const memberEmail = `${member.name.toLowerCase().replace(/[^a-z0-9]/g, '.')}@pending.mopc.local`
let memberUser = await prisma.user.findUnique({
where: { email: memberEmail },
})
if (!memberUser) {
memberUser = await prisma.user.create({
data: {
email: memberEmail,
name: member.name,
role: 'APPLICANT',
status: 'INVITED',
metadataJson: {
isPendingEmailVerification: true,
originalName: member.name,
},
},
})
}
// Check if membership already exists
const existingMembership = await prisma.teamMember.findUnique({
where: {
projectId_userId: {
projectId: project.id,
userId: memberUser.id,
},
},
})
if (!existingMembership) {
await prisma.teamMember.create({
data: {
projectId: project.id,
userId: memberUser.id,
role: 'MEMBER',
},
})
}
}
console.log(`Imported: ${projectName} (${email}) - ${teamMembers.length} team members`)
imported++
} catch (err) {
console.error(`Error importing row:`, err)
errors++
}
}
// Backfill: update any existing projects with null country
console.log('\nBackfilling missing country codes...\n')
let backfilled = 0
const nullCountryProjects = await prisma.project.findMany({
where: { roundId: round.id, country: null },
select: { id: true, submittedByEmail: true, title: true },
})
for (const project of nullCountryProjects) {
// Find the matching CSV row by email or title
const matchingRow = rows.find(
(r) =>
r['E-mail']?.trim() === project.submittedByEmail ||
r["Project's name"]?.trim() === project.title
)
if (matchingRow?.['Country']) {
const countryCode = extractCountry(matchingRow['Country'])
if (countryCode) {
await prisma.project.update({
where: { id: project.id },
data: { country: countryCode },
})
console.log(` Updated: ${project.title}${countryCode}`)
backfilled++
}
}
}
console.log(` Backfilled: ${backfilled} projects\n`)
console.log('\n========================================')
console.log(`Import complete!`)
console.log(` Imported: ${imported}`)
console.log(` Skipped: ${skipped}`)
console.log(` Errors: ${errors}`)
console.log(` Backfilled: ${backfilled}`)
console.log('========================================\n')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

View File

@ -0,0 +1,21 @@
DO $$
DECLARE
jury_id TEXT;
round_id TEXT;
proj RECORD;
BEGIN
SELECT id INTO jury_id FROM "User" WHERE email = 'jury.demo@monaco-opc.com';
SELECT id INTO round_id FROM "Round" WHERE slug = 'mopc-2026-round-1';
UPDATE "Round" SET status = 'ACTIVE', "votingStartAt" = NOW() - INTERVAL '7 days', "votingEndAt" = NOW() + INTERVAL '30 days' WHERE id = round_id;
FOR proj IN SELECT id, title FROM "Project" WHERE "roundId" = round_id ORDER BY "createdAt" DESC LIMIT 8
LOOP
INSERT INTO "Assignment" (id, "userId", "projectId", "roundId", method, "isRequired", "isCompleted", "createdAt")
VALUES ('demo-assign-' || substr(proj.id, 1, 15), jury_id, proj.id, round_id, 'MANUAL', true, false, NOW())
ON CONFLICT ("userId", "projectId", "roundId") DO NOTHING;
RAISE NOTICE 'Assigned: %', proj.title;
END LOOP;
RAISE NOTICE 'Done! Assigned projects to jury member.';
END $$;

95
prisma/seed-jury-demo.sql Normal file
View File

@ -0,0 +1,95 @@
-- Create demo jury member
INSERT INTO "User" (id, email, name, role, status, "passwordHash", "mustSetPassword", "passwordSetAt", "onboardingCompletedAt", "expertiseTags", "notificationPreference", "createdAt", "updatedAt")
VALUES (
'demo-jury-member-001',
'jury.demo@monaco-opc.com',
'Dr. Marie Laurent',
'JURY_MEMBER',
'ACTIVE',
'$2b$12$xUQpxLay9.0CJ08GvXrjm.yls.bp0Yeaa4TF5b4kLsIJGLrVMCVZ.',
false,
NOW(),
NOW(),
ARRAY['Marine Biology', 'Ocean Conservation', 'Sustainable Innovation'],
'EMAIL',
NOW(),
NOW()
)
ON CONFLICT (email) DO UPDATE SET
"passwordHash" = EXCLUDED."passwordHash",
"mustSetPassword" = false,
status = 'ACTIVE',
"onboardingCompletedAt" = NOW(),
"updatedAt" = NOW();
-- Get the user ID
DO $$
DECLARE
jury_id TEXT;
round_id TEXT;
proj RECORD;
form_exists BOOLEAN;
BEGIN
SELECT id INTO jury_id FROM "User" WHERE email = 'jury.demo@monaco-opc.com';
RAISE NOTICE 'Jury user ID: %', jury_id;
-- Get round
SELECT id INTO round_id FROM "Round" WHERE slug = 'mopc-2026-round-1';
RAISE NOTICE 'Round ID: %', round_id;
IF round_id IS NULL THEN
RAISE EXCEPTION 'Round not found!';
END IF;
-- Open voting window
UPDATE "Round" SET
status = 'ACTIVE',
"votingStartAt" = NOW() - INTERVAL '7 days',
"votingEndAt" = NOW() + INTERVAL '30 days'
WHERE id = round_id;
RAISE NOTICE 'Voting window opened';
-- Assign 8 projects
FOR proj IN
SELECT id, title FROM "Project" WHERE "roundId" = round_id ORDER BY "createdAt" DESC LIMIT 8
LOOP
INSERT INTO "Assignment" (id, "userId", "projectId", "roundId", method, "isRequired", "isCompleted", "createdAt")
VALUES (
'demo-assign-' || substr(proj.id, 1, 15),
jury_id,
proj.id,
round_id,
'MANUAL',
true,
false,
NOW()
)
ON CONFLICT ("userId", "projectId", "roundId") DO NOTHING;
RAISE NOTICE 'Assigned: %', proj.title;
END LOOP;
-- Check if evaluation form exists
SELECT EXISTS(SELECT 1 FROM "EvaluationForm" WHERE "roundId" = round_id) INTO form_exists;
IF NOT form_exists THEN
INSERT INTO "EvaluationForm" (id, "roundId", name, "isActive", "criteriaJson", "createdAt", "updatedAt")
VALUES (
'demo-eval-form-001',
round_id,
'Round 1 Evaluation',
true,
'[{"id":"innovation","label":"Innovation & Originality","description":"How innovative is the proposed solution?","scale":10,"weight":25,"required":true},{"id":"feasibility","label":"Technical Feasibility","description":"Is the solution technically viable?","scale":10,"weight":25,"required":true},{"id":"impact","label":"Environmental Impact","description":"What is the potential positive impact on ocean health?","scale":10,"weight":30,"required":true},{"id":"team","label":"Team Capability","description":"Does the team have the skills to execute?","scale":10,"weight":20,"required":true}]'::jsonb,
NOW(),
NOW()
);
RAISE NOTICE 'Created evaluation form';
ELSE
RAISE NOTICE 'Evaluation form already exists';
END IF;
RAISE NOTICE '========================================';
RAISE NOTICE 'Setup complete!';
RAISE NOTICE 'Email: jury.demo@monaco-opc.com';
RAISE NOTICE 'Password: JuryDemo2026!';
RAISE NOTICE '========================================';
END $$;

180
prisma/seed-jury-demo.ts Normal file
View File

@ -0,0 +1,180 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
console.log('Setting up demo jury member...\n')
// Hash a password for the demo jury account
const password = 'JuryDemo2026!'
const passwordHash = await bcrypt.hash(password, 12)
// Create or update jury member
const juryUser = await prisma.user.upsert({
where: { email: 'jury.demo@monaco-opc.com' },
update: {
passwordHash,
mustSetPassword: false,
status: 'ACTIVE',
onboardingCompletedAt: new Date(),
},
create: {
email: 'jury.demo@monaco-opc.com',
name: 'Dr. Marie Laurent',
role: 'JURY_MEMBER',
status: 'ACTIVE',
passwordHash,
mustSetPassword: false,
passwordSetAt: new Date(),
onboardingCompletedAt: new Date(),
expertiseTags: ['Marine Biology', 'Ocean Conservation', 'Sustainable Innovation'],
notificationPreference: 'EMAIL',
},
})
console.log(`Jury user: ${juryUser.email} (${juryUser.id})`)
console.log(`Password: ${password}\n`)
// Find the round
const round = await prisma.round.findFirst({
where: { slug: 'mopc-2026-round-1' },
})
if (!round) {
console.error('Round not found! Run seed-candidatures first.')
process.exit(1)
}
console.log(`Round: ${round.name} (${round.id})`)
// Ensure voting window is open
const now = new Date()
const votingStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) // 7 days ago
const votingEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) // 30 days from now
await prisma.round.update({
where: { id: round.id },
data: {
status: 'ACTIVE',
votingStartAt: votingStart,
votingEndAt: votingEnd,
},
})
console.log(`Voting window: ${votingStart.toISOString()}${votingEnd.toISOString()}\n`)
// Get some projects to assign
const projects = await prisma.project.findMany({
where: { roundId: round.id },
take: 8,
orderBy: { createdAt: 'desc' },
select: { id: true, title: true },
})
if (projects.length === 0) {
console.error('No projects found! Run seed-candidatures first.')
process.exit(1)
}
console.log(`Found ${projects.length} projects to assign\n`)
// Create assignments
let created = 0
let skipped = 0
for (const project of projects) {
try {
await prisma.assignment.upsert({
where: {
userId_projectId_roundId: {
userId: juryUser.id,
projectId: project.id,
roundId: round.id,
},
},
update: {},
create: {
userId: juryUser.id,
projectId: project.id,
roundId: round.id,
method: 'MANUAL',
isRequired: true,
},
})
console.log(` Assigned: ${project.title}`)
created++
} catch {
skipped++
}
}
// Ensure evaluation criteria exist for this round
const existingForm = await prisma.evaluationForm.findFirst({
where: { roundId: round.id },
})
if (!existingForm) {
await prisma.evaluationForm.create({
data: {
roundId: round.id,
name: 'Round 1 Evaluation',
isActive: true,
criteriaJson: [
{
id: 'innovation',
label: 'Innovation & Originality',
description: 'How innovative is the proposed solution? Does it bring a new approach to ocean conservation?',
scale: 10,
weight: 25,
required: true,
},
{
id: 'feasibility',
label: 'Technical Feasibility',
description: 'Is the solution technically viable? Can it be realistically implemented?',
scale: 10,
weight: 25,
required: true,
},
{
id: 'impact',
label: 'Environmental Impact',
description: 'What is the potential positive impact on ocean health and marine ecosystems?',
scale: 10,
weight: 30,
required: true,
},
{
id: 'team',
label: 'Team Capability',
description: 'Does the team have the skills, experience, and commitment to execute?',
scale: 10,
weight: 20,
required: true,
},
],
},
})
console.log('\nCreated evaluation form with 4 criteria')
} else {
console.log('\nEvaluation form already exists')
}
console.log('\n========================================')
console.log('Demo jury member setup complete!')
console.log(` Email: jury.demo@monaco-opc.com`)
console.log(` Password: ${password}`)
console.log(` Assignments: ${created} created, ${skipped} skipped`)
console.log(` Voting: OPEN (${round.name})`)
console.log('========================================\n')
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

488
prisma/seed.ts Normal file
View File

@ -0,0 +1,488 @@
import {
PrismaClient,
UserRole,
UserStatus,
ProgramStatus,
RoundStatus,
SettingType,
SettingCategory,
} from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
console.log('🌱 Seeding database...')
// ==========================================================================
// Create System Settings
// ==========================================================================
console.log('📋 Creating system settings...')
const settings = [
// AI Settings
{
key: 'ai_enabled',
value: 'false',
type: SettingType.BOOLEAN,
category: SettingCategory.AI,
description: 'Enable AI-powered jury assignment suggestions',
},
{
key: 'ai_provider',
value: 'openai',
type: SettingType.STRING,
category: SettingCategory.AI,
description: 'AI provider for smart assignment (openai)',
},
{
key: 'ai_model',
value: 'gpt-4o',
type: SettingType.STRING,
category: SettingCategory.AI,
description: 'OpenAI model to use for suggestions',
},
{
key: 'ai_send_descriptions',
value: 'false',
type: SettingType.BOOLEAN,
category: SettingCategory.AI,
description: 'Send anonymized project descriptions to AI',
},
// Branding Settings
{
key: 'platform_name',
value: 'Monaco Ocean Protection Challenge',
type: SettingType.STRING,
category: SettingCategory.BRANDING,
description: 'Platform display name',
},
{
key: 'primary_color',
value: '#de0f1e',
type: SettingType.STRING,
category: SettingCategory.BRANDING,
description: 'Primary brand color (hex)',
},
{
key: 'secondary_color',
value: '#053d57',
type: SettingType.STRING,
category: SettingCategory.BRANDING,
description: 'Secondary brand color (hex)',
},
{
key: 'accent_color',
value: '#557f8c',
type: SettingType.STRING,
category: SettingCategory.BRANDING,
description: 'Accent color (hex)',
},
// Security Settings
{
key: 'session_duration_hours',
value: '24',
type: SettingType.NUMBER,
category: SettingCategory.SECURITY,
description: 'Session duration in hours',
},
{
key: 'magic_link_expiry_minutes',
value: '15',
type: SettingType.NUMBER,
category: SettingCategory.SECURITY,
description: 'Magic link expiry time in minutes',
},
{
key: 'rate_limit_requests_per_minute',
value: '60',
type: SettingType.NUMBER,
category: SettingCategory.SECURITY,
description: 'API rate limit per minute',
},
// Storage Settings
{
key: 'storage_provider',
value: 's3',
type: SettingType.STRING,
category: SettingCategory.STORAGE,
description: 'Storage provider: s3 (MinIO) or local (filesystem)',
},
{
key: 'local_storage_path',
value: './uploads',
type: SettingType.STRING,
category: SettingCategory.STORAGE,
description: 'Base path for local file storage',
},
{
key: 'max_file_size_mb',
value: '500',
type: SettingType.NUMBER,
category: SettingCategory.STORAGE,
description: 'Maximum file upload size in MB',
},
{
key: 'avatar_max_size_mb',
value: '5',
type: SettingType.NUMBER,
category: SettingCategory.STORAGE,
description: 'Maximum avatar image size in MB',
},
{
key: 'allowed_file_types',
value: JSON.stringify(['application/pdf', 'video/mp4', 'video/quicktime', 'image/png', 'image/jpeg']),
type: SettingType.JSON,
category: SettingCategory.STORAGE,
description: 'Allowed MIME types for file uploads',
},
{
key: 'allowed_image_types',
value: JSON.stringify(['image/png', 'image/jpeg', 'image/webp']),
type: SettingType.JSON,
category: SettingCategory.STORAGE,
description: 'Allowed MIME types for avatar/logo uploads',
},
// Default Settings
{
key: 'default_timezone',
value: 'Europe/Monaco',
type: SettingType.STRING,
category: SettingCategory.DEFAULTS,
description: 'Default timezone for date displays',
},
{
key: 'default_page_size',
value: '20',
type: SettingType.NUMBER,
category: SettingCategory.DEFAULTS,
description: 'Default pagination size',
},
{
key: 'autosave_interval_seconds',
value: '30',
type: SettingType.NUMBER,
category: SettingCategory.DEFAULTS,
description: 'Autosave interval for evaluation forms',
},
// WhatsApp Settings (Phase 2)
{
key: 'whatsapp_enabled',
value: 'false',
type: SettingType.BOOLEAN,
category: SettingCategory.WHATSAPP,
description: 'Enable WhatsApp notifications',
},
{
key: 'whatsapp_provider',
value: 'META',
type: SettingType.STRING,
category: SettingCategory.WHATSAPP,
description: 'WhatsApp provider (META or TWILIO)',
},
{
key: 'whatsapp_meta_phone_number_id',
value: '',
type: SettingType.STRING,
category: SettingCategory.WHATSAPP,
description: 'Meta WhatsApp Phone Number ID',
},
{
key: 'whatsapp_meta_access_token',
value: '',
type: SettingType.SECRET,
category: SettingCategory.WHATSAPP,
description: 'Meta WhatsApp Access Token',
isSecret: true,
},
{
key: 'whatsapp_meta_business_account_id',
value: '',
type: SettingType.STRING,
category: SettingCategory.WHATSAPP,
description: 'Meta WhatsApp Business Account ID',
},
{
key: 'whatsapp_twilio_account_sid',
value: '',
type: SettingType.SECRET,
category: SettingCategory.WHATSAPP,
description: 'Twilio Account SID',
isSecret: true,
},
{
key: 'whatsapp_twilio_auth_token',
value: '',
type: SettingType.SECRET,
category: SettingCategory.WHATSAPP,
description: 'Twilio Auth Token',
isSecret: true,
},
{
key: 'whatsapp_twilio_phone_number',
value: '',
type: SettingType.STRING,
category: SettingCategory.WHATSAPP,
description: 'Twilio WhatsApp Phone Number',
},
// OpenAI API Key (Phase 2)
{
key: 'openai_api_key',
value: '',
type: SettingType.SECRET,
category: SettingCategory.AI,
description: 'OpenAI API Key for AI-powered features',
isSecret: true,
},
]
for (const setting of settings) {
await prisma.systemSettings.upsert({
where: { key: setting.key },
update: {},
create: setting,
})
}
// ==========================================================================
// Create Super Admin
// ==========================================================================
console.log('👤 Creating super admin...')
const admin = await prisma.user.upsert({
where: { email: 'matt.ciaccio@gmail.com' },
update: {},
create: {
email: 'matt.ciaccio@gmail.com',
name: 'Matt Ciaccio',
role: UserRole.SUPER_ADMIN,
status: UserStatus.ACTIVE,
},
})
console.log(` Created admin: ${admin.email}`)
// ==========================================================================
// Create Sample Program
// ==========================================================================
console.log('📁 Creating sample program...')
const program = await prisma.program.upsert({
where: { name_year: { name: 'Monaco Ocean Protection Challenge', year: 2026 } },
update: {},
create: {
name: 'Monaco Ocean Protection Challenge',
year: 2026,
status: ProgramStatus.ACTIVE,
description: 'Annual ocean conservation startup competition supporting innovative solutions for ocean protection.',
},
})
console.log(` Created program: ${program.name} ${program.year}`)
// ==========================================================================
// Create Round 1
// ==========================================================================
console.log('🔄 Creating Round 1...')
const round1 = await prisma.round.upsert({
where: {
id: 'round-1-2026', // Use a deterministic ID for upsert
},
update: {},
create: {
id: 'round-1-2026',
programId: program.id,
name: 'Round 1 - Semi-Finalists Selection',
status: RoundStatus.DRAFT,
requiredReviews: 3,
votingStartAt: new Date('2026-02-18T09:00:00Z'),
votingEndAt: new Date('2026-02-23T18:00:00Z'),
settingsJson: {
allowGracePeriods: true,
showAggregatesAfterClose: true,
juryCanSeeOwnPastEvaluations: true,
},
},
})
console.log(` Created round: ${round1.name}`)
// ==========================================================================
// Create Evaluation Form for Round 1
// ==========================================================================
console.log('📝 Creating evaluation form...')
await prisma.evaluationForm.upsert({
where: {
roundId_version: {
roundId: round1.id,
version: 1,
},
},
update: {},
create: {
roundId: round1.id,
version: 1,
isActive: true,
criteriaJson: [
{
id: 'need_clarity',
label: 'Need Clarity',
description: 'How clearly is the problem/need articulated?',
scale: '1-5',
weight: 1,
required: true,
},
{
id: 'solution_relevance',
label: 'Solution Relevance',
description: 'How relevant and innovative is the proposed solution?',
scale: '1-5',
weight: 1,
required: true,
},
{
id: 'gap_analysis',
label: 'Gap Analysis',
description: 'How well does the project analyze existing gaps in the market?',
scale: '1-5',
weight: 1,
required: true,
},
{
id: 'target_customers',
label: 'Target Customer Clarity',
description: 'How clearly are target customers/beneficiaries defined?',
scale: '1-5',
weight: 1,
required: true,
},
{
id: 'ocean_impact',
label: 'Ocean Impact',
description: 'What is the potential positive impact on ocean conservation?',
scale: '1-5',
weight: 2, // Higher weight for ocean impact
required: true,
},
],
scalesJson: {
'1-5': {
min: 1,
max: 5,
labels: {
1: 'Poor',
2: 'Below Average',
3: 'Average',
4: 'Good',
5: 'Excellent',
},
},
'1-10': {
min: 1,
max: 10,
labels: {
1: 'Poor',
5: 'Average',
10: 'Excellent',
},
},
},
},
})
console.log(' Created evaluation form v1')
// ==========================================================================
// Create Sample Jury Members
// ==========================================================================
console.log('👥 Creating sample jury members...')
const juryMembers = [
{
email: 'jury1@example.com',
name: 'Dr. Marine Expert',
expertiseTags: ['marine_biology', 'conservation', 'policy'],
},
{
email: 'jury2@example.com',
name: 'Tech Innovator',
expertiseTags: ['technology', 'innovation', 'startups'],
},
{
email: 'jury3@example.com',
name: 'Ocean Advocate',
expertiseTags: ['conservation', 'sustainability', 'education'],
},
]
for (const jury of juryMembers) {
await prisma.user.upsert({
where: { email: jury.email },
update: {},
create: {
email: jury.email,
name: jury.name,
role: UserRole.JURY_MEMBER,
status: UserStatus.INVITED,
expertiseTags: jury.expertiseTags,
maxAssignments: 15,
},
})
console.log(` Created jury member: ${jury.email}`)
}
// ==========================================================================
// Create Sample Projects
// ==========================================================================
console.log('📦 Creating sample projects...')
const sampleProjects = [
{
title: 'OceanAI - Plastic Detection System',
teamName: 'BlueWave Tech',
description: 'AI-powered system using satellite imagery and drones to detect and map ocean plastic concentrations for targeted cleanup operations.',
tags: ['technology', 'ai', 'plastic_pollution'],
},
{
title: 'Coral Restoration Network',
teamName: 'ReefGuard Foundation',
description: 'Community-driven coral nursery and transplantation program using innovative 3D-printed substrates.',
tags: ['conservation', 'coral', 'community'],
},
{
title: 'SeaTrack - Sustainable Fishing Tracker',
teamName: 'FishRight Solutions',
description: 'Blockchain-based supply chain tracking system ensuring sustainable fishing practices from ocean to table.',
tags: ['technology', 'sustainable_fishing', 'blockchain'],
},
]
for (const project of sampleProjects) {
await prisma.project.create({
data: {
roundId: round1.id,
title: project.title,
teamName: project.teamName,
description: project.description,
tags: project.tags,
},
})
console.log(` Created project: ${project.title}`)
}
console.log('')
console.log('✅ Seeding completed successfully!')
console.log('')
console.log('📧 Admin login: matt.ciaccio@gmail.com')
console.log(' (Use magic link authentication)')
}
main()
.catch((e) => {
console.error('❌ Seeding failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

1
prisma/set-admin-pw.sql Normal file
View File

@ -0,0 +1 @@
UPDATE "User" SET "passwordHash" = '$2b$12$W79XaxCcUvrSFDg6rY7/8ebMFZD7RsD1OSHYvIUeftzZL9blvTI8q', "mustSetPassword" = false WHERE email = 'admin@monaco-opc.com';

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

103
scripts/deploy.sh Normal file
View File

@ -0,0 +1,103 @@
#!/bin/bash
# =============================================================================
# MOPC Platform - First-Time Deployment Script
# =============================================================================
# Usage: ./scripts/deploy.sh
# Run this once on the Linux VPS to set up the platform.
# The Docker image is built by Gitea CI and pulled from the registry.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOCKER_DIR="$PROJECT_DIR/docker"
echo "============================================"
echo " MOPC Platform - Deployment"
echo "============================================"
echo ""
# 1. Check Docker is available
if ! command -v docker &> /dev/null; then
echo "ERROR: Docker is not installed."
exit 1
fi
if ! docker compose version &> /dev/null; then
echo "ERROR: Docker Compose v2 is not available."
exit 1
fi
# 2. Check environment file
if [ ! -f "$DOCKER_DIR/.env" ]; then
echo "No .env file found in docker/."
echo "Copying template..."
cp "$DOCKER_DIR/.env.production" "$DOCKER_DIR/.env"
echo ""
echo "IMPORTANT: Edit docker/.env with your production values before continuing."
echo " nano $DOCKER_DIR/.env"
echo ""
exit 1
fi
# 3. Load registry URL from env
source "$DOCKER_DIR/.env"
if [ -z "$REGISTRY_URL" ] || [ "$REGISTRY_URL" = "CHANGE_ME" ]; then
echo "ERROR: REGISTRY_URL is not set in docker/.env"
echo "Set it to your Gitea registry URL (e.g. gitea.example.com/your-org)"
exit 1
fi
# 4. Log in to container registry
echo "==> Logging in to container registry ($REGISTRY_URL)..."
docker login "$REGISTRY_URL"
# 5. Create data directories
echo "==> Creating data directories..."
sudo mkdir -p /data/mopc/postgres
sudo chown -R 1000:1000 /data/mopc
# 6. Pull and start
echo "==> Pulling latest images..."
cd "$DOCKER_DIR"
docker compose pull app
echo "==> Starting services..."
docker compose up -d
# 7. Wait for health check
echo "==> Waiting for application to start..."
MAX_WAIT=120
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
if curl -sf http://localhost:7600/api/health > /dev/null 2>&1; then
echo ""
echo "============================================"
echo " Application is running!"
echo "============================================"
echo ""
echo " URL: http://localhost:7600"
echo " Health: http://localhost:7600/api/health"
echo ""
echo " NEXT STEPS:"
echo " 1. Run the one-time database seed:"
echo " ./scripts/seed.sh"
echo ""
echo " 2. Set up Nginx reverse proxy:"
echo " sudo ln -s $DOCKER_DIR/nginx/mopc-platform.conf /etc/nginx/sites-enabled/"
echo " sudo nginx -t && sudo systemctl reload nginx"
echo ""
echo " 3. Set up SSL:"
echo " sudo certbot --nginx -d portal.monaco-opc.com"
echo ""
exit 0
fi
sleep 2
WAITED=$((WAITED + 2))
printf "."
done
echo ""
echo "WARNING: Application did not become healthy within ${MAX_WAIT}s."
echo "Check logs: cd $DOCKER_DIR && docker compose logs -f app"
exit 1

52
scripts/seed.sh Normal file
View File

@ -0,0 +1,52 @@
#!/bin/bash
# =============================================================================
# MOPC Platform - One-Time Database Seed
# =============================================================================
# Usage: ./scripts/seed.sh
# Run this ONCE after the first deployment to populate the database with:
# - Super admin user
# - System settings
# - Program & Round 1 configuration
# - Evaluation form
# - Candidature data from CSV
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOCKER_DIR="$PROJECT_DIR/docker"
echo "============================================"
echo " MOPC Platform - Database Seed"
echo "============================================"
echo ""
echo "This will seed the database with:"
echo " - Admin user & system settings"
echo " - Program, Round 1, evaluation form"
echo " - Candidature data from CSV"
echo ""
read -p "Continue? (y/N) " -n 1 -r
echo ""
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted."
exit 0
fi
echo ""
echo "==> Running base seed (admin, settings, program, round, eval form)..."
cd "$DOCKER_DIR"
docker compose exec app npx tsx prisma/seed.ts
echo ""
echo "==> Running candidatures import from CSV..."
docker compose exec app npx tsx prisma/seed-candidatures.ts
echo ""
echo "============================================"
echo " Seed complete!"
echo "============================================"
echo ""
echo " Admin login: matt.ciaccio@gmail.com"
echo " (Use magic link authentication)"
echo ""

49
scripts/update.sh Normal file
View File

@ -0,0 +1,49 @@
#!/bin/bash
# =============================================================================
# MOPC Platform - Update / Redeploy Script
# =============================================================================
# Usage: ./scripts/update.sh
# Pulls the latest image from the registry and restarts the app.
# PostgreSQL is NOT restarted.
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
DOCKER_DIR="$PROJECT_DIR/docker"
echo "============================================"
echo " MOPC Platform - Update"
echo "============================================"
echo ""
# 1. Pull latest image from registry
echo "==> Pulling latest image..."
cd "$DOCKER_DIR"
docker compose pull app
# 2. Restart app only (postgres stays running)
echo "==> Restarting app..."
docker compose up -d app
# 3. Wait for health check
echo "==> Waiting for application to start..."
MAX_WAIT=120
WAITED=0
while [ $WAITED -lt $MAX_WAIT ]; do
if curl -sf http://localhost:7600/api/health > /dev/null 2>&1; then
echo ""
echo "============================================"
echo " Update complete! App is healthy."
echo "============================================"
exit 0
fi
sleep 2
WAITED=$((WAITED + 2))
printf "."
done
echo ""
echo "WARNING: Application did not become healthy within ${MAX_WAIT}s."
echo "Check logs: cd $DOCKER_DIR && docker compose logs -f app"
exit 1

View File

@ -0,0 +1,624 @@
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Download,
Filter,
ChevronDown,
ChevronUp,
Clock,
User,
Activity,
Database,
Globe,
ChevronLeft,
ChevronRight,
RefreshCw,
RotateCcw,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { cn } from '@/lib/utils'
// Action type options
const ACTION_TYPES = [
'CREATE',
'UPDATE',
'DELETE',
'IMPORT',
'EXPORT',
'LOGIN',
'SUBMIT_EVALUATION',
'UPDATE_STATUS',
'UPLOAD_FILE',
'DELETE_FILE',
'BULK_CREATE',
'BULK_UPDATE_STATUS',
'UPDATE_EVALUATION_FORM',
]
// Entity type options
const ENTITY_TYPES = [
'User',
'Program',
'Round',
'Project',
'Assignment',
'Evaluation',
'EvaluationForm',
'ProjectFile',
'GracePeriod',
]
// Color map for action types
const actionColors: Record<string, 'default' | 'destructive' | 'secondary' | 'outline'> = {
CREATE: 'default',
UPDATE: 'secondary',
DELETE: 'destructive',
IMPORT: 'default',
EXPORT: 'outline',
LOGIN: 'outline',
SUBMIT_EVALUATION: 'default',
}
export default function AuditLogPage() {
// Filter state
const [filters, setFilters] = useState({
userId: '',
action: '',
entityType: '',
startDate: '',
endDate: '',
})
const [page, setPage] = useState(1)
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set())
const [showFilters, setShowFilters] = useState(true)
// Build query input
const queryInput = useMemo(
() => ({
userId: filters.userId || undefined,
action: filters.action || undefined,
entityType: filters.entityType || undefined,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate
? new Date(filters.endDate + 'T23:59:59')
: undefined,
page,
perPage: 50,
}),
[filters, page]
)
// Fetch audit logs
const { data, isLoading, refetch } = trpc.audit.list.useQuery(queryInput)
// Fetch users for filter dropdown
const { data: usersData } = trpc.user.list.useQuery({
page: 1,
perPage: 100,
})
// Export mutation
const exportLogs = trpc.export.auditLogs.useQuery(
{
userId: filters.userId || undefined,
action: filters.action || undefined,
entityType: filters.entityType || undefined,
startDate: filters.startDate ? new Date(filters.startDate) : undefined,
endDate: filters.endDate
? new Date(filters.endDate + 'T23:59:59')
: undefined,
},
{ enabled: false }
)
// Handle export
const handleExport = async () => {
const result = await exportLogs.refetch()
if (result.data) {
const { data: rows, columns } = result.data
// Build CSV
const csvContent = [
columns.join(','),
...rows.map((row) =>
columns
.map((col) => {
const value = row[col as keyof typeof row]
// Escape quotes and wrap in quotes if contains comma
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
return `"${value.replace(/"/g, '""')}"`
}
return value ?? ''
})
.join(',')
),
].join('\n')
// Download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}
}
// Reset filters
const resetFilters = () => {
setFilters({
userId: '',
action: '',
entityType: '',
startDate: '',
endDate: '',
})
setPage(1)
}
// Toggle row expansion
const toggleRow = (id: string) => {
const newExpanded = new Set(expandedRows)
if (newExpanded.has(id)) {
newExpanded.delete(id)
} else {
newExpanded.add(id)
}
setExpandedRows(newExpanded)
}
const hasFilters = Object.values(filters).some((v) => v !== '')
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground">
View system activity and user actions
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="icon" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={handleExport}
disabled={exportLogs.isFetching}
>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
</div>
{/* Filters */}
<Collapsible open={showFilters} onOpenChange={setShowFilters}>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4" />
<CardTitle className="text-lg">Filters</CardTitle>
{hasFilters && (
<Badge variant="secondary" className="ml-2">
Active
</Badge>
)}
</div>
{showFilters ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</div>
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
{/* User Filter */}
<div className="space-y-2">
<Label>User</Label>
<Select
value={filters.userId}
onValueChange={(v) =>
setFilters({ ...filters, userId: v === '__all__' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All users" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All users</SelectItem>
{usersData?.users.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name || user.email}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Action Filter */}
<div className="space-y-2">
<Label>Action</Label>
<Select
value={filters.action}
onValueChange={(v) =>
setFilters({ ...filters, action: v === '__all__' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All actions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All actions</SelectItem>
{ACTION_TYPES.map((action) => (
<SelectItem key={action} value={action}>
{action.replace(/_/g, ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Entity Type Filter */}
<div className="space-y-2">
<Label>Entity Type</Label>
<Select
value={filters.entityType}
onValueChange={(v) =>
setFilters({ ...filters, entityType: v === '__all__' ? '' : v })
}
>
<SelectTrigger>
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All types</SelectItem>
{ENTITY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Start Date */}
<div className="space-y-2">
<Label>From Date</Label>
<Input
type="date"
value={filters.startDate}
onChange={(e) =>
setFilters({ ...filters, startDate: e.target.value })
}
/>
</div>
{/* End Date */}
<div className="space-y-2">
<Label>To Date</Label>
<Input
type="date"
value={filters.endDate}
onChange={(e) =>
setFilters({ ...filters, endDate: e.target.value })
}
/>
</div>
</div>
{hasFilters && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" />
Reset Filters
</Button>
</div>
)}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
{/* Results */}
{isLoading ? (
<AuditLogSkeleton />
) : data && data.logs.length > 0 ? (
<>
{/* Desktop Table View */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[180px]">Timestamp</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Entity</TableHead>
<TableHead>IP Address</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.logs.map((log) => {
const isExpanded = expandedRows.has(log.id)
return (
<>
<TableRow
key={log.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => toggleRow(log.id)}
>
<TableCell className="font-mono text-xs">
{formatDate(log.timestamp)}
</TableCell>
<TableCell>
<div>
<p className="font-medium text-sm">
{log.user?.name || 'System'}
</p>
<p className="text-xs text-muted-foreground">
{log.user?.email}
</p>
</div>
</TableCell>
<TableCell>
<Badge
variant={actionColors[log.action] || 'secondary'}
>
{log.action.replace(/_/g, ' ')}
</Badge>
</TableCell>
<TableCell>
<div>
<p className="text-sm">{log.entityType}</p>
{log.entityId && (
<p className="text-xs text-muted-foreground font-mono">
{log.entityId.slice(0, 8)}...
</p>
)}
</div>
</TableCell>
<TableCell className="font-mono text-xs">
{log.ipAddress || '-'}
</TableCell>
<TableCell>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</TableCell>
</TableRow>
{isExpanded && (
<TableRow key={`${log.id}-details`}>
<TableCell colSpan={6} className="bg-muted/30">
<div className="p-4 space-y-2">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-xs font-medium text-muted-foreground">
Entity ID
</p>
<p className="font-mono text-sm">
{log.entityId || 'N/A'}
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">
User Agent
</p>
<p className="text-sm truncate max-w-md">
{log.userAgent || 'N/A'}
</p>
</div>
</div>
{log.detailsJson && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Details
</p>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
)
})}
</TableBody>
</Table>
</Card>
{/* Mobile Card View */}
<div className="space-y-4 md:hidden">
{data.logs.map((log) => {
const isExpanded = expandedRows.has(log.id)
return (
<Card
key={log.id}
className="cursor-pointer"
onClick={() => toggleRow(log.id)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Badge
variant={actionColors[log.action] || 'secondary'}
>
{log.action.replace(/_/g, ' ')}
</Badge>
<span className="text-sm text-muted-foreground">
{log.entityType}
</span>
</div>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</div>
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1 text-muted-foreground">
<Clock className="h-3 w-3" />
<span className="text-xs">
{formatDate(log.timestamp)}
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
<span className="text-xs">
{log.user?.name || 'System'}
</span>
</div>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground">
Entity ID
</p>
<p className="font-mono text-xs">
{log.entityId || 'N/A'}
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">
IP Address
</p>
<p className="font-mono text-xs">
{log.ipAddress || 'N/A'}
</p>
</div>
{log.detailsJson && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Details
</p>
<pre className="text-xs bg-muted rounded p-2 overflow-x-auto">
{JSON.stringify(log.detailsJson, null, 2)}
</pre>
</div>
)}
</div>
)}
</CardContent>
</Card>
)
})}
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {(page - 1) * 50 + 1} to{' '}
{Math.min(page * 50, data.total)} of {data.total} results
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<span className="text-sm">
Page {page} of {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= data.totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Activity className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No audit logs found</p>
<p className="text-sm text-muted-foreground">
{hasFilters
? 'Try adjusting your filters'
: 'Activity will appear here as users interact with the system'}
</p>
</CardContent>
</Card>
)}
</div>
)
}
function AuditLogSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(10)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-24 ml-auto" />
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,241 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2, Plus, Trash2, GripVertical, Save } from 'lucide-react'
import { toast } from 'sonner'
interface FormEditorProps {
form: {
id: string
name: string
description: string | null
status: string
isPublic: boolean
publicSlug: string | null
submissionLimit: number | null
opensAt: Date | null
closesAt: Date | null
confirmationMessage: string | null
fields: Array<{
id: string
fieldType: string
name: string
label: string
description: string | null
placeholder: string | null
required: boolean
sortOrder: number
}>
}
}
export function FormEditor({ form }: FormEditorProps) {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: form.name,
description: form.description || '',
status: form.status,
isPublic: form.isPublic,
publicSlug: form.publicSlug || '',
confirmationMessage: form.confirmationMessage || '',
})
const updateForm = trpc.applicationForm.update.useMutation({
onSuccess: () => {
toast.success('Form updated successfully')
router.refresh()
setIsSubmitting(false)
},
onError: (error) => {
toast.error(error.message || 'Failed to update form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
updateForm.mutate({
id: form.id,
name: formData.name,
status: formData.status as 'DRAFT' | 'PUBLISHED' | 'CLOSED',
isPublic: formData.isPublic,
description: formData.description || null,
publicSlug: formData.publicSlug || null,
confirmationMessage: formData.confirmationMessage || null,
})
}
return (
<Tabs defaultValue="settings" className="space-y-6">
<TabsList>
<TabsTrigger value="settings">Settings</TabsTrigger>
<TabsTrigger value="fields">Fields ({form.fields.length})</TabsTrigger>
</TabsList>
<TabsContent value="settings">
<Card>
<CardHeader>
<CardTitle>Form Settings</CardTitle>
<CardDescription>
Configure the basic settings for this form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Form Name</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData({ ...formData, status: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="PUBLISHED">Published</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
value={formData.publicSlug}
onChange={(e) => setFormData({ ...formData, publicSlug: e.target.value })}
className="flex-1"
/>
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData({ ...formData, isPublic: checked })}
/>
<Label htmlFor="isPublic">Public form (accessible without login)</Label>
</div>
<div className="space-y-2">
<Label htmlFor="confirmationMessage">Confirmation Message</Label>
<Textarea
id="confirmationMessage"
value={formData.confirmationMessage}
onChange={(e) => setFormData({ ...formData, confirmationMessage: e.target.value })}
placeholder="Thank you for your submission..."
rows={3}
maxLength={1000}
/>
</div>
<div className="flex justify-end pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Save className="mr-2 h-4 w-4" />
Save Settings
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="fields">
<Card>
<CardHeader>
<CardTitle>Form Fields</CardTitle>
<CardDescription>
Add and arrange the fields for your application form
</CardDescription>
</CardHeader>
<CardContent>
{form.fields.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No fields added yet.</p>
<p className="text-sm">Add fields to start building your form.</p>
</div>
) : (
<div className="space-y-2">
{form.fields.map((field) => (
<div
key={field.id}
className="flex items-center gap-3 p-3 border rounded-lg"
>
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<div className="flex-1">
<div className="font-medium">{field.label}</div>
<div className="text-sm text-muted-foreground">
{field.fieldType} {field.required && '(required)'}
</div>
</div>
<Button variant="ghost" size="icon" aria-label="Delete field">
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
<div className="mt-4">
<Button variant="outline">
<Plus className="mr-2 h-4 w-4" />
Add Field
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)
}

View File

@ -0,0 +1,83 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Settings, Eye, FileText, Plus } from 'lucide-react'
import { FormEditor } from './form-editor'
interface FormDetailPageProps {
params: Promise<{ id: string }>
}
export default async function FormDetailPage({ params }: FormDetailPageProps) {
const { id } = await params
const caller = await api()
let form
try {
form = await caller.applicationForm.get({ id })
} catch {
notFound()
}
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/forms">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{form.name}</h1>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
</div>
<p className="text-muted-foreground">
{form.fields.length} fields - {form._count.submissions} submissions
</p>
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline">
<Eye className="mr-2 h-4 w-4" />
Preview
</Button>
</a>
)}
<Link href={`/admin/forms/${id}/submissions`}>
<Button variant="outline">
<FileText className="mr-2 h-4 w-4" />
Submissions
</Button>
</Link>
</div>
</div>
<FormEditor form={form} />
</div>
)
}

View File

@ -0,0 +1,130 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { ArrowLeft, Download, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SubmissionDetailPageProps {
params: Promise<{ id: string; submissionId: string }>
}
const statusColors = {
SUBMITTED: 'bg-blue-100 text-blue-800',
REVIEWED: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}
export default async function SubmissionDetailPage({ params }: SubmissionDetailPageProps) {
const { id, submissionId } = await params
const caller = await api()
let submission
try {
submission = await caller.applicationForm.getSubmission({ id: submissionId })
} catch {
notFound()
}
const data = submission.dataJson as Record<string, unknown>
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/forms/${id}/submissions`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">
{submission.name || submission.email || 'Anonymous Submission'}
</h1>
<Badge className={statusColors[submission.status as keyof typeof statusColors]}>
{submission.status}
</Badge>
</div>
<p className="text-muted-foreground">
Submitted {formatDate(submission.createdAt)}
</p>
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Submission Data</CardTitle>
<CardDescription>
All fields submitted in this application
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{submission.form.fields.map((field) => {
const value = data[field.name]
return (
<div key={field.id} className="border-b pb-4 last:border-0">
<div className="font-medium text-sm text-muted-foreground">
{field.label}
</div>
<div className="mt-1">
{value !== undefined && value !== null && value !== '' ? (
typeof value === 'object' ? (
<pre className="text-sm bg-muted p-2 rounded">
{JSON.stringify(value, null, 2)}
</pre>
) : (
<span>{String(value)}</span>
)
) : (
<span className="text-muted-foreground italic">Not provided</span>
)}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{submission.files.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Attached Files</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{submission.files.map((file) => (
<div
key={file.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div>
<div className="font-medium">{file.fileName}</div>
<div className="text-sm text-muted-foreground">
{file.size ? `${(file.size / 1024).toFixed(1)} KB` : 'Unknown size'}
</div>
</div>
<Button variant="ghost" size="icon">
<Download className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,135 @@
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Inbox, Eye, Download } from 'lucide-react'
import { formatDate } from '@/lib/utils'
interface SubmissionsPageProps {
params: Promise<{ id: string }>
}
const statusColors = {
SUBMITTED: 'bg-blue-100 text-blue-800',
REVIEWED: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
}
async function SubmissionsList({ formId }: { formId: string }) {
const caller = await api()
const { data: submissions } = await caller.applicationForm.listSubmissions({
formId,
perPage: 50,
})
if (submissions.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Inbox className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No submissions yet</h3>
<p className="text-muted-foreground">
Submissions will appear here once people start filling out the form
</p>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{submissions.map((submission) => (
<Card key={submission.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium">
{submission.name || submission.email || 'Anonymous'}
</h3>
<Badge className={statusColors[submission.status as keyof typeof statusColors]}>
{submission.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">
{submission.email && <span>{submission.email} - </span>}
Submitted {formatDate(submission.createdAt)}
</p>
</div>
<div className="flex items-center gap-2">
<Link href={`/admin/forms/${formId}/submissions/${submission.id}`}>
<Button variant="ghost" size="icon">
<Eye className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default async function SubmissionsPage({ params }: SubmissionsPageProps) {
const { id } = await params
const caller = await api()
let form
try {
form = await caller.applicationForm.get({ id })
} catch {
notFound()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/forms/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Submissions</h1>
<p className="text-muted-foreground">
{form.name} - {form._count.submissions} total submissions
</p>
</div>
</div>
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<SubmissionsList formId={id} />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,131 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewFormPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const createForm = trpc.applicationForm.create.useMutation({
onSuccess: (data) => {
toast.success('Form created successfully')
router.push(`/admin/forms/${data.id}`)
},
onError: (error) => {
toast.error(error.message || 'Failed to create form')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const description = formData.get('description') as string
const publicSlug = formData.get('publicSlug') as string
createForm.mutate({
programId: null,
name,
description: description || undefined,
publicSlug: publicSlug || undefined,
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/forms">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Application Form</h1>
<p className="text-muted-foreground">
Set up a new application form
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Form Details</CardTitle>
<CardDescription>
Basic information about your application form
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Form Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., 2024 Project Applications"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the purpose of this form..."
rows={3}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label htmlFor="publicSlug">Public URL Slug</Label>
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">/apply/</span>
<Input
id="publicSlug"
name="publicSlug"
placeholder="e.g., 2024-applications"
className="flex-1"
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to generate automatically. Only lowercase letters, numbers, and hyphens.
</p>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/forms">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Form
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,152 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Pencil,
FileText,
ExternalLink,
Inbox,
Copy,
MoreHorizontal,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
const statusColors = {
DRAFT: 'bg-gray-100 text-gray-800',
PUBLISHED: 'bg-green-100 text-green-800',
CLOSED: 'bg-red-100 text-red-800',
}
async function FormsList() {
const caller = await api()
const { data: forms } = await caller.applicationForm.list({
perPage: 50,
})
if (forms.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No forms yet</h3>
<p className="text-muted-foreground mb-4">
Create your first application form
</p>
<Link href="/admin/forms/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{forms.map((form) => (
<Card key={form.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<FileText className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{form.name}</h3>
<Badge className={statusColors[form.status as keyof typeof statusColors]}>
{form.status}
</Badge>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span>{form._count.fields} fields</span>
<span>-</span>
<span>{form._count.submissions} submissions</span>
{form.publicSlug && (
<>
<span>-</span>
<span className="text-primary">/apply/{form.publicSlug}</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-2">
{form.publicSlug && form.status === 'PUBLISHED' && (
<a
href={`/apply/${form.publicSlug}`}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon" title="View Public Form">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/forms/${form.id}/submissions`}>
<Button variant="ghost" size="icon" title="View Submissions">
<Inbox className="h-4 w-4" />
</Button>
</Link>
<Link href={`/admin/forms/${form.id}`}>
<Button variant="ghost" size="icon" title="Edit Form">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function FormsPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Application Forms</h1>
<p className="text-muted-foreground">
Create and manage custom application forms
</p>
</div>
<Link href="/admin/forms/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Form
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<FormsList />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,473 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
FileText,
Video,
Link as LinkIcon,
File,
Trash2,
Eye,
AlertCircle,
} from 'lucide-react'
// Dynamically import BlockEditor to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
]
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
export default function EditLearningResourcePage() {
const params = useParams()
const router = useRouter()
const resourceId = params.id as string
// Fetch resource
const { data: resource, isLoading, error } = trpc.learningResource.get.useQuery({ id: resourceId })
const { data: stats } = trpc.learningResource.getStats.useQuery({ id: resourceId })
// Form state
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
const [programId, setProgramId] = useState<string | null>(null)
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const updateResource = trpc.learningResource.update.useMutation()
const deleteResource = trpc.learningResource.delete.useMutation()
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
// Populate form when resource loads
useEffect(() => {
if (resource) {
setTitle(resource.title)
setDescription(resource.description || '')
setContentJson(resource.contentJson ? JSON.stringify(resource.contentJson) : '')
setResourceType(resource.resourceType)
setCohortLevel(resource.cohortLevel)
setExternalUrl(resource.externalUrl || '')
setIsPublished(resource.isPublished)
setProgramId(resource.programId)
}
}, [resource])
// Handle file upload for BlockNote
const handleUploadFile = async (file: File): Promise<string> => {
try {
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
fileName: file.name,
mimeType: file.type,
})
await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
})
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch (error) {
toast.error('Failed to upload file')
throw error
}
}
const handleSubmit = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await updateResource.mutateAsync({
id: resourceId,
title,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
externalUrl: externalUrl || null,
isPublished,
})
toast.success('Resource updated successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update resource')
}
}
const handleDelete = async () => {
try {
await deleteResource.mutateAsync({ id: resourceId })
toast.success('Resource deleted successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete resource')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-40" />
</div>
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-96 w-full" />
</div>
<div className="space-y-6">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
</div>
</div>
)
}
if (error || !resource) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Resource not found</AlertTitle>
<AlertDescription>
The resource you&apos;re looking for does not exist.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit Resource</h1>
<p className="text-muted-foreground">
Update this learning resource
</p>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Resource</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{resource.title}&quot;? This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{resourceType === 'LINK' && (
<div className="space-y-2">
<Label htmlFor="url">External URL *</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
key={resourceId}
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Statistics */}
{stats && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Statistics
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-2xl font-semibold">{stats.totalViews}</p>
<p className="text-sm text-muted-foreground">Total views</p>
</div>
<div>
<p className="text-2xl font-semibold">{stats.uniqueUsers}</p>
<p className="text-sm text-muted-foreground">Unique users</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
onClick={handleSubmit}
disabled={updateResource.isPending || !title.trim()}
className="w-full"
>
{updateResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,324 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import dynamic from 'next/dynamic'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from 'sonner'
import { ArrowLeft, Save, Loader2, FileText, Video, Link as LinkIcon, File } from 'lucide-react'
// Dynamically import BlockEditor to avoid SSR issues
const BlockEditor = dynamic(
() => import('@/components/shared/block-editor').then((mod) => mod.BlockEditor),
{
ssr: false,
loading: () => (
<div className="min-h-[300px] rounded-lg border bg-muted/20 animate-pulse" />
),
}
)
const resourceTypeOptions = [
{ value: 'DOCUMENT', label: 'Document', icon: FileText },
{ value: 'PDF', label: 'PDF', icon: FileText },
{ value: 'VIDEO', label: 'Video', icon: Video },
{ value: 'LINK', label: 'External Link', icon: LinkIcon },
{ value: 'OTHER', label: 'Other', icon: File },
]
const cohortOptions = [
{ value: 'ALL', label: 'All Members', description: 'Visible to everyone' },
{ value: 'SEMIFINALIST', label: 'Semi-finalists', description: 'Visible to semi-finalist evaluators' },
{ value: 'FINALIST', label: 'Finalists', description: 'Visible to finalist evaluators only' },
]
export default function NewLearningResourcePage() {
const router = useRouter()
// Form state
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [contentJson, setContentJson] = useState<string>('')
const [resourceType, setResourceType] = useState<string>('DOCUMENT')
const [cohortLevel, setCohortLevel] = useState<string>('ALL')
const [externalUrl, setExternalUrl] = useState('')
const [isPublished, setIsPublished] = useState(false)
// API
const { data: programs } = trpc.program.list.useQuery({ status: 'ACTIVE' })
const [programId, setProgramId] = useState<string | null>(null)
const createResource = trpc.learningResource.create.useMutation()
const getUploadUrl = trpc.learningResource.getUploadUrl.useMutation()
// Handle file upload for BlockNote
const handleUploadFile = async (file: File): Promise<string> => {
try {
const { url, bucket, objectKey } = await getUploadUrl.mutateAsync({
fileName: file.name,
mimeType: file.type,
})
// Upload to MinIO
await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
})
// Return the MinIO URL
const minioEndpoint = process.env.NEXT_PUBLIC_MINIO_ENDPOINT || 'http://localhost:9000'
return `${minioEndpoint}/${bucket}/${objectKey}`
} catch (error) {
toast.error('Failed to upload file')
throw error
}
}
const handleSubmit = async () => {
if (!title.trim()) {
toast.error('Please enter a title')
return
}
if (resourceType === 'LINK' && !externalUrl) {
toast.error('Please enter an external URL')
return
}
try {
await createResource.mutateAsync({
programId,
title,
description: description || undefined,
contentJson: contentJson ? JSON.parse(contentJson) : undefined,
resourceType: resourceType as 'PDF' | 'VIDEO' | 'DOCUMENT' | 'LINK' | 'OTHER',
cohortLevel: cohortLevel as 'ALL' | 'SEMIFINALIST' | 'FINALIST',
externalUrl: externalUrl || undefined,
isPublished,
})
toast.success('Resource created successfully')
router.push('/admin/learning')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to create resource')
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/learning">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Learning Hub
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Add Resource</h1>
<p className="text-muted-foreground">
Create a new learning resource for jury members
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Resource Details</CardTitle>
<CardDescription>
Basic information about this resource
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Conservation Best Practices"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Short Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this resource"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="type">Resource Type</Label>
<Select value={resourceType} onValueChange={setResourceType}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{resourceTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<option.icon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cohort">Access Level</Label>
<Select value={cohortLevel} onValueChange={setCohortLevel}>
<SelectTrigger id="cohort">
<SelectValue />
</SelectTrigger>
<SelectContent>
{cohortOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{resourceType === 'LINK' && (
<div className="space-y-2">
<Label htmlFor="url">External URL *</Label>
<Input
id="url"
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://example.com/resource"
/>
</div>
)}
</CardContent>
</Card>
{/* Content Editor */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
<CardDescription>
Rich text content with images and videos. Type / for commands.
</CardDescription>
</CardHeader>
<CardContent>
<BlockEditor
initialContent={contentJson || undefined}
onChange={setContentJson}
onUploadFile={handleUploadFile}
className="min-h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Publish Settings */}
<Card>
<CardHeader>
<CardTitle>Publish Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="published">Published</Label>
<p className="text-sm text-muted-foreground">
Make this resource visible to jury members
</p>
</div>
<Switch
id="published"
checked={isPublished}
onCheckedChange={setIsPublished}
/>
</div>
<div className="space-y-2">
<Label htmlFor="program">Program</Label>
<Select
value={programId || 'global'}
onValueChange={(v) => setProgramId(v === 'global' ? null : v)}
>
<SelectTrigger id="program">
<SelectValue placeholder="Select program" />
</SelectTrigger>
<SelectContent>
<SelectItem value="global">Global (All Programs)</SelectItem>
{programs?.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} {program.year}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Actions */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-2">
<Button
onClick={handleSubmit}
disabled={createResource.isPending || !title.trim()}
className="w-full"
>
{createResource.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Create Resource
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/admin/learning">Cancel</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,159 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
FileText,
Video,
Link as LinkIcon,
File,
Eye,
Pencil,
ExternalLink,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
const resourceTypeIcons = {
PDF: FileText,
VIDEO: Video,
DOCUMENT: File,
LINK: LinkIcon,
OTHER: File,
}
const cohortColors = {
ALL: 'bg-gray-100 text-gray-800',
SEMIFINALIST: 'bg-blue-100 text-blue-800',
FINALIST: 'bg-purple-100 text-purple-800',
}
async function LearningResourcesList() {
const caller = await api()
const { data: resources } = await caller.learningResource.list({
perPage: 50,
})
if (resources.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No resources yet</h3>
<p className="text-muted-foreground mb-4">
Start by adding your first learning resource
</p>
<Link href="/admin/learning/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Resource
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4">
{resources.map((resource) => {
const Icon = resourceTypeIcons[resource.resourceType]
return (
<Card key={resource.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{resource.title}</h3>
{!resource.isPublished && (
<Badge variant="secondary">Draft</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge className={cohortColors[resource.cohortLevel]} variant="outline">
{resource.cohortLevel}
</Badge>
<span>{resource.resourceType}</span>
<span>-</span>
<span>{resource._count.accessLogs} views</span>
</div>
</div>
<div className="flex items-center gap-2">
{resource.externalUrl && (
<a
href={resource.externalUrl}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</a>
)}
<Link href={`/admin/learning/${resource.id}`}>
<Button variant="ghost" size="icon">
<Pencil className="h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardContent className="flex items-center gap-4 py-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function LearningHubPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Learning Hub</h1>
<p className="text-muted-foreground">
Manage educational resources for jury members
</p>
</div>
<Link href="/admin/learning/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Resource
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<LearningResourcesList />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,788 @@
import type { Metadata } from 'next'
import { Suspense } from 'react'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import Link from 'next/link'
export const metadata: Metadata = { title: 'Admin Dashboard' }
export const dynamic = 'force-dynamic'
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 {
CircleDot,
ClipboardList,
Users,
CheckCircle2,
Calendar,
TrendingUp,
ArrowRight,
Layers,
} from 'lucide-react'
import { GeographicSummaryCard } from '@/components/charts'
import { ProjectLogo } from '@/components/shared/project-logo'
import { getCountryName } from '@/lib/countries'
import {
formatDateOnly,
formatEnumLabel,
truncate,
daysUntil,
} from '@/lib/utils'
type DashboardStatsProps = {
editionId: string | null
sessionName: string
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
SUBMITTED: 'secondary',
ELIGIBLE: 'default',
ASSIGNED: 'default',
UNDER_REVIEW: 'default',
SHORTLISTED: 'success',
SEMIFINALIST: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
async function DashboardStats({ editionId, sessionName }: DashboardStatsProps) {
if (!editionId) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No edition selected</p>
<p className="text-sm text-muted-foreground">
Select an edition from the sidebar to view dashboard
</p>
</CardContent>
</Card>
)
}
const edition = await prisma.program.findUnique({
where: { id: editionId },
select: { name: true, year: true },
})
if (!edition) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">Edition not found</p>
<p className="text-sm text-muted-foreground">
The selected edition could not be found
</p>
</CardContent>
</Card>
)
}
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const [
activeRoundCount,
totalRoundCount,
projectCount,
newProjectsThisWeek,
totalJurors,
activeJurors,
evaluationStats,
totalAssignments,
recentRounds,
latestProjects,
categoryBreakdown,
oceanIssueBreakdown,
] = await Promise.all([
prisma.round.count({
where: { programId: editionId, status: 'ACTIVE' },
}),
prisma.round.count({
where: { programId: editionId },
}),
prisma.project.count({
where: { round: { programId: editionId } },
}),
prisma.project.count({
where: {
round: { programId: editionId },
createdAt: { gte: sevenDaysAgo },
},
}),
prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: { in: ['ACTIVE', 'INVITED'] },
assignments: { some: { round: { programId: editionId } } },
},
}),
prisma.user.count({
where: {
role: 'JURY_MEMBER',
status: 'ACTIVE',
assignments: { some: { round: { programId: editionId } } },
},
}),
prisma.evaluation.groupBy({
by: ['status'],
where: { assignment: { round: { programId: editionId } } },
_count: true,
}),
prisma.assignment.count({
where: { round: { programId: editionId } },
}),
prisma.round.findMany({
where: { programId: editionId },
orderBy: { createdAt: 'desc' },
take: 5,
include: {
_count: {
select: {
projects: true,
assignments: true,
},
},
assignments: {
select: {
evaluation: { select: { status: true } },
},
},
},
}),
prisma.project.findMany({
where: { round: { programId: editionId } },
orderBy: { createdAt: 'desc' },
take: 8,
select: {
id: true,
title: true,
teamName: true,
status: true,
country: true,
competitionCategory: true,
oceanIssue: true,
logoKey: true,
createdAt: true,
submittedAt: true,
round: { select: { name: true } },
},
}),
prisma.project.groupBy({
by: ['competitionCategory'],
where: { round: { programId: editionId } },
_count: true,
}),
prisma.project.groupBy({
by: ['oceanIssue'],
where: { round: { programId: editionId } },
_count: true,
}),
])
const submittedCount =
evaluationStats.find((e) => e.status === 'SUBMITTED')?._count || 0
const draftCount =
evaluationStats.find((e) => e.status === 'DRAFT')?._count || 0
const totalEvaluations = submittedCount + draftCount
const completionRate =
totalEvaluations > 0 ? (submittedCount / totalEvaluations) * 100 : 0
const invitedJurors = totalJurors - activeJurors
// Compute per-round eval stats
const roundsWithEvalStats = recentRounds.map((round) => {
const submitted = round.assignments.filter(
(a) => a.evaluation?.status === 'SUBMITTED'
).length
const total = round._count.assignments
const percent = total > 0 ? Math.round((submitted / total) * 100) : 0
return { ...round, submittedEvals: submitted, totalEvals: total, evalPercent: percent }
})
// Upcoming deadlines from rounds
const now = new Date()
const deadlines: { label: string; roundName: string; date: Date }[] = []
for (const round of recentRounds) {
if (round.votingEndAt && new Date(round.votingEndAt) > now) {
deadlines.push({
label: 'Voting closes',
roundName: round.name,
date: new Date(round.votingEndAt),
})
}
if (round.submissionEndDate && new Date(round.submissionEndDate) > now) {
deadlines.push({
label: 'Submissions close',
roundName: round.name,
date: new Date(round.submissionEndDate),
})
}
}
deadlines.sort((a, b) => a.date.getTime() - b.date.getTime())
const upcomingDeadlines = deadlines.slice(0, 4)
// Category/issue bars
const categories = categoryBreakdown
.filter((c) => c.competitionCategory !== null)
.map((c) => ({
label: formatEnumLabel(c.competitionCategory!),
count: c._count,
}))
.sort((a, b) => b.count - a.count)
const issues = oceanIssueBreakdown
.filter((i) => i.oceanIssue !== null)
.map((i) => ({
label: formatEnumLabel(i.oceanIssue!),
count: i._count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 5)
const maxCategoryCount = Math.max(...categories.map((c) => c.count), 1)
const maxIssueCount = Math.max(...issues.map((i) => i.count), 1)
return (
<>
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">
Welcome back, {sessionName} &mdash; {edition.name} {edition.year}
</p>
</div>
{/* Stats Grid */}
<div className="grid gap-4 md: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">Rounds</CardTitle>
<CircleDot className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalRoundCount}</div>
<p className="text-xs text-muted-foreground">
{activeRoundCount} active round{activeRoundCount !== 1 ? 's' : ''}
</p>
</CardContent>
</Card>
<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">{projectCount}</div>
<p className="text-xs text-muted-foreground">
{newProjectsThisWeek > 0
? `${newProjectsThisWeek} new this week`
: 'In this edition'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalJurors}</div>
<p className="text-xs text-muted-foreground">
{activeJurors} active{invitedJurors > 0 && `, ${invitedJurors} invited`}
</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>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{submittedCount}
{totalAssignments > 0 && (
<span className="text-sm font-normal text-muted-foreground">
{' '}/ {totalAssignments}
</span>
)}
</div>
<div className="mt-2">
<Progress value={completionRate} className="h-2" />
<p className="mt-1 text-xs text-muted-foreground">
{completionRate.toFixed(0)}% completion rate
</p>
</div>
</CardContent>
</Card>
</div>
{/* Two-Column Content */}
<div className="grid gap-6 lg:grid-cols-12">
{/* Left Column */}
<div className="space-y-6 lg:col-span-7">
{/* Rounds Card (enhanced) */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Rounds</CardTitle>
<CardDescription>
Voting rounds in {edition.name}
</CardDescription>
</div>
<Link
href="/admin/rounds"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</CardHeader>
<CardContent>
{roundsWithEvalStats.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CircleDot className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No rounds created yet
</p>
<Link
href="/admin/rounds/new"
className="mt-4 text-sm font-medium text-primary hover:underline"
>
Create your first round
</Link>
</div>
) : (
<div className="space-y-3">
{roundsWithEvalStats.map((round) => (
<Link
key={round.id}
href={`/admin/rounds/${round.id}`}
className="block"
>
<div className="rounded-lg border p-4 transition-colors hover:bg-muted/50">
<div className="flex items-start justify-between gap-2">
<div className="space-y-1.5 flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{round.name}</p>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'success'
: 'secondary'
}
>
{round.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{round._count.projects} projects &middot; {round._count.assignments} assignments
{round.totalEvals > 0 && (
<> &middot; {round.evalPercent}% evaluated</>
)}
</p>
{round.votingStartAt && round.votingEndAt && (
<p className="text-xs text-muted-foreground">
Voting: {formatDateOnly(round.votingStartAt)} &ndash; {formatDateOnly(round.votingEndAt)}
</p>
)}
</div>
</div>
{round.totalEvals > 0 && (
<Progress value={round.evalPercent} className="mt-3 h-1.5" />
)}
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
{/* Latest Projects Card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Latest Projects</CardTitle>
<CardDescription>Recently submitted projects</CardDescription>
</div>
<Link
href="/admin/projects"
className="flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</CardHeader>
<CardContent>
{latestProjects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">
No projects submitted yet
</p>
</div>
) : (
<div className="space-y-1">
{latestProjects.map((project) => (
<Link
key={project.id}
href={`/admin/projects/${project.id}`}
className="block"
>
<div className="flex items-start gap-3 rounded-lg p-3 transition-colors hover:bg-muted/50">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="font-medium text-sm leading-tight truncate">
{truncate(project.title, 45)}
</p>
<Badge
variant={statusColors[project.status] || 'secondary'}
className="shrink-0 text-[10px] px-1.5 py-0"
>
{project.status.replace('_', ' ')}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{[
project.teamName,
project.country ? getCountryName(project.country) : null,
formatDateOnly(project.submittedAt || project.createdAt),
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
{(project.competitionCategory || project.oceanIssue) && (
<p className="text-xs text-muted-foreground/70 mt-0.5">
{[
project.competitionCategory
? formatEnumLabel(project.competitionCategory)
: null,
project.oceanIssue
? formatEnumLabel(project.oceanIssue)
: null,
]
.filter(Boolean)
.join(' \u00b7 ')}
</p>
)}
</div>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
{/* Right Column */}
<div className="space-y-6 lg:col-span-5">
{/* Evaluation Progress Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Evaluation Progress
</CardTitle>
</CardHeader>
<CardContent>
{roundsWithEvalStats.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0).length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<TrendingUp className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No evaluations in progress
</p>
</div>
) : (
<div className="space-y-5">
{roundsWithEvalStats
.filter((r) => r.status !== 'DRAFT' && r.totalEvals > 0)
.map((round) => (
<div key={round.id} className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium truncate">{round.name}</p>
<span className="text-sm font-semibold tabular-nums">
{round.evalPercent}%
</span>
</div>
<Progress value={round.evalPercent} className="h-2" />
<p className="text-xs text-muted-foreground">
{round.submittedEvals} of {round.totalEvals} evaluations submitted
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Category Breakdown Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Layers className="h-4 w-4" />
Project Categories
</CardTitle>
</CardHeader>
<CardContent>
{categories.length === 0 && issues.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Layers className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No category data available
</p>
</div>
) : (
<div className="space-y-5">
{categories.length > 0 && (
<div className="space-y-2.5">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
By Type
</p>
{categories.map((cat) => (
<div key={cat.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span>{cat.label}</span>
<span className="font-medium tabular-nums">{cat.count}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${(cat.count / maxCategoryCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
{issues.length > 0 && (
<div className="space-y-2.5">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Top Issues
</p>
{issues.map((issue) => (
<div key={issue.label} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="truncate mr-2">{issue.label}</span>
<span className="font-medium tabular-nums">{issue.count}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-accent transition-all"
style={{ width: `${(issue.count / maxIssueCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Upcoming Deadlines Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
Upcoming Deadlines
</CardTitle>
</CardHeader>
<CardContent>
{upcomingDeadlines.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center">
<Calendar className="h-8 w-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">
No upcoming deadlines
</p>
</div>
) : (
<div className="space-y-4">
{upcomingDeadlines.map((deadline, i) => {
const days = daysUntil(deadline.date)
const isUrgent = days <= 7
return (
<div key={i} className="flex items-start gap-3">
<div className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${isUrgent ? 'bg-destructive/10' : 'bg-muted'}`}>
<Calendar className={`h-4 w-4 ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`} />
</div>
<div className="space-y-0.5">
<p className="text-sm font-medium">
{deadline.label} &mdash; {deadline.roundName}
</p>
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
{formatDateOnly(deadline.date)} &middot; in {days} day{days !== 1 ? 's' : ''}
</p>
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Geographic Distribution (full width, at the bottom) */}
<GeographicSummaryCard programId={editionId} />
</>
)
}
function DashboardSkeleton() {
return (
<>
{/* Header skeleton */}
<div>
<Skeleton className="h-8 w-40" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
{/* Stats grid skeleton */}
<div className="grid gap-4 md: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>
{/* Two-column content skeleton */}
<div className="grid gap-6 lg:grid-cols-12">
<div className="space-y-6 lg:col-span-7">
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-52" />
</CardHeader>
<CardContent>
<div className="space-y-2">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6 lg:col-span-5">
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader><Skeleton className="h-6 w-40" /></CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Map skeleton */}
<Skeleton className="h-[450px] w-full rounded-lg" />
</>
)
}
type PageProps = {
searchParams: Promise<{ edition?: string }>
}
export default async function AdminDashboardPage({ searchParams }: PageProps) {
const [session, params] = await Promise.all([
auth(),
searchParams,
])
let editionId = params.edition || null
if (!editionId) {
const defaultEdition = await prisma.program.findFirst({
where: { status: 'ACTIVE' },
orderBy: { year: 'desc' },
select: { id: true },
})
editionId = defaultEdition?.id || null
if (!editionId) {
const anyEdition = await prisma.program.findFirst({
orderBy: { year: 'desc' },
select: { id: true },
})
editionId = anyEdition?.id || null
}
}
const sessionName = session?.user?.name || 'Admin'
return (
<div className="space-y-6">
<Suspense fallback={<DashboardSkeleton />}>
<DashboardStats editionId={editionId} sessionName={sessionName} />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,278 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
export default function EditPartnerPage() {
const router = useRouter()
const params = useParams()
const id = params.id as string
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: '',
description: '',
website: '',
partnerType: 'PARTNER',
visibility: 'ADMIN_ONLY',
isActive: true,
})
const { data: partner, isLoading } = trpc.partner.get.useQuery({ id })
useEffect(() => {
if (partner) {
setFormData({
name: partner.name,
description: partner.description || '',
website: partner.website || '',
partnerType: partner.partnerType,
visibility: partner.visibility,
isActive: partner.isActive,
})
}
}, [partner])
const updatePartner = trpc.partner.update.useMutation({
onSuccess: () => {
toast.success('Partner updated successfully')
router.push('/admin/partners')
},
onError: (error) => {
toast.error(error.message || 'Failed to update partner')
setIsSubmitting(false)
},
})
const deletePartner = trpc.partner.delete.useMutation({
onSuccess: () => {
toast.success('Partner deleted successfully')
router.push('/admin/partners')
},
onError: (error) => {
toast.error(error.message || 'Failed to delete partner')
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
updatePartner.mutate({
id,
name: formData.name,
description: formData.description || null,
website: formData.website || null,
partnerType: formData.partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
visibility: formData.visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
isActive: formData.isActive,
})
}
// Delete handled via AlertDialog in JSX
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="space-y-4 pt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/partners">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Edit Partner</h1>
<p className="text-muted-foreground">
Update partner information
</p>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Partner</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this partner. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deletePartner.mutate({ id })}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card>
<CardHeader>
<CardTitle>Partner Details</CardTitle>
<CardDescription>
Basic information about the partner organization
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Organization Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="partnerType">Partner Type</Label>
<Select
value={formData.partnerType}
onValueChange={(value) => setFormData({ ...formData, partnerType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SPONSOR">Sponsor</SelectItem>
<SelectItem value="PARTNER">Partner</SelectItem>
<SelectItem value="SUPPORTER">Supporter</SelectItem>
<SelectItem value="MEDIA">Media</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
maxLength={500}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input
id="website"
type="url"
value={formData.website}
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="visibility">Visibility</Label>
<Select
value={formData.visibility}
onValueChange={(value) => setFormData({ ...formData, visibility: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
<SelectItem value="PUBLIC">Public</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 pt-8">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive">Active</Label>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/partners">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,168 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewPartnerPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const [partnerType, setPartnerType] = useState('PARTNER')
const [visibility, setVisibility] = useState('ADMIN_ONLY')
const createPartner = trpc.partner.create.useMutation({
onSuccess: () => {
toast.success('Partner created successfully')
router.push('/admin/partners')
},
onError: (error) => {
toast.error(error.message || 'Failed to create partner')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const description = formData.get('description') as string
const website = formData.get('website') as string
createPartner.mutate({
name,
programId: null,
description: description || undefined,
website: website || undefined,
partnerType: partnerType as 'SPONSOR' | 'PARTNER' | 'SUPPORTER' | 'MEDIA' | 'OTHER',
visibility: visibility as 'ADMIN_ONLY' | 'JURY_VISIBLE' | 'PUBLIC',
})
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/partners">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Add Partner</h1>
<p className="text-muted-foreground">
Add a new partner or sponsor organization
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Partner Details</CardTitle>
<CardDescription>
Basic information about the partner organization
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Organization Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., Ocean Conservation Foundation"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="partnerType">Partner Type</Label>
<Select value={partnerType} onValueChange={setPartnerType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SPONSOR">Sponsor</SelectItem>
<SelectItem value="PARTNER">Partner</SelectItem>
<SelectItem value="SUPPORTER">Supporter</SelectItem>
<SelectItem value="MEDIA">Media</SelectItem>
<SelectItem value="OTHER">Other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the organization and partnership..."
rows={3}
maxLength={500}
/>
</div>
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input
id="website"
name="website"
type="url"
placeholder="https://example.org"
/>
</div>
<div className="space-y-2">
<Label htmlFor="visibility">Visibility</Label>
<Select value={visibility} onValueChange={setVisibility}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN_ONLY">Admin Only</SelectItem>
<SelectItem value="JURY_VISIBLE">Visible to Jury</SelectItem>
<SelectItem value="PUBLIC">Public</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/partners">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Add Partner
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,164 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Plus,
Pencil,
ExternalLink,
Building2,
Eye,
EyeOff,
Globe,
} from 'lucide-react'
const visibilityIcons = {
ADMIN_ONLY: EyeOff,
JURY_VISIBLE: Eye,
PUBLIC: Globe,
}
const partnerTypeColors = {
SPONSOR: 'bg-yellow-100 text-yellow-800',
PARTNER: 'bg-blue-100 text-blue-800',
SUPPORTER: 'bg-green-100 text-green-800',
MEDIA: 'bg-purple-100 text-purple-800',
OTHER: 'bg-gray-100 text-gray-800',
}
async function PartnersList() {
const caller = await api()
const { data: partners } = await caller.partner.list({
perPage: 50,
})
if (partners.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Building2 className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No partners yet</h3>
<p className="text-muted-foreground mb-4">
Start by adding your first partner organization
</p>
<Link href="/admin/partners/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Partner
</Button>
</Link>
</CardContent>
</Card>
)
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{partners.map((partner) => {
const VisibilityIcon = visibilityIcons[partner.visibility]
return (
<Card key={partner.id} className={!partner.isActive ? 'opacity-60' : ''}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-muted shrink-0">
<Building2 className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium truncate">{partner.name}</h3>
{!partner.isActive && (
<Badge variant="secondary">Inactive</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1">
<Badge className={partnerTypeColors[partner.partnerType]} variant="outline">
{partner.partnerType}
</Badge>
<VisibilityIcon className="h-3 w-3 text-muted-foreground" />
</div>
{partner.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
{partner.description}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-4 pt-4 border-t">
{partner.website && (
<a
href={partner.website}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="ghost" size="sm">
<ExternalLink className="h-4 w-4 mr-1" />
Website
</Button>
</a>
)}
<Link href={`/admin/partners/${partner.id}`}>
<Button variant="ghost" size="sm">
<Pencil className="h-4 w-4 mr-1" />
Edit
</Button>
</Link>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
function LoadingSkeleton() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-start gap-4">
<Skeleton className="h-12 w-12 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-full" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function PartnersPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Partners</h1>
<p className="text-muted-foreground">
Manage partner and sponsor organizations
</p>
</div>
<Link href="/admin/partners/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Partner
</Button>
</Link>
</div>
<Suspense fallback={<LoadingSkeleton />}>
<PartnersList />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,226 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowLeft, Loader2, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
export default function EditProgramPage() {
const router = useRouter()
const params = useParams()
const id = params.id as string
const [isSubmitting, setIsSubmitting] = useState(false)
const [formData, setFormData] = useState({
name: '',
description: '',
status: 'DRAFT',
})
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
useEffect(() => {
if (program) {
setFormData({
name: program.name,
description: program.description || '',
status: program.status,
})
}
}, [program])
const updateProgram = trpc.program.update.useMutation({
onSuccess: () => {
toast.success('Program updated successfully')
router.push(`/admin/programs/${id}`)
},
onError: (error) => {
toast.error(error.message || 'Failed to update program')
setIsSubmitting(false)
},
})
const deleteProgram = trpc.program.delete.useMutation({
onSuccess: () => {
toast.success('Program deleted successfully')
router.push('/admin/programs')
},
onError: (error) => {
toast.error(error.message || 'Failed to delete program')
},
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
updateProgram.mutate({
id,
name: formData.name,
description: formData.description || undefined,
status: formData.status as 'DRAFT' | 'ACTIVE' | 'ARCHIVED',
})
}
// Delete handled via AlertDialog in JSX
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="space-y-4 pt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/programs/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Edit Program</h1>
<p className="text-muted-foreground">
Update program information
</p>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Program</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete this program and all its rounds and projects.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => deleteProgram.mutate({ id })}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Card>
<CardHeader>
<CardTitle>Program Details</CardTitle>
<CardDescription>
Basic information about the program
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Program Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData({ ...formData, status: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="ARCHIVED">Archived</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
maxLength={2000}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href={`/admin/programs/${id}`}>
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,153 @@
import { notFound } from 'next/navigation'
import Link from 'next/link'
import { api } from '@/lib/trpc/server'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { ArrowLeft, Pencil, Plus, Settings } from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
interface ProgramDetailPageProps {
params: Promise<{ id: string }>
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
DRAFT: 'secondary',
ACTIVE: 'default',
CLOSED: 'success',
ARCHIVED: 'secondary',
}
export default async function ProgramDetailPage({ params }: ProgramDetailPageProps) {
const { id } = await params
const caller = await api()
let program
try {
program = await caller.program.get({ id })
} catch {
notFound()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/admin/programs">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{program.name}</h1>
<Badge variant={statusColors[program.status] || 'secondary'}>
{program.status}
</Badge>
</div>
<p className="text-muted-foreground">
{program.year} Edition
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/programs/${id}/settings`}>
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</Button>
</div>
</div>
{program.description && (
<Card>
<CardHeader>
<CardTitle>Description</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">{program.description}</p>
</CardContent>
</Card>
)}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Rounds</CardTitle>
<CardDescription>
Voting rounds for this program
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?programId=${id}`}>
<Plus className="mr-2 h-4 w-4" />
New Round
</Link>
</Button>
</CardHeader>
<CardContent>
{program.rounds.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No rounds created yet. Create a round to start accepting projects.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{program.rounds.map((round) => (
<TableRow key={round.id}>
<TableCell>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
</TableCell>
<TableCell>
<Badge variant={statusColors[round.status] || 'secondary'}>
{round.status}
</Badge>
</TableCell>
<TableCell>{round._count.projects}</TableCell>
<TableCell>{round._count.assignments}</TableCell>
<TableCell>{formatDateOnly(round.createdAt)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,83 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft } from 'lucide-react'
export default function ProgramSettingsPage() {
const params = useParams()
const id = params.id as string
const { data: program, isLoading } = trpc.program.get.useQuery({ id })
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="space-y-4 pt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href={`/admin/programs/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Program Settings</h1>
<p className="text-muted-foreground">
Configure settings for {program?.name}
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Program Configuration</CardTitle>
<CardDescription>
Advanced settings for this program
</CardDescription>
</CardHeader>
<CardContent>
<div className="py-8 text-center text-muted-foreground">
Program-specific settings will be available in a future update.
<br />
For now, manage rounds and projects through the program detail page.
</div>
<div className="flex justify-center">
<Button asChild>
<Link href={`/admin/programs/${id}`}>
Back to Program
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,131 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
export default function NewProgramPage() {
const router = useRouter()
const [isSubmitting, setIsSubmitting] = useState(false)
const createProgram = trpc.program.create.useMutation({
onSuccess: () => {
toast.success('Program created successfully')
router.push('/admin/programs')
},
onError: (error) => {
toast.error(error.message || 'Failed to create program')
setIsSubmitting(false)
},
})
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsSubmitting(true)
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const year = parseInt(formData.get('year') as string, 10)
const description = formData.get('description') as string
createProgram.mutate({
name,
year,
description: description || undefined,
})
}
const currentYear = new Date().getFullYear()
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link href="/admin/programs">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Create Program</h1>
<p className="text-muted-foreground">
Set up a new ocean protection program
</p>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Program Details</CardTitle>
<CardDescription>
Basic information about the program
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name">Program Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., Monaco Ocean Protection Challenge 2026"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="year">Year *</Label>
<Input
id="year"
name="year"
type="number"
min={2020}
max={2100}
defaultValue={currentYear}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
placeholder="Describe the program objectives and scope..."
rows={4}
maxLength={2000}
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Link href="/admin/programs">
<Button type="button" variant="outline">
Cancel
</Button>
</Link>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Program
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,258 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
MoreHorizontal,
FolderKanban,
Settings,
Eye,
Pencil,
} from 'lucide-react'
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
include: {
_count: {
select: {
rounds: true,
},
},
rounds: {
where: { status: 'ACTIVE' },
select: { id: true },
},
},
orderBy: { createdAt: 'desc' },
})
if (programs.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FolderKanban className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No programs yet</p>
<p className="text-sm text-muted-foreground">
Create your first program to start managing projects and rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">
<Plus className="mr-2 h-4 w-4" />
Create Program
</Link>
</Button>
</CardContent>
</Card>
)
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
ACTIVE: 'default',
COMPLETED: 'success',
DRAFT: 'secondary',
ARCHIVED: 'secondary',
}
return (
<>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Program</TableHead>
<TableHead>Year</TableHead>
<TableHead>Rounds</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{programs.map((program) => (
<TableRow key={program.id}>
<TableCell>
<div>
<p className="font-medium">{program.name}</p>
{program.description && (
<p className="text-sm text-muted-foreground line-clamp-1">
{program.description}
</p>
)}
</div>
</TableCell>
<TableCell>{program.year}</TableCell>
<TableCell>
<div>
<p>{program._count.rounds} total</p>
{program.rounds.length > 0 && (
<p className="text-sm text-muted-foreground">
{program.rounds.length} active
</p>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={statusColors[program.status] || 'secondary'}>
{program.status}
</Badge>
</TableCell>
<TableCell>{formatDateOnly(program.createdAt)}</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/programs/${program.id}/settings`}>
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{programs.map((program) => (
<Card key={program.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="space-y-1">
<CardTitle className="text-base">{program.name}</CardTitle>
<CardDescription>{program.year}</CardDescription>
</div>
<Badge variant={statusColors[program.status] || 'secondary'}>
{program.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Rounds</span>
<span>
{program._count.rounds} ({program.rounds.length} active)
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Created</span>
<span>{formatDateOnly(program.createdAt)}</span>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}`}>
<Eye className="mr-2 h-4 w-4" />
View
</Link>
</Button>
<Button variant="outline" size="sm" className="flex-1" asChild>
<Link href={`/admin/programs/${program.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</>
)
}
function ProgramsSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-9" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default function ProgramsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Programs</h1>
<p className="text-muted-foreground">
Manage your ocean protection programs
</p>
</div>
<Button asChild>
<Link href="/admin/programs/new">
<Plus className="mr-2 h-4 w-4" />
New Program
</Link>
</Button>
</div>
{/* Content */}
<Suspense fallback={<ProgramsSkeleton />}>
<ProgramsContent />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,177 @@
'use client'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { ArrowLeft, Plus, UserMinus } from 'lucide-react'
import { toast } from 'sonner'
export default function ProjectAssignmentsPage() {
const params = useParams()
const id = params.id as string
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({ id })
const { data: assignments = [], isLoading: assignmentsLoading } = trpc.assignment.listByProject.useQuery({ projectId: id })
const utils = trpc.useUtils()
const removeAssignment = trpc.assignment.delete.useMutation({
onSuccess: () => {
toast.success('Assignment removed')
utils.assignment.listByProject.invalidate({ projectId: id })
},
onError: (error) => {
toast.error(error.message || 'Failed to remove assignment')
},
})
// Remove handled via AlertDialog in JSX
const isLoading = projectLoading || assignmentsLoading
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Card>
<CardContent className="space-y-4 pt-6">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href={`/admin/projects/${id}`}>
<Button variant="ghost" size="icon">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold">Jury Assignments</h1>
<p className="text-muted-foreground">
{project?.title}
</p>
</div>
</div>
<Button asChild>
<Link href={`/admin/rounds/${project?.roundId}/assignments`}>
<Plus className="mr-2 h-4 w-4" />
Manage in Round
</Link>
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Assigned Jury Members</CardTitle>
<CardDescription>
{assignments.length} jury member{assignments.length !== 1 ? 's' : ''} assigned to evaluate this project
</CardDescription>
</CardHeader>
<CardContent>
{assignments.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
No jury members assigned yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Jury Member</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<div>
<p className="font-medium">{assignment.user.name}</p>
<p className="text-sm text-muted-foreground">{assignment.user.email}</p>
</div>
</TableCell>
<TableCell>
<Badge variant={assignment.evaluation?.status === 'SUBMITTED' ? 'success' : 'secondary'}>
{assignment.evaluation?.status || 'Pending'}
</Badge>
</TableCell>
<TableCell className="text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={removeAssignment.isPending}
>
<UserMinus className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Assignment</AlertDialogTitle>
<AlertDialogDescription>
Remove this jury member from the project? Their evaluation data will also be deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => removeAssignment.mutate({ id: assignment.id })}>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,669 @@
'use client'
import { Suspense, use, useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogo } from '@/components/shared/project-logo'
import { LogoUpload } from '@/components/shared/logo-upload'
import {
ArrowLeft,
Loader2,
AlertCircle,
Trash2,
X,
Plus,
FileText,
Film,
Presentation,
FileIcon,
} from 'lucide-react'
import { formatFileSize } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
const updateProjectSchema = z.object({
title: z.string().min(1, 'Title is required').max(500),
teamName: z.string().optional(),
description: z.string().optional(),
status: z.enum([
'SUBMITTED',
'ELIGIBLE',
'ASSIGNED',
'SEMIFINALIST',
'FINALIST',
'REJECTED',
]),
tags: z.array(z.string()),
})
type UpdateProjectForm = z.infer<typeof updateProjectSchema>
// File type icons
const fileTypeIcons: Record<string, React.ReactNode> = {
EXEC_SUMMARY: <FileText className="h-4 w-4" />,
PRESENTATION: <Presentation className="h-4 w-4" />,
VIDEO: <Film className="h-4 w-4" />,
OTHER: <FileIcon className="h-4 w-4" />,
}
function EditProjectContent({ projectId }: { projectId: string }) {
const router = useRouter()
const [tagInput, setTagInput] = useState('')
// Fetch project data
const { data: project, isLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch files
const { data: files, refetch: refetchFiles } = trpc.file.listByProject.useQuery({
projectId,
})
// Fetch logo URL
const { data: logoUrl, refetch: refetchLogo } = trpc.logo.getUrl.useQuery({
projectId,
})
// Fetch existing tags for suggestions
const { data: existingTags } = trpc.project.getTags.useQuery({
roundId: project?.roundId,
})
// Mutations
const updateProject = trpc.project.update.useMutation({
onSuccess: () => {
router.push(`/admin/projects/${projectId}`)
},
})
const deleteProject = trpc.project.delete.useMutation({
onSuccess: () => {
router.push('/admin/projects')
},
})
const deleteFile = trpc.file.delete.useMutation({
onSuccess: () => {
refetchFiles()
},
})
// Initialize form
const form = useForm<UpdateProjectForm>({
resolver: zodResolver(updateProjectSchema),
defaultValues: {
title: '',
teamName: '',
description: '',
status: 'SUBMITTED',
tags: [],
},
})
// Update form when project loads
useEffect(() => {
if (project) {
form.reset({
title: project.title,
teamName: project.teamName || '',
description: project.description || '',
status: project.status as UpdateProjectForm['status'],
tags: project.tags || [],
})
}
}, [project, form])
const tags = form.watch('tags')
// Add tag
const addTag = useCallback(() => {
const tag = tagInput.trim()
if (tag && !tags.includes(tag)) {
form.setValue('tags', [...tags, tag])
setTagInput('')
}
}, [tagInput, tags, form])
// Remove tag
const removeTag = useCallback(
(tag: string) => {
form.setValue(
'tags',
tags.filter((t) => t !== tag)
)
},
[tags, form]
)
const onSubmit = async (data: UpdateProjectForm) => {
await updateProject.mutateAsync({
id: projectId,
title: data.title,
teamName: data.teamName || null,
description: data.description || null,
status: data.status,
tags: data.tags,
})
}
const handleDelete = async () => {
await deleteProject.mutateAsync({ id: projectId })
}
const handleDeleteFile = async (fileId: string) => {
await deleteFile.mutateAsync({ id: fileId })
}
if (isLoading) {
return <EditProjectSkeleton />
}
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Project Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/projects">Back to Projects</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
const isPending = updateProject.isPending || deleteProject.isPending
// Filter tag suggestions (exclude already selected)
const tagSuggestions =
existingTags?.filter((t) => !tags.includes(t)).slice(0, 5) || []
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit Project</h1>
<p className="text-muted-foreground">
Update project information and manage files
</p>
</div>
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Project Logo */}
<div className="flex items-start gap-4 pb-4 border-b">
<ProjectLogo
project={project}
logoUrl={logoUrl}
size="lg"
/>
<div className="flex-1 space-y-1">
<FormLabel>Project Logo</FormLabel>
<FormDescription>
Upload a logo for this project. It will be displayed in project lists and cards.
</FormDescription>
<div className="pt-2">
<LogoUpload
project={project}
currentLogoUrl={logoUrl}
onUploadComplete={() => refetchLogo()}
/>
</div>
</div>
</div>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Project title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="teamName"
render={({ field }) => (
<FormItem>
<FormLabel>Team Name</FormLabel>
<FormControl>
<Input
placeholder="Team or organization name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Project description..."
rows={4}
maxLength={2000}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="SUBMITTED">Submitted</SelectItem>
<SelectItem value="ELIGIBLE">Eligible</SelectItem>
<SelectItem value="ASSIGNED">Assigned</SelectItem>
<SelectItem value="SEMIFINALIST">Semifinalist</SelectItem>
<SelectItem value="FINALIST">Finalist</SelectItem>
<SelectItem value="REJECTED">Rejected</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Tags */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Tags</CardTitle>
<CardDescription>
Add tags to categorize this project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="Add a tag..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
/>
<Button type="button" variant="outline" onClick={addTag}>
<Plus className="h-4 w-4" />
</Button>
</div>
{tagSuggestions.length > 0 && tagInput && (
<div className="flex flex-wrap gap-2">
<span className="text-xs text-muted-foreground">
Suggestions:
</span>
{tagSuggestions
.filter((t) =>
t.toLowerCase().includes(tagInput.toLowerCase())
)
.map((tag) => (
<Badge
key={tag}
variant="outline"
className="cursor-pointer hover:bg-muted"
onClick={() => {
if (!tags.includes(tag)) {
form.setValue('tags', [...tags, tag])
}
setTagInput('')
}}
>
{tag}
</Badge>
))}
</div>
)}
{tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
{/* Files */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Files</CardTitle>
<CardDescription>
Manage project documents and materials
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>File</TableHead>
<TableHead>Type</TableHead>
<TableHead>Size</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{files.map((file) => (
<TableRow key={file.id}>
<TableCell>
<div className="flex items-center gap-2">
{fileTypeIcons[file.fileType] || (
<FileIcon className="h-4 w-4" />
)}
<span className="text-sm truncate max-w-[200px]">
{file.fileName}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{file.fileType.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{formatFileSize(file.size)}
</TableCell>
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete file?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{file.fileName}&quot;?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteFile(file.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<p className="text-sm text-muted-foreground">
No files uploaded yet
</p>
)}
<div className="pt-4 border-t">
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
onUploadComplete={() => refetchFiles()}
/>
</div>
</CardContent>
</Card>
{/* Error Display */}
{updateProject.error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-4">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">
{updateProject.error.message}
</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href={`/admin/projects/${projectId}`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isPending}>
{updateProject.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</div>
</form>
</Form>
{/* Danger Zone */}
<Card className="border-destructive/50">
<CardHeader>
<CardTitle className="text-lg text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently affect this project
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" disabled={deleteProject.isPending}>
{deleteProject.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Trash2 className="mr-2 h-4 w-4" />
)}
Delete Project
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete project?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete &quot;{project.title}&quot; and all
associated files, assignments, and evaluations. This action
cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete Project
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{deleteProject.error && (
<p className="mt-2 text-sm text-destructive">
{deleteProject.error.message}
</p>
)}
</CardContent>
</Card>
</div>
)
}
function EditProjectSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-24 w-full" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-16" />
</CardHeader>
<CardContent>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function EditProjectPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<EditProjectSkeleton />}>
<EditProjectContent projectId={id} />
</Suspense>
)
}

View File

@ -0,0 +1,393 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { toast } from 'sonner'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Progress } from '@/components/ui/progress'
import {
ArrowLeft,
Loader2,
Sparkles,
User,
Check,
Wand2,
} from 'lucide-react'
import { getInitials } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Type for mentor suggestion from the API
interface MentorSuggestion {
mentorId: string
confidenceScore: number
expertiseMatchScore: number
reasoning: string
mentor: {
id: string
name: string | null
email: string
expertiseTags: string[]
assignmentCount: number
} | null
}
function MentorAssignmentContent({ projectId }: { projectId: string }) {
const [selectedMentorId, setSelectedMentorId] = useState<string | null>(null)
const utils = trpc.useUtils()
// Fetch project
const { data: project, isLoading: projectLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch suggestions
const { data: suggestions, isLoading: suggestionsLoading, refetch } = trpc.mentor.getSuggestions.useQuery(
{ projectId, limit: 5 },
{ enabled: !!project && !project.mentorAssignment }
)
// Assign mentor mutation
const assignMutation = trpc.mentor.assign.useMutation({
onSuccess: () => {
toast.success('Mentor assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Auto-assign mutation
const autoAssignMutation = trpc.mentor.autoAssign.useMutation({
onSuccess: () => {
toast.success('Mentor auto-assigned!')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
// Unassign mutation
const unassignMutation = trpc.mentor.unassign.useMutation({
onSuccess: () => {
toast.success('Mentor removed')
utils.project.get.invalidate({ id: projectId })
utils.mentor.getSuggestions.invalidate({ projectId })
},
onError: (error) => {
toast.error(error.message)
},
})
const handleAssign = (mentorId: string, suggestion?: MentorSuggestion) => {
assignMutation.mutate({
projectId,
mentorId,
method: suggestion ? 'AI_SUGGESTED' : 'MANUAL',
aiConfidenceScore: suggestion?.confidenceScore,
expertiseMatchScore: suggestion?.expertiseMatchScore,
aiReasoning: suggestion?.reasoning,
})
}
if (projectLoading) {
return <MentorAssignmentSkeleton />
}
if (!project) {
return (
<Card>
<CardContent className="py-12 text-center">
<p>Project not found</p>
</CardContent>
</Card>
)
}
const hasMentor = !!project.mentorAssignment
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/projects/${projectId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Project
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mentor Assignment</h1>
<p className="text-muted-foreground">{project.title}</p>
</div>
{/* Current Assignment */}
{hasMentor && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Mentor</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Avatar className="h-12 w-12">
<AvatarFallback>
{getInitials(project.mentorAssignment!.mentor.name || project.mentorAssignment!.mentor.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{project.mentorAssignment!.mentor.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">{project.mentorAssignment!.mentor.email}</p>
{project.mentorAssignment!.mentor.expertiseTags && project.mentorAssignment!.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{project.mentorAssignment!.mentor.expertiseTags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
)}
</div>
</div>
<div className="text-right">
<Badge variant="outline" className="mb-2">
{project.mentorAssignment!.method.replace(/_/g, ' ')}
</Badge>
<div>
<Button
variant="destructive"
size="sm"
onClick={() => unassignMutation.mutate({ projectId })}
disabled={unassignMutation.isPending}
>
{unassignMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Remove'
)}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* AI Suggestions */}
{!hasMentor && (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
AI-Suggested Mentors
</CardTitle>
<CardDescription>
Mentors matched based on expertise and project needs
</CardDescription>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => refetch()}
disabled={suggestionsLoading}
>
{suggestionsLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
'Refresh'
)}
</Button>
<Button
onClick={() => autoAssignMutation.mutate({ projectId, useAI: true })}
disabled={autoAssignMutation.isPending}
>
{autoAssignMutation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Wand2 className="mr-2 h-4 w-4" />
)}
Auto-Assign Best Match
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{suggestionsLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : suggestions?.suggestions.length === 0 ? (
<p className="text-muted-foreground text-center py-8">
No mentor suggestions available. Try adding more users with expertise tags.
</p>
) : (
<div className="space-y-4">
{suggestions?.suggestions.map((suggestion, index) => (
<div
key={suggestion.mentorId}
className={`p-4 rounded-lg border-2 transition-colors ${
selectedMentorId === suggestion.mentorId
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-4 flex-1">
<div className="relative">
<Avatar className="h-12 w-12">
<AvatarFallback>
{suggestion.mentor ? getInitials(suggestion.mentor.name || suggestion.mentor.email) : '?'}
</AvatarFallback>
</Avatar>
{index === 0 && (
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
1
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{suggestion.mentor?.name || 'Unnamed'}</p>
<Badge variant="outline" className="text-xs">
{suggestion.mentor?.assignmentCount || 0} projects
</Badge>
</div>
<p className="text-sm text-muted-foreground">{suggestion.mentor?.email}</p>
{/* Expertise tags */}
{suggestion.mentor?.expertiseTags && suggestion.mentor.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{suggestion.mentor.expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
{/* Match scores */}
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Confidence:</span>
<Progress value={suggestion.confidenceScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.confidenceScore * 100).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground w-28">Expertise Match:</span>
<Progress value={suggestion.expertiseMatchScore * 100} className="flex-1 h-2" />
<span className="w-12 text-right">{(suggestion.expertiseMatchScore * 100).toFixed(0)}%</span>
</div>
</div>
{/* AI Reasoning */}
{suggestion.reasoning && (
<p className="mt-2 text-sm text-muted-foreground italic">
&quot;{suggestion.reasoning}&quot;
</p>
)}
</div>
</div>
<Button
onClick={() => handleAssign(suggestion.mentorId, suggestion)}
disabled={assignMutation.isPending}
variant={selectedMentorId === suggestion.mentorId ? 'default' : 'outline'}
>
{assignMutation.isPending && selectedMentorId === suggestion.mentorId ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Check className="mr-2 h-4 w-4" />
Assign
</>
)}
</Button>
</div>
</div>
))}
</div>
)}
</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>
)
}
function MentorAssignmentSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-48" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default function MentorAssignmentPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<MentorAssignmentSkeleton />}>
<MentorAssignmentContent projectId={id} />
</Suspense>
)
}

View File

@ -0,0 +1,653 @@
'use client'
import { Suspense, use } from 'react'
import Link from 'next/link'
import type { Route } from 'next'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { FileViewer } from '@/components/shared/file-viewer'
import { FileUpload } from '@/components/shared/file-upload'
import { ProjectLogoWithUrl } from '@/components/shared/project-logo-with-url'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
ArrowLeft,
Edit,
AlertCircle,
Users,
FileText,
Calendar,
CheckCircle2,
XCircle,
Clock,
BarChart3,
ThumbsUp,
ThumbsDown,
MapPin,
Waves,
GraduationCap,
Heart,
Crown,
UserPlus,
} from 'lucide-react'
import { formatDate, formatDateOnly, getInitials } from '@/lib/utils'
interface PageProps {
params: Promise<{ id: string }>
}
// Status badge colors
const statusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
SUBMITTED: 'secondary',
ELIGIBLE: 'default',
ASSIGNED: 'default',
SEMIFINALIST: 'default',
FINALIST: 'default',
REJECTED: 'destructive',
}
// Evaluation status colors
const evalStatusColors: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
NOT_STARTED: 'outline',
DRAFT: 'secondary',
SUBMITTED: 'default',
LOCKED: 'default',
}
function ProjectDetailContent({ projectId }: { projectId: string }) {
// Fetch project data
const { data: project, isLoading } = trpc.project.get.useQuery({
id: projectId,
})
// Fetch files
const { data: files } = trpc.file.listByProject.useQuery({ projectId })
// Fetch assignments
const { data: assignments } = trpc.assignment.listByProject.useQuery({
projectId,
})
// Fetch evaluation stats
const { data: stats } = trpc.evaluation.getProjectStats.useQuery({
projectId,
})
const utils = trpc.useUtils()
if (isLoading) {
return <ProjectDetailSkeleton />
}
if (!project) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Project Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/projects">Back to Projects</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
<ProjectLogoWithUrl
project={project}
size="lg"
fallback="initials"
/>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link
href={`/admin/rounds/${project.round.id}`}
className="hover:underline"
>
{project.round.name}
</Link>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
{project.title}
</h1>
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
</Badge>
</div>
{project.teamName && (
<p className="text-muted-foreground">{project.teamName}</p>
)}
</div>
</div>
<Button variant="outline" asChild>
<Link href={`/admin/projects/${projectId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
</div>
<Separator />
{/* Stats Grid */}
{stats && (
<div className="grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Average Score
</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.averageGlobalScore?.toFixed(1) || '-'}
</div>
<p className="text-xs text-muted-foreground">
Range: {stats.minScore || '-'} - {stats.maxScore || '-'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Recommendations
</CardTitle>
<ThumbsUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.yesPercentage?.toFixed(0) || 0}%
</div>
<p className="text-xs text-muted-foreground">
{stats.yesVotes} yes / {stats.noVotes} no
</p>
</CardContent>
</Card>
</div>
)}
{/* Project Info */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Category & Ocean Issue badges */}
<div className="flex flex-wrap gap-2">
{project.competitionCategory && (
<Badge variant="outline" className="gap-1">
<GraduationCap className="h-3 w-3" />
{project.competitionCategory === 'STARTUP' ? 'Start-up' : 'Business Concept'}
</Badge>
)}
{project.oceanIssue && (
<Badge variant="outline" className="gap-1">
<Waves className="h-3 w-3" />
{project.oceanIssue.replace(/_/g, ' ')}
</Badge>
)}
{project.wantsMentorship && (
<Badge variant="outline" className="gap-1 text-pink-600 border-pink-200 bg-pink-50">
<Heart className="h-3 w-3" />
Wants Mentorship
</Badge>
)}
</div>
{project.description && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-1">
Description
</p>
<p className="text-sm whitespace-pre-wrap">{project.description}</p>
</div>
)}
{/* Location & Institution */}
<div className="grid gap-4 sm:grid-cols-2">
{(project.country || project.geographicZone) && (
<div className="flex items-start gap-2">
<MapPin className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Location</p>
<p className="text-sm">{project.geographicZone || project.country}</p>
</div>
</div>
)}
{project.institution && (
<div className="flex items-start gap-2">
<GraduationCap className="h-4 w-4 text-muted-foreground mt-0.5" />
<div>
<p className="text-sm font-medium text-muted-foreground">Institution</p>
<p className="text-sm">{project.institution}</p>
</div>
</div>
)}
</div>
{/* Submission URLs */}
{(project.phase1SubmissionUrl || project.phase2SubmissionUrl) && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Submission Links</p>
<div className="flex flex-wrap gap-2">
{project.phase1SubmissionUrl && (
<Button variant="outline" size="sm" asChild>
<a href={project.phase1SubmissionUrl} target="_blank" rel="noopener noreferrer">
Phase 1 Submission
</a>
</Button>
)}
{project.phase2SubmissionUrl && (
<Button variant="outline" size="sm" asChild>
<a href={project.phase2SubmissionUrl} target="_blank" rel="noopener noreferrer">
Phase 2 Submission
</a>
</Button>
)}
</div>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div>
<p className="text-sm font-medium text-muted-foreground mb-2">
Tags
</p>
<div className="flex flex-wrap gap-2">
{project.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Internal Info */}
{(project.internalComments || project.applicationStatus || project.referralSource) && (
<div className="border-t pt-4 mt-4">
<p className="text-sm font-medium text-muted-foreground mb-3">Internal Notes</p>
<div className="grid gap-3 sm:grid-cols-2">
{project.applicationStatus && (
<div>
<p className="text-xs text-muted-foreground">Application Status</p>
<p className="text-sm">{project.applicationStatus}</p>
</div>
)}
{project.referralSource && (
<div>
<p className="text-xs text-muted-foreground">Referral Source</p>
<p className="text-sm">{project.referralSource}</p>
</div>
)}
</div>
{project.internalComments && (
<div className="mt-3">
<p className="text-xs text-muted-foreground">Comments</p>
<p className="text-sm whitespace-pre-wrap">{project.internalComments}</p>
</div>
)}
</div>
)}
<div className="flex flex-wrap gap-6 text-sm pt-2">
<div>
<span className="text-muted-foreground">Created:</span>{' '}
{formatDateOnly(project.createdAt)}
</div>
<div>
<span className="text-muted-foreground">Updated:</span>{' '}
{formatDateOnly(project.updatedAt)}
</div>
</div>
</CardContent>
</Card>
{/* Team Members Section */}
{project.teamMembers && project.teamMembers.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5" />
Team Members ({project.teamMembers.length})
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2">
{project.teamMembers.map((member: { id: string; role: string; title: string | null; user: { id: string; name: string | null; email: string } }) => (
<div key={member.id} className="flex items-center gap-3 p-3 rounded-lg border">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
{member.role === 'LEAD' ? (
<Crown className="h-5 w-5 text-yellow-500" />
) : (
<span className="text-sm font-medium">
{getInitials(member.user.name || member.user.email)}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
{member.user.name || 'Unnamed'}
</p>
<Badge variant="outline" className="text-xs">
{member.role === 'LEAD' ? 'Lead' : member.role === 'ADVISOR' ? 'Advisor' : 'Member'}
</Badge>
</div>
<p className="text-xs text-muted-foreground truncate">
{member.user.email}
</p>
{member.title && (
<p className="text-xs text-muted-foreground">{member.title}</p>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Mentor Assignment Section */}
{project.wantsMentorship && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Heart className="h-5 w-5" />
Mentor Assignment
</CardTitle>
{!project.mentorAssignment && (
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/projects/${projectId}/mentor` as Route}>
<UserPlus className="mr-2 h-4 w-4" />
Assign Mentor
</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent>
{project.mentorAssignment ? (
<div className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback className="text-sm">
{getInitials(project.mentorAssignment.mentor.name || project.mentorAssignment.mentor.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">
{project.mentorAssignment.mentor.name || 'Unnamed'}
</p>
<p className="text-sm text-muted-foreground">
{project.mentorAssignment.mentor.email}
</p>
</div>
</div>
<Badge variant="outline">
{project.mentorAssignment.method.replace('_', ' ')}
</Badge>
</div>
) : (
<p className="text-sm text-muted-foreground">
No mentor assigned yet. The applicant has requested mentorship support.
</p>
)}
</CardContent>
</Card>
)}
{/* Files Section */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Files</CardTitle>
<CardDescription>
Project documents and materials
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{files && files.length > 0 ? (
<FileViewer
files={files.map((f) => ({
id: f.id,
fileName: f.fileName,
fileType: f.fileType,
mimeType: f.mimeType,
size: f.size,
bucket: f.bucket,
objectKey: f.objectKey,
}))}
/>
) : (
<p className="text-sm text-muted-foreground">No files uploaded yet</p>
)}
<Separator className="my-4" />
<div>
<p className="text-sm font-medium mb-3">Upload New Files</p>
<FileUpload
projectId={projectId}
onUploadComplete={() => {
utils.file.listByProject.invalidate({ projectId })
}}
/>
</div>
</CardContent>
</Card>
{/* Assignments Section */}
{assignments && assignments.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">Jury Assignments</CardTitle>
<CardDescription>
{assignments.filter((a) => a.evaluation?.status === 'SUBMITTED')
.length}{' '}
of {assignments.length} evaluations completed
</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/rounds/${project.roundId}/assignments`}>
Manage
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Juror</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Status</TableHead>
<TableHead>Score</TableHead>
<TableHead>Decision</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assignments.map((assignment) => (
<TableRow key={assignment.id}>
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{getInitials(assignment.user.name || assignment.user.email)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-sm">
{assignment.user.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground">
{assignment.user.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{assignment.user.expertiseTags?.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{(assignment.user.expertiseTags?.length || 0) > 2 && (
<Badge variant="outline" className="text-xs">
+{(assignment.user.expertiseTags?.length || 0) - 2}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
evalStatusColors[
assignment.evaluation?.status || 'NOT_STARTED'
] || 'secondary'
}
>
{(assignment.evaluation?.status || 'NOT_STARTED').replace(
'_',
' '
)}
</Badge>
</TableCell>
<TableCell>
{assignment.evaluation?.globalScore !== null &&
assignment.evaluation?.globalScore !== undefined ? (
<span className="font-medium">
{assignment.evaluation.globalScore}/10
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{assignment.evaluation?.binaryDecision !== null &&
assignment.evaluation?.binaryDecision !== undefined ? (
assignment.evaluation.binaryDecision ? (
<div className="flex items-center gap-1 text-green-600">
<ThumbsUp className="h-4 w-4" />
<span className="text-sm">Yes</span>
</div>
) : (
<div className="flex items-center gap-1 text-red-600">
<ThumbsDown className="h-4 w-4" />
<span className="text-sm">No</span>
</div>
)
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
)
}
function ProjectDetailSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="flex items-start justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-40" />
</div>
<Skeleton className="h-10 w-24" />
</div>
<Skeleton className="h-px w-full" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-24 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function ProjectDetailPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<ProjectDetailSkeleton />}>
<ProjectDetailContent projectId={id} />
</Suspense>
)
}

View File

@ -0,0 +1,237 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { CSVImportForm } from '@/components/forms/csv-import-form'
import { NotionImportForm } from '@/components/forms/notion-import-form'
import { TypeformImportForm } from '@/components/forms/typeform-import-form'
import { ArrowLeft, FileSpreadsheet, AlertCircle, Database, FileText } from 'lucide-react'
function ImportPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const roundIdParam = searchParams.get('round')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
// Fetch active programs with rounds
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
})
// Get all rounds from programs
const rounds = programs?.flatMap((p) =>
(p.rounds || []).map((r) => ({
...r,
programName: p.name,
}))
) || []
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
if (loadingPrograms) {
return <ImportPageSkeleton />
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Import Projects</h1>
<p className="text-muted-foreground">
Import projects from a CSV file into a round
</p>
</div>
{/* Round selection */}
{!selectedRoundId && (
<Card>
<CardHeader>
<CardTitle>Select Round</CardTitle>
<CardDescription>
Choose the round you want to import projects into
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rounds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Rounds</p>
<p className="text-sm text-muted-foreground">
Create a round first before importing projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new">Create Round</Link>
</Button>
</div>
) : (
<>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
<div className="flex flex-col">
<span>{round.name}</span>
<span className="text-xs text-muted-foreground">
{round.programName}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => {
if (selectedRoundId) {
router.push(`/admin/projects/import?round=${selectedRoundId}`)
}
}}
disabled={!selectedRoundId}
>
Continue
</Button>
</>
)}
</CardContent>
</Card>
)}
{/* Import form */}
{selectedRoundId && selectedRound && (
<div className="space-y-4">
<div className="flex items-center gap-4">
<FileSpreadsheet className="h-8 w-8 text-muted-foreground" />
<div>
<p className="font-medium">Importing into: {selectedRound.name}</p>
<p className="text-sm text-muted-foreground">
{selectedRound.programName}
</p>
</div>
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={() => {
setSelectedRoundId('')
router.push('/admin/projects/import')
}}
>
Change Round
</Button>
</div>
<Tabs defaultValue="csv" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="csv" className="flex items-center gap-2">
<FileSpreadsheet className="h-4 w-4" />
CSV
</TabsTrigger>
<TabsTrigger value="notion" className="flex items-center gap-2">
<Database className="h-4 w-4" />
Notion
</TabsTrigger>
<TabsTrigger value="typeform" className="flex items-center gap-2">
<FileText className="h-4 w-4" />
Typeform
</TabsTrigger>
</TabsList>
<TabsContent value="csv" className="mt-4">
<CSVImportForm
roundId={selectedRoundId}
roundName={selectedRound.name}
onSuccess={() => {
// Optionally redirect after success
}}
/>
</TabsContent>
<TabsContent value="notion" className="mt-4">
<NotionImportForm
roundId={selectedRoundId}
roundName={selectedRound.name}
onSuccess={() => {
// Optionally redirect after success
}}
/>
</TabsContent>
<TabsContent value="typeform" className="mt-4">
<TypeformImportForm
roundId={selectedRoundId}
roundName={selectedRound.name}
onSuccess={() => {
// Optionally redirect after success
}}
/>
</TabsContent>
</Tabs>
</div>
)}
</div>
)
}
function ImportPageSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-36" />
</div>
<div>
<Skeleton className="h-8 w-48" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-24" />
</CardContent>
</Card>
</div>
)
}
export default function ImportProjectsPage() {
return (
<Suspense fallback={<ImportPageSkeleton />}>
<ImportPageContent />
</Suspense>
)
}

View File

@ -0,0 +1,418 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { TagInput } from '@/components/shared/tag-input'
import { toast } from 'sonner'
import {
ArrowLeft,
Save,
Loader2,
AlertCircle,
FolderPlus,
Plus,
X,
} from 'lucide-react'
function NewProjectPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const roundIdParam = searchParams.get('round')
const [selectedRoundId, setSelectedRoundId] = useState<string>(roundIdParam || '')
// Form state
const [title, setTitle] = useState('')
const [teamName, setTeamName] = useState('')
const [description, setDescription] = useState('')
const [tags, setTags] = useState<string[]>([])
const [contactEmail, setContactEmail] = useState('')
const [contactName, setContactName] = useState('')
const [country, setCountry] = useState('')
const [customFields, setCustomFields] = useState<{ key: string; value: string }[]>([])
// Fetch active programs with rounds
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery({
status: 'ACTIVE',
includeRounds: true,
})
// Create mutation
const createProject = trpc.project.create.useMutation({
onSuccess: () => {
toast.success('Project created successfully')
router.push(`/admin/projects?round=${selectedRoundId}`)
},
onError: (error) => {
toast.error(error.message)
},
})
// Get all rounds from programs
const rounds = programs?.flatMap((p) =>
(p.rounds || []).map((r) => ({
...r,
programName: p.name,
}))
) || []
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
const addCustomField = () => {
setCustomFields([...customFields, { key: '', value: '' }])
}
const updateCustomField = (index: number, key: string, value: string) => {
const newFields = [...customFields]
newFields[index] = { key, value }
setCustomFields(newFields)
}
const removeCustomField = (index: number) => {
setCustomFields(customFields.filter((_, i) => i !== index))
}
const handleSubmit = () => {
if (!title.trim()) {
toast.error('Please enter a project title')
return
}
if (!selectedRoundId) {
toast.error('Please select a round')
return
}
// Build metadata
const metadataJson: Record<string, unknown> = {}
if (contactEmail) metadataJson.contactEmail = contactEmail
if (contactName) metadataJson.contactName = contactName
if (country) metadataJson.country = country
// Add custom fields
customFields.forEach((field) => {
if (field.key.trim() && field.value.trim()) {
metadataJson[field.key.trim()] = field.value.trim()
}
})
createProject.mutate({
roundId: selectedRoundId,
title: title.trim(),
teamName: teamName.trim() || undefined,
description: description.trim() || undefined,
tags: tags.length > 0 ? tags : undefined,
metadataJson: Object.keys(metadataJson).length > 0 ? metadataJson : undefined,
})
}
if (loadingPrograms) {
return <NewProjectPageSkeleton />
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/projects">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Projects
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<FolderPlus className="h-6 w-6 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">Add Project</h1>
<p className="text-muted-foreground">
Manually create a new project submission
</p>
</div>
</div>
{/* Round selection */}
{!selectedRoundId ? (
<Card>
<CardHeader>
<CardTitle>Select Round</CardTitle>
<CardDescription>
Choose the round for this project submission
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{rounds.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Active Rounds</p>
<p className="text-sm text-muted-foreground">
Create a round first before adding projects
</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds/new">Create Round</Link>
</Button>
</div>
) : (
<>
<Select value={selectedRoundId} onValueChange={setSelectedRoundId}>
<SelectTrigger>
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</CardContent>
</Card>
) : (
<>
{/* Selected round info */}
<Card>
<CardContent className="flex items-center justify-between py-4">
<div>
<p className="font-medium">{selectedRound?.programName}</p>
<p className="text-sm text-muted-foreground">{selectedRound?.name}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setSelectedRoundId('')}
>
Change Round
</Button>
</CardContent>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle>Project Details</CardTitle>
<CardDescription>
Basic information about the project
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Project Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Ocean Cleanup Initiative"
/>
</div>
<div className="space-y-2">
<Label htmlFor="teamName">Team/Organization Name</Label>
<Input
id="teamName"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="e.g., Blue Ocean Foundation"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of the project..."
rows={4}
maxLength={2000}
/>
</div>
<div className="space-y-2">
<Label>Tags</Label>
<TagInput
value={tags}
onChange={setTags}
placeholder="Select project tags..."
maxTags={10}
/>
</div>
</CardContent>
</Card>
{/* Contact Info */}
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
<CardDescription>
Contact details for the project team
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="contactName">Contact Name</Label>
<Input
id="contactName"
value={contactName}
onChange={(e) => setContactName(e.target.value)}
placeholder="e.g., John Smith"
/>
</div>
<div className="space-y-2">
<Label htmlFor="contactEmail">Contact Email</Label>
<Input
id="contactEmail"
type="email"
value={contactEmail}
onChange={(e) => setContactEmail(e.target.value)}
placeholder="e.g., john@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="country">Country</Label>
<Input
id="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
placeholder="e.g., Monaco"
/>
</div>
</CardContent>
</Card>
</div>
{/* Custom Fields */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Additional Information</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={addCustomField}
>
<Plus className="mr-2 h-4 w-4" />
Add Field
</Button>
</CardTitle>
<CardDescription>
Add custom metadata fields for this project
</CardDescription>
</CardHeader>
<CardContent>
{customFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No additional fields. Click &quot;Add Field&quot; to add custom information.
</p>
) : (
<div className="space-y-3">
{customFields.map((field, index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="Field name"
value={field.key}
onChange={(e) =>
updateCustomField(index, e.target.value, field.value)
}
className="flex-1"
/>
<Input
placeholder="Value"
value={field.value}
onChange={(e) =>
updateCustomField(index, field.key, e.target.value)
}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeCustomField(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-4">
<Button variant="outline" asChild>
<Link href="/admin/projects">Cancel</Link>
</Button>
<Button
onClick={handleSubmit}
disabled={createProject.isPending || !title.trim()}
>
{createProject.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Create Project
</Button>
</div>
</>
)}
</div>
)
}
function NewProjectPageSkeleton() {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-32" />
</div>
<Skeleton className="h-8 w-48" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function NewProjectPage() {
return (
<Suspense fallback={<NewProjectPageSkeleton />}>
<NewProjectPageContent />
</Suspense>
)
}

View File

@ -0,0 +1,300 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
MoreHorizontal,
ClipboardList,
Eye,
Pencil,
FileUp,
Users,
} from 'lucide-react'
import { formatDateOnly, truncate } from '@/lib/utils'
import { ProjectLogo } from '@/components/shared/project-logo'
async function ProjectsContent() {
const projects = await prisma.project.findMany({
// Note: PROGRAM_ADMIN filtering should be handled via middleware or a separate relation
select: {
id: true,
title: true,
teamName: true,
status: true,
logoKey: true,
createdAt: true,
round: {
select: {
id: true,
name: true,
status: true,
program: {
select: {
name: true,
},
},
},
},
_count: {
select: {
assignments: true,
files: true,
},
},
},
orderBy: { createdAt: 'desc' },
take: 100,
})
if (projects.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<ClipboardList className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No projects yet</p>
<p className="text-sm text-muted-foreground">
Import projects via CSV or create them manually
</p>
<div className="mt-4 flex gap-2">
<Button asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />
Import CSV
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/admin/projects/new">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Link>
</Button>
</div>
</CardContent>
</Card>
)
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive' | 'warning'> = {
SUBMITTED: 'secondary',
UNDER_REVIEW: 'default',
SHORTLISTED: 'success',
FINALIST: 'success',
WINNER: 'success',
REJECTED: 'destructive',
WITHDRAWN: 'secondary',
}
return (
<>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Project</TableHead>
<TableHead>Round</TableHead>
<TableHead>Files</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow key={project.id} className="group relative cursor-pointer hover:bg-muted/50">
<TableCell>
<Link href={`/admin/projects/${project.id}`} className="flex items-center gap-3 after:absolute after:inset-0 after:content-['']">
<ProjectLogo
project={project}
size="sm"
fallback="initials"
/>
<div>
<p className="font-medium hover:text-primary">
{truncate(project.title, 40)}
</p>
<p className="text-sm text-muted-foreground">
{project.teamName}
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div>
<p>{project.round.name}</p>
<p className="text-sm text-muted-foreground">
{project.round.program.name}
</p>
</div>
</TableCell>
<TableCell>{project._count.files}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{project._count.assignments}
</div>
</TableCell>
<TableCell>
<Badge variant={statusColors[project.status] || 'secondary'}>
{project.status.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell className="relative z-10 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/projects/${project.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{projects.map((project) => (
<Link key={project.id} href={`/admin/projects/${project.id}`} className="block">
<Card className="transition-colors hover:bg-muted/50">
<CardHeader className="pb-3">
<div className="flex items-start gap-3">
<ProjectLogo
project={project}
size="md"
fallback="initials"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base line-clamp-2">
{project.title}
</CardTitle>
<Badge variant={statusColors[project.status] || 'secondary'} className="shrink-0">
{project.status.replace('_', ' ')}
</Badge>
</div>
<CardDescription>{project.teamName}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Round</span>
<span>{project.round.name}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>{project._count.assignments} jurors</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</>
)
}
function ProjectsSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-64" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-9 w-9" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default function ProjectsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage submitted projects across all rounds
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/admin/projects/import">
<FileUp className="mr-2 h-4 w-4" />
Import
</Link>
</Button>
<Button asChild>
<Link href="/admin/projects/new">
<Plus className="mr-2 h-4 w-4" />
Add Project
</Link>
</Button>
</div>
</div>
{/* Content */}
<Suspense fallback={<ProjectsSkeleton />}>
<ProjectsContent />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,451 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { 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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import {
FileSpreadsheet,
Download,
BarChart3,
Users,
ClipboardList,
CheckCircle2,
PieChart,
TrendingUp,
} from 'lucide-react'
import { formatDateOnly } from '@/lib/utils'
import {
ScoreDistributionChart,
EvaluationTimelineChart,
StatusBreakdownChart,
JurorWorkloadChart,
ProjectRankingsChart,
CriteriaScoresChart,
GeographicDistribution,
} from '@/components/charts'
function ReportsOverview() {
const { data: programs, isLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Flatten rounds from all programs
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programId: p.id, programName: p.name }))) || []
if (isLoading) {
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>
)
}
if (!rounds || rounds.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<FileSpreadsheet className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No data to report</p>
<p className="text-sm text-muted-foreground">
Create rounds and assign jury members to generate reports
</p>
</CardContent>
</Card>
)
}
// Calculate totals
const totalProjects = programs?.reduce((acc, p) => acc + (p._count?.rounds || 0), 0) || 0
const totalPrograms = programs?.length || 0
const activeRounds = rounds.filter((r) => r.status === 'ACTIVE').length
return (
<div className="space-y-6">
{/* Quick Stats */}
<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">Total Rounds</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{rounds.length}</div>
<p className="text-xs text-muted-foreground">
{activeRounds} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<ClipboardList className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalProjects}</div>
<p className="text-xs text-muted-foreground">Across all rounds</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Rounds</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{activeRounds}</div>
<p className="text-xs text-muted-foreground">Currently active</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Programs</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{totalPrograms}</div>
<p className="text-xs text-muted-foreground">Total programs</p>
</CardContent>
</Card>
</div>
{/* Rounds Table */}
<Card>
<CardHeader>
<CardTitle>Round Reports</CardTitle>
<CardDescription>
View progress and export data for each round
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Program</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Export</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rounds.map((round) => (
<TableRow key={round.id}>
<TableCell>
<div>
<p className="font-medium">{round.name}</p>
{round.votingEndAt && (
<p className="text-sm text-muted-foreground">
Ends: {formatDateOnly(round.votingEndAt)}
</p>
)}
</div>
</TableCell>
<TableCell>{round.programName}</TableCell>
<TableCell>-</TableCell>
<TableCell>
<Badge
variant={
round.status === 'ACTIVE'
? 'default'
: round.status === 'CLOSED'
? 'secondary'
: 'outline'
}
>
{round.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/evaluations?roundId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Evaluations
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<a
href={`/api/export/results?roundId=${round.id}`}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Results
</a>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
function RoundAnalytics() {
const [selectedRoundId, setSelectedRoundId] = useState<string | null>(null)
const { data: programs, isLoading: roundsLoading } = trpc.program.list.useQuery({ includeRounds: true })
// Flatten rounds from all programs with program name
const rounds = programs?.flatMap(p => p.rounds.map(r => ({ ...r, programName: p.name }))) || []
// Set default selected round
if (rounds.length && !selectedRoundId) {
setSelectedRoundId(rounds[0].id)
}
const { data: scoreDistribution, isLoading: scoreLoading } =
trpc.analytics.getScoreDistribution.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: timeline, isLoading: timelineLoading } =
trpc.analytics.getEvaluationTimeline.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: statusBreakdown, isLoading: statusLoading } =
trpc.analytics.getStatusBreakdown.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: jurorWorkload, isLoading: workloadLoading } =
trpc.analytics.getJurorWorkload.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const { data: projectRankings, isLoading: rankingsLoading } =
trpc.analytics.getProjectRankings.useQuery(
{ roundId: selectedRoundId!, limit: 15 },
{ enabled: !!selectedRoundId }
)
const { data: criteriaScores, isLoading: criteriaLoading } =
trpc.analytics.getCriteriaScores.useQuery(
{ roundId: selectedRoundId! },
{ enabled: !!selectedRoundId }
)
const selectedRound = rounds.find((r) => r.id === selectedRoundId)
const { data: geoData, isLoading: geoLoading } =
trpc.analytics.getGeographicDistribution.useQuery(
{ programId: selectedRound?.programId || '', roundId: selectedRoundId! },
{ enabled: !!selectedRoundId && !!selectedRound?.programId }
)
if (roundsLoading) {
return (
<div className="space-y-4">
<Skeleton className="h-10 w-64" />
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-[350px]" />
<Skeleton className="h-[350px]" />
</div>
</div>
)
}
if (!rounds?.length) {
return (
<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">No rounds available</p>
<p className="text-sm text-muted-foreground">
Create a round to view analytics
</p>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* Round Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium">Select Round:</label>
<Select value={selectedRoundId || ''} onValueChange={setSelectedRoundId}>
<SelectTrigger className="w-[300px]">
<SelectValue placeholder="Select a round" />
</SelectTrigger>
<SelectContent>
{rounds.map((round) => (
<SelectItem key={round.id} value={round.id}>
{round.programName} - {round.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRoundId && (
<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>
)}
{/* Row 6: Geographic Distribution */}
{geoLoading ? (
<Skeleton className="h-[500px]" />
) : geoData?.length ? (
<GeographicDistribution data={geoData} />
) : null}
</div>
)}
</div>
)
}
export default function ReportsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Reports</h1>
<p className="text-muted-foreground">
View progress, analytics, and export evaluation data
</p>
</div>
{/* Tabs */}
<Tabs defaultValue="overview" 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">
<TrendingUp className="h-4 w-4" />
Analytics
</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<ReportsOverview />
</TabsContent>
<TabsContent value="analytics">
<RoundAnalytics />
</TabsContent>
</Tabs>
</div>
)
}

View File

@ -0,0 +1,545 @@
'use client'
import { Suspense, use, useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import {
ArrowLeft,
Users,
FileText,
CheckCircle2,
Clock,
AlertCircle,
Sparkles,
Loader2,
Plus,
Trash2,
RefreshCw,
} from 'lucide-react'
interface PageProps {
params: Promise<{ id: string }>
}
function AssignmentManagementContent({ roundId }: { roundId: string }) {
const [selectedSuggestions, setSelectedSuggestions] = useState<Set<string>>(new Set())
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({ id: roundId })
const { data: assignments, isLoading: loadingAssignments } = trpc.assignment.listByRound.useQuery({ roundId })
const { data: stats, isLoading: loadingStats } = trpc.assignment.getStats.useQuery({ roundId })
const { data: suggestions, isLoading: loadingSuggestions, refetch: refetchSuggestions } = trpc.assignment.getSuggestions.useQuery(
{ roundId, maxPerJuror: 10, minPerProject: 3 },
{ enabled: !!round }
)
const utils = trpc.useUtils()
const deleteAssignment = trpc.assignment.delete.useMutation({
onSuccess: () => {
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
},
})
const applySuggestions = trpc.assignment.applySuggestions.useMutation({
onSuccess: () => {
utils.assignment.listByRound.invalidate({ roundId })
utils.assignment.getStats.invalidate({ roundId })
utils.assignment.getSuggestions.invalidate({ roundId })
setSelectedSuggestions(new Set())
},
})
if (loadingRound || loadingAssignments) {
return <AssignmentsSkeleton />
}
if (!round) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
const handleToggleSuggestion = (key: string) => {
setSelectedSuggestions((prev) => {
const newSet = new Set(prev)
if (newSet.has(key)) {
newSet.delete(key)
} else {
newSet.add(key)
}
return newSet
})
}
const handleSelectAllSuggestions = () => {
if (suggestions) {
if (selectedSuggestions.size === suggestions.length) {
setSelectedSuggestions(new Set())
} else {
setSelectedSuggestions(
new Set(suggestions.map((s) => `${s.userId}-${s.projectId}`))
)
}
}
}
const handleApplySelected = async () => {
if (!suggestions) return
const selected = suggestions.filter((s) =>
selectedSuggestions.has(`${s.userId}-${s.projectId}`)
)
await applySuggestions.mutateAsync({
roundId,
assignments: selected.map((s) => ({
userId: s.userId,
projectId: s.projectId,
reasoning: s.reasoning.join('; '),
})),
})
}
// Group assignments by project
const assignmentsByProject = assignments?.reduce((acc, assignment) => {
const projectId = assignment.project.id
if (!acc[projectId]) {
acc[projectId] = {
project: assignment.project,
assignments: [],
}
}
acc[projectId].assignments.push(assignment)
return acc
}, {} as Record<string, { project: (typeof assignments)[0]['project'], assignments: typeof assignments }>) || {}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.name}
</Link>
<span>/</span>
<Link href={`/admin/rounds/${roundId}`} className="hover:underline">
{round.name}
</Link>
</div>
<h1 className="text-2xl font-semibold tracking-tight">
Manage Assignments
</h1>
</div>
</div>
{/* Stats */}
{stats && (
<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">Total Assignments</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.totalAssignments}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.completedAssignments}</div>
<p className="text-xs text-muted-foreground">
{stats.completionPercentage}% complete
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Projects Covered</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{stats.projectsWithFullCoverage}/{stats.totalProjects}
</div>
<p className="text-xs text-muted-foreground">
{stats.coveragePercentage}% have {round.requiredReviews}+ reviews
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Jury Members</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.juryMembersAssigned}</div>
<p className="text-xs text-muted-foreground">assigned to projects</p>
</CardContent>
</Card>
</div>
)}
{/* Coverage Progress */}
{stats && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Project Coverage</CardTitle>
<CardDescription>
{stats.projectsWithFullCoverage} of {stats.totalProjects} projects have
at least {round.requiredReviews} reviewers assigned
</CardDescription>
</CardHeader>
<CardContent>
<Progress value={stats.coveragePercentage} className="h-3" />
</CardContent>
</Card>
)}
{/* Smart Suggestions */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
Smart Assignment Suggestions
</CardTitle>
<CardDescription>
AI-powered recommendations based on expertise matching and workload
balance
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetchSuggestions()}
disabled={loadingSuggestions}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${loadingSuggestions ? 'animate-spin' : ''}`}
/>
Refresh
</Button>
</div>
</CardHeader>
<CardContent>
{loadingSuggestions ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : suggestions && suggestions.length > 0 ? (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox
checked={selectedSuggestions.size === suggestions.length}
onCheckedChange={handleSelectAllSuggestions}
/>
<span className="text-sm text-muted-foreground">
{selectedSuggestions.size} of {suggestions.length} selected
</span>
</div>
<Button
onClick={handleApplySelected}
disabled={selectedSuggestions.size === 0 || applySuggestions.isPending}
>
{applySuggestions.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-2 h-4 w-4" />
)}
Apply Selected ({selectedSuggestions.size})
</Button>
</div>
<div className="rounded-lg border max-h-[400px] overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>Juror</TableHead>
<TableHead>Project</TableHead>
<TableHead>Score</TableHead>
<TableHead>Reasoning</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => {
const key = `${suggestion.userId}-${suggestion.projectId}`
const isSelected = selectedSuggestions.has(key)
return (
<TableRow
key={key}
className={isSelected ? 'bg-muted/50' : ''}
>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSuggestion(key)}
/>
</TableCell>
<TableCell className="font-medium">
{suggestion.userId.slice(0, 8)}...
</TableCell>
<TableCell>
{suggestion.projectId.slice(0, 8)}...
</TableCell>
<TableCell>
<Badge
variant={
suggestion.score >= 60
? 'default'
: suggestion.score >= 40
? 'secondary'
: 'outline'
}
>
{suggestion.score.toFixed(0)}
</Badge>
</TableCell>
<TableCell className="max-w-xs">
<ul className="text-xs text-muted-foreground">
{suggestion.reasoning.map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<CheckCircle2 className="h-12 w-12 text-green-500/50" />
<p className="mt-2 font-medium">All projects are covered!</p>
<p className="text-sm text-muted-foreground">
No additional assignments are needed at this time
</p>
</div>
)}
</CardContent>
</Card>
{/* Current Assignments */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Current Assignments</CardTitle>
<CardDescription>
View and manage existing project assignments
</CardDescription>
</CardHeader>
<CardContent>
{Object.keys(assignmentsByProject).length > 0 ? (
<div className="space-y-6">
{Object.entries(assignmentsByProject).map(
([projectId, { project, assignments: projectAssignments }]) => (
<div key={projectId} className="space-y-2">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{project.title}</p>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{projectAssignments.length} reviewer
{projectAssignments.length !== 1 ? 's' : ''}
</Badge>
{projectAssignments.length >= round.requiredReviews && (
<Badge variant="secondary" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Full coverage
</Badge>
)}
</div>
</div>
</div>
<div className="pl-4 border-l-2 border-muted space-y-2">
{projectAssignments.map((assignment) => (
<div
key={assignment.id}
className="flex items-center justify-between py-1"
>
<div className="flex items-center gap-2">
<span className="text-sm">
{assignment.user.name || assignment.user.email}
</span>
{assignment.evaluation?.status === 'SUBMITTED' ? (
<Badge variant="default" className="text-xs">
<CheckCircle2 className="mr-1 h-3 w-3" />
Submitted
</Badge>
) : assignment.evaluation?.status === 'DRAFT' ? (
<Badge variant="secondary" className="text-xs">
<Clock className="mr-1 h-3 w-3" />
In Progress
</Badge>
) : (
<Badge variant="outline" className="text-xs">
Pending
</Badge>
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={
assignment.evaluation?.status === 'SUBMITTED'
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove Assignment?
</AlertDialogTitle>
<AlertDialogDescription>
This will remove {assignment.user.name || assignment.user.email} from
evaluating this project. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
deleteAssignment.mutate({ id: assignment.id })
}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
))}
</div>
</div>
)
)}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Assignments Yet</p>
<p className="text-sm text-muted-foreground">
Use the smart suggestions above or manually assign jury members to
projects
</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}
function AssignmentsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-8 w-64" />
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-48" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function AssignmentManagementPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<AssignmentsSkeleton />}>
<AssignmentManagementContent roundId={id} />
</Suspense>
)
}

View File

@ -0,0 +1,450 @@
'use client'
import { Suspense, use, useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
EvaluationFormBuilder,
type Criterion,
} from '@/components/forms/evaluation-form-builder'
import { RoundTypeSettings } from '@/components/forms/round-type-settings'
import { ArrowLeft, Loader2, AlertCircle, AlertTriangle } from 'lucide-react'
import { format } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
const updateRoundSchema = z
.object({
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
votingStartAt: z.string().optional(),
votingEndAt: z.string().optional(),
})
.refine(
(data) => {
if (data.votingStartAt && data.votingEndAt) {
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
}
return true
},
{
message: 'End date must be after start date',
path: ['votingEndAt'],
}
)
type UpdateRoundForm = z.infer<typeof updateRoundSchema>
// Convert ISO date to datetime-local format
function toDatetimeLocal(date: Date | string | null | undefined): string {
if (!date) return ''
const d = new Date(date)
// Format: YYYY-MM-DDTHH:mm
return format(d, "yyyy-MM-dd'T'HH:mm")
}
function EditRoundContent({ roundId }: { roundId: string }) {
const router = useRouter()
const [criteria, setCriteria] = useState<Criterion[]>([])
const [criteriaInitialized, setCriteriaInitialized] = useState(false)
const [roundType, setRoundType] = useState<'FILTERING' | 'EVALUATION' | 'LIVE_EVENT'>('EVALUATION')
const [roundSettings, setRoundSettings] = useState<Record<string, unknown>>({})
// Fetch round data
const { data: round, isLoading: loadingRound } = trpc.round.get.useQuery({
id: roundId,
})
// Fetch evaluation form
const { data: evaluationForm, isLoading: loadingForm } =
trpc.round.getEvaluationForm.useQuery({ roundId })
// Check if evaluations exist
const { data: hasEvaluations } = trpc.round.hasEvaluations.useQuery({
roundId,
})
// Mutations
const updateRound = trpc.round.update.useMutation({
onSuccess: () => {
router.push(`/admin/rounds/${roundId}`)
},
})
const updateEvaluationForm = trpc.round.updateEvaluationForm.useMutation()
// Initialize form with existing data
const form = useForm<UpdateRoundForm>({
resolver: zodResolver(updateRoundSchema),
defaultValues: {
name: '',
requiredReviews: 3,
votingStartAt: '',
votingEndAt: '',
},
})
// Update form when round data loads
useEffect(() => {
if (round) {
form.reset({
name: round.name,
requiredReviews: round.requiredReviews,
votingStartAt: toDatetimeLocal(round.votingStartAt),
votingEndAt: toDatetimeLocal(round.votingEndAt),
})
// Set round type and settings
setRoundType((round.roundType as typeof roundType) || 'EVALUATION')
setRoundSettings((round.settingsJson as Record<string, unknown>) || {})
}
}, [round, form])
// Initialize criteria from evaluation form
useEffect(() => {
if (evaluationForm && !criteriaInitialized) {
const existingCriteria = evaluationForm.criteriaJson as unknown as Criterion[]
if (Array.isArray(existingCriteria)) {
setCriteria(existingCriteria)
}
setCriteriaInitialized(true)
} else if (!loadingForm && !evaluationForm && !criteriaInitialized) {
setCriteriaInitialized(true)
}
}, [evaluationForm, loadingForm, criteriaInitialized])
const onSubmit = async (data: UpdateRoundForm) => {
// Update round with type and settings
await updateRound.mutateAsync({
id: roundId,
name: data.name,
requiredReviews: data.requiredReviews,
roundType,
settingsJson: roundSettings,
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : null,
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : null,
})
// Update evaluation form if criteria changed and no evaluations exist
if (!hasEvaluations && criteria.length > 0) {
await updateEvaluationForm.mutateAsync({
roundId,
criteria,
})
}
}
const isLoading = loadingRound || loadingForm
if (isLoading) {
return <EditRoundSkeleton />
}
if (!round) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
const isPending = updateRound.isPending || updateEvaluationForm.isPending
const isActive = round.status === 'ACTIVE'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">Edit Round</h1>
<Badge variant={isActive ? 'default' : 'secondary'}>
{round.status}
</Badge>
</div>
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Round Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., Round 1 - Semi-Finalists"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) =>
field.onChange(parseInt(e.target.value) || 1)
}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Round Type & Settings */}
<RoundTypeSettings
roundType={roundType}
onRoundTypeChange={setRoundType}
settings={roundSettings}
onSettingsChange={setRoundSettings}
/>
{/* Voting Window */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
<CardDescription>
Set when jury members can submit their evaluations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isActive && (
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 p-3 text-amber-700">
<AlertTriangle className="h-4 w-4 shrink-0" />
<p className="text-sm">
This round is active. Changing the voting window may affect
ongoing evaluations.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="votingStartAt"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date & Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="votingEndAt"
render={({ field }) => (
<FormItem>
<FormLabel>End Date & Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to disable the voting window enforcement.
</p>
</CardContent>
</Card>
{/* Evaluation Criteria */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Criteria</CardTitle>
<CardDescription>
Define the criteria jurors will use to evaluate projects
</CardDescription>
</CardHeader>
<CardContent>
{hasEvaluations ? (
<div className="space-y-4">
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 p-3 text-amber-700">
<AlertTriangle className="h-4 w-4 shrink-0" />
<p className="text-sm">
Criteria cannot be modified after evaluations have been
submitted. {criteria.length} criteria defined.
</p>
</div>
<EvaluationFormBuilder
initialCriteria={criteria}
onChange={() => {}}
disabled={true}
/>
</div>
) : (
<EvaluationFormBuilder
initialCriteria={criteria}
onChange={setCriteria}
/>
)}
</CardContent>
</Card>
{/* Error Display */}
{(updateRound.error || updateEvaluationForm.error) && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-4">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">
{updateRound.error?.message ||
updateEvaluationForm.error?.message}
</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href={`/admin/rounds/${roundId}`}>Cancel</Link>
</Button>
<Button type="submit" disabled={isPending}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Changes
</Button>
</div>
</form>
</Form>
</div>
)
}
function EditRoundSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-6 w-16" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-10 w-32" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-32" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function EditRoundPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<EditRoundSkeleton />}>
<EditRoundContent roundId={id} />
</Suspense>
)
}

View File

@ -0,0 +1,537 @@
'use client'
import { Suspense, use, useState, useEffect } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Progress } from '@/components/ui/progress'
import { toast } from 'sonner'
import {
ArrowLeft,
Play,
Pause,
Square,
Clock,
Users,
Zap,
GripVertical,
AlertCircle,
ExternalLink,
RefreshCw,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
interface PageProps {
params: Promise<{ id: string }>
}
interface Project {
id: string
title: string
teamName: string | null
}
function SortableProject({
project,
isActive,
isVoting,
}: {
project: Project
isActive: boolean
isVoting: boolean
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: project.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-3 rounded-lg border p-3 ${
isDragging ? 'opacity-50 shadow-lg' : ''
} ${isActive ? 'border-primary bg-primary/5' : ''} ${
isVoting ? 'ring-2 ring-green-500 animate-pulse' : ''
}`}
>
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{project.title}</p>
{project.teamName && (
<p className="text-sm text-muted-foreground truncate">
{project.teamName}
</p>
)}
</div>
{isActive && (
<Badge variant={isVoting ? 'default' : 'secondary'}>
{isVoting ? 'Voting' : 'Current'}
</Badge>
)}
</div>
)
}
function LiveVotingContent({ roundId }: { roundId: string }) {
const utils = trpc.useUtils()
const [projectOrder, setProjectOrder] = useState<string[]>([])
const [countdown, setCountdown] = useState<number | null>(null)
const [votingDuration, setVotingDuration] = useState(30)
// Fetch session data
const { data: sessionData, isLoading, refetch } = trpc.liveVoting.getSession.useQuery(
{ roundId },
{ refetchInterval: 2000 } // Poll every 2 seconds
)
// Mutations
const setOrder = trpc.liveVoting.setProjectOrder.useMutation({
onSuccess: () => {
toast.success('Project order updated')
},
onError: (error) => {
toast.error(error.message)
},
})
const startVoting = trpc.liveVoting.startVoting.useMutation({
onSuccess: () => {
toast.success('Voting started')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const stopVoting = trpc.liveVoting.stopVoting.useMutation({
onSuccess: () => {
toast.success('Voting stopped')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const endSession = trpc.liveVoting.endSession.useMutation({
onSuccess: () => {
toast.success('Session ended')
refetch()
},
onError: (error) => {
toast.error(error.message)
},
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// Initialize project order
useEffect(() => {
if (sessionData) {
const storedOrder = (sessionData.projectOrderJson as string[]) || []
if (storedOrder.length > 0) {
setProjectOrder(storedOrder)
} else {
setProjectOrder(sessionData.round.projects.map((p) => p.id))
}
}
}, [sessionData])
// Countdown timer
useEffect(() => {
if (!sessionData?.votingEndsAt || sessionData.status !== 'IN_PROGRESS') {
setCountdown(null)
return
}
const updateCountdown = () => {
const remaining = new Date(sessionData.votingEndsAt!).getTime() - Date.now()
setCountdown(Math.max(0, Math.floor(remaining / 1000)))
}
updateCountdown()
const interval = setInterval(updateCountdown, 1000)
return () => clearInterval(interval)
}, [sessionData?.votingEndsAt, sessionData?.status])
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const oldIndex = projectOrder.indexOf(active.id as string)
const newIndex = projectOrder.indexOf(over.id as string)
const newOrder = arrayMove(projectOrder, oldIndex, newIndex)
setProjectOrder(newOrder)
if (sessionData) {
setOrder.mutate({
sessionId: sessionData.id,
projectIds: newOrder,
})
}
}
}
const handleStartVoting = (projectId: string) => {
if (!sessionData) return
startVoting.mutate({
sessionId: sessionData.id,
projectId,
durationSeconds: votingDuration,
})
}
const handleStopVoting = () => {
if (!sessionData) return
stopVoting.mutate({ sessionId: sessionData.id })
}
const handleEndSession = () => {
if (!sessionData) return
endSession.mutate({ sessionId: sessionData.id })
}
if (isLoading) {
return <LiveVotingSkeleton />
}
if (!sessionData) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load session</AlertDescription>
</Alert>
)
}
const projects = sessionData.round.projects
const sortedProjects = projectOrder
.map((id) => projects.find((p) => p.id === id))
.filter((p): p is Project => !!p)
// Add any projects not in the order
const missingProjects = projects.filter((p) => !projectOrder.includes(p.id))
const allProjects = [...sortedProjects, ...missingProjects]
const isVoting = sessionData.status === 'IN_PROGRESS'
const isCompleted = sessionData.status === 'COMPLETED'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href={`/admin/rounds/${roundId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Round
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<Zap className="h-6 w-6 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">Live Voting</h1>
<Badge
variant={
isVoting ? 'default' : isCompleted ? 'secondary' : 'outline'
}
>
{sessionData.status.replace('_', ' ')}
</Badge>
</div>
<p className="text-muted-foreground">
{sessionData.round.program.name} - {sessionData.round.name}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main control panel */}
<div className="lg:col-span-2 space-y-6">
{/* Voting status */}
{isVoting && (
<Card className="border-green-500 bg-green-500/10">
<CardContent className="py-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Currently Voting
</p>
<p className="text-xl font-semibold">
{projects.find((p) => p.id === sessionData.currentProjectId)?.title}
</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-primary">
{countdown !== null ? countdown : '--'}s
</div>
<p className="text-sm text-muted-foreground">remaining</p>
</div>
</div>
{countdown !== null && (
<Progress
value={(countdown / votingDuration) * 100}
className="mt-4"
/>
)}
<div className="flex gap-2 mt-4">
<Button
variant="destructive"
onClick={handleStopVoting}
disabled={stopVoting.isPending}
>
<Pause className="mr-2 h-4 w-4" />
Stop Voting
</Button>
</div>
</CardContent>
</Card>
)}
{/* Project order */}
<Card>
<CardHeader>
<CardTitle>Presentation Order</CardTitle>
<CardDescription>
Drag to reorder projects. Click &quot;Start Voting&quot; to begin voting
for a project.
</CardDescription>
</CardHeader>
<CardContent>
{allProjects.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No finalist projects found for this round
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={allProjects.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{allProjects.map((project) => (
<div key={project.id} className="flex items-center gap-2">
<SortableProject
project={project}
isActive={sessionData.currentProjectId === project.id}
isVoting={
isVoting &&
sessionData.currentProjectId === project.id
}
/>
<Button
size="sm"
variant="outline"
onClick={() => handleStartVoting(project.id)}
disabled={
isVoting ||
isCompleted ||
startVoting.isPending
}
>
<Play className="h-4 w-4" />
</Button>
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Controls */}
<Card>
<CardHeader>
<CardTitle>Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Voting Duration</label>
<div className="flex items-center gap-2">
<input
type="number"
min="10"
max="300"
value={votingDuration}
onChange={(e) =>
setVotingDuration(parseInt(e.target.value) || 30)
}
className="w-20 px-2 py-1 border rounded text-center"
disabled={isVoting}
/>
<span className="text-sm text-muted-foreground">seconds</span>
</div>
</div>
<div className="pt-4 border-t">
<Button
variant="destructive"
className="w-full"
onClick={handleEndSession}
disabled={isCompleted || endSession.isPending}
>
<Square className="mr-2 h-4 w-4" />
End Session
</Button>
</div>
</CardContent>
</Card>
{/* Live stats */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Current Votes
</CardTitle>
</CardHeader>
<CardContent>
{sessionData.currentVotes.length === 0 ? (
<p className="text-muted-foreground text-center py-4">
No votes yet
</p>
) : (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total votes</span>
<span className="font-medium">
{sessionData.currentVotes.length}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Average score</span>
<span className="font-medium">
{(
sessionData.currentVotes.reduce(
(sum, v) => sum + v.score,
0
) / sessionData.currentVotes.length
).toFixed(1)}
</span>
</div>
</div>
)}
</CardContent>
</Card>
{/* Links */}
<Card>
<CardHeader>
<CardTitle>Voting Links</CardTitle>
<CardDescription>
Share these links with participants
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" className="w-full justify-start" asChild>
<Link href={`/jury/live/${sessionData.id}`} target="_blank">
<ExternalLink className="mr-2 h-4 w-4" />
Jury Voting Page
</Link>
</Button>
<Button variant="outline" className="w-full justify-start" asChild>
<Link
href={`/live-scores/${sessionData.id}`}
target="_blank"
>
<ExternalLink className="mr-2 h-4 w-4" />
Public Score Display
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
function LiveVotingSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-40" />
<Skeleton className="h-8 w-64" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="h-48 w-full" />
</div>
<div>
<Skeleton className="h-48 w-full" />
</div>
</div>
</div>
)
}
export default function LiveVotingPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<LiveVotingSkeleton />}>
<LiveVotingContent roundId={id} />
</Suspense>
)
}

View File

@ -0,0 +1,418 @@
'use client'
import { Suspense, use } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import {
ArrowLeft,
Edit,
Users,
FileText,
Calendar,
CheckCircle2,
Clock,
AlertCircle,
Archive,
Play,
Pause,
BarChart3,
Upload,
} from 'lucide-react'
import { format, formatDistanceToNow, isPast, isFuture } from 'date-fns'
interface PageProps {
params: Promise<{ id: string }>
}
function RoundDetailContent({ roundId }: { roundId: string }) {
const router = useRouter()
const { data: round, isLoading } = trpc.round.get.useQuery({ id: roundId })
const { data: progress } = trpc.round.getProgress.useQuery({ id: roundId })
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.round.get.invalidate({ id: roundId })
},
})
if (isLoading) {
return <RoundDetailSkeleton />
}
if (!round) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-destructive/50" />
<p className="mt-2 font-medium">Round Not Found</p>
<Button asChild className="mt-4">
<Link href="/admin/rounds">Back to Rounds</Link>
</Button>
</CardContent>
</Card>
)
}
const now = new Date()
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
const getStatusBadge = () => {
if (round.status === 'ACTIVE' && isVotingOpen) {
return (
<Badge variant="default" className="bg-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voting Open
</Badge>
)
}
switch (round.status) {
case 'DRAFT':
return <Badge variant="secondary">Draft</Badge>
case 'ACTIVE':
return (
<Badge variant="default">
<Clock className="mr-1 h-3 w-3" />
Active
</Badge>
)
case 'CLOSED':
return <Badge variant="outline">Closed</Badge>
case 'ARCHIVED':
return (
<Badge variant="outline">
<Archive className="mr-1 h-3 w-3" />
Archived
</Badge>
)
default:
return <Badge variant="secondary">{round.status}</Badge>
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link href={`/admin/programs/${round.program.id}`} className="hover:underline">
{round.program.name}
</Link>
</div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{round.name}</h1>
{getStatusBadge()}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
{round.status === 'DRAFT' && (
<Button
onClick={() => updateStatus.mutate({ id: round.id, status: 'ACTIVE' })}
disabled={updateStatus.isPending}
>
<Play className="mr-2 h-4 w-4" />
Activate
</Button>
)}
{round.status === 'ACTIVE' && (
<Button
variant="secondary"
onClick={() => updateStatus.mutate({ id: round.id, status: 'CLOSED' })}
disabled={updateStatus.isPending}
>
<Pause className="mr-2 h-4 w-4" />
Close Round
</Button>
)}
</div>
</div>
<Separator />
{/* Stats Grid */}
<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>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round._count.projects}</div>
<Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/projects?round=${round.id}`}>View projects</Link>
</Button>
</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">{round._count.assignments}</div>
<Button variant="link" size="sm" className="px-0" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
Manage assignments
</Link>
</Button>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Required Reviews</CardTitle>
<BarChart3 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{round.requiredReviews}</div>
<p className="text-xs text-muted-foreground">per project</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">
{progress?.completionPercentage || 0}%
</div>
<p className="text-xs text-muted-foreground">
{progress?.completedAssignments || 0} of {progress?.totalAssignments || 0}
</p>
</CardContent>
</Card>
</div>
{/* Progress */}
{progress && progress.totalAssignments > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Evaluation Progress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex items-center justify-between text-sm mb-2">
<span>Overall Completion</span>
<span>{progress.completionPercentage}%</span>
</div>
<Progress value={progress.completionPercentage} />
</div>
<div className="grid gap-4 sm:grid-cols-4">
{Object.entries(progress.evaluationsByStatus).map(([status, count]) => (
<div key={status} className="text-center p-3 rounded-lg bg-muted">
<p className="text-2xl font-bold">{count}</p>
<p className="text-xs text-muted-foreground capitalize">
{status.toLowerCase().replace('_', ' ')}
</p>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Voting Window */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground mb-1">Start Date</p>
{round.votingStartAt ? (
<div>
<p className="font-medium">
{format(new Date(round.votingStartAt), 'PPP')}
</p>
<p className="text-sm text-muted-foreground">
{format(new Date(round.votingStartAt), 'p')}
</p>
</div>
) : (
<p className="text-muted-foreground italic">Not set</p>
)}
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">End Date</p>
{round.votingEndAt ? (
<div>
<p className="font-medium">
{format(new Date(round.votingEndAt), 'PPP')}
</p>
<p className="text-sm text-muted-foreground">
{format(new Date(round.votingEndAt), 'p')}
</p>
{isFuture(new Date(round.votingEndAt)) && (
<p className="text-sm text-amber-600 mt-1">
Ends {formatDistanceToNow(new Date(round.votingEndAt), { addSuffix: true })}
</p>
)}
</div>
) : (
<p className="text-muted-foreground italic">Not set</p>
)}
</div>
</div>
{/* Voting status */}
{round.votingStartAt && round.votingEndAt && (
<div
className={`p-4 rounded-lg ${
isVotingOpen
? 'bg-green-500/10 text-green-700'
: isFuture(new Date(round.votingStartAt))
? 'bg-amber-500/10 text-amber-700'
: 'bg-muted text-muted-foreground'
}`}
>
{isVotingOpen ? (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
<span className="font-medium">Voting is currently open</span>
</div>
) : isFuture(new Date(round.votingStartAt)) ? (
<div className="flex items-center gap-2">
<Clock className="h-5 w-5" />
<span className="font-medium">
Voting opens {formatDistanceToNow(new Date(round.votingStartAt), { addSuffix: true })}
</span>
</div>
) : (
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
<span className="font-medium">Voting period has ended</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Quick Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
<Button variant="outline" asChild>
<Link href={`/admin/projects/import?round=${round.id}`}>
<Upload className="mr-2 h-4 w-4" />
Import Projects
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</Button>
<Button variant="outline" asChild>
<Link href={`/admin/projects?round=${round.id}`}>
<FileText className="mr-2 h-4 w-4" />
View Projects
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
function RoundDetailSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="flex items-start justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-64" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 w-24" />
<Skeleton className="h-10 w-28" />
</div>
</div>
<Skeleton className="h-px w-full" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<Card key={i}>
<CardHeader className="pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
<Skeleton className="mt-1 h-4 w-20" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent>
<Skeleton className="h-3 w-full" />
</CardContent>
</Card>
</div>
)
}
export default function RoundDetailPage({ params }: PageProps) {
const { id } = use(params)
return (
<Suspense fallback={<RoundDetailSkeleton />}>
<RoundDetailContent roundId={id} />
</Suspense>
)
}

View File

@ -0,0 +1,335 @@
'use client'
import { Suspense, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
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 { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { ArrowLeft, Loader2, AlertCircle } from 'lucide-react'
const createRoundSchema = z.object({
programId: z.string().min(1, 'Please select a program'),
name: z.string().min(1, 'Name is required').max(255),
requiredReviews: z.number().int().min(1).max(10),
votingStartAt: z.string().optional(),
votingEndAt: z.string().optional(),
}).refine((data) => {
if (data.votingStartAt && data.votingEndAt) {
return new Date(data.votingEndAt) > new Date(data.votingStartAt)
}
return true
}, {
message: 'End date must be after start date',
path: ['votingEndAt'],
})
type CreateRoundForm = z.infer<typeof createRoundSchema>
function CreateRoundContent() {
const router = useRouter()
const searchParams = useSearchParams()
const programIdParam = searchParams.get('program')
const { data: programs, isLoading: loadingPrograms } = trpc.program.list.useQuery()
const createRound = trpc.round.create.useMutation({
onSuccess: (data) => {
router.push(`/admin/rounds/${data.id}`)
},
})
const form = useForm<CreateRoundForm>({
resolver: zodResolver(createRoundSchema),
defaultValues: {
programId: programIdParam || '',
name: '',
requiredReviews: 3,
votingStartAt: '',
votingEndAt: '',
},
})
const onSubmit = async (data: CreateRoundForm) => {
await createRound.mutateAsync({
programId: data.programId,
name: data.name,
requiredReviews: data.requiredReviews,
votingStartAt: data.votingStartAt ? new Date(data.votingStartAt) : undefined,
votingEndAt: data.votingEndAt ? new Date(data.votingEndAt) : undefined,
})
}
if (loadingPrograms) {
return <CreateRoundSkeleton />
}
if (!programs || programs.length === 0) {
return (
<div className="space-y-6">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Programs Found</p>
<p className="text-sm text-muted-foreground">
Create a program first before creating rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">Create Program</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/rounds">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Rounds
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Create Round</h1>
<p className="text-muted-foreground">
Set up a new selection round for project evaluation
</p>
</div>
{/* Form */}
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Basic Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="programId"
render={({ field }) => (
<FormItem>
<FormLabel>Program</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a program" />
</SelectTrigger>
</FormControl>
<SelectContent>
{programs.map((program) => (
<SelectItem key={program.id} value={program.id}>
{program.name} ({program.year})
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Round Name</FormLabel>
<FormControl>
<Input
placeholder="e.g., Round 1 - Semi-Finalists"
{...field}
/>
</FormControl>
<FormDescription>
A descriptive name for this selection round
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="requiredReviews"
render={({ field }) => (
<FormItem>
<FormLabel>Required Reviews per Project</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={10}
{...field}
onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
/>
</FormControl>
<FormDescription>
Minimum number of evaluations each project should receive
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">Voting Window</CardTitle>
<CardDescription>
Optional: Set when jury members can submit their evaluations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="votingStartAt"
render={({ field }) => (
<FormItem>
<FormLabel>Start Date & Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="votingEndAt"
render={({ field }) => (
<FormItem>
<FormLabel>End Date & Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<p className="text-sm text-muted-foreground">
Leave empty to set the voting window later. The round will need to be
activated before jury members can submit evaluations.
</p>
</CardContent>
</Card>
{/* Error */}
{createRound.error && (
<Card className="border-destructive">
<CardContent className="flex items-center gap-2 py-4">
<AlertCircle className="h-5 w-5 text-destructive" />
<p className="text-sm text-destructive">
{createRound.error.message}
</p>
</CardContent>
</Card>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<Button type="button" variant="outline" asChild>
<Link href="/admin/rounds">Cancel</Link>
</Button>
<Button type="submit" disabled={createRound.isPending}>
{createRound.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Round
</Button>
</div>
</form>
</Form>
</div>
)
}
function CreateRoundSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-36" />
<div className="space-y-1">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-5 w-40" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-10 w-32" />
</div>
</CardContent>
</Card>
</div>
)
}
export default function CreateRoundPage() {
return (
<Suspense fallback={<CreateRoundSkeleton />}>
<CreateRoundContent />
</Suspense>
)
}

View File

@ -0,0 +1,347 @@
'use client'
import { Suspense } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Plus,
MoreHorizontal,
Eye,
Edit,
Users,
FileText,
Calendar,
CheckCircle2,
Clock,
Archive,
} from 'lucide-react'
import { format, isPast, isFuture } from 'date-fns'
function RoundsContent() {
const { data: programs, isLoading } = trpc.program.list.useQuery({
includeRounds: true,
})
if (isLoading) {
return <RoundsListSkeleton />
}
if (!programs || programs.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Calendar className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No Programs Found</p>
<p className="text-sm text-muted-foreground">
Create a program first to start managing rounds
</p>
<Button asChild className="mt-4">
<Link href="/admin/programs/new">Create Program</Link>
</Button>
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{programs.map((program) => (
<Card key={program.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg">{program.name}</CardTitle>
<CardDescription>
{program.year} - {program.status}
</CardDescription>
</div>
<Button asChild>
<Link href={`/admin/rounds/new?program=${program.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Round
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
{program.rounds && program.rounds.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Round</TableHead>
<TableHead>Status</TableHead>
<TableHead>Voting Window</TableHead>
<TableHead>Projects</TableHead>
<TableHead>Assignments</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{program.rounds.map((round) => (
<RoundRow key={round.id} round={round} />
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-muted-foreground">
<Calendar className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No rounds created yet</p>
</div>
)}
</CardContent>
</Card>
))}
</div>
)
}
function RoundRow({ round }: { round: any }) {
const utils = trpc.useUtils()
const updateStatus = trpc.round.updateStatus.useMutation({
onSuccess: () => {
utils.program.list.invalidate()
},
})
const getStatusBadge = () => {
const now = new Date()
const isVotingOpen =
round.status === 'ACTIVE' &&
round.votingStartAt &&
round.votingEndAt &&
new Date(round.votingStartAt) <= now &&
new Date(round.votingEndAt) >= now
if (round.status === 'ACTIVE' && isVotingOpen) {
return (
<Badge variant="default" className="bg-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Voting Open
</Badge>
)
}
switch (round.status) {
case 'DRAFT':
return <Badge variant="secondary">Draft</Badge>
case 'ACTIVE':
return (
<Badge variant="default">
<Clock className="mr-1 h-3 w-3" />
Active
</Badge>
)
case 'CLOSED':
return <Badge variant="outline">Closed</Badge>
case 'ARCHIVED':
return (
<Badge variant="outline">
<Archive className="mr-1 h-3 w-3" />
Archived
</Badge>
)
default:
return <Badge variant="secondary">{round.status}</Badge>
}
}
const getVotingWindow = () => {
if (!round.votingStartAt || !round.votingEndAt) {
return <span className="text-muted-foreground">Not set</span>
}
const start = new Date(round.votingStartAt)
const end = new Date(round.votingEndAt)
const now = new Date()
if (isFuture(start)) {
return (
<span className="text-sm">
Opens {format(start, 'MMM d, yyyy')}
</span>
)
}
if (isPast(end)) {
return (
<span className="text-sm text-muted-foreground">
Ended {format(end, 'MMM d, yyyy')}
</span>
)
}
return (
<span className="text-sm">
Until {format(end, 'MMM d, yyyy')}
</span>
)
}
return (
<TableRow>
<TableCell>
<Link
href={`/admin/rounds/${round.id}`}
className="font-medium hover:underline"
>
{round.name}
</Link>
</TableCell>
<TableCell>{getStatusBadge()}</TableCell>
<TableCell>{getVotingWindow()}</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<FileText className="h-4 w-4 text-muted-foreground" />
{round._count?.projects || 0}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Users className="h-4 w-4 text-muted-foreground" />
{round._count?.assignments || 0}
</div>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" aria-label="Round actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Round
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/rounds/${round.id}/assignments`}>
<Users className="mr-2 h-4 w-4" />
Manage Assignments
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
{round.status === 'DRAFT' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ACTIVE' })
}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Activate Round
</DropdownMenuItem>
)}
{round.status === 'ACTIVE' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'CLOSED' })
}
>
<Clock className="mr-2 h-4 w-4" />
Close Round
</DropdownMenuItem>
)}
{round.status === 'CLOSED' && (
<DropdownMenuItem
onClick={() =>
updateStatus.mutate({ id: round.id, status: 'ARCHIVED' })
}
>
<Archive className="mr-2 h-4 w-4" />
Archive Round
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
}
function RoundsListSkeleton() {
return (
<div className="space-y-6">
{[1, 2].map((i) => (
<Card key={i}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-10 w-28" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3].map((j) => (
<div key={j} className="flex justify-between items-center py-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-4 w-12" />
<Skeleton className="h-8 w-8" />
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)
}
export default function RoundsPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rounds</h1>
<p className="text-muted-foreground">
Manage selection rounds and voting periods
</p>
</div>
</div>
{/* Content */}
<Suspense fallback={<RoundsListSkeleton />}>
<RoundsContent />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,76 @@
import { Suspense } from 'react'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { SettingsContent } from '@/components/settings/settings-content'
async function SettingsLoader() {
const settings = await prisma.systemSettings.findMany({
orderBy: [{ category: 'asc' }, { key: 'asc' }],
})
// Convert settings array to key-value map
// For secrets, pass a marker but not the actual value
const settingsMap: Record<string, string> = {}
settings.forEach((setting) => {
if (setting.isSecret && setting.value) {
// Pass marker for UI to show "existing" state
settingsMap[setting.key] = '********'
} else {
settingsMap[setting.key] = setting.value
}
})
return <SettingsContent initialSettings={settingsMap} />
}
function SettingsSkeleton() {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-full" />
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</CardContent>
</Card>
</div>
)
}
export default async function SettingsPage() {
const session = await auth()
// Only super admins can access settings
if (session?.user?.role !== 'SUPER_ADMIN') {
redirect('/admin')
}
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
<p className="text-muted-foreground">
Configure platform settings and preferences
</p>
</div>
{/* Content */}
<Suspense fallback={<SettingsSkeleton />}>
<SettingsLoader />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,717 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'sonner'
import {
ArrowLeft,
Plus,
MoreHorizontal,
Pencil,
Trash2,
Loader2,
Tags,
Users,
FolderKanban,
GripVertical,
} from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
// Default categories
const DEFAULT_CATEGORIES = [
'Marine Science',
'Technology',
'Policy',
'Conservation',
'Business',
'Education',
'Engineering',
'Other',
]
// Default colors
const TAG_COLORS = [
{ value: '#de0f1e', label: 'Red' },
{ value: '#053d57', label: 'Dark Blue' },
{ value: '#557f8c', label: 'Teal' },
{ value: '#059669', label: 'Green' },
{ value: '#7c3aed', label: 'Purple' },
{ value: '#ea580c', label: 'Orange' },
{ value: '#0284c7', label: 'Blue' },
{ value: '#be185d', label: 'Pink' },
]
interface Tag {
id: string
name: string
description: string | null
category: string | null
color: string | null
isActive: boolean
sortOrder: number
userCount?: number
projectCount?: number
totalUsage?: number
}
function SortableTagRow({
tag,
onEdit,
onDelete,
}: {
tag: Tag
onEdit: (tag: Tag) => void
onDelete: (tag: Tag) => void
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tag.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center gap-3 rounded-lg border bg-card p-3 ${
isDragging ? 'opacity-50 shadow-lg' : ''
}`}
>
<button
className="cursor-grab touch-none text-muted-foreground hover:text-foreground"
aria-label="Drag to reorder"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<div
className="h-4 w-4 rounded-full shrink-0"
style={{ backgroundColor: tag.color || '#6b7280' }}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{tag.name}</span>
{!tag.isActive && (
<Badge variant="secondary" className="text-xs">
Inactive
</Badge>
)}
</div>
{tag.category && (
<p className="text-xs text-muted-foreground">{tag.category}</p>
)}
</div>
<div className="hidden sm:flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1" title="Users with this tag">
<Users className="h-3.5 w-3.5" />
<span>{tag.userCount || 0}</span>
</div>
<div className="flex items-center gap-1" title="Projects with this tag">
<FolderKanban className="h-3.5 w-3.5" />
<span>{tag.projectCount || 0}</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" aria-label="Tag actions">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(tag)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(tag)}
className="text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
export default function TagsSettingsPage() {
const utils = trpc.useUtils()
const [isCreateOpen, setIsCreateOpen] = useState(false)
const [editingTag, setEditingTag] = useState<Tag | null>(null)
const [deletingTag, setDeletingTag] = useState<Tag | null>(null)
const [categoryFilter, setCategoryFilter] = useState<string>('all')
// Form state
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('')
const [color, setColor] = useState('#557f8c')
const [isActive, setIsActive] = useState(true)
// Queries
const { data: tagsData, isLoading } = trpc.tag.list.useQuery({
includeUsageCount: true,
})
const { data: categories } = trpc.tag.getCategories.useQuery()
// Mutations
const createTag = trpc.tag.create.useMutation({
onSuccess: () => {
toast.success('Tag created successfully')
setIsCreateOpen(false)
resetForm()
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const updateTag = trpc.tag.update.useMutation({
onSuccess: () => {
toast.success('Tag updated successfully')
setEditingTag(null)
resetForm()
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const deleteTag = trpc.tag.delete.useMutation({
onSuccess: () => {
toast.success('Tag deleted successfully')
setDeletingTag(null)
utils.tag.list.invalidate()
utils.tag.getCategories.invalidate()
},
onError: (error) => {
toast.error(error.message)
},
})
const reorderTags = trpc.tag.reorder.useMutation({
onError: (error) => {
toast.error(error.message)
utils.tag.list.invalidate()
},
})
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
const resetForm = () => {
setName('')
setDescription('')
setCategory('')
setColor('#557f8c')
setIsActive(true)
}
const openEditDialog = (tag: Tag) => {
setEditingTag(tag)
setName(tag.name)
setDescription(tag.description || '')
setCategory(tag.category || '')
setColor(tag.color || '#557f8c')
setIsActive(tag.isActive)
}
const handleCreate = () => {
if (!name.trim()) {
toast.error('Please enter a tag name')
return
}
createTag.mutate({
name: name.trim(),
description: description || undefined,
category: category || undefined,
color: color || undefined,
})
}
const handleUpdate = () => {
if (!editingTag || !name.trim()) return
updateTag.mutate({
id: editingTag.id,
name: name.trim(),
description: description || null,
category: category || null,
color: color || null,
isActive,
})
}
const handleDelete = () => {
if (!deletingTag) return
deleteTag.mutate({ id: deletingTag.id })
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
if (over && active.id !== over.id) {
const tags = filteredTags
const oldIndex = tags.findIndex((t) => t.id === active.id)
const newIndex = tags.findIndex((t) => t.id === over.id)
const newOrder = arrayMove(tags, oldIndex, newIndex)
const items = newOrder.map((tag, index) => ({
id: tag.id,
sortOrder: index,
}))
reorderTags.mutate({ items })
}
}
// Filter tags by category
const filteredTags = (tagsData?.tags || []).filter((tag) => {
if (categoryFilter === 'all') return true
if (categoryFilter === 'uncategorized') return !tag.category
return tag.category === categoryFilter
})
// Get unique categories for filter
const allCategories = Array.from(
new Set([
...DEFAULT_CATEGORIES,
...(categories || []),
])
).sort()
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-40" />
</div>
<Skeleton className="h-8 w-64" />
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/settings">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Settings
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight flex items-center gap-2">
<Tags className="h-6 w-6" />
Expertise Tags
</h1>
<p className="text-muted-foreground">
Manage tags used for jury expertise and project categorization
</p>
</div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => resetForm()}>
<Plus className="mr-2 h-4 w-4" />
Add Tag
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Tag</DialogTitle>
<DialogDescription>
Add a new expertise tag for categorizing jury members and projects
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Marine Biology"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of this expertise area"
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select value={color} onValueChange={setColor}>
<SelectTrigger id="color">
<SelectValue>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color }}
/>
{TAG_COLORS.find((c) => c.value === color)?.label || 'Custom'}
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{TAG_COLORS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: c.value }}
/>
{c.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCreateOpen(false)}
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={createTag.isPending || !name.trim()}
>
{createTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Create Tag
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Filters */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Filter by Category</CardTitle>
</CardHeader>
<CardContent>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Categories</SelectItem>
<SelectItem value="uncategorized">Uncategorized</SelectItem>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* Tags List */}
<Card>
<CardHeader>
<CardTitle>Tags ({filteredTags.length})</CardTitle>
<CardDescription>
Drag to reorder tags. Changes are saved automatically.
</CardDescription>
</CardHeader>
<CardContent>
{filteredTags.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Tags className="mx-auto h-8 w-8 mb-2 opacity-50" />
<p>No tags found</p>
<Button
variant="link"
onClick={() => setIsCreateOpen(true)}
className="mt-2"
>
Create your first tag
</Button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredTags.map((t) => t.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{filteredTags.map((tag) => (
<SortableTagRow
key={tag.id}
tag={tag}
onEdit={openEditDialog}
onDelete={setDeletingTag}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog
open={!!editingTag}
onOpenChange={(open) => !open && setEditingTag(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tag</DialogTitle>
<DialogDescription>
Update this expertise tag. Renaming will update all users and projects.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Name *</Label>
<Input
id="edit-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
maxLength={500}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit-category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="edit-category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">None</SelectItem>
{allCategories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit-color">Color</Label>
<Select value={color} onValueChange={setColor}>
<SelectTrigger id="edit-color">
<SelectValue>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: color }}
/>
{TAG_COLORS.find((c) => c.value === color)?.label || 'Custom'}
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{TAG_COLORS.map((c) => (
<SelectItem key={c.value} value={c.value}>
<div className="flex items-center gap-2">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: c.value }}
/>
{c.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="edit-active">Active</Label>
<p className="text-sm text-muted-foreground">
Inactive tags won&apos;t appear in selection lists
</p>
</div>
<Switch
id="edit-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingTag(null)}>
Cancel
</Button>
<Button
onClick={handleUpdate}
disabled={updateTag.isPending || !name.trim()}
>
{updateTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<AlertDialog
open={!!deletingTag}
onOpenChange={(open) => !open && setDeletingTag(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Tag</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{deletingTag?.name}&quot;? This will
remove the tag from {deletingTag?.userCount || 0} users and{' '}
{deletingTag?.projectCount || 0} projects.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteTag.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View File

@ -0,0 +1,316 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { toast } from 'sonner'
import { TagInput } from '@/components/shared/tag-input'
import {
ArrowLeft,
Save,
Mail,
User,
Shield,
Loader2,
CheckCircle,
AlertCircle,
} from 'lucide-react'
export default function UserEditPage() {
const params = useParams()
const router = useRouter()
const userId = params.id as string
const { data: user, isLoading, refetch } = trpc.user.get.useQuery({ id: userId })
const updateUser = trpc.user.update.useMutation()
const sendInvitation = trpc.user.sendInvitation.useMutation()
const [name, setName] = useState('')
const [role, setRole] = useState<'JURY_MEMBER' | 'OBSERVER' | 'PROGRAM_ADMIN'>('JURY_MEMBER')
const [status, setStatus] = useState<'INVITED' | 'ACTIVE' | 'SUSPENDED'>('INVITED')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [maxAssignments, setMaxAssignments] = useState<string>('')
// Populate form when user data loads
useEffect(() => {
if (user) {
setName(user.name || '')
setRole(user.role as 'JURY_MEMBER' | 'OBSERVER' | 'PROGRAM_ADMIN')
setStatus(user.status as 'INVITED' | 'ACTIVE' | 'SUSPENDED')
setExpertiseTags(user.expertiseTags || [])
setMaxAssignments(user.maxAssignments?.toString() || '')
}
}, [user])
const handleSave = async () => {
try {
await updateUser.mutateAsync({
id: userId,
name: name || null,
role,
status,
expertiseTags,
maxAssignments: maxAssignments ? parseInt(maxAssignments) : null,
})
toast.success('User updated successfully')
router.push('/admin/users')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to update user')
}
}
const handleSendInvitation = async () => {
try {
await sendInvitation.mutateAsync({ userId })
toast.success('Invitation email sent successfully')
refetch()
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to send invitation')
}
}
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-9 w-32" />
</div>
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-72" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
</div>
)
}
if (!user) {
return (
<div className="space-y-6">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>User not found</AlertTitle>
<AlertDescription>
The user you&apos;re looking for does not exist.
</AlertDescription>
</Alert>
<Button asChild>
<Link href="/admin/users">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Users
</Link>
</Button>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/users">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Users
</Link>
</Button>
</div>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit User</h1>
<p className="text-muted-foreground">{user.email}</p>
</div>
{user.status === 'INVITED' && (
<Button
variant="outline"
onClick={handleSendInvitation}
disabled={sendInvitation.isPending}
>
{sendInvitation.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Send Invitation
</Button>
)}
</div>
<div className="grid gap-6 md:grid-cols-2">
{/* Basic Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Basic Information
</CardTitle>
<CardDescription>
Update the user&apos;s profile information
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" value={user.email} disabled />
<p className="text-xs text-muted-foreground">
Email cannot be changed
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as typeof role)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
<SelectItem value="PROGRAM_ADMIN">Program Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="status">Status</Label>
<Select value={status} onValueChange={(v) => setStatus(v as typeof status)}>
<SelectTrigger id="status">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="INVITED">Invited</SelectItem>
<SelectItem value="ACTIVE">Active</SelectItem>
<SelectItem value="SUSPENDED">Suspended</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Assignment Settings */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Assignment Settings
</CardTitle>
<CardDescription>
Configure expertise tags and assignment limits
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Expertise Tags</Label>
<TagInput
value={expertiseTags}
onChange={setExpertiseTags}
placeholder="Select expertise tags..."
maxTags={15}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxAssignments">Max Assignments</Label>
<Input
id="maxAssignments"
type="number"
min="1"
max="100"
value={maxAssignments}
onChange={(e) => setMaxAssignments(e.target.value)}
placeholder="Unlimited"
/>
<p className="text-xs text-muted-foreground">
Maximum number of projects this user can be assigned
</p>
</div>
{user._count && (
<div className="pt-4 border-t">
<h4 className="font-medium mb-2">Statistics</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Total Assignments</p>
<p className="text-2xl font-semibold">{user._count.assignments}</p>
</div>
<div>
<p className="text-muted-foreground">Last Login</p>
<p className="text-lg">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Status Alert */}
{user.status === 'INVITED' && (
<Alert>
<Mail className="h-4 w-4" />
<AlertTitle>Invitation Pending</AlertTitle>
<AlertDescription>
This user hasn&apos;t accepted their invitation yet. You can resend the
invitation email using the button above.
</AlertDescription>
</Alert>
)}
{/* Save Button */}
<div className="flex justify-end gap-4">
<Button variant="outline" asChild>
<Link href="/admin/users">Cancel</Link>
</Button>
<Button onClick={handleSave} disabled={updateUser.isPending}>
{updateUser.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,676 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import Papa from 'papaparse'
import { trpc } from '@/lib/trpc/client'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
ArrowLeft,
ArrowRight,
AlertCircle,
CheckCircle2,
Loader2,
Upload,
Users,
X,
Mail,
FileSpreadsheet,
} from 'lucide-react'
import { cn } from '@/lib/utils'
type Step = 'input' | 'preview' | 'sending' | 'complete'
type Role = 'JURY_MEMBER' | 'MENTOR' | 'OBSERVER'
interface ParsedUser {
email: string
name?: string
isValid: boolean
error?: string
isDuplicate?: boolean
}
// Email validation regex
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export default function UserInvitePage() {
const router = useRouter()
const [step, setStep] = useState<Step>('input')
// Input state
const [inputMethod, setInputMethod] = useState<'textarea' | 'csv'>('textarea')
const [emailsText, setEmailsText] = useState('')
const [csvFile, setCsvFile] = useState<File | null>(null)
const [role, setRole] = useState<Role>('JURY_MEMBER')
const [expertiseTags, setExpertiseTags] = useState<string[]>([])
const [tagInput, setTagInput] = useState('')
// Parsed users
const [parsedUsers, setParsedUsers] = useState<ParsedUser[]>([])
// Send progress
const [sendProgress, setSendProgress] = useState(0)
// Result
const [result, setResult] = useState<{ created: number; skipped: number } | null>(null)
// Mutation
const bulkCreate = trpc.user.bulkCreate.useMutation()
// Parse emails from textarea
const parseEmailsFromText = useCallback((text: string): ParsedUser[] => {
const lines = text
.split(/[\n,;]+/)
.map((line) => line.trim())
.filter(Boolean)
const seenEmails = new Set<string>()
return lines.map((line) => {
// Try to extract name and email like "Name <email@example.com>" or just "email@example.com"
const matchWithName = line.match(/^(.+?)\s*<(.+?)>$/)
const email = matchWithName ? matchWithName[2].trim().toLowerCase() : line.toLowerCase()
const name = matchWithName ? matchWithName[1].trim() : undefined
const isValidFormat = emailRegex.test(email)
const isDuplicate = seenEmails.has(email)
if (isValidFormat && !isDuplicate) {
seenEmails.add(email)
}
const isValid = isValidFormat && !isDuplicate
return {
email,
name,
isValid,
isDuplicate,
error: !isValidFormat
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: undefined,
}
})
}, [])
// Parse CSV file
const handleCSVUpload = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setCsvFile(file)
Papa.parse<Record<string, string>>(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
const seenEmails = new Set<string>()
const users: ParsedUser[] = results.data.map((row) => {
// Try to find email column (case-insensitive)
const emailKey = Object.keys(row).find(
(key) =>
key.toLowerCase() === 'email' ||
key.toLowerCase().includes('email')
)
const nameKey = Object.keys(row).find(
(key) =>
key.toLowerCase() === 'name' ||
key.toLowerCase().includes('name')
)
const email = emailKey ? row[emailKey]?.trim().toLowerCase() : ''
const name = nameKey ? row[nameKey]?.trim() : undefined
const isValidFormat = emailRegex.test(email)
const isDuplicate = email ? seenEmails.has(email) : false
if (isValidFormat && !isDuplicate && email) {
seenEmails.add(email)
}
const isValid = isValidFormat && !isDuplicate
return {
email,
name,
isValid,
isDuplicate,
error: !email
? 'No email found'
: !isValidFormat
? 'Invalid email format'
: isDuplicate
? 'Duplicate email'
: undefined,
}
})
setParsedUsers(users.filter((u) => u.email))
setStep('preview')
},
error: (error) => {
console.error('CSV parse error:', error)
},
})
},
[]
)
// Handle text input and proceed to preview
const handleTextProceed = () => {
const users = parseEmailsFromText(emailsText)
setParsedUsers(users)
setStep('preview')
}
// Add expertise tag
const addTag = () => {
const tag = tagInput.trim()
if (tag && !expertiseTags.includes(tag)) {
setExpertiseTags([...expertiseTags, tag])
setTagInput('')
}
}
// Remove expertise tag
const removeTag = (tag: string) => {
setExpertiseTags(expertiseTags.filter((t) => t !== tag))
}
// Summary stats
const summary = useMemo(() => {
const validUsers = parsedUsers.filter((u) => u.isValid)
const invalidUsers = parsedUsers.filter((u) => !u.isValid)
const duplicateUsers = parsedUsers.filter((u) => u.isDuplicate)
return {
total: parsedUsers.length,
valid: validUsers.length,
invalid: invalidUsers.length,
duplicates: duplicateUsers.length,
validUsers,
invalidUsers,
duplicateUsers,
}
}, [parsedUsers])
// Remove invalid users
const removeInvalidUsers = () => {
setParsedUsers(parsedUsers.filter((u) => u.isValid))
}
// Send invites
const handleSendInvites = async () => {
if (summary.valid === 0) return
setStep('sending')
setSendProgress(0)
try {
const result = await bulkCreate.mutateAsync({
users: summary.validUsers.map((u) => ({
email: u.email,
name: u.name,
role,
expertiseTags: expertiseTags.length > 0 ? expertiseTags : undefined,
})),
})
setSendProgress(100)
setResult(result)
setStep('complete')
} catch (error) {
console.error('Bulk create failed:', error)
setStep('preview')
}
}
// Reset form
const resetForm = () => {
setStep('input')
setEmailsText('')
setCsvFile(null)
setParsedUsers([])
setResult(null)
setSendProgress(0)
}
// Steps indicator
const steps: Array<{ key: Step; label: string }> = [
{ key: 'input', label: 'Input' },
{ key: 'preview', label: 'Preview' },
{ key: 'sending', label: 'Send' },
{ key: 'complete', label: 'Done' },
]
const currentStepIndex = steps.findIndex((s) => s.key === step)
const renderStep = () => {
switch (step) {
case 'input':
return (
<Card>
<CardHeader>
<CardTitle>Invite Users</CardTitle>
<CardDescription>
Add email addresses to invite new jury members or observers
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Input Method Toggle */}
<div className="flex gap-2">
<Button
type="button"
variant={inputMethod === 'textarea' ? 'default' : 'outline'}
size="sm"
onClick={() => setInputMethod('textarea')}
>
<Mail className="mr-2 h-4 w-4" />
Enter Emails
</Button>
<Button
type="button"
variant={inputMethod === 'csv' ? 'default' : 'outline'}
size="sm"
onClick={() => setInputMethod('csv')}
>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Upload CSV
</Button>
</div>
{/* Input Area */}
{inputMethod === 'textarea' ? (
<div className="space-y-2">
<Label htmlFor="emails">Email Addresses</Label>
<Textarea
id="emails"
value={emailsText}
onChange={(e) => setEmailsText(e.target.value)}
placeholder="Enter email addresses, one per line or comma-separated.
You can also use format: Name <email@example.com>"
rows={8}
maxLength={10000}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
One email per line, or separated by commas
</p>
</div>
) : (
<div className="space-y-2">
<Label>CSV File</Label>
<div
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors',
'hover:border-primary/50'
)}
onClick={() => document.getElementById('csv-input')?.click()}
>
<FileSpreadsheet className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-2 font-medium">
{csvFile ? csvFile.name : 'Drop CSV file here or click to browse'}
</p>
<p className="text-sm text-muted-foreground">
CSV should have an &quot;email&quot; column, optionally a &quot;name&quot; column
</p>
<Input
id="csv-input"
type="file"
accept=".csv"
onChange={handleCSVUpload}
className="hidden"
/>
</div>
</div>
)}
{/* Role Selection */}
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
<SelectTrigger id="role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="JURY_MEMBER">Jury Member</SelectItem>
<SelectItem value="MENTOR">Mentor</SelectItem>
<SelectItem value="OBSERVER">Observer</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{role === 'JURY_MEMBER'
? 'Can evaluate assigned projects'
: role === 'MENTOR'
? 'Can view and mentor assigned projects'
: 'Read-only access to dashboards'}
</p>
</div>
{/* Expertise Tags */}
<div className="space-y-2">
<Label htmlFor="expertise">Expertise Tags (Optional)</Label>
<div className="flex gap-2">
<Input
id="expertise"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
placeholder="e.g., Marine Biology"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addTag()
}
}}
/>
<Button type="button" variant="outline" onClick={addTag}>
Add
</Button>
</div>
{expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1">
{tag}
<button
type="button"
onClick={() => removeTag(tag)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex justify-between pt-4">
<Button variant="outline" asChild>
<Link href="/admin/users">
<ArrowLeft className="mr-2 h-4 w-4" />
Cancel
</Link>
</Button>
<Button
onClick={handleTextProceed}
disabled={inputMethod === 'textarea' && !emailsText.trim()}
>
Preview
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
)
case 'preview':
return (
<Card>
<CardHeader>
<CardTitle>Preview Invitations</CardTitle>
<CardDescription>
Review the list of users to invite
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Summary Stats */}
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-lg bg-muted p-4 text-center">
<p className="text-3xl font-bold">{summary.total}</p>
<p className="text-sm text-muted-foreground">Total</p>
</div>
<div className="rounded-lg bg-green-500/10 p-4 text-center">
<p className="text-3xl font-bold text-green-600">
{summary.valid}
</p>
<p className="text-sm text-muted-foreground">Valid</p>
</div>
<div className="rounded-lg bg-red-500/10 p-4 text-center">
<p className="text-3xl font-bold text-red-600">
{summary.invalid}
</p>
<p className="text-sm text-muted-foreground">Invalid</p>
</div>
</div>
{/* Invalid users warning */}
{summary.invalid > 0 && (
<div className="flex items-start gap-3 rounded-lg bg-amber-500/10 p-4 text-amber-700">
<AlertCircle className="h-5 w-5 shrink-0 mt-0.5" />
<div className="flex-1">
<p className="font-medium">
{summary.invalid} email(s) have issues
</p>
<p className="text-sm mt-1">
{summary.duplicates > 0 && (
<span>{summary.duplicates} duplicate(s). </span>
)}
{summary.invalid - summary.duplicates > 0 && (
<span>{summary.invalid - summary.duplicates} invalid format(s). </span>
)}
These will be excluded from the invitation.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={removeInvalidUsers}
className="shrink-0"
>
Remove Invalid
</Button>
</div>
)}
{/* Settings Summary */}
<div className="flex flex-wrap gap-4 text-sm">
<div>
<span className="text-muted-foreground">Role:</span>{' '}
<Badge variant="outline">{role.replace('_', ' ')}</Badge>
</div>
{expertiseTags.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Tags:</span>
{expertiseTags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
</div>
{/* Users Table */}
<div className="rounded-lg border max-h-80 overflow-y-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parsedUsers.map((user, index) => (
<TableRow
key={index}
className={cn(!user.isValid && 'bg-red-500/5')}
>
<TableCell className="font-mono text-sm">
{user.email}
</TableCell>
<TableCell>{user.name || '-'}</TableCell>
<TableCell>
{user.isValid ? (
<Badge variant="outline" className="text-green-600">
<CheckCircle2 className="mr-1 h-3 w-3" />
Valid
</Badge>
) : (
<Badge variant="destructive">{user.error}</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Actions */}
<div className="flex justify-between pt-4">
<Button
variant="outline"
onClick={() => {
setParsedUsers([])
setStep('input')
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
<Button
onClick={handleSendInvites}
disabled={summary.valid === 0 || bulkCreate.isPending}
>
{bulkCreate.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Users className="mr-2 h-4 w-4" />
)}
Create {summary.valid} User{summary.valid !== 1 ? 's' : ''}
</Button>
</div>
{/* Error */}
{bulkCreate.error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-4 text-destructive">
<AlertCircle className="h-5 w-5" />
<span>{bulkCreate.error.message}</span>
</div>
)}
</CardContent>
</Card>
)
case 'sending':
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<p className="mt-4 font-medium">Creating users...</p>
<p className="text-sm text-muted-foreground">
Please wait while we process your request
</p>
<Progress value={sendProgress} className="mt-4 w-48" />
</CardContent>
</Card>
)
case 'complete':
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-green-500/10">
<CheckCircle2 className="h-8 w-8 text-green-600" />
</div>
<p className="mt-4 text-xl font-semibold">Users Created!</p>
<p className="text-muted-foreground text-center max-w-sm mt-2">
{result?.created} user{result?.created !== 1 ? 's' : ''} created
successfully.
{result?.skipped ? ` ${result.skipped} skipped (already exist).` : ''}
</p>
<div className="mt-6 flex gap-3">
<Button variant="outline" asChild>
<Link href="/admin/users">View Users</Link>
</Button>
<Button onClick={resetForm}>Invite More</Button>
</div>
</CardContent>
</Card>
)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" asChild className="-ml-4">
<Link href="/admin/users">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Users
</Link>
</Button>
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Invite Users</h1>
<p className="text-muted-foreground">
Add new jury members or observers to the platform
</p>
</div>
{/* Progress indicator */}
<div className="flex items-center justify-center gap-2">
{steps.map((s, index) => (
<div key={s.key} className="flex items-center">
{index > 0 && (
<div
className={cn(
'h-0.5 w-8 mx-1',
index <= currentStepIndex ? 'bg-primary' : 'bg-muted'
)}
/>
)}
<div
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium',
index === currentStepIndex
? 'bg-primary text-primary-foreground'
: index < currentStepIndex
? 'bg-primary/20 text-primary'
: 'bg-muted text-muted-foreground'
)}
>
{index + 1}
</div>
</div>
))}
</div>
{renderStep()}
</div>
)
}

View File

@ -0,0 +1,280 @@
import { Suspense } from 'react'
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Plus, Users } from 'lucide-react'
import { formatDate, getInitials } from '@/lib/utils'
import { UserActions, UserMobileActions } from '@/components/admin/user-actions'
async function UsersContent() {
const users = await prisma.user.findMany({
where: {
role: { in: ['JURY_MEMBER', 'OBSERVER'] },
},
include: {
_count: {
select: {
assignments: true,
},
},
assignments: {
select: {
evaluation: {
select: { status: true },
},
},
},
},
orderBy: [{ role: 'asc' }, { name: 'asc' }],
})
if (users.length === 0) {
return (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<Users className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-2 font-medium">No jury members yet</p>
<p className="text-sm text-muted-foreground">
Invite jury members to start assigning projects for evaluation
</p>
<Button asChild className="mt-4">
<Link href="/admin/users/invite">
<Plus className="mr-2 h-4 w-4" />
Invite Member
</Link>
</Button>
</CardContent>
</Card>
)
}
const statusColors: Record<string, 'default' | 'success' | 'secondary' | 'destructive'> = {
ACTIVE: 'success',
PENDING: 'secondary',
INACTIVE: 'secondary',
SUSPENDED: 'destructive',
}
const roleColors: Record<string, 'default' | 'outline' | 'secondary'> = {
JURY_MEMBER: 'default',
OBSERVER: 'outline',
}
return (
<>
{/* Desktop table view */}
<Card className="hidden md:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Member</TableHead>
<TableHead>Role</TableHead>
<TableHead>Expertise</TableHead>
<TableHead>Assignments</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Login</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">
{getInitials(user.name || user.email || 'U')}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{user.name || 'Unnamed'}</p>
<p className="text-sm text-muted-foreground">
{user.email}
</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace('_', ' ')}
</Badge>
</TableCell>
<TableCell>
{user.expertiseTags && user.expertiseTags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{user.expertiseTags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
{user.expertiseTags.length > 2 && (
<Badge variant="outline" className="text-xs">
+{user.expertiseTags.length - 2}
</Badge>
)}
</div>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<div>
<p>{user._count.assignments} assigned</p>
<p className="text-sm text-muted-foreground">
{user.assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length} completed
</p>
</div>
</TableCell>
<TableCell>
<Badge variant={statusColors[user.status] || 'secondary'}>
{user.status}
</Badge>
</TableCell>
<TableCell>
{user.lastLoginAt ? (
formatDate(user.lastLoginAt)
) : (
<span className="text-muted-foreground">Never</span>
)}
</TableCell>
<TableCell className="text-right">
<UserActions
userId={user.id}
userEmail={user.email}
userStatus={user.status}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Mobile card view */}
<div className="space-y-4 md:hidden">
{users.map((user) => (
<Card key={user.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback>
{getInitials(user.name || user.email || 'U')}
</AvatarFallback>
</Avatar>
<div>
<CardTitle className="text-base">
{user.name || 'Unnamed'}
</CardTitle>
<CardDescription className="text-xs">
{user.email}
</CardDescription>
</div>
</div>
<Badge variant={statusColors[user.status] || 'secondary'}>
{user.status}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Role</span>
<Badge variant={roleColors[user.role] || 'secondary'}>
{user.role.replace('_', ' ')}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Assignments</span>
<span>
{user.assignments.filter(a => a.evaluation?.status === 'SUBMITTED').length}/{user._count.assignments} completed
</span>
</div>
{user.expertiseTags && user.expertiseTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{user.expertiseTags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
<UserMobileActions
userId={user.id}
userEmail={user.email}
userStatus={user.status}
/>
</CardContent>
</Card>
))}
</div>
</>
)
}
function UsersSkeleton() {
return (
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-9 w-9" />
</div>
))}
</div>
</CardContent>
</Card>
)
}
export default function UsersPage() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Jury Members</h1>
<p className="text-muted-foreground">
Manage jury members and observers
</p>
</div>
<Button asChild>
<Link href="/admin/users/invite">
<Plus className="mr-2 h-4 w-4" />
Invite Member
</Link>
</Button>
</div>
{/* Content */}
<Suspense fallback={<UsersSkeleton />}>
<UsersContent />
</Suspense>
</div>
)
}

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