Initial commit: MOPC platform with Docker deployment setup
Build and Push Docker Image / build (push) Failing after 10s
Details
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:
commit
a606292aaa
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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 18–23 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., 1–5 or 1–10)
|
||||
|
||||
1. Need clarity
|
||||
2. Solution relevance
|
||||
3. Gap analysis (market/competitors)
|
||||
4. Target customers clarity
|
||||
5. Ocean impact
|
||||
* **Global score**: 1–10
|
||||
* **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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "==> Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
echo "==> Starting application..."
|
||||
exec node server.js
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 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,
|
||||
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"
|
||||
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 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,
|
||||
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,
|
||||
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 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,
|
||||
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 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,
|
||||
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 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,
|
||||
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,
|
||||
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,
|
||||
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 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,
|
||||
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 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
|
||||
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 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,
|
||||
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 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
|
||||
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
|
||||
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.
|
||||
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,
|
||||
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 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"
|
||||
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 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,
|
||||
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 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"
|
||||
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 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"
|
||||
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.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
|
||||
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
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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 $$;
|
||||
|
|
@ -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 $$;
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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 "{resource.title}"? This action
|
||||
cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteResource.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} — {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 · {round._count.assignments} assignments
|
||||
{round.totalEvals > 0 && (
|
||||
<> · {round.evalPercent}% evaluated</>
|
||||
)}
|
||||
</p>
|
||||
{round.votingStartAt && round.votingEndAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Voting: {formatDateOnly(round.votingStartAt)} – {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} — {deadline.roundName}
|
||||
</p>
|
||||
<p className={`text-xs ${isUrgent ? 'text-destructive' : 'text-muted-foreground'}`}>
|
||||
{formatDateOnly(deadline.date)} · 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "{file.fileName}"?
|
||||
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 "{project.title}" 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
"{suggestion.reasoning}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "Add Field" 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "Start Voting" 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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 "{deletingTag?.name}"? 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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'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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 "email" column, optionally a "name" 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue