Initial v2 commit: complete rebuild with unified API + React admin
Phase 1-14 complete: - Unified Express.js API (TypeScript, Prisma ORM, PostgreSQL 16) - React Admin GUI (Vite + Ant Design + Zustand) - JWT auth with refresh tokens - Influence: Campaigns, Representatives, Responses, Email Queue - Map: Locations, Cuts, Shifts, Canvassing System - NAR data import infrastructure (2025 format) - Listmonk newsletter integration - Landing page builder (GrapesJS) - MkDocs + Code Server integration - Volunteer portal with GPS tracking - Monitoring stack (Prometheus, Grafana, Alertmanager) - Pangolin tunnel integration Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
a77306fac2
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
# Node modules
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
**/node_modules/
|
||||
|
||||
/configs/code-server/.local/*
|
||||
!/configs/code-server/.local/.gitkeep
|
||||
|
||||
/configs/code-server/.config/*
|
||||
!/configs/code-server/.config/.gitkeep
|
||||
|
||||
# MkDocs cache and built site (created by containers)
|
||||
/mkdocs/.cache/*
|
||||
!/mkdocs/.cache/.gitkeep
|
||||
/mkdocs/site/*
|
||||
!/mkdocs/site/.gitkeep
|
||||
|
||||
# Homepage logs (created by container)
|
||||
/configs/homepage/logs/*
|
||||
!/configs/homepage/logs/.gitkeep
|
||||
|
||||
.env
|
||||
.env*
|
||||
|
||||
/configs/cloudflare/*.json
|
||||
/configs/cloudflare/*.yaml
|
||||
/configs/cloudflare/*.yml
|
||||
|
||||
.excalidraw
|
||||
|
||||
/.VSCodeCounter
|
||||
|
||||
/influence/app/public/uploadsdata/
|
||||
|
||||
# NAR data directory (large voter registry files)
|
||||
/data/
|
||||
217
CLAUDE.md
Normal file
217
CLAUDE.md
Normal file
@ -0,0 +1,217 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
|
||||
|
||||
**Current state:** V2 rebuild in progress on the `v2` branch. See `V2_PLAN.md` for the full roadmap.
|
||||
|
||||
---
|
||||
|
||||
## V2 Architecture (Active Development)
|
||||
|
||||
### Stack
|
||||
|
||||
- **Single unified Express.js API** — TypeScript, port 4000, Prisma ORM + PostgreSQL 16
|
||||
- **React Admin GUI** — Vite + Ant Design + Zustand, port 3000
|
||||
- **Nginx reverse proxy** — subdomain routing (`*.cmlite.org`)
|
||||
- **NocoDB v2** — read-only data browser on port 8091
|
||||
- **JWT auth** — access tokens (15min) + refresh tokens (7 days, stored in DB)
|
||||
- **BullMQ** — async email job queue, **Listmonk** for newsletters
|
||||
- **Redis** — caching, rate limiting, BullMQ backend
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
changemaker.lite/
|
||||
├── api/ # Unified Express.js API (TypeScript)
|
||||
│ ├── prisma/ # Schema, migrations, seed
|
||||
│ └── src/
|
||||
│ ├── config/ # env.ts, database.ts, redis.ts
|
||||
│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac
|
||||
│ ├── modules/
|
||||
│ │ ├── auth/ # auth.service, auth.routes, auth.schemas
|
||||
│ │ ├── users/ # users.service, users.routes, users.schemas
|
||||
│ │ ├── influence/ # campaigns, representatives, responses, postal-codes
|
||||
│ │ └── map/ # locations, shifts, cuts
|
||||
│ ├── types/ # express.d.ts (Request augmentation)
|
||||
│ └── utils/ # logger.ts (Winston), metrics.ts (prom-client)
|
||||
├── admin/ # React Admin (Vite + Ant Design + Zustand)
|
||||
│ └── src/
|
||||
│ ├── components/ # ProtectedRoute, AppLayout
|
||||
│ ├── pages/ # LoginPage, DashboardPage, UsersPage
|
||||
│ ├── stores/ # auth.store.ts (Zustand)
|
||||
│ ├── lib/ # api.ts (axios instance + interceptors)
|
||||
│ └── types/ # api.ts (TypeScript interfaces)
|
||||
├── nginx/ # Reverse proxy config
|
||||
├── public-web/ # Public landing pages
|
||||
├── docker-compose.yml # V2 orchestration
|
||||
├── docker-compose.v1.yml # V1 backup for reference
|
||||
└── V2_PLAN.md # Full 14-phase roadmap
|
||||
```
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `api/prisma/schema.prisma` | Full database schema (20+ models) |
|
||||
| `api/src/server.ts` | API entry point, middleware stack, route wiring |
|
||||
| `api/src/config/env.ts` | Zod-validated environment config |
|
||||
| `api/src/modules/auth/` | JWT auth (login, register, refresh, logout) |
|
||||
| `api/src/modules/users/` | User CRUD with pagination + search |
|
||||
| `admin/src/App.tsx` | React admin shell with routing |
|
||||
| `admin/src/stores/auth.store.ts` | Zustand auth state with token persistence |
|
||||
| `admin/src/lib/api.ts` | Axios instance with 401 refresh interceptor |
|
||||
| `docker-compose.yml` | V2 service orchestration |
|
||||
| `.env.example` | All required environment variables |
|
||||
|
||||
### Auth Flow
|
||||
|
||||
- JWT-based: access tokens (15min) + refresh tokens (7 days, stored in DB)
|
||||
- Login → verify bcrypt hash → generate token pair → return tokens + user
|
||||
- Refresh → validate refresh token → rotate (invalidate old, issue new) → return new pair
|
||||
- Roles: `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `USER`, `TEMP`
|
||||
- RBAC middleware: `requireRole(...roles)`, `requireNonTemp`
|
||||
|
||||
### Nginx Routing
|
||||
|
||||
| Subdomain | Target |
|
||||
|-----------|--------|
|
||||
| `app.cmlite.org` | Admin React app (port 3000) |
|
||||
| `api.cmlite.org` | Express API (port 4000) |
|
||||
| `data.cmlite.org` | NocoDB read-only (port 8091) |
|
||||
| `docs.cmlite.org` | MkDocs (port 4001) |
|
||||
| `cmlite.org` | Public landing pages |
|
||||
|
||||
---
|
||||
|
||||
## V2 Development Commands
|
||||
|
||||
### API Development
|
||||
```bash
|
||||
cd api && npm run dev # Dev server with tsx watch (auto-reload)
|
||||
cd api && npx tsc --noEmit # Type-check without emitting
|
||||
cd api && npx prisma migrate dev # Run/create migrations
|
||||
cd api && npx prisma studio # Browse database in browser
|
||||
cd api && npx prisma generate # Regenerate Prisma client
|
||||
```
|
||||
|
||||
### Admin GUI Development
|
||||
```bash
|
||||
cd admin && npm run dev # Vite dev server (port 3000)
|
||||
cd admin && npx tsc --noEmit # Type-check without emitting
|
||||
cd admin && npm run build # Production build (tsc + vite)
|
||||
```
|
||||
|
||||
### Docker (V2 Services)
|
||||
```bash
|
||||
docker compose up -d v2-postgres redis api # Start API + dependencies
|
||||
docker compose up -d admin # Start admin GUI
|
||||
docker compose up -d # Start all v2 services
|
||||
docker compose logs -f api # Tail API logs
|
||||
docker compose exec api npx prisma migrate dev # Run migrations in container
|
||||
docker compose down # Stop all services
|
||||
```
|
||||
|
||||
### Type Checking (Both Projects)
|
||||
```bash
|
||||
cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Port Reference (V2)
|
||||
|
||||
| Port | Service |
|
||||
|------|---------|
|
||||
| 3000 | Admin GUI (Vite dev / React) |
|
||||
| 3001 | Grafana |
|
||||
| 3010 | Homepage |
|
||||
| 3030 | Gitea |
|
||||
| 4000 | V2 API (Express.js) |
|
||||
| 4001 | MkDocs (built static) |
|
||||
| 5432 | Listmonk PostgreSQL |
|
||||
| 5433 | V2 PostgreSQL (localhost) |
|
||||
| 5678 | n8n |
|
||||
| 6379 | Redis |
|
||||
| 8025 | MailHog Web UI |
|
||||
| 8080 | cAdvisor |
|
||||
| 8089 | Mini QR |
|
||||
| 8091 | NocoDB v2 (read-only) |
|
||||
| 8888 | Code Server |
|
||||
| 9001 | Listmonk |
|
||||
| 9090 | Prometheus |
|
||||
| 9093 | Alertmanager |
|
||||
|
||||
---
|
||||
|
||||
## V1 Reference (Legacy)
|
||||
|
||||
V1 code is preserved in `influence/` and `map/` directories and backed up in `docker-compose.v1.yml`.
|
||||
|
||||
### V1 Architecture
|
||||
|
||||
Two independent Express.js apps using NocoDB REST API as data layer:
|
||||
|
||||
- **Influence** (`influence/app/`, port 3333) — Postal code → representative lookup, email campaigns, response tracking
|
||||
- **Map** (`map/app/`, port 3000) — Leaflet.js map, volunteer shifts, walk sheets, QR codes
|
||||
|
||||
Both apps use: session-based auth (Redis-backed), bcryptjs passwords, Bull job queues, NocoDB REST API (not direct DB).
|
||||
|
||||
### V1 Express App Structure
|
||||
```
|
||||
app/
|
||||
├── server.js # Entry point, middleware stack
|
||||
├── config/ # Environment-based configuration
|
||||
├── routes/ # Express route definitions
|
||||
├── controllers/ # Business logic
|
||||
├── services/ # External integrations (nocodb.js, email.js, listmonk.js)
|
||||
├── middleware/ # auth.js, csrf.js, rateLimiter.js
|
||||
├── utils/ # logger.js, metrics.js, validators.js
|
||||
├── public/ # Static assets
|
||||
└── templates/ # Server-rendered HTML templates
|
||||
```
|
||||
|
||||
### V1 Commands
|
||||
```bash
|
||||
cd influence && cp example.env .env
|
||||
./scripts/build-nocodb.sh # Initialize NocoDB tables
|
||||
docker compose up -d
|
||||
docker compose exec influence-app npm test # Run Jest tests
|
||||
|
||||
cd map && cp example.env .env
|
||||
./build-nocodb.sh # Initialize NocoDB tables
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### V1 Build Scripts
|
||||
- `config.sh` — Interactive wizard that generates `.env` with secure random passwords
|
||||
- `start-production.sh` — Installs cloudflared, creates tunnel, configures DNS
|
||||
- `map/build-nocodb.sh` and `influence/scripts/build-nocodb.sh` — Create NocoDB schema + seed data
|
||||
- `reset-site.sh` — Resets MkDocs to baseline
|
||||
|
||||
### V1 Documentation
|
||||
- `influence/README.MD` — Features, config, campaign management, email testing
|
||||
- `influence/files-explainer.md` — File-by-file code documentation
|
||||
- `map/README.md` — Features, config, setup instructions
|
||||
- `map/files-explainer.md` — File-by-file code documentation
|
||||
|
||||
---
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `docker-compose.yml` | V2 orchestration (all services) |
|
||||
| `docker-compose.v1.yml` | V1 backup |
|
||||
| `.env` / `.env.example` | Environment variables (never committed) |
|
||||
| `api/prisma/schema.prisma` | Database schema |
|
||||
| `nginx/` | Reverse proxy configuration |
|
||||
| `configs/prometheus/prometheus.yml` | Monitoring scrape targets |
|
||||
| `configs/cloudflare/tunnel-config.yml` | Production ingress routing |
|
||||
|
||||
## Networking
|
||||
|
||||
All containers share the `changemaker-lite` bridge network and reference each other by container name. Production uses Cloudflare tunnel with ingress rules mapping `*.cmlite.org` subdomains.
|
||||
95
Dockerfile.code-server
Normal file
95
Dockerfile.code-server
Normal file
@ -0,0 +1,95 @@
|
||||
FROM codercom/code-server:latest
|
||||
|
||||
USER root
|
||||
|
||||
# Install Node.js 18+ and npm
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
|
||||
&& apt-get install -y nodejs
|
||||
|
||||
# Install Claude Code globally as root
|
||||
RUN npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Install Ollama (needs zstd for extraction)
|
||||
RUN apt-get update && apt-get install -y zstd && rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL https://ollama.com/install.sh | sh
|
||||
|
||||
# Install Python and dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
python3-full \
|
||||
pipx \
|
||||
# Dependencies for CairoSVG and Pillow (PIL)
|
||||
libcairo2-dev \
|
||||
libfreetype6-dev \
|
||||
libffi-dev \
|
||||
libjpeg-dev \
|
||||
libpng-dev \
|
||||
libz-dev \
|
||||
python3-dev \
|
||||
pkg-config \
|
||||
# Additional dependencies for advanced image processing
|
||||
libwebp-dev \
|
||||
libtiff5-dev \
|
||||
libopenjp2-7-dev \
|
||||
liblcms2-dev \
|
||||
libxml2-dev \
|
||||
libxslt1-dev \
|
||||
# PDF generation dependencies
|
||||
weasyprint \
|
||||
fonts-roboto \
|
||||
# Git for git-based plugins
|
||||
git \
|
||||
# For lxml
|
||||
zlib1g-dev \
|
||||
# Required for some plugins
|
||||
build-essential \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Switch to non-root user (coder)
|
||||
USER coder
|
||||
|
||||
# Set up a virtual environment for mkdocs
|
||||
RUN mkdir -p /home/coder/.venv
|
||||
RUN python3 -m venv /home/coder/.venv/mkdocs
|
||||
|
||||
# Install mkdocs-material in the virtual environment with all extras
|
||||
RUN /home/coder/.venv/mkdocs/bin/pip install "mkdocs-material[imaging,recommended,git]"
|
||||
|
||||
# Install additional useful MkDocs plugins
|
||||
RUN /home/coder/.venv/mkdocs/bin/pip install \
|
||||
mkdocs-minify-plugin \
|
||||
mkdocs-git-revision-date-localized-plugin \
|
||||
mkdocs-glightbox \
|
||||
mkdocs-redirects \
|
||||
mkdocs-awesome-pages-plugin \
|
||||
mkdocs-blog-plugin \
|
||||
mkdocs-rss-plugin \
|
||||
mkdocs-meta-descriptions-plugin \
|
||||
mkdocs-swagger-ui-tag \
|
||||
mkdocs-macros-plugin \
|
||||
mkdocs-material-extensions \
|
||||
mkdocs-section-index \
|
||||
mkdocs-table-reader-plugin \
|
||||
mkdocs-pdf-export-plugin \
|
||||
mkdocs-mermaid2-plugin \
|
||||
pymdown-extensions \
|
||||
pygments \
|
||||
pillow \
|
||||
cairosvg
|
||||
|
||||
# Add the virtual environment bin to PATH
|
||||
ENV PATH="/home/coder/.venv/mkdocs/bin:${PATH}"
|
||||
|
||||
# Add shell configuration to activate the virtual environment in .bashrc
|
||||
RUN echo 'export PATH="/home/coder/.venv/mkdocs/bin:$PATH"' >> ~/.bashrc
|
||||
RUN echo 'export PATH="/home/coder/.local/bin:$PATH"' >> ~/.bashrc
|
||||
|
||||
# Create a convenience script to simplify running mkdocs commands
|
||||
RUN mkdir -p /home/coder/.local/bin \
|
||||
&& echo '#!/bin/bash\ncd /home/coder/mkdocs\nmkdocs "$@"' > /home/coder/.local/bin/run-mkdocs \
|
||||
&& chmod +x /home/coder/.local/bin/run-mkdocs
|
||||
|
||||
WORKDIR /home/coder
|
||||
140
README.md
Normal file
140
README.md
Normal file
@ -0,0 +1,140 @@
|
||||
# Changemaker Lite
|
||||
|
||||
Changemaker Lite is a streamlined documentation and development platform featuring essential self-hosted services for creating, managing, and automating political campaign workflows.
|
||||
|
||||
## Features
|
||||
|
||||
- **Homepage**: Modern dashboard for accessing all services
|
||||
- **Code Server**: VS Code in your browser for remote development
|
||||
- **MkDocs Material**: Beautiful documentation with live preview
|
||||
- **Static Site Server**: High-performance hosting for built sites
|
||||
- **Listmonk**: Self-hosted newsletter and email campaign management
|
||||
- **PostgreSQL**: Reliable database backend
|
||||
- **n8n**: Workflow automation and service integration
|
||||
- **NocoDB**: No-code database platform and smart spreadsheet interface
|
||||
- **Map**: Interactive map visualization for geographic data with real-time geolocation, walk sheet generation, and QR code integration
|
||||
- **Influence**: Campaign tool for connecting Alberta residents with elected representatives at all government levels
|
||||
|
||||
## Quick Start
|
||||
|
||||
The whole system can be set up in minutes using Docker Compose. It is recommended to run this on a server with at least 8GB of RAM and 4 CPU cores for optimal performance. Instructions to build to production are available in the mkdocs/docs/build directory, at cmlite.org, or in the site preview.
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://gitea.bnkops.com/admin/changemaker.lite
|
||||
cd changemaker.lite
|
||||
|
||||
# Configure environment (creates .env file)
|
||||
./config.sh
|
||||
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Map
|
||||
|
||||
Instructions on how to build the map are available in the map directory.
|
||||
|
||||
Instructions on how to build for production are available in the mkdocs/docs/build directory or in the site preview.
|
||||
|
||||
### Quick Start for Map
|
||||
|
||||
Update the .env file in the map directory with your NocoDB URLs, and then run:
|
||||
|
||||
```bash
|
||||
cd map
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Influence
|
||||
|
||||
The Influence Campaign Tool helps Alberta residents connect with elected representatives at federal, provincial, and municipal levels. Users can look up representatives by postal code and send advocacy emails through customizable campaigns.
|
||||
|
||||
Detailed setup and configuration instructions are available in the `influence/README.MD` file.
|
||||
|
||||
### Quick Start for Influence
|
||||
|
||||
Configure your environment and start the service:
|
||||
|
||||
```bash
|
||||
cd influence
|
||||
cp example.env .env
|
||||
# Edit .env with your NocoDB and SMTP settings
|
||||
./scripts/build-nocodb.sh # Set up database tables
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Service Access
|
||||
|
||||
After starting, access services at:
|
||||
|
||||
- **Homepage Dashboard**: http://localhost:3010
|
||||
- **Documentation (Dev)**: http://localhost:4000
|
||||
- **Documentation (Built)**: http://localhost:4001
|
||||
- **Code Server**: http://localhost:8888
|
||||
- **Listmonk**: http://localhost:9000
|
||||
- **n8n**: http://localhost:5678
|
||||
- **NocoDB**: http://localhost:8090
|
||||
- **Map Viewer**: http://localhost:3000
|
||||
- **Influence Campaign Tool**: http://localhost:3333
|
||||
|
||||
## Production Deployment
|
||||
|
||||
If you are deploying to production, using Cloudflare, you can use the included 'start-production.sh' script to set up a secure deployment with HTTPS. Ensure your domain and cloudflare settings are correctly configured in the root .env before running. More information on the required API tokens and settings can be found in the mkdocs/docs/build directory or at cmlite.org.
|
||||
|
||||
```bash
|
||||
./start-production.sh
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
Complete documentation is available in the MkDocs site, including:
|
||||
|
||||
- Service configuration guides
|
||||
- Integration examples
|
||||
- Workflow automation tutorials
|
||||
- Map application setup and usage
|
||||
- Troubleshooting guides
|
||||
|
||||
Visit http://localhost:4000 after starting services to access the full documentation.
|
||||
|
||||
## Licensing
|
||||
|
||||
This project is licensed under the Apache License 2.0 - https://opensource.org/license/apache-2-0
|
||||
|
||||
## AI Disclaimer
|
||||
|
||||
This project used AI tools to assist in its creation and large amounts of the boilerplate code was reviewed using AI. AI tools (although not activated or connected) are pre-installed in the Coder docker image. See `docker.code-server` for more details.
|
||||
|
||||
While these tools can help generate code and documentation, they may also introduce errors or inaccuracies. Users should review and test all content to ensure it meets their requirements and standards.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Permission Denied Errors (EACCES)
|
||||
|
||||
If you see errors like `EACCES: permission denied` when starting containers, run the included fix script:
|
||||
|
||||
```bash
|
||||
./fix-permissions.sh
|
||||
```
|
||||
|
||||
This fixes permissions on directories that containers need to write to, such as:
|
||||
- `configs/code-server/.config` and `.local` (Code Server)
|
||||
- `mkdocs/.cache` (MkDocs social cards plugin)
|
||||
- `mkdocs/site` (MkDocs built output)
|
||||
|
||||
If the script can't fix some directories (owned by a different container UID), it will prompt to use `sudo`.
|
||||
|
||||
### Manual Permission Fix
|
||||
|
||||
If you prefer to fix manually:
|
||||
|
||||
```bash
|
||||
# Fix all permissions at once
|
||||
sudo chown -R $(id -u):$(id -g) .
|
||||
chmod -R 755 .
|
||||
|
||||
# Or fix specific directories
|
||||
chmod -R 777 configs/code-server/.config configs/code-server/.local
|
||||
chmod -R 777 mkdocs/.cache mkdocs/site
|
||||
```
|
||||
380
V2_PLAN.md
Normal file
380
V2_PLAN.md
Normal file
@ -0,0 +1,380 @@
|
||||
# V2 Roadmap — Changemaker Lite
|
||||
|
||||
This document is the full roadmap for the v2 rebuild of Changemaker Lite, a self-hosted political campaign platform. V1 consisted of two separate Express apps (Influence and Map) using NocoDB as a data layer. V2 consolidates everything into a single unified TypeScript API with a React admin interface.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Single unified Express.js API** (TypeScript, port 4000) with Prisma ORM + PostgreSQL 16
|
||||
- **React Admin GUI** (Vite + Ant Design + Zustand, port 3000)
|
||||
- **Nginx reverse proxy** (subdomain routing: *.cmlite.org)
|
||||
- **NocoDB v2** as read-only data browser on port 8091
|
||||
- **JWT auth** (access + refresh tokens), bcrypt passwords
|
||||
- **BullMQ** for Influence advocacy emails, Listmonk for newsletters
|
||||
- **Redis** for caching, rate limiting, BullMQ
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
changemaker.lite/
|
||||
├── api/ # Unified Express.js API (TypeScript)
|
||||
│ ├── prisma/ # Schema, migrations, seed
|
||||
│ └── src/
|
||||
│ ├── config/ # env.ts, database.ts, redis.ts
|
||||
│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac
|
||||
│ ├── modules/
|
||||
│ │ ├── auth/ # auth.service, auth.routes, auth.schemas
|
||||
│ │ ├── users/ # users.service, users.routes, users.schemas
|
||||
│ │ ├── influence/
|
||||
│ │ │ ├── campaigns/
|
||||
│ │ │ ├── representatives/
|
||||
│ │ │ ├── responses/
|
||||
│ │ │ └── postal-codes/
|
||||
│ │ └── map/
|
||||
│ │ ├── locations/
|
||||
│ │ ├── shifts/
|
||||
│ │ └── cuts/
|
||||
│ ├── types/ # express.d.ts
|
||||
│ └── utils/ # logger.ts, metrics.ts
|
||||
├── admin/ # React Admin (Vite + Ant Design)
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ ├── stores/
|
||||
│ └── services/
|
||||
├── nginx/ # Reverse proxy config
|
||||
├── public-web/ # Public landing pages
|
||||
└── docker-compose.yml # V2 orchestration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema Summary
|
||||
|
||||
27+ models organized into the following groups:
|
||||
|
||||
- **Auth & Users** — User, RefreshToken
|
||||
- **Influence** — Campaign, CampaignEmail, Representative, RepresentativeResponse, ResponseUpvote, CustomRecipient, PostalCodeCache, EmailLog, EmailVerification, Call
|
||||
- **Map** — Location, Shift, ShiftSignup, Cut, MapSettings
|
||||
- **Canvassing** — CanvassSession, CanvassVisit, TrackingSession, TrackPoint
|
||||
- **Landing Pages** — LandingPage, PageBlock
|
||||
- **Settings** — SiteSettings
|
||||
|
||||
---
|
||||
|
||||
## Auth Flow
|
||||
|
||||
- JWT-based with access tokens (15min) and refresh tokens (7 days, stored in DB)
|
||||
- **Login:** verify bcrypt hash, generate token pair, return tokens + user
|
||||
- **Refresh:** validate refresh token, rotate (invalidate old, issue new), return new pair
|
||||
- **Roles:** SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
|
||||
|
||||
---
|
||||
|
||||
## Email Architecture
|
||||
|
||||
- **BullMQ job queue** for Influence advocacy emails (async send via SMTP)
|
||||
- **Listmonk** for newsletter/marketing emails
|
||||
- **MailHog** for dev email capture (EMAIL_TEST_MODE=true)
|
||||
|
||||
---
|
||||
|
||||
## Nginx Routing
|
||||
|
||||
| Subdomain | Target |
|
||||
|-----------------------|--------------------------------|
|
||||
| app.cmlite.org | Admin React app (port 3000) |
|
||||
| api.cmlite.org | Express API (port 4000) |
|
||||
| db.cmlite.org | NocoDB read-only (port 8091) |
|
||||
| docs.cmlite.org | MkDocs (port 4003) |
|
||||
| code.cmlite.org | Code Server (port 8888) |
|
||||
| n8n.cmlite.org | n8n Workflows (port 5678) |
|
||||
| git.cmlite.org | Gitea (port 3030) |
|
||||
| home.cmlite.org | Homepage (port 3010) |
|
||||
| grafana.cmlite.org | Grafana (port 3001) |
|
||||
| listmonk.cmlite.org | Listmonk (port 9001) |
|
||||
| cmlite.org | Public static site (MkDocs) |
|
||||
|
||||
---
|
||||
|
||||
## Phase Checklist
|
||||
|
||||
### Phase 1: Foundation [x] COMPLETE
|
||||
|
||||
- [x] Initialize api/ with TypeScript, Express, Prisma
|
||||
- [x] Create prisma/schema.prisma with all models
|
||||
- [x] Set up config (env.ts, database.ts, redis.ts)
|
||||
- [x] Create middleware (error-handler.ts, validate.ts, rate-limit.ts)
|
||||
- [x] Set up utils (logger.ts, metrics.ts)
|
||||
- [x] Create server.ts with health check and metrics
|
||||
- [x] Initialize admin/ with Vite + React + Ant Design
|
||||
- [x] Create docker-compose.yml for v2
|
||||
- [x] Create .env.example
|
||||
- [x] Backup v1 to docker-compose.v1.yml
|
||||
|
||||
> Note: DB migrations are pending until `docker compose up` runs PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Auth + User Management [x] COMPLETE
|
||||
|
||||
- [x] Express Request type augmentation (express.d.ts)
|
||||
- [x] Auth schemas (Zod validation)
|
||||
- [x] Auth service (login, register, refresh, logout)
|
||||
- [x] Auth middleware (JWT verification)
|
||||
- [x] RBAC middleware (role-based access)
|
||||
- [x] Auth routes (login, register, refresh, logout, me)
|
||||
- [x] User schemas (Zod validation)
|
||||
- [x] User service (CRUD + pagination)
|
||||
- [x] User routes (list, get, create, update, delete)
|
||||
- [x] Wire routes into server.ts
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Admin GUI Foundation [x] COMPLETE
|
||||
|
||||
- [x] Zustand auth store with token management
|
||||
- [x] Login page
|
||||
- [x] Protected route wrapper
|
||||
- [x] App layout with sidebar navigation
|
||||
- [x] User management page (CRUD table)
|
||||
- [x] API client with interceptors (auto-refresh)
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Influence — Campaigns [x] COMPLETE
|
||||
|
||||
- [x] Campaign schemas (Zod validation)
|
||||
- [x] Campaign service (CRUD, slug generation, highlight toggling)
|
||||
- [x] Campaign routes with admin protection
|
||||
- [x] Admin campaign management page (table, filters, create/edit/delete)
|
||||
- [ ] Public campaign view page (deferred to later phase)
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Influence — Representatives + Postal Codes [x] COMPLETE
|
||||
|
||||
- [x] Postal code schemas (normalize, validate all Canadian postal codes)
|
||||
- [x] Postal code cache service (upsert, find, paginate, delete)
|
||||
- [x] Represent API client (typed HTTP client, in-memory rate limiter 55/min)
|
||||
- [x] Representative schemas (list/filter validation)
|
||||
- [x] Representative service (cache-first lookup, fire-and-forget cache write, admin CRUD, cache stats)
|
||||
- [x] Representative routes (public: lookup + health check; admin: list, stats, detail, delete)
|
||||
- [x] Admin representatives page (lookup, stats cards, table with filters, detail modal)
|
||||
- [ ] Public postal code to representative lookup UI (deferred to later phase)
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Influence — Email Sending [x] COMPLETE
|
||||
|
||||
- [x] BullMQ email queue setup (email-queue.service.ts)
|
||||
- [x] Email worker (SMTP send via nodemailer, email.service.ts)
|
||||
- [x] Campaign email service (compose, queue, track)
|
||||
- [x] Campaign email routes (public: send-email, track-mailto; admin: list, stats)
|
||||
- [x] Email queue admin routes (stats, pause, resume, clean)
|
||||
- [x] Admin email queue page (stats cards, pause/resume, clean)
|
||||
- [x] Admin campaign emails drawer (stats + email list from CampaignsPage)
|
||||
- [x] Email rate limiting (30 req/hour per IP)
|
||||
- [ ] Public email sending UI (SMTP + mailto fallback) — deferred to later phase
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Influence — Response Wall + Public Campaign View [x]
|
||||
|
||||
- [x] Response service (submit, moderate, verify)
|
||||
- [x] Response routes (3 routers: campaign-public, response-public, admin)
|
||||
- [x] Email verification for responses (HTML templates, verify/report endpoints)
|
||||
- [x] Admin moderation interface (ResponsesPage with filters, approve/reject/delete)
|
||||
- [x] Public response wall display (ResponseWallPage with sort, filter, submit modal)
|
||||
- [x] Upvoting system (IP + user dedup, optimistic UI)
|
||||
- [x] Public campaign page (CampaignPage with postal code lookup, email sending)
|
||||
- [x] PublicLayout with light theme for public pages
|
||||
- [x] Campaign public details endpoint (findBySlugPublic)
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Map — Locations [x]
|
||||
|
||||
- [x] Multi-provider geocoding service (Nominatim, ArcGIS, Photon, Mapbox)
|
||||
- [x] Location service (CRUD, geocoding, stats, bulk operations)
|
||||
- [x] Location routes (admin + public)
|
||||
- [x] Map settings service + routes (singleton config)
|
||||
- [x] Admin LocationsPage (table, stats, create/edit, geocode button)
|
||||
- [x] Admin MapSettingsPage (center/zoom, walk sheet config)
|
||||
- [x] Public Leaflet.js map (circle markers, color-coded support levels, multi-unit grouping)
|
||||
- [x] CSV import/export with flexible column mapping
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Map — Shifts [x] COMPLETE
|
||||
|
||||
- [x] Shift service (CRUD, signup management)
|
||||
- [x] Shift routes (admin + public)
|
||||
- [x] Admin shift management (ShiftsPage with signups drawer)
|
||||
- [x] Public shift calendar + signup
|
||||
- [x] Temp user creation on public signup
|
||||
- [x] Confirmation emails for signups
|
||||
|
||||
---
|
||||
|
||||
### Phase 10: Walk Sheets & QR Codes [x] COMPLETE
|
||||
|
||||
- [x] QR code generation endpoint (GET /api/qr)
|
||||
- [x] Walk sheet page (printable form with QR codes)
|
||||
- [x] Cut export page (printable location report)
|
||||
- [x] Sidebar navigation + route wiring
|
||||
|
||||
---
|
||||
|
||||
### Phase 11: Listmonk Integration [x] COMPLETE
|
||||
|
||||
- [x] Listmonk API client service (typed HTTP, basic auth)
|
||||
- [x] Sync service (campaign participants, locations, users → subscriber lists)
|
||||
- [x] Admin routes (status, stats, sync triggers, test connection, reinitialize)
|
||||
- [x] Admin ListmonkPage (status, sync buttons, list stats)
|
||||
- [x] Sidebar navigation + route wiring
|
||||
- [x] Proton Mail SMTP configuration (listmonk-init auto-configures via SQL)
|
||||
|
||||
---
|
||||
|
||||
### Phase 12: Landing Page Builder [x] COMPLETE
|
||||
|
||||
- [x] Landing page service (CRUD, slug generation, MkDocs export)
|
||||
- [x] Page block service (seed blocks, CRUD, library API)
|
||||
- [x] GrapesJS editor integration (custom blocks, Ctrl+S save, error boundary)
|
||||
- [x] Admin page builder UI (LandingPagesPage, PageEditorPage full-screen)
|
||||
- [x] Public page rendering (/p/:slug)
|
||||
- [x] MkDocs export (Jinja2 Material override template, themed + standalone modes)
|
||||
|
||||
---
|
||||
|
||||
### MkDocs + Code Server + Docs Editor [x] COMPLETE
|
||||
|
||||
- [x] Docker port fix (MkDocs → 4003, avoiding API conflict)
|
||||
- [x] API mkdocs volume mount for override file sync
|
||||
- [x] Nginx code.cmlite.org + X-Frame-Options per-server-block
|
||||
- [x] Docs API routes (status, config health check for MkDocs/Code Server)
|
||||
- [x] DocsEditorPage (split-view with Code Server + MkDocs iframes, draggable divider)
|
||||
- [x] DocsPage (management — status cards, MkDocs export table)
|
||||
|
||||
---
|
||||
|
||||
### Phase 13: Settings + Branding [x] COMPLETE
|
||||
|
||||
- [x] SiteSettings Prisma model (singleton, organization/theme/feature toggles)
|
||||
- [x] Settings API module (public GET, SUPER_ADMIN PUT)
|
||||
- [x] Zustand settings store (fetch on app startup, public endpoint)
|
||||
- [x] Admin SettingsPage with tabs (Organization, Theme, Email, Features)
|
||||
- [x] Dynamic admin theme (colorPrimary, colorBgBase from settings)
|
||||
- [x] Dynamic public theme (colors, gradient, footer from settings)
|
||||
- [x] Dynamic branding (organization name, short name, login subtitle)
|
||||
- [x] Feature toggle navigation (hide sidebar sections when disabled)
|
||||
|
||||
---
|
||||
|
||||
### Code Editor (Standalone) [x] COMPLETE
|
||||
|
||||
- [x] Docker volume mount for entire project directory
|
||||
- [x] Admin CodeEditorPage with full-bleed Code Server iframe
|
||||
- [x] Health status check (reuses /api/docs/status endpoint)
|
||||
- [x] Sidebar navigation under Web submenu
|
||||
- [x] SUPER_ADMIN access restriction
|
||||
|
||||
---
|
||||
|
||||
### Volunteer Canvassing System [x] COMPLETE
|
||||
|
||||
- [x] CanvassSession + CanvassVisit Prisma models (session lifecycle, visit outcomes)
|
||||
- [x] TrackingSession + TrackPoint Prisma models (GPS trail recording)
|
||||
- [x] Canvass API — volunteer routes (start/end session, record visits, walking route)
|
||||
- [x] Canvass API — admin routes (dashboard stats, activity feed, cut progress, leaderboard)
|
||||
- [x] Walking route algorithm (nearest-neighbor with haversine distance)
|
||||
- [x] Canvass visit rate limiter (30/min per IP)
|
||||
- [x] Abandoned session cleanup (startup + hourly interval, ACTIVE > 12h → ABANDONED)
|
||||
- [x] GPS tracking routes (volunteer + admin)
|
||||
- [x] Old tracking data cleanup (startup + daily, 30-day retention)
|
||||
- [x] Stale tracking session cleanup (no data for 2h, hourly check)
|
||||
- [x] VolunteerLayout (top-nav, dark theme, mobile hamburger menu)
|
||||
- [x] Volunteer portal — full-screen canvass map (Leaflet, GPS, markers, route, bottom sheet visit recording)
|
||||
- [x] Volunteer portal — activity page (visit history + outcome breakdown)
|
||||
- [x] Admin CanvassDashboardPage (stats, activity feed, cut progress, leaderboard)
|
||||
- [x] Admin WalkSheetPage enhancements
|
||||
- [x] ShiftsPage cutId dropdown (link shifts to cuts)
|
||||
- [x] Role-aware login redirect (ADMIN_ROLES → /app, USER/TEMP → /volunteer)
|
||||
- [x] Shift.cutId optional relation — shifts without a cut don't appear in volunteer assignments
|
||||
|
||||
---
|
||||
|
||||
### Platform Service Integration [x] COMPLETE
|
||||
|
||||
- [x] Services API module (health check NocoDB, n8n, Gitea + config endpoint)
|
||||
- [x] Admin NocoDBPage (iframe to db.cmlite.org, status badge, open in new tab)
|
||||
- [x] Admin N8nPage (iframe to n8n.cmlite.org, status badge, open in new tab)
|
||||
- [x] Admin GiteaPage (iframe to git.cmlite.org, status badge, open in new tab)
|
||||
- [x] Sidebar "Services" submenu (Database, Workflows, Git)
|
||||
- [x] Nginx CSP frame-ancestors for NocoDB, n8n, Gitea (iframe embedding from admin)
|
||||
- [x] Embed proxy ports (8881-8883) for X-Frame-Options stripping
|
||||
- [x] `buildServiceUrl()` helper for dynamic iframe URLs
|
||||
|
||||
---
|
||||
|
||||
### Phase 14: Monitoring + DevOps [x] COMPLETE
|
||||
|
||||
**Pangolin Tunnel (replaced Cloudflare Tunnel):**
|
||||
- [x] Pangolin Integration API client (`api/src/services/pangolin.client.ts`)
|
||||
- [x] Admin pangolin routes — status, config, sites, resources, setup, sync, delete
|
||||
- [x] Admin PangolinPage — setup wizard + resource status dashboard
|
||||
- [x] Newt container in docker-compose.yml
|
||||
- [x] Env vars: PANGOLIN_API_URL, API_KEY, ORG_ID, SITE_ID, ENDPOINT, NEWT_ID, NEWT_SECRET
|
||||
- [x] Retired Cloudflare scripts → `scripts/legacy/`
|
||||
- [x] Sidebar "Tunnel" nav item under Services
|
||||
|
||||
**Prometheus Metrics:**
|
||||
- [x] 12 domain-specific `cm_*` metrics in `api/src/utils/metrics.ts`
|
||||
- [x] Email: cm_emails_sent_total, cm_emails_failed_total, cm_email_queue_size, cm_email_send_duration_seconds
|
||||
- [x] Auth: cm_login_attempts_total, cm_active_sessions
|
||||
- [x] Campaigns: cm_campaign_emails_total, cm_response_submissions_total
|
||||
- [x] Canvass: cm_canvass_visits_total, cm_active_canvass_sessions, cm_shift_signups_total
|
||||
- [x] Services: cm_external_service_up
|
||||
- [x] Instrumented: email-queue, auth, campaign-emails, responses, canvass, shifts, services
|
||||
|
||||
**Monitoring Configs:**
|
||||
- [x] Prometheus: V2 API scrape job (`changemaker-v2-api:4000`), removed V1 influence-app
|
||||
- [x] Alerts: rewritten for V2 `cm_*` / `http_*` metric names
|
||||
- [x] Alertmanager: Gotify webhook receiver (commented, ready to enable)
|
||||
- [x] Grafana dashboards: system-health (updated), application-overview (new), api-performance (new)
|
||||
|
||||
**Docker Healthchecks:**
|
||||
- [x] API (wget /api/health, 15s)
|
||||
- [x] Admin (wget /, 30s)
|
||||
- [x] Nginx (wget /, 30s)
|
||||
- [x] NocoDB (wget /api/v1/health, 30s)
|
||||
- [x] n8n (wget /healthz, 30s)
|
||||
- [x] Gitea (curl /, 30s)
|
||||
- [x] Listmonk (wget /, 30s)
|
||||
|
||||
**Backup:**
|
||||
- [x] `scripts/backup.sh` — V2 PostgreSQL dump + Listmonk dump + uploads archive
|
||||
- [x] Manifest with timestamps, sizes, sha256 checksums
|
||||
- [x] Configurable retention (default 30 days)
|
||||
- [x] Optional S3 upload (--s3 flag)
|
||||
|
||||
---
|
||||
|
||||
### Phase 15: Testing + Polish [ ]
|
||||
|
||||
- [ ] API integration tests (Jest/Vitest)
|
||||
- [ ] Admin E2E tests
|
||||
- [ ] Performance optimization
|
||||
- [ ] Security audit
|
||||
- [ ] Documentation updates
|
||||
|
||||
### PHASE 1: Extras
|
||||
- [ ] Add apache answers
|
||||
- [ ] Add geo-tracking and blocking
|
||||
- [ ] Add in the video platform
|
||||
- [ ] Add in excalidraw
|
||||
- [ ] Add in chats - integrate into canvass application
|
||||
23
admin/Dockerfile
Normal file
23
admin/Dockerfile
Normal file
@ -0,0 +1,23 @@
|
||||
FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Development stage
|
||||
FROM base AS development
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
# Build stage
|
||||
FROM base AS build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage — serve with Nginx
|
||||
FROM nginx:alpine AS production
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 3000
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
admin/index.html
Normal file
12
admin/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Changemaker Lite - Admin</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#1a1025">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
admin/nginx.conf
Normal file
17
admin/nginx.conf
Normal file
@ -0,0 +1,17 @@
|
||||
server {
|
||||
listen 3000;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://changemaker-v2-api:4000;
|
||||
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;
|
||||
}
|
||||
}
|
||||
3309
admin/package-lock.json
generated
Normal file
3309
admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
admin/package.json
Normal file
46
admin/package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "changemaker-v2-admin",
|
||||
"private": true,
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.0",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"antd": "^5.23.0",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.19",
|
||||
"grapesjs": "^0.22.14",
|
||||
"grapesjs-blocks-basic": "^1.0.2",
|
||||
"grapesjs-component-countdown": "^1.0.2",
|
||||
"grapesjs-custom-code": "^1.0.2",
|
||||
"grapesjs-navbar": "^1.0.2",
|
||||
"grapesjs-plugin-export": "^1.0.12",
|
||||
"grapesjs-plugin-forms": "^2.0.6",
|
||||
"grapesjs-preset-webpage": "^1.0.3",
|
||||
"grapesjs-style-gradient": "^3.0.3",
|
||||
"grapesjs-tabs": "^1.0.6",
|
||||
"grapesjs-touch": "^0.1.1",
|
||||
"grapesjs-typed": "^2.0.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"yaml": "^2.8.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^19.0.7",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
359
admin/src/App.tsx
Normal file
359
admin/src/App.tsx
Normal file
@ -0,0 +1,359 @@
|
||||
import { useEffect } from 'react';
|
||||
import { App as AntApp, ConfigProvider, theme, Spin } from 'antd';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import FeatureGate from '@/components/FeatureGate';
|
||||
import AppLayout from '@/components/AppLayout';
|
||||
import PublicLayout from '@/components/PublicLayout';
|
||||
import VolunteerLayout from '@/components/VolunteerLayout';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import DashboardPage from '@/pages/DashboardPage';
|
||||
import UsersPage from '@/pages/UsersPage';
|
||||
import CampaignsPage from '@/pages/CampaignsPage';
|
||||
import RepresentativesPage from '@/pages/RepresentativesPage';
|
||||
import EmailQueuePage from '@/pages/EmailQueuePage';
|
||||
import ResponsesPage from '@/pages/ResponsesPage';
|
||||
import LocationsPage from '@/pages/LocationsPage';
|
||||
import CutsPage from '@/pages/CutsPage';
|
||||
import ShiftsPage from '@/pages/ShiftsPage';
|
||||
import MapSettingsPage from '@/pages/MapSettingsPage';
|
||||
import CutExportPage from '@/pages/CutExportPage';
|
||||
import CanvassDashboardPage from '@/pages/CanvassDashboardPage';
|
||||
import ListmonkPage from '@/pages/ListmonkPage';
|
||||
import LandingPagesPage from '@/pages/LandingPagesPage';
|
||||
import PageEditorPage from '@/pages/PageEditorPage';
|
||||
import DocsPage from '@/pages/DocsPage';
|
||||
import MkDocsSettingsPage from '@/pages/MkDocsSettingsPage';
|
||||
import CodeEditorPage from '@/pages/CodeEditorPage';
|
||||
import NocoDBPage from '@/pages/NocoDBPage';
|
||||
import N8nPage from '@/pages/N8nPage';
|
||||
import GiteaPage from '@/pages/GiteaPage';
|
||||
import MailHogPage from '@/pages/MailHogPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
import PangolinPage from '@/pages/PangolinPage';
|
||||
import PublicLandingPage from '@/pages/public/LandingPage';
|
||||
import CampaignsListPage from '@/pages/public/CampaignsListPage';
|
||||
import CampaignPage from '@/pages/public/CampaignPage';
|
||||
import ResponseWallPage from '@/pages/public/ResponseWallPage';
|
||||
import MapPage from '@/pages/public/MapPage';
|
||||
import PublicShiftsPage from '@/pages/public/ShiftsPage';
|
||||
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
|
||||
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
|
||||
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
|
||||
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
|
||||
import { ADMIN_ROLES } from '@/types/api';
|
||||
|
||||
function RoleAwareRedirect() {
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||
if (user && ADMIN_ROLES.includes(user.role)) return <Navigate to="/app" replace />;
|
||||
return <Navigate to="/volunteer" replace />;
|
||||
}
|
||||
|
||||
function NavigateToCutMap() {
|
||||
const { cutId } = useParams<{ cutId: string }>();
|
||||
return <Navigate to={`/volunteer?cutId=${cutId}`} replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { hydrate, isLoading } = useAuthStore();
|
||||
const { settings, fetchSettings } = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
fetchSettings();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Dynamic document title + favicon from settings
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
document.title = `${settings.organizationName || 'Changemaker Lite'} — Admin`;
|
||||
if (settings.organizationFaviconUrl) {
|
||||
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = settings.organizationFaviconUrl;
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: settings?.adminColorPrimary ?? '#9d4edd',
|
||||
colorBgBase: settings?.adminColorBgBase ?? '#1a1025',
|
||||
colorBorder: 'rgba(255,255,255,0.08)',
|
||||
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 6,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntApp>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public pages (no auth, dark blue theme) — feature-gated */}
|
||||
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<CampaignsListPage />} />
|
||||
</Route>
|
||||
<Route path="/campaign" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
|
||||
<Route path=":slug" element={<CampaignPage />} />
|
||||
<Route path=":slug/responses" element={<ResponseWallPage />} />
|
||||
</Route>
|
||||
<Route path="/shifts" element={<FeatureGate feature="enableMap"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<PublicShiftsPage />} />
|
||||
</Route>
|
||||
<Route path="/map" element={<FeatureGate feature="enableMap"><MapPage /></FeatureGate>} />
|
||||
<Route path="/p/:slug" element={<FeatureGate feature="enableLandingPages"><PublicLandingPage /></FeatureGate>} />
|
||||
|
||||
{/* Volunteer map — full-screen, default landing page */}
|
||||
<Route
|
||||
path="/volunteer"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<VolunteerMapPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Volunteer pages with VolunteerLayout */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<VolunteerLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route path="/volunteer/activity" element={<MyActivityPage />} />
|
||||
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
|
||||
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
|
||||
</Route>
|
||||
|
||||
{/* Redirect old canvass routes to map with query param */}
|
||||
<Route
|
||||
path="/volunteer/canvass/:cutId"
|
||||
element={<NavigateToCutMap />}
|
||||
/>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/app/pages/:id/edit"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<PageEditorPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route
|
||||
path="users"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="campaigns"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<CampaignsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="representatives"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<RepresentativesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="email-queue"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<EmailQueuePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="responses"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<ResponsesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="listmonk"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||
<ListmonkPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="pages"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<LandingPagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="docs"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<DocsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="docs/settings"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<MkDocsSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="code"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||
<CodeEditorPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="services/nocodb"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||
<NocoDBPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="services/n8n"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||
<N8nPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="services/gitea"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||
<GiteaPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="services/mailhog"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||
<MailHogPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="settings"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tunnel"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
||||
<PangolinPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<LocationsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map/shifts"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<ShiftsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map/cuts"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<CutsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map/settings"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<MapSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map/cuts/:id/export"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<CutExportPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="map/canvass"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
||||
<CanvassDashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="*" element={<RoleAwareRedirect />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
332
admin/src/components/AppLayout.tsx
Normal file
332
admin/src/components/AppLayout.tsx
Normal file
@ -0,0 +1,332 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
|
||||
import { Layout, Menu, Dropdown, Button, Typography, Drawer, Grid, theme } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
SendOutlined,
|
||||
IdcardOutlined,
|
||||
MailOutlined,
|
||||
MessageOutlined,
|
||||
EnvironmentOutlined,
|
||||
TeamOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
MenuOutlined,
|
||||
ScissorOutlined,
|
||||
CalendarOutlined,
|
||||
FileTextOutlined,
|
||||
NotificationOutlined,
|
||||
BookOutlined,
|
||||
GlobalOutlined,
|
||||
CodeOutlined,
|
||||
DatabaseOutlined,
|
||||
ApiOutlined,
|
||||
BranchesOutlined,
|
||||
CloudServerOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
const { Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
export interface PageHeaderConfig {
|
||||
title?: string;
|
||||
actions?: ReactNode;
|
||||
fullBleed?: boolean;
|
||||
}
|
||||
|
||||
export interface AppOutletContext {
|
||||
setPageHeader: (config: PageHeaderConfig | null) => void;
|
||||
}
|
||||
|
||||
function buildMenuItems(settings: import('@/types/api').SiteSettings | null): MenuProps['items'] {
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '/app',
|
||||
icon: <DashboardOutlined />,
|
||||
label: 'Dashboard',
|
||||
},
|
||||
];
|
||||
|
||||
if (settings?.enableInfluence !== false) {
|
||||
items.push({
|
||||
key: 'influence-submenu',
|
||||
icon: <SendOutlined />,
|
||||
label: 'Influence',
|
||||
children: [
|
||||
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
|
||||
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
|
||||
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
|
||||
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (settings?.enableNewsletter !== false) {
|
||||
items.push({
|
||||
key: '/app/listmonk',
|
||||
icon: <NotificationOutlined />,
|
||||
label: 'Newsletter',
|
||||
});
|
||||
}
|
||||
|
||||
// Web submenu — conditionally include Landing Pages
|
||||
const webChildren: MenuProps['items'] = [];
|
||||
if (settings?.enableLandingPages !== false) {
|
||||
webChildren.push({ key: '/app/pages', icon: <FileTextOutlined />, label: 'Landing Pages' });
|
||||
}
|
||||
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
|
||||
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
|
||||
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
|
||||
items.push({
|
||||
key: 'web-submenu',
|
||||
icon: <GlobalOutlined />,
|
||||
label: 'Web',
|
||||
children: webChildren,
|
||||
});
|
||||
|
||||
if (settings?.enableMap !== false) {
|
||||
items.push({
|
||||
key: 'map-submenu',
|
||||
icon: <EnvironmentOutlined />,
|
||||
label: 'Map',
|
||||
children: [
|
||||
{ key: '/app/map', label: 'Locations' },
|
||||
{ key: '/app/map/shifts', icon: <CalendarOutlined />, label: 'Shifts' },
|
||||
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Cuts' },
|
||||
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
|
||||
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
key: 'services-submenu',
|
||||
icon: <CloudServerOutlined />,
|
||||
label: 'Services',
|
||||
children: [
|
||||
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
|
||||
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
|
||||
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
|
||||
{ key: '/app/services/mailhog', icon: <MailOutlined />, label: 'MailHog' },
|
||||
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
|
||||
],
|
||||
});
|
||||
|
||||
items.push(
|
||||
{
|
||||
key: '/app/users',
|
||||
icon: <TeamOutlined />,
|
||||
label: 'Users',
|
||||
},
|
||||
{
|
||||
key: '/app/settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Settings',
|
||||
},
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [pageHeader, setPageHeader] = useState<PageHeaderConfig | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const { token } = theme.useToken();
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const menuItems = buildMenuItems(settings);
|
||||
|
||||
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
|
||||
navigate(key);
|
||||
if (isMobile) setDrawerOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Logout',
|
||||
onClick: handleLogout,
|
||||
},
|
||||
];
|
||||
|
||||
// Match the current path to a menu key (supports submenus)
|
||||
const selectedKey = (() => {
|
||||
const path = location.pathname;
|
||||
// Exact match first
|
||||
if (path === '/app') return '/app';
|
||||
// Check all items including children — longest match wins
|
||||
let best = '';
|
||||
for (const item of menuItems || []) {
|
||||
if (!item || !('key' in item)) continue;
|
||||
if ('children' in item && item.children) {
|
||||
for (const child of item.children) {
|
||||
if (!child || !('key' in child)) continue;
|
||||
const k = child.key as string;
|
||||
if (path === k || path.startsWith(k + '/')) {
|
||||
if (k.length > best.length) best = k;
|
||||
}
|
||||
}
|
||||
}
|
||||
const k = item.key?.toString() || '';
|
||||
if (k.startsWith('/') && k !== '/app' && (path === k || path.startsWith(k + '/'))) {
|
||||
if (k.length > best.length) best = k;
|
||||
}
|
||||
}
|
||||
return best || '/app';
|
||||
})();
|
||||
|
||||
const fullBleed = pageHeader?.fullBleed === true;
|
||||
|
||||
const logoUrl = settings?.organizationLogoUrl;
|
||||
const showLogo = logoUrl && !(collapsed && !isMobile);
|
||||
|
||||
const sidebarMenu = (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: 64,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: collapsed && !isMobile ? 14 : 16,
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
padding: '0 8px',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{showLogo ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={settings?.organizationName ?? 'Logo'}
|
||||
style={{ maxHeight: 36, maxWidth: collapsed && !isMobile ? 48 : 160, objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
collapsed && !isMobile
|
||||
? (settings?.organizationShortName ?? 'CML')
|
||||
: (settings?.organizationName ?? 'Changemaker Lite')
|
||||
)}
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultOpenKeys={['influence-submenu', 'map-submenu', 'web-submenu', 'services-submenu']}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
{isMobile ? (
|
||||
<Drawer
|
||||
placement="left"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={256}
|
||||
styles={{ body: { padding: 0, background: '#001529' }, header: { display: 'none' } }}
|
||||
>
|
||||
{sidebarMenu}
|
||||
</Drawer>
|
||||
) : (
|
||||
<Sider
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
style={{ overflow: 'auto', height: '100vh', position: 'sticky', top: 0, left: 0 }}
|
||||
>
|
||||
{sidebarMenu}
|
||||
</Sider>
|
||||
)}
|
||||
<Layout>
|
||||
<Header
|
||||
style={{
|
||||
padding: '0 24px',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={
|
||||
isMobile
|
||||
? <MenuOutlined />
|
||||
: collapsed
|
||||
? <MenuUnfoldOutlined />
|
||||
: <MenuFoldOutlined />
|
||||
}
|
||||
onClick={() => (isMobile ? setDrawerOpen(true) : setCollapsed(!collapsed))}
|
||||
/>
|
||||
{pageHeader?.title && (
|
||||
<Text strong style={{ fontSize: 16, whiteSpace: 'nowrap' }}>
|
||||
{pageHeader.title}
|
||||
</Text>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{pageHeader?.actions}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<GlobalOutlined />}
|
||||
onClick={() => window.open(`//${window.location.hostname}:4004`, '_blank')}
|
||||
title="Open Static Site"
|
||||
>
|
||||
{!isMobile && 'Open Site'}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EnvironmentOutlined />}
|
||||
onClick={() => navigate('/volunteer')}
|
||||
title="Switch to Volunteer Portal"
|
||||
>
|
||||
{!isMobile && 'Canvass'}
|
||||
</Button>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Button type="text" icon={<UserOutlined />}>
|
||||
{!isMobile && (
|
||||
<Text style={{ marginLeft: 8 }}>
|
||||
{user?.name || user?.email || 'User'}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
|
||||
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
|
||||
background: 'transparent',
|
||||
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
|
||||
minHeight: 280,
|
||||
overflow: fullBleed ? 'hidden' : undefined,
|
||||
}}
|
||||
>
|
||||
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
29
admin/src/components/FeatureGate.tsx
Normal file
29
admin/src/components/FeatureGate.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Result } from 'antd';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import type { SiteSettings } from '@/types/api';
|
||||
|
||||
interface FeatureGateProps {
|
||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter'>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function FeatureGate({ feature, children }: FeatureGateProps) {
|
||||
const { settings, loading } = useSettingsStore();
|
||||
|
||||
// While loading or if settings haven't arrived yet, render children (optimistic)
|
||||
if (loading || !settings) return <>{children}</>;
|
||||
|
||||
if (settings[feature] === false) {
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="Not Available"
|
||||
subTitle="This feature is currently disabled."
|
||||
style={{ paddingTop: 80 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
223
admin/src/components/GrapesJSEditor.tsx
Normal file
223
admin/src/components/GrapesJSEditor.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { useEffect, useRef, useImperativeHandle, forwardRef, useState } from 'react';
|
||||
import grapesjs, { Editor } from 'grapesjs';
|
||||
import 'grapesjs/dist/css/grapes.min.css';
|
||||
import blocksBasicPlugin from 'grapesjs-blocks-basic';
|
||||
import presetWebpagePlugin from 'grapesjs-preset-webpage';
|
||||
import formsPlugin from 'grapesjs-plugin-forms';
|
||||
import navbarPlugin from 'grapesjs-navbar';
|
||||
import countdownPlugin from 'grapesjs-component-countdown';
|
||||
import tabsPlugin from 'grapesjs-tabs';
|
||||
import typedPlugin from 'grapesjs-typed';
|
||||
import customCodePlugin from 'grapesjs-custom-code';
|
||||
import exportPlugin from 'grapesjs-plugin-export';
|
||||
import styleGradientPlugin from 'grapesjs-style-gradient';
|
||||
import touchPlugin from 'grapesjs-touch';
|
||||
import type { PageBlock } from '@/types/api';
|
||||
|
||||
interface GrapesJSEditorProps {
|
||||
initialData?: Record<string, unknown>;
|
||||
onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;
|
||||
customBlocks?: PageBlock[];
|
||||
}
|
||||
|
||||
export interface GrapesJSEditorHandle {
|
||||
triggerSave: () => void;
|
||||
}
|
||||
|
||||
const GrapesJSEditor = forwardRef<GrapesJSEditorHandle, GrapesJSEditorProps>(
|
||||
function GrapesJSEditor({ initialData, onSave, customBlocks }, ref) {
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onSaveRef = useRef(onSave);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
onSaveRef.current = onSave;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
triggerSave() {
|
||||
editorRef.current?.runCommand('save-page');
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
let editor: Editor;
|
||||
try {
|
||||
editor = grapesjs.init({
|
||||
container: containerRef.current,
|
||||
height: '100%',
|
||||
width: 'auto',
|
||||
storageManager: false,
|
||||
plugins: [
|
||||
blocksBasicPlugin,
|
||||
presetWebpagePlugin,
|
||||
formsPlugin,
|
||||
navbarPlugin,
|
||||
countdownPlugin,
|
||||
tabsPlugin,
|
||||
typedPlugin,
|
||||
customCodePlugin,
|
||||
exportPlugin,
|
||||
styleGradientPlugin,
|
||||
touchPlugin,
|
||||
],
|
||||
pluginsOpts: {
|
||||
[blocksBasicPlugin as unknown as string]: {
|
||||
flexGrid: true,
|
||||
},
|
||||
[presetWebpagePlugin as unknown as string]: {},
|
||||
[formsPlugin as unknown as string]: {},
|
||||
[navbarPlugin as unknown as string]: {},
|
||||
[countdownPlugin as unknown as string]: {},
|
||||
[tabsPlugin as unknown as string]: {},
|
||||
[typedPlugin as unknown as string]: {},
|
||||
[customCodePlugin as unknown as string]: {},
|
||||
[exportPlugin as unknown as string]: {},
|
||||
[styleGradientPlugin as unknown as string]: {},
|
||||
[touchPlugin as unknown as string]: {},
|
||||
},
|
||||
canvas: {
|
||||
styles: [
|
||||
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('GrapesJS init error:', err);
|
||||
setError('Failed to initialize the page editor. Please refresh the page.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Register custom blocks from PageBlock library
|
||||
if (customBlocks && customBlocks.length > 0) {
|
||||
const blockManager = editor.Blocks;
|
||||
for (const block of customBlocks) {
|
||||
const defaults = block.defaults as Record<string, unknown>;
|
||||
const html = generateBlockHtml(block.type, defaults);
|
||||
blockManager.add(`custom-${block.type}`, {
|
||||
label: block.label,
|
||||
category: block.category || 'Campaign',
|
||||
content: html,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Register save command
|
||||
editor.Commands.add('save-page', {
|
||||
run(ed: Editor) {
|
||||
const projectData = ed.getProjectData() as Record<string, unknown>;
|
||||
const html = ed.getHtml();
|
||||
const css = ed.getCss() || '';
|
||||
onSaveRef.current({ projectData, html, css });
|
||||
},
|
||||
});
|
||||
|
||||
// Keyboard shortcut: Ctrl+S / Cmd+S
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
editor.runCommand('save-page');
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Load initial project data
|
||||
if (initialData && Object.keys(initialData).length > 0) {
|
||||
editor.loadProjectData(initialData);
|
||||
}
|
||||
|
||||
editorRef.current = editor;
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
editor.destroy();
|
||||
editorRef.current = null;
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d4f' }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="application"
|
||||
aria-label="Page builder editor"
|
||||
style={{ flex: 1, overflow: 'hidden' }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function generateBlockHtml(type: string, defaults: Record<string, unknown>): string {
|
||||
switch (type) {
|
||||
case 'hero':
|
||||
return `
|
||||
<section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
|
||||
<h1 style="font-size: 2.5rem; margin-bottom: 16px;">${defaults.title || 'Hero Title'}</h1>
|
||||
<p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">${defaults.subtitle || 'Subtitle text here'}</p>
|
||||
<a href="${defaults.ctaUrl || '#'}" style="display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;">${defaults.ctaText || 'Get Started'}</a>
|
||||
</section>`;
|
||||
case 'text':
|
||||
return `
|
||||
<section style="padding: 60px 40px; max-width: 800px; margin: 0 auto;">
|
||||
<h2 style="font-size: 1.75rem; margin-bottom: 16px;">${defaults.heading || 'Heading'}</h2>
|
||||
<p style="font-size: 1rem; line-height: 1.7; opacity: 0.85;">${defaults.body || 'Body text goes here.'}</p>
|
||||
</section>`;
|
||||
case 'features': {
|
||||
const features = (defaults.features as Array<{ title: string; description: string }>) || [];
|
||||
const featureHtml = features.map(f => `
|
||||
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
|
||||
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">${f.title}</h3>
|
||||
<p style="opacity: 0.8;">${f.description}</p>
|
||||
</div>`).join('');
|
||||
return `
|
||||
<section style="padding: 60px 40px;">
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
|
||||
${featureHtml}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
case 'cta':
|
||||
return `
|
||||
<section style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;">
|
||||
<h2 style="font-size: 2rem; margin-bottom: 12px;">${defaults.heading || 'Call to Action'}</h2>
|
||||
<p style="font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;">${defaults.description || 'Description here'}</p>
|
||||
<a href="${defaults.buttonUrl || '#'}" style="display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;">${defaults.buttonText || 'Click Here'}</a>
|
||||
</section>`;
|
||||
case 'testimonials': {
|
||||
const quotes = (defaults.quotes as Array<{ text: string; author: string; role: string }>) || [];
|
||||
const quoteHtml = quotes.map(q => `
|
||||
<div style="flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;">
|
||||
<p style="font-style: italic; margin-bottom: 12px;">"${q.text}"</p>
|
||||
<p style="font-weight: 600; margin-bottom: 2px;">${q.author}</p>
|
||||
<p style="font-size: 0.85rem; opacity: 0.7;">${q.role}</p>
|
||||
</div>`).join('');
|
||||
return `
|
||||
<section style="padding: 60px 40px;">
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
|
||||
${quoteHtml}
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
case 'contact-form':
|
||||
return `
|
||||
<section style="padding: 60px 40px; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="text-align: center; margin-bottom: 24px;">${defaults.heading || 'Contact Us'}</h2>
|
||||
<form style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<input type="text" placeholder="Name" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;" />
|
||||
<input type="email" placeholder="Email" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;" />
|
||||
<textarea placeholder="Message" rows="4" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit; resize: vertical;"></textarea>
|
||||
<button type="submit" style="padding: 12px 24px; background: #9d4edd; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">Send Message</button>
|
||||
</form>
|
||||
</section>`;
|
||||
default:
|
||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||
}
|
||||
}
|
||||
|
||||
export default GrapesJSEditor;
|
||||
47
admin/src/components/ProtectedRoute.tsx
Normal file
47
admin/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Spin, Result } from 'antd';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import type { UserRole } from '@/types/api';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requiredRoles?: UserRole[];
|
||||
}
|
||||
|
||||
export default function ProtectedRoute({
|
||||
children,
|
||||
requiredRoles,
|
||||
}: ProtectedRouteProps) {
|
||||
const { isAuthenticated, isLoading, user } = useAuthStore();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
title="403"
|
||||
subTitle="You do not have permission to access this page."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
104
admin/src/components/PublicLayout.tsx
Normal file
104
admin/src/components/PublicLayout.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ConfigProvider, Layout, Typography, theme } from 'antd';
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
const { Header, Content, Footer } = Layout;
|
||||
|
||||
export default function PublicLayout() {
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
|
||||
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
||||
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
|
||||
const headerGradient = settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
|
||||
const orgName = settings?.organizationName ?? 'Changemaker Lite';
|
||||
const footerText = settings?.footerText ?? 'Powered by Changemaker Lite';
|
||||
const logoUrl = settings?.organizationLogoUrl;
|
||||
|
||||
// Dynamic document title + favicon for public pages
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
document.title = settings.organizationName || 'Changemaker Lite';
|
||||
if (settings.organizationFaviconUrl) {
|
||||
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = settings.organizationFaviconUrl;
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary,
|
||||
colorBgBase,
|
||||
colorBgContainer,
|
||||
colorBgElevated: colorBgContainer,
|
||||
colorBorder: 'rgba(255,255,255,0.1)',
|
||||
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 8,
|
||||
colorLink: colorPrimary,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
|
||||
<Header
|
||||
style={{
|
||||
background: headerGradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 24px',
|
||||
height: 56,
|
||||
borderBottom: 'none',
|
||||
}}
|
||||
>
|
||||
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={orgName}
|
||||
style={{ maxHeight: 32, objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
<Typography.Text strong style={{ fontSize: 18, color: '#fff' }}>
|
||||
{orgName}
|
||||
</Typography.Text>
|
||||
</Link>
|
||||
</Header>
|
||||
<Content
|
||||
style={{
|
||||
maxWidth: 960,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
padding: '24px 16px',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
<Footer
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
background: 'transparent',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
fontSize: 13,
|
||||
borderTop: '1px solid rgba(255,255,255,0.06)',
|
||||
}}
|
||||
>
|
||||
<div>{footerText}</div>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
|
||||
Return to Main Page
|
||||
</Link>
|
||||
</div>
|
||||
</Footer>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
77
admin/src/components/VolunteerFooterNav.tsx
Normal file
77
admin/src/components/VolunteerFooterNav.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { theme } from 'antd';
|
||||
import {
|
||||
EnvironmentOutlined,
|
||||
CalendarOutlined,
|
||||
HistoryOutlined,
|
||||
NodeIndexOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' },
|
||||
{ key: '/volunteer/shifts', icon: CalendarOutlined, label: 'Shifts' },
|
||||
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
|
||||
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },
|
||||
];
|
||||
|
||||
interface VolunteerFooterNavProps {
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function VolunteerFooterNav({ style }: VolunteerFooterNavProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const activeKey = (() => {
|
||||
const path = location.pathname;
|
||||
if (path === '/volunteer') return '/volunteer';
|
||||
for (const item of NAV_ITEMS) {
|
||||
if (item.key !== '/volunteer' && path.startsWith(item.key)) return item.key;
|
||||
}
|
||||
return '/volunteer';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
minHeight: 56,
|
||||
background: 'rgba(13, 27, 42, 0.95)',
|
||||
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{NAV_ITEMS.map(({ key, icon: Icon, label }) => {
|
||||
const isActive = activeKey === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => navigate(key)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
cursor: 'pointer',
|
||||
padding: '6px 0',
|
||||
color: isActive ? token.colorPrimary : 'rgba(255,255,255,0.5)',
|
||||
transition: 'color 0.2s',
|
||||
}}
|
||||
>
|
||||
<Icon style={{ fontSize: 22, marginBottom: 2 }} />
|
||||
<span style={{ fontSize: 12, lineHeight: '16px', fontWeight: isActive ? 600 : 400 }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
admin/src/components/VolunteerLayout.tsx
Normal file
91
admin/src/components/VolunteerLayout.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useNavigate, Outlet } from 'react-router-dom';
|
||||
import { ConfigProvider, Layout, Button, Typography, Dropdown, theme } from 'antd';
|
||||
import { LogoutOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
|
||||
|
||||
const { Header, Content, Footer } = Layout;
|
||||
|
||||
export default function VolunteerLayout() {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { settings } = useSettingsStore();
|
||||
|
||||
const orgName = settings?.organizationName ?? 'Changemaker Lite';
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const userMenuItems: MenuProps['items'] = [
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: 'Logout', onClick: handleLogout },
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
|
||||
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
|
||||
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
|
||||
colorBgElevated: settings?.publicColorBgContainer ?? '#1b2838',
|
||||
colorBorder: 'rgba(255,255,255,0.1)',
|
||||
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Layout style={{ minHeight: '100vh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}>
|
||||
<Header
|
||||
style={{
|
||||
background: settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 16px',
|
||||
height: 48,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 16, color: '#fff', flex: 1 }}>
|
||||
{orgName}
|
||||
</Typography.Text>
|
||||
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||
<Button type="text" size="small" icon={<UserOutlined style={{ color: '#fff' }} />}>
|
||||
<Typography.Text style={{ marginLeft: 4, color: '#fff', fontSize: 13 }}>
|
||||
{user?.name || user?.email || 'User'}
|
||||
</Typography.Text>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
|
||||
<Content
|
||||
style={{
|
||||
maxWidth: 800,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
padding: '16px 12px max(72px, calc(56px + 16px + env(safe-area-inset-bottom))) 12px',
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</Content>
|
||||
|
||||
<Footer
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 0,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<VolunteerFooterNav />
|
||||
</Footer>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
293
admin/src/components/canvass/AddLocationDrawer.tsx
Normal file
293
admin/src/components/canvass/AddLocationDrawer.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Drawer, Form, Input, Button, Spin, Typography, Space, App } from 'antd';
|
||||
import type { SupportLevel, UserRole } from '@/types/api';
|
||||
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS, MAP_ADMIN_ROLES } from '@/types/api';
|
||||
import type { VisitOutcome } from '@/types/canvass';
|
||||
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
|
||||
import { useCanvassStore } from '@/stores/canvass.store';
|
||||
import { useTrackingStore } from '@/stores/tracking.store';
|
||||
|
||||
interface AddLocationDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
userRole: UserRole;
|
||||
sessionId?: string;
|
||||
shiftId?: string;
|
||||
}
|
||||
|
||||
const outcomeKeys: VisitOutcome[] = [
|
||||
'SPOKE_WITH',
|
||||
'NOT_HOME',
|
||||
'REFUSED',
|
||||
'LEFT_LITERATURE',
|
||||
'COME_BACK_LATER',
|
||||
'MOVED',
|
||||
'ALREADY_VOTED',
|
||||
];
|
||||
|
||||
const supportLevelKeys: SupportLevel[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'];
|
||||
|
||||
export default function AddLocationDrawer({
|
||||
open,
|
||||
onClose,
|
||||
lat,
|
||||
lng,
|
||||
userRole,
|
||||
sessionId,
|
||||
shiftId,
|
||||
}: AddLocationDrawerProps) {
|
||||
const [form] = Form.useForm();
|
||||
const { message } = App.useApp();
|
||||
const { addLocation, recordVisit, reverseGeocode } = useCanvassStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [geocoding, setGeocoding] = useState(false);
|
||||
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
|
||||
const [supportLevel, setSupportLevel] = useState<SupportLevel | undefined>(undefined);
|
||||
const [signRequested, setSignRequested] = useState(false);
|
||||
const [signSize, setSignSize] = useState<string | undefined>(undefined);
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const isTemp = userRole === 'TEMP';
|
||||
const isAdmin = MAP_ADMIN_ROLES.includes(userRole);
|
||||
const showDetailFields = !isTemp;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || lat === null || lng === null) return;
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ address: '' });
|
||||
setOutcome(null);
|
||||
setSupportLevel(undefined);
|
||||
setSignRequested(false);
|
||||
setSignSize(undefined);
|
||||
setNotes('');
|
||||
|
||||
// Reverse geocode the coordinates
|
||||
setGeocoding(true);
|
||||
reverseGeocode(lat, lng)
|
||||
.then((result) => {
|
||||
form.setFieldsValue({ address: result.address });
|
||||
})
|
||||
.catch(() => {
|
||||
// Non-critical — user can type address manually
|
||||
})
|
||||
.finally(() => setGeocoding(false));
|
||||
}, [open, lat, lng, form, reverseGeocode]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (lat === null || lng === null) return;
|
||||
if (!outcome) {
|
||||
message.warning('Select an outcome');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
// Build location data — include support level + sign from visit fields
|
||||
const locationData: Record<string, unknown> = {
|
||||
...values,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
};
|
||||
if (showDetailFields && supportLevel) locationData.supportLevel = supportLevel;
|
||||
if (showDetailFields && signRequested) {
|
||||
locationData.sign = true;
|
||||
if (signSize) locationData.signSize = signSize;
|
||||
}
|
||||
if (showDetailFields && notes) locationData.notes = notes;
|
||||
|
||||
// Create location
|
||||
const newLoc = await addLocation(locationData);
|
||||
|
||||
// Track event point for location added
|
||||
useTrackingStore.getState().addEventPoint(lat, lng, 'LOCATION_ADDED');
|
||||
|
||||
// Record visit on the new location
|
||||
await recordVisit({
|
||||
locationId: newLoc.id,
|
||||
outcome,
|
||||
supportLevel,
|
||||
signRequested,
|
||||
signSize,
|
||||
notes: notes || undefined,
|
||||
sessionId,
|
||||
shiftId,
|
||||
});
|
||||
|
||||
message.success('Location added & visit recorded');
|
||||
onClose();
|
||||
} catch {
|
||||
message.error('Failed to add location');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
placement="bottom"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
height="auto"
|
||||
forceRender
|
||||
styles={{
|
||||
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },
|
||||
}}
|
||||
title="New Door"
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={handleSubmit} loading={loading} disabled={!outcome}>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{geocoding && (
|
||||
<div style={{ textAlign: 'center', padding: 20 }}>
|
||||
<Spin size="small" />
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||
Looking up address...
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: geocoding ? 'none' : undefined }}>
|
||||
<Form form={form} layout="vertical" size="small">
|
||||
<Form.Item
|
||||
name="address"
|
||||
label="Address"
|
||||
rules={[{ required: true, message: 'Address is required' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="unitNumber" label="Unit / Apt #">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Form.Item name="firstName" label="First Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="lastName" label="Last Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="Email">
|
||||
<Input type="email" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="Phone">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
{/* Outcome — always visible */}
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||
Outcome
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{outcomeKeys.map((key) => {
|
||||
const color = VISIT_OUTCOME_COLORS[key];
|
||||
const selected = outcome === key;
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
size="middle"
|
||||
type={selected ? 'primary' : 'default'}
|
||||
style={{
|
||||
borderColor: color,
|
||||
background: selected ? color : 'transparent',
|
||||
color: selected ? '#fff' : color,
|
||||
fontSize: 12,
|
||||
}}
|
||||
onClick={() => setOutcome(key)}
|
||||
>
|
||||
{VISIT_OUTCOME_LABELS[key]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Support level — non-TEMP, only when SPOKE_WITH */}
|
||||
{showDetailFields && outcome === 'SPOKE_WITH' && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||
Support Level
|
||||
</Typography.Text>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
{supportLevelKeys.map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
shape="circle"
|
||||
size="large"
|
||||
type={supportLevel === key ? 'primary' : 'default'}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: supportLevel === key ? SUPPORT_LEVEL_COLORS[key] : undefined,
|
||||
borderColor: SUPPORT_LEVEL_COLORS[key],
|
||||
color: supportLevel === key ? '#fff' : SUPPORT_LEVEL_COLORS[key],
|
||||
fontWeight: 700,
|
||||
}}
|
||||
onClick={() => setSupportLevel(supportLevel === key ? undefined : key)}
|
||||
>
|
||||
{key.replace('LEVEL_', '')}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
{supportLevel && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{SUPPORT_LEVEL_LABELS[supportLevel]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||
Sign
|
||||
</Typography.Text>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
type={signRequested ? 'primary' : 'default'}
|
||||
onClick={() => { setSignRequested(!signRequested); if (signRequested) setSignSize(undefined); }}
|
||||
>
|
||||
{signRequested ? 'Yes' : 'No Sign'}
|
||||
</Button>
|
||||
{signRequested && (
|
||||
<>
|
||||
{(['Regular', 'Large', 'Unsure'] as const).map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
type={signSize === size ? 'primary' : 'default'}
|
||||
onClick={() => setSignSize(size)}
|
||||
size="small"
|
||||
>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Notes — non-TEMP */}
|
||||
{showDetailFields && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||
Notes
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Optional notes..."
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
104
admin/src/components/canvass/AddressSearchOverlay.tsx
Normal file
104
admin/src/components/canvass/AddressSearchOverlay.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Input, Button, App } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { SearchOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { useCanvassStore } from '@/stores/canvass.store';
|
||||
|
||||
interface Props {
|
||||
onFlyTo: (lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
export default function AddressSearchOverlay({ onFlyTo }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const { message } = App.useApp();
|
||||
const { geocodeSearch } = useCanvassStore();
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!query.trim()) return;
|
||||
setSearching(true);
|
||||
try {
|
||||
const result = await geocodeSearch(query.trim());
|
||||
onFlyTo(result.latitude, result.longitude);
|
||||
setExpanded(false);
|
||||
setQuery('');
|
||||
} catch {
|
||||
message.error('Address not found');
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
title="Search address"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 60,
|
||||
zIndex: 1000,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 16,
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
>
|
||||
<SearchOutlined />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
left: 60,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
borderRadius: 8,
|
||||
padding: '4px 6px',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Search address..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
size="small"
|
||||
style={{ width: 200, background: 'rgba(255,255,255,0.1)', border: 'none', color: '#fff' }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleSearch}
|
||||
loading={searching}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => { setExpanded(false); setQuery(''); }}
|
||||
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', color: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
admin/src/components/canvass/AdminLiveMap.tsx
Normal file
67
admin/src/components/canvass/AdminLiveMap.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { MapContainer, TileLayer } from 'react-leaflet';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { api } from '@/lib/api';
|
||||
import type { LiveVolunteer } from '@/types/tracking';
|
||||
import type { PublicCut, MapSettings } from '@/types/api';
|
||||
import CutOverlays from '@/components/map/CutOverlays';
|
||||
import VolunteerMarker from './VolunteerMarker';
|
||||
|
||||
const DARK_TILE = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
const DEFAULT_CENTER: [number, number] = [45.42, -75.69];
|
||||
const DEFAULT_ZOOM = 13;
|
||||
const POLL_INTERVAL = 15000;
|
||||
|
||||
interface AdminLiveMapProps {
|
||||
cuts: PublicCut[];
|
||||
mapSettings: MapSettings | null;
|
||||
}
|
||||
|
||||
export default function AdminLiveMap({ cuts, mapSettings }: AdminLiveMapProps) {
|
||||
const [volunteers, setVolunteers] = useState<LiveVolunteer[]>([]);
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
|
||||
const fetchLive = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/map/tracking/live');
|
||||
setVolunteers(data);
|
||||
} catch {
|
||||
// Silently fail — will retry
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLive();
|
||||
const interval = setInterval(fetchLive, POLL_INTERVAL);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchLive]);
|
||||
|
||||
const center: [number, number] = mapSettings?.latitude && mapSettings?.longitude
|
||||
? [parseFloat(mapSettings.latitude), parseFloat(mapSettings.longitude)]
|
||||
: DEFAULT_CENTER;
|
||||
const zoom = mapSettings?.zoom ?? DEFAULT_ZOOM;
|
||||
|
||||
const visibleCutIds = new Set(cuts.map((c) => c.id));
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
style={{ width: '100%', height: '100%', borderRadius: 6 }}
|
||||
zoomControl={true}
|
||||
ref={mapRef}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://carto.com">CARTO</a>'
|
||||
url={DARK_TILE}
|
||||
/>
|
||||
|
||||
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
|
||||
|
||||
{volunteers.map((v, i) => (
|
||||
<VolunteerMarker key={v.trackingSessionId} volunteer={v} index={i} />
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
130
admin/src/components/canvass/CanvassBottomToolbar.tsx
Normal file
130
admin/src/components/canvass/CanvassBottomToolbar.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { Button, Badge } from 'antd';
|
||||
import {
|
||||
AimOutlined,
|
||||
NodeIndexOutlined,
|
||||
ArrowRightOutlined,
|
||||
PlusOutlined,
|
||||
FullscreenOutlined,
|
||||
FullscreenExitOutlined,
|
||||
MenuOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface CanvassBottomToolbarProps {
|
||||
visitedCount: number;
|
||||
totalCount: number;
|
||||
routeVisible: boolean;
|
||||
gpsFollowing: boolean;
|
||||
onNextDoor: () => void;
|
||||
onToggleRoute: () => void;
|
||||
onToggleGps: () => void;
|
||||
sessionActive?: boolean;
|
||||
onAddAtCenter?: () => void;
|
||||
fullscreen?: boolean;
|
||||
onToggleFullscreen?: () => void;
|
||||
onMenuOpen?: () => void;
|
||||
bottomOffset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating toolbar above the footer nav bar.
|
||||
*/
|
||||
export default function CanvassBottomToolbar({
|
||||
visitedCount,
|
||||
totalCount,
|
||||
routeVisible,
|
||||
gpsFollowing,
|
||||
onNextDoor,
|
||||
onToggleRoute,
|
||||
onToggleGps,
|
||||
sessionActive = true,
|
||||
onAddAtCenter,
|
||||
fullscreen,
|
||||
onToggleFullscreen,
|
||||
onMenuOpen,
|
||||
bottomOffset = 72,
|
||||
}: CanvassBottomToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: bottomOffset,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.8)',
|
||||
borderRadius: 12,
|
||||
padding: '6px 10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
{onMenuOpen && (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<MenuOutlined />}
|
||||
onClick={onMenuOpen}
|
||||
size="middle"
|
||||
aria-label="Open menu"
|
||||
/>
|
||||
)}
|
||||
{sessionActive && (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ArrowRightOutlined />}
|
||||
onClick={onNextDoor}
|
||||
size="middle"
|
||||
aria-label="Next door"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
type={routeVisible ? 'primary' : 'default'}
|
||||
icon={<NodeIndexOutlined />}
|
||||
onClick={onToggleRoute}
|
||||
size="middle"
|
||||
ghost={routeVisible}
|
||||
aria-label="Toggle walking route"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type={gpsFollowing ? 'primary' : 'default'}
|
||||
icon={<AimOutlined />}
|
||||
onClick={onToggleGps}
|
||||
size="middle"
|
||||
ghost={gpsFollowing}
|
||||
aria-label="Toggle GPS following"
|
||||
/>
|
||||
{onToggleFullscreen && (
|
||||
<Button
|
||||
type={fullscreen ? 'primary' : 'default'}
|
||||
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={onToggleFullscreen}
|
||||
size="middle"
|
||||
aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
/>
|
||||
)}
|
||||
{onAddAtCenter && (
|
||||
<Button
|
||||
type="default"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAddAtCenter}
|
||||
size="middle"
|
||||
aria-label="Add location at crosshair"
|
||||
/>
|
||||
)}
|
||||
{sessionActive && (
|
||||
<Badge
|
||||
count={`${visitedCount}/${totalCount}`}
|
||||
style={{
|
||||
backgroundColor: visitedCount === totalCount && totalCount > 0 ? '#27ae60' : '#3498db',
|
||||
fontSize: 12,
|
||||
}}
|
||||
overflowCount={99}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
admin/src/components/canvass/CanvassHeader.tsx
Normal file
60
admin/src/components/canvass/CanvassHeader.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import { ArrowLeftOutlined, StopOutlined } from '@ant-design/icons';
|
||||
import SessionTimer from './SessionTimer';
|
||||
|
||||
interface CanvassHeaderProps {
|
||||
cutName: string;
|
||||
startedAt: string;
|
||||
onBack: () => void;
|
||||
onEndSession: () => void;
|
||||
endingSession: boolean;
|
||||
}
|
||||
|
||||
export default function CanvassHeader({
|
||||
cutName,
|
||||
startedAt,
|
||||
onBack,
|
||||
onEndSession,
|
||||
endingSession,
|
||||
}: CanvassHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 48,
|
||||
zIndex: 1000,
|
||||
background: 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 8px',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined style={{ color: '#fff' }} />}
|
||||
onClick={onBack}
|
||||
size="small"
|
||||
/>
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{ color: '#fff', fontSize: 14, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{cutName}
|
||||
</Typography.Text>
|
||||
<SessionTimer startedAt={startedAt} />
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<StopOutlined />}
|
||||
onClick={onEndSession}
|
||||
loading={endingSession}
|
||||
>
|
||||
End
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
admin/src/components/canvass/CanvassLegend.tsx
Normal file
60
admin/src/components/canvass/CanvassLegend.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { VISIT_OUTCOME_COLORS, VISIT_OUTCOME_LABELS, type VisitOutcome } from '@/types/canvass';
|
||||
|
||||
const items: { key: string; label: string; color: string }[] = [
|
||||
{ key: 'unvisited', label: 'Not Visited', color: '#95a5a6' },
|
||||
...Object.entries(VISIT_OUTCOME_LABELS).map(([key, label]) => ({
|
||||
key,
|
||||
label,
|
||||
color: VISIT_OUTCOME_COLORS[key as VisitOutcome],
|
||||
})),
|
||||
];
|
||||
|
||||
export default function CanvassLegend() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
maxWidth: 180,
|
||||
}}
|
||||
>
|
||||
{/* Icon type indicators */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 6, paddingBottom: 4, borderBottom: '1px solid rgba(255,255,255,0.15)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<path d="M12 3L4 10v10a1 1 0 001 1h4v-6h6v6h4a1 1 0 001-1V10z" fill="rgba(255,255,255,0.7)" />
|
||||
</svg>
|
||||
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: 10 }}>House</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<svg width={14} height={14} viewBox="0 0 24 24">
|
||||
<rect x="4" y="3" width="16" height="18" rx="1" fill="rgba(255,255,255,0.7)" />
|
||||
<rect x="7" y="6" width="3" height="2.5" rx="0.3" fill="rgba(0,0,0,0.3)" />
|
||||
<rect x="14" y="6" width="3" height="2.5" rx="0.3" fill="rgba(0,0,0,0.3)" />
|
||||
</svg>
|
||||
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: 10 }}>Apt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item) => (
|
||||
<div key={item.key} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: item.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: '#fff', fontSize: 11 }}>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
admin/src/components/canvass/CanvassMarker.tsx
Normal file
78
admin/src/components/canvass/CanvassMarker.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Marker, Tooltip } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import type { CanvassLocation } from '@/types/canvass';
|
||||
import { VISIT_OUTCOME_COLORS } from '@/types/canvass';
|
||||
|
||||
interface CanvassMarkerProps {
|
||||
location: CanvassLocation;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function getMarkerColor(location: CanvassLocation): string {
|
||||
if (!location.lastVisit) return '#95a5a6'; // gray — unvisited
|
||||
return VISIT_OUTCOME_COLORS[location.lastVisit.outcome] ?? '#95a5a6';
|
||||
}
|
||||
|
||||
// Inline SVG for house icon
|
||||
function houseSvg(color: string, size: number, selected: boolean): string {
|
||||
const glow = selected ? `<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="none" stroke="white" stroke-width="3" opacity="0.6"/>` : '';
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
|
||||
${glow ? `<circle cx="12" cy="12" r="12" fill="none" stroke="white" stroke-width="2" opacity="0.5"/>` : ''}
|
||||
<path d="M12 3L4 10v10a1 1 0 001 1h4v-6h6v6h4a1 1 0 001-1V10z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
|
||||
<path d="M12 3L4 10h16z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Inline SVG for apartment/building icon
|
||||
function apartmentSvg(color: string, size: number, selected: boolean): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
|
||||
${selected ? `<circle cx="12" cy="12" r="12" fill="none" stroke="white" stroke-width="2" opacity="0.5"/>` : ''}
|
||||
<rect x="4" y="3" width="16" height="18" rx="1" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
|
||||
<rect x="7" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
|
||||
<rect x="14" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
|
||||
<rect x="7" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
|
||||
<rect x="14" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
|
||||
<rect x="10" y="16" width="4" height="5" rx="0.3" fill="rgba(255,255,255,0.4)"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
export default function CanvassMarker({ location, isSelected, onClick }: CanvassMarkerProps) {
|
||||
const color = getMarkerColor(location);
|
||||
const isApartment = !!location.unitNumber;
|
||||
const size = isSelected ? 34 : 26;
|
||||
|
||||
const icon = useMemo(() => {
|
||||
const svgHtml = isApartment
|
||||
? apartmentSvg(color, size, isSelected)
|
||||
: houseSvg(color, size, isSelected);
|
||||
|
||||
return L.divIcon({
|
||||
html: `<div style="width:44px;height:44px;display:flex;align-items:center;justify-content:center">${svgHtml}</div>`,
|
||||
iconSize: [44, 44],
|
||||
iconAnchor: [22, 22],
|
||||
className: '',
|
||||
});
|
||||
}, [color, size, isSelected, isApartment]);
|
||||
|
||||
return (
|
||||
<Marker
|
||||
position={[location.latitude, location.longitude]}
|
||||
icon={icon}
|
||||
eventHandlers={{ click: onClick }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -16]}>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<strong>{location.address || 'Unknown'}</strong>
|
||||
{location.unitNumber && <span> #{location.unitNumber}</span>}
|
||||
{location.lastVisit && (
|
||||
<div style={{ color: '#888' }}>
|
||||
Last: {location.lastVisit.outcome.replace('_', ' ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
111
admin/src/components/canvass/GPSTracker.tsx
Normal file
111
admin/src/components/canvass/GPSTracker.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { CircleMarker, useMap } from 'react-leaflet';
|
||||
import { useTrackingStore } from '@/stores/tracking.store';
|
||||
|
||||
interface GPSTrackerProps {
|
||||
following: boolean;
|
||||
onPositionChange?: (lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
export default function GPSTracker({ following, onPositionChange }: GPSTrackerProps) {
|
||||
const map = useMap();
|
||||
const [position, setPosition] = useState<[number, number] | null>(null);
|
||||
const watchRef = useRef<number | null>(null);
|
||||
const addPoint = useTrackingStore((s) => s.addPoint);
|
||||
const flushPoints = useTrackingStore((s) => s.flushPoints);
|
||||
const isTracking = useTrackingStore((s) => s.isTracking);
|
||||
|
||||
useEffect(() => {
|
||||
if (!navigator.geolocation) return;
|
||||
|
||||
watchRef.current = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||
setPosition(latlng);
|
||||
onPositionChange?.(latlng[0], latlng[1]);
|
||||
|
||||
if (isTracking) {
|
||||
addPoint({
|
||||
latitude: pos.coords.latitude,
|
||||
longitude: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy ?? undefined,
|
||||
recordedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (following) {
|
||||
map.setView(latlng, map.getZoom(), { animate: true });
|
||||
}
|
||||
},
|
||||
() => {},
|
||||
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10000 },
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (watchRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchRef.current);
|
||||
}
|
||||
};
|
||||
}, [map, following, onPositionChange, isTracking, addPoint]);
|
||||
|
||||
// Flush GPS points every 30 seconds
|
||||
useEffect(() => {
|
||||
if (!isTracking) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
flushPoints();
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
// Final flush on unmount
|
||||
flushPoints();
|
||||
};
|
||||
}, [isTracking, flushPoints]);
|
||||
|
||||
// Best-effort flush on page unload
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
const { trackingSessionId, pendingPoints } = useTrackingStore.getState();
|
||||
if (trackingSessionId && pendingPoints.length > 0) {
|
||||
const payload = JSON.stringify({ points: pendingPoints });
|
||||
navigator.sendBeacon?.(
|
||||
`/api/map/tracking/sessions/${trackingSessionId}/points`,
|
||||
new Blob([payload], { type: 'application/json' }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
if (!position) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Outer pulse ring */}
|
||||
<CircleMarker
|
||||
center={position}
|
||||
radius={16}
|
||||
pathOptions={{
|
||||
color: '#3498db',
|
||||
fillColor: '#3498db',
|
||||
fillOpacity: 0.15,
|
||||
weight: 1,
|
||||
}}
|
||||
/>
|
||||
{/* Inner dot */}
|
||||
<CircleMarker
|
||||
center={position}
|
||||
radius={6}
|
||||
pathOptions={{
|
||||
color: '#fff',
|
||||
fillColor: '#3498db',
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
205
admin/src/components/canvass/HistoricalRoutesDrawer.tsx
Normal file
205
admin/src/components/canvass/HistoricalRoutesDrawer.tsx
Normal file
@ -0,0 +1,205 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Drawer, Table, DatePicker, Select, Button, Space, Typography, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { HistoryOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TrackingSessionSummary, SessionRoute } from '@/types/tracking';
|
||||
import type { PaginationMeta } from '@/types/api';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
interface HistoricalRoutesDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSelectRoute: (route: SessionRoute | null) => void;
|
||||
volunteers: { userId: string; name: string | null; email: string }[];
|
||||
}
|
||||
|
||||
export default function HistoricalRoutesDrawer({
|
||||
open,
|
||||
onClose,
|
||||
onSelectRoute,
|
||||
volunteers,
|
||||
}: HistoricalRoutesDrawerProps) {
|
||||
const [sessions, setSessions] = useState<TrackingSessionSummary[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [routeLoading, setRouteLoading] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [userId, setUserId] = useState<string | undefined>(undefined);
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = { page: page.toString(), limit: '15' };
|
||||
if (userId) params.userId = userId;
|
||||
if (dateRange?.[0]) params.from = dateRange[0].startOf('day').toISOString();
|
||||
if (dateRange?.[1]) params.to = dateRange[1].endOf('day').toISOString();
|
||||
|
||||
const { data } = await api.get('/map/tracking/sessions', { params });
|
||||
setSessions(data.sessions);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
// Silent
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, userId, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) fetchSessions();
|
||||
}, [open, fetchSessions]);
|
||||
|
||||
const handleViewRoute = async (sessionId: string) => {
|
||||
if (selectedId === sessionId) {
|
||||
setSelectedId(null);
|
||||
onSelectRoute(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setRouteLoading(sessionId);
|
||||
try {
|
||||
const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`);
|
||||
setSelectedId(sessionId);
|
||||
onSelectRoute(data);
|
||||
} catch {
|
||||
// Silent
|
||||
} finally {
|
||||
setRouteLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (startedAt: string, endedAt: string | null) => {
|
||||
if (!endedAt) return 'Active';
|
||||
const dur = dayjs.duration(dayjs(endedAt).diff(dayjs(startedAt)));
|
||||
const hours = Math.floor(dur.asHours());
|
||||
const mins = dur.minutes();
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
};
|
||||
|
||||
const formatDistance = (meters: number) => {
|
||||
if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`;
|
||||
return `${Math.round(meters)} m`;
|
||||
};
|
||||
|
||||
const columns: ColumnsType<TrackingSessionSummary> = [
|
||||
{
|
||||
title: 'Volunteer',
|
||||
key: 'volunteer',
|
||||
render: (_: unknown, r: TrackingSessionSummary) => r.userName || r.userEmail,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Date',
|
||||
dataIndex: 'startedAt',
|
||||
key: 'date',
|
||||
render: (val: string) => dayjs(val).format('MMM D, h:mm A'),
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
key: 'duration',
|
||||
render: (_: unknown, r: TrackingSessionSummary) => formatDuration(r.startedAt, r.endedAt),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: 'Distance',
|
||||
dataIndex: 'totalDistanceM',
|
||||
key: 'distance',
|
||||
render: (val: number) => formatDistance(val),
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: 'Points',
|
||||
dataIndex: 'totalPoints',
|
||||
key: 'points',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_: unknown, r: TrackingSessionSummary) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={selectedId === r.id ? 'primary' : 'default'}
|
||||
loading={routeLoading === r.id}
|
||||
onClick={() => handleViewRoute(r.id)}
|
||||
>
|
||||
{selectedId === r.id ? 'Hide' : 'View'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={<><HistoryOutlined /> Route History</>}
|
||||
placement="right"
|
||||
width={600}
|
||||
open={open}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
onSelectRoute(null);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
placeholder="All volunteers"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
value={userId}
|
||||
onChange={(v) => { setUserId(v); setPage(1); }}
|
||||
options={volunteers.map((v) => ({
|
||||
label: v.name || v.email,
|
||||
value: v.userId,
|
||||
}))}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(val) => { setDateRange(val as [dayjs.Dayjs, dayjs.Dayjs] | null); setPage(1); }}
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{selectedId && (
|
||||
<Tag color="blue" closable onClose={() => { setSelectedId(null); onSelectRoute(null); }} style={{ marginBottom: 12 }}>
|
||||
Showing route on map
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
<Table
|
||||
dataSource={sessions}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={
|
||||
pagination
|
||||
? {
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (p) => setPage(p),
|
||||
}
|
||||
: false
|
||||
}
|
||||
rowClassName={(r) => (r.id === selectedId ? 'ant-table-row-selected' : '')}
|
||||
/>
|
||||
|
||||
{sessions.length === 0 && !loading && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 24 }}>
|
||||
No tracking sessions found
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
108
admin/src/components/canvass/LocationEditDrawer.tsx
Normal file
108
admin/src/components/canvass/LocationEditDrawer.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Drawer, Form, Input, Select, Switch, Button, message } from 'antd';
|
||||
import type { CanvassLocation } from '@/types/canvass';
|
||||
import type { SupportLevel } from '@/types/api';
|
||||
import { SUPPORT_LEVEL_LABELS } from '@/types/api';
|
||||
import { useCanvassStore } from '@/stores/canvass.store';
|
||||
|
||||
interface LocationEditDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
location: CanvassLocation | null;
|
||||
}
|
||||
|
||||
const supportLevelOptions = (['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'] as SupportLevel[]).map(
|
||||
(k) => ({ label: SUPPORT_LEVEL_LABELS[k], value: k }),
|
||||
);
|
||||
|
||||
export default function LocationEditDrawer({
|
||||
open,
|
||||
onClose,
|
||||
location,
|
||||
}: LocationEditDrawerProps) {
|
||||
const [form] = Form.useForm();
|
||||
const { updateLocationFields } = useCanvassStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (location && open) {
|
||||
form.setFieldsValue({
|
||||
firstName: location.firstName,
|
||||
lastName: location.lastName,
|
||||
address: location.address,
|
||||
unitNumber: location.unitNumber,
|
||||
supportLevel: location.supportLevel,
|
||||
sign: location.sign,
|
||||
signSize: location.signSize,
|
||||
notes: location.notes,
|
||||
});
|
||||
}
|
||||
}, [location, open, form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!location) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await updateLocationFields(location.id, values);
|
||||
message.success('Location updated');
|
||||
onClose();
|
||||
} catch {
|
||||
message.error('Failed to update location');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
placement="bottom"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
height="auto"
|
||||
styles={{
|
||||
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },
|
||||
}}
|
||||
title="Edit Location"
|
||||
extra={
|
||||
<Button type="primary" size="small" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" size="small">
|
||||
<Form.Item name="firstName" label="First Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="lastName" label="Last Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="Address">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="unitNumber" label="Unit">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportLevel" label="Support Level">
|
||||
<Select
|
||||
options={supportLevelOptions}
|
||||
allowClear
|
||||
placeholder="Select..."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sign" label="Sign" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="signSize" label="Sign Size">
|
||||
<Select
|
||||
options={[
|
||||
{ label: 'Regular', value: 'Regular' },
|
||||
{ label: 'Large', value: 'Large' },
|
||||
]}
|
||||
allowClear
|
||||
placeholder="Select..."
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="Notes">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
111
admin/src/components/canvass/MapCrosshair.tsx
Normal file
111
admin/src/components/canvass/MapCrosshair.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
interface MapCrosshairProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent crosshair overlay at the dead center of the map.
|
||||
* The center dot is tappable — clicking it triggers `onClick` to add
|
||||
* a new location at the crosshair position.
|
||||
*
|
||||
* Crosshair lines remain pointer-events: none so they don't block
|
||||
* map dragging. Only the center tap target captures clicks.
|
||||
*/
|
||||
export default function MapCrosshair({ onClick }: MapCrosshairProps) {
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@keyframes crosshair-pulse {
|
||||
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
|
||||
50% { transform: translate(-50%, -50%) scale(1.6); opacity: 0.4; }
|
||||
}
|
||||
`}</style>
|
||||
{/* Crosshair lines — no pointer events so map drags work */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 999,
|
||||
pointerEvents: 'none',
|
||||
width: 44,
|
||||
height: 44,
|
||||
}}
|
||||
>
|
||||
{/* Vertical line */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 2,
|
||||
height: '100%',
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
{/* Horizontal line */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: 0,
|
||||
transform: 'translateY(-50%)',
|
||||
width: '100%',
|
||||
height: 2,
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
{/* Pulse ring */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
border: '2px solid rgba(52, 152, 219, 0.5)',
|
||||
animation: 'crosshair-pulse 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Tappable center dot — separate layer with pointer events */}
|
||||
<div
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
aria-label="Add location at crosshair"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick?.(); }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 1000,
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer',
|
||||
width: 44,
|
||||
height: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
background: '#3498db',
|
||||
border: '2px solid #fff',
|
||||
boxShadow: '0 0 6px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
admin/src/components/canvass/SessionTimer.tsx
Normal file
37
admin/src/components/canvass/SessionTimer.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Typography } from 'antd';
|
||||
|
||||
interface SessionTimerProps {
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export default function SessionTimer({ startedAt }: SessionTimerProps) {
|
||||
const [elapsed, setElapsed] = useState('00:00');
|
||||
|
||||
useEffect(() => {
|
||||
const start = new Date(startedAt).getTime();
|
||||
|
||||
const update = () => {
|
||||
const diff = Math.floor((Date.now() - start) / 1000);
|
||||
const hours = Math.floor(diff / 3600);
|
||||
const minutes = Math.floor((diff % 3600) / 60);
|
||||
const seconds = diff % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
setElapsed(`${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`);
|
||||
} else {
|
||||
setElapsed(`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
const interval = setInterval(update, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [startedAt]);
|
||||
|
||||
return (
|
||||
<Typography.Text style={{ color: '#fff', fontFamily: 'monospace', fontSize: 14, textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>
|
||||
{elapsed}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
198
admin/src/components/canvass/VisitRecordingForm.tsx
Normal file
198
admin/src/components/canvass/VisitRecordingForm.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Input, Space, Typography, message } from 'antd';
|
||||
import type { VisitOutcome, RecordVisitPayload, CanvassLocation } from '@/types/canvass';
|
||||
import type { SupportLevel, UserRole } from '@/types/api';
|
||||
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
|
||||
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
||||
|
||||
interface VisitRecordingFormProps {
|
||||
location: CanvassLocation;
|
||||
sessionId?: string;
|
||||
shiftId?: string;
|
||||
onRecord: (payload: RecordVisitPayload) => Promise<void>;
|
||||
recording: boolean;
|
||||
userRole?: UserRole;
|
||||
}
|
||||
|
||||
const outcomeKeys: VisitOutcome[] = [
|
||||
'SPOKE_WITH',
|
||||
'NOT_HOME',
|
||||
'REFUSED',
|
||||
'LEFT_LITERATURE',
|
||||
'COME_BACK_LATER',
|
||||
'MOVED',
|
||||
'ALREADY_VOTED',
|
||||
];
|
||||
|
||||
const supportLevelKeys: SupportLevel[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'];
|
||||
|
||||
export default function VisitRecordingForm({
|
||||
location,
|
||||
sessionId,
|
||||
shiftId,
|
||||
onRecord,
|
||||
recording,
|
||||
userRole,
|
||||
}: VisitRecordingFormProps) {
|
||||
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
|
||||
const [supportLevel, setSupportLevel] = useState<SupportLevel | undefined>(undefined);
|
||||
const [signRequested, setSignRequested] = useState(false);
|
||||
const [signSize, setSignSize] = useState<string | undefined>(undefined);
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const showDetailFields = userRole !== 'TEMP';
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!outcome) {
|
||||
message.warning('Select an outcome');
|
||||
return;
|
||||
}
|
||||
|
||||
await onRecord({
|
||||
locationId: location.id,
|
||||
outcome,
|
||||
supportLevel,
|
||||
signRequested,
|
||||
signSize,
|
||||
notes: notes || undefined,
|
||||
sessionId,
|
||||
shiftId,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setOutcome(null);
|
||||
setSupportLevel(undefined);
|
||||
setSignRequested(false);
|
||||
setSignSize(undefined);
|
||||
setNotes('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 4px' }}>
|
||||
<Typography.Text strong style={{ fontSize: 15, display: 'block', marginBottom: 8 }}>
|
||||
{location.address || 'Unknown Address'}
|
||||
{location.unitNumber && ` #${location.unitNumber}`}
|
||||
</Typography.Text>
|
||||
|
||||
{location.firstName && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
{location.firstName} {location.lastName}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||
Outcome
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||
{outcomeKeys.map((key) => {
|
||||
const color = VISIT_OUTCOME_COLORS[key];
|
||||
const selected = outcome === key;
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
size="middle"
|
||||
type={selected ? 'primary' : 'default'}
|
||||
style={{
|
||||
borderColor: color,
|
||||
background: selected ? color : 'transparent',
|
||||
color: selected ? '#fff' : color,
|
||||
fontSize: 12,
|
||||
}}
|
||||
onClick={() => setOutcome(key)}
|
||||
>
|
||||
{VISIT_OUTCOME_LABELS[key]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showDetailFields && outcome === 'SPOKE_WITH' && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||
Support Level
|
||||
</Typography.Text>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
{supportLevelKeys.map((key) => (
|
||||
<Button
|
||||
key={key}
|
||||
shape="circle"
|
||||
size="large"
|
||||
type={supportLevel === key ? 'primary' : 'default'}
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: supportLevel === key ? SUPPORT_LEVEL_COLORS[key] : undefined,
|
||||
borderColor: SUPPORT_LEVEL_COLORS[key],
|
||||
color: supportLevel === key ? '#fff' : SUPPORT_LEVEL_COLORS[key],
|
||||
fontWeight: 700,
|
||||
}}
|
||||
onClick={() => setSupportLevel(supportLevel === key ? undefined : key)}
|
||||
>
|
||||
{key.replace('LEVEL_', '')}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{supportLevel && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{SUPPORT_LEVEL_LABELS[supportLevel]}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||
Sign
|
||||
</Typography.Text>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
type={signRequested ? 'primary' : 'default'}
|
||||
onClick={() => { setSignRequested(!signRequested); if (signRequested) setSignSize(undefined); }}
|
||||
>
|
||||
{signRequested ? 'Yes' : 'No Sign'}
|
||||
</Button>
|
||||
{signRequested && (
|
||||
<>
|
||||
{['Regular', 'Large', 'Unsure'].map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
type={signSize === size ? 'primary' : 'default'}
|
||||
onClick={() => setSignSize(size)}
|
||||
size="small"
|
||||
>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDetailFields && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
|
||||
Notes
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Optional notes..."
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
onClick={handleSubmit}
|
||||
loading={recording}
|
||||
disabled={!outcome}
|
||||
>
|
||||
Record Visit
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
admin/src/components/canvass/VolunteerMapDrawer.tsx
Normal file
163
admin/src/components/canvass/VolunteerMapDrawer.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List } from 'antd';
|
||||
import {
|
||||
HistoryOutlined,
|
||||
LogoutOutlined,
|
||||
PlayCircleOutlined,
|
||||
AimOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import type { MyAssignment, MyCanvassStats } from '@/types/canvass';
|
||||
import type { PublicCut } from '@/types/api';
|
||||
|
||||
interface VolunteerMapDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
cuts: PublicCut[];
|
||||
onStartSession: (cutId: string, shiftId?: string) => void;
|
||||
}
|
||||
|
||||
export default function VolunteerMapDrawer({
|
||||
open,
|
||||
onClose,
|
||||
cuts,
|
||||
onStartSession,
|
||||
}: VolunteerMapDrawerProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuthStore();
|
||||
const [stats, setStats] = useState<MyCanvassStats | null>(null);
|
||||
const [assignments, setAssignments] = useState<MyAssignment[]>([]);
|
||||
const [freeCutId, setFreeCutId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Load stats and assignments when drawer opens
|
||||
api.get('/map/canvass/my/stats').then(({ data }) => setStats(data)).catch(() => {});
|
||||
api.get('/map/canvass/my/assignments').then(({ data }) => setAssignments(data)).catch(() => {});
|
||||
}, [open]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
placement="left"
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={300}
|
||||
styles={{
|
||||
body: { padding: '16px', display: 'flex', flexDirection: 'column' },
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 4 }}>
|
||||
{user?.name || user?.email || 'Volunteer'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 16 }}>
|
||||
{user?.email}
|
||||
</Typography.Text>
|
||||
|
||||
{/* Mini stats */}
|
||||
{stats && (
|
||||
<Space size="large" style={{ marginBottom: 16 }}>
|
||||
<Statistic title="Today" value={stats.todayVisits} valueStyle={{ fontSize: 20 }} />
|
||||
<Statistic title="Total" value={stats.totalVisits} valueStyle={{ fontSize: 20 }} />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
{/* Assignments */}
|
||||
{assignments.length > 0 && (
|
||||
<>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||
My Assignments
|
||||
</Typography.Text>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={assignments}
|
||||
style={{ marginBottom: 12, maxHeight: 200, overflowY: 'auto' }}
|
||||
renderItem={(a) => (
|
||||
<List.Item
|
||||
style={{ padding: '6px 0' }}
|
||||
actions={[
|
||||
<Button
|
||||
key="start"
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }}
|
||||
>
|
||||
Start
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<span style={{ fontSize: 13 }}>{a.cutName}</span>}
|
||||
description={
|
||||
<span style={{ fontSize: 11 }}>
|
||||
{a.shiftTitle} · {Math.round(a.completionPercentage)}%
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Free session — pick a cut */}
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
|
||||
Start Session (Any Cut)
|
||||
</Typography.Text>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="Select a cut..."
|
||||
style={{ flex: 1 }}
|
||||
value={freeCutId}
|
||||
onChange={setFreeCutId}
|
||||
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AimOutlined />}
|
||||
disabled={!freeCutId}
|
||||
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{/* Navigation links */}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<HistoryOutlined />}
|
||||
block
|
||||
style={{ textAlign: 'left', marginBottom: 4 }}
|
||||
onClick={() => { navigate('/volunteer/activity'); onClose(); }}
|
||||
>
|
||||
My Activity
|
||||
</Button>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<LogoutOutlined />}
|
||||
block
|
||||
style={{ textAlign: 'left' }}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
68
admin/src/components/canvass/VolunteerMapHeader.tsx
Normal file
68
admin/src/components/canvass/VolunteerMapHeader.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { Button, Typography } from 'antd';
|
||||
import { StopOutlined } from '@ant-design/icons';
|
||||
import SessionTimer from './SessionTimer';
|
||||
|
||||
interface VolunteerSessionBarProps {
|
||||
sessionCutName?: string;
|
||||
sessionStartedAt?: string;
|
||||
onEndSession: () => void;
|
||||
endingSession: boolean;
|
||||
headerGradient?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slim session info bar pinned above the footer nav.
|
||||
* Only rendered when a canvass session is active.
|
||||
* Shows cut name, timer, and end button.
|
||||
*/
|
||||
export default function VolunteerSessionBar({
|
||||
sessionCutName,
|
||||
sessionStartedAt,
|
||||
onEndSession,
|
||||
endingSession,
|
||||
headerGradient,
|
||||
}: VolunteerSessionBarProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
zIndex: 1000,
|
||||
background: headerGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 12px',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
>
|
||||
{sessionCutName ?? 'Session'}
|
||||
</Typography.Text>
|
||||
{sessionStartedAt && <SessionTimer startedAt={sessionStartedAt} />}
|
||||
<Button
|
||||
danger
|
||||
size="middle"
|
||||
icon={<StopOutlined />}
|
||||
onClick={onEndSession}
|
||||
loading={endingSession}
|
||||
aria-label="End session"
|
||||
>
|
||||
End
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
admin/src/components/canvass/VolunteerMarker.tsx
Normal file
93
admin/src/components/canvass/VolunteerMarker.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { Marker, Popup, Tooltip, Polyline } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import type { LiveVolunteer } from '@/types/tracking';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
// Color palette for distinguishing volunteers
|
||||
const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#e91e63'];
|
||||
|
||||
function getColor(index: number) {
|
||||
return COLORS[index % COLORS.length]!;
|
||||
}
|
||||
|
||||
function createVolunteerIcon(color: string) {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
html: `
|
||||
<div style="
|
||||
width: 16px; height: 16px;
|
||||
background: ${color};
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px ${color}80;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
"></div>
|
||||
<style>
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 6px ${color}80; }
|
||||
50% { box-shadow: 0 0 14px ${color}cc; }
|
||||
}
|
||||
</style>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
interface VolunteerMarkerProps {
|
||||
volunteer: LiveVolunteer;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function VolunteerMarker({ volunteer, index }: VolunteerMarkerProps) {
|
||||
const color = getColor(index);
|
||||
const position: [number, number] = [volunteer.latitude, volunteer.longitude];
|
||||
const name = volunteer.name || volunteer.email.split('@')[0]!;
|
||||
const distanceText = volunteer.recentTrail.length > 1
|
||||
? `${volunteer.recentTrail.length} recent points`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Recent trail polyline */}
|
||||
{volunteer.recentTrail.length > 1 && (
|
||||
<Polyline
|
||||
positions={volunteer.recentTrail}
|
||||
pathOptions={{
|
||||
color,
|
||||
weight: 2,
|
||||
opacity: 0.6,
|
||||
dashArray: '6 4',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Volunteer position marker */}
|
||||
<Marker position={position} icon={createVolunteerIcon(color)}>
|
||||
<Tooltip permanent direction="top" offset={[0, -12]}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600 }}>{name}</span>
|
||||
</Tooltip>
|
||||
<Popup>
|
||||
<div style={{ minWidth: 160 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>{name}</div>
|
||||
<div style={{ fontSize: 12, color: '#888' }}>{volunteer.email}</div>
|
||||
<div style={{ fontSize: 12, marginTop: 6 }}>
|
||||
Last update: {dayjs(volunteer.lastRecordedAt).fromNow()}
|
||||
</div>
|
||||
{distanceText && (
|
||||
<div style={{ fontSize: 12 }}>{distanceText}</div>
|
||||
)}
|
||||
{volunteer.canvassSessionId && (
|
||||
<div style={{ fontSize: 12, marginTop: 4, color: '#2ecc71' }}>
|
||||
In canvass session
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
</>
|
||||
);
|
||||
}
|
||||
45
admin/src/components/canvass/WalkingRouteLine.tsx
Normal file
45
admin/src/components/canvass/WalkingRouteLine.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import { Polyline, CircleMarker, Tooltip } from 'react-leaflet';
|
||||
import type { WalkingRoute } from '@/types/canvass';
|
||||
|
||||
interface WalkingRouteLineProps {
|
||||
route: WalkingRoute;
|
||||
}
|
||||
|
||||
export default function WalkingRouteLine({ route }: WalkingRouteLineProps) {
|
||||
if (route.orderedLocations.length === 0) return null;
|
||||
|
||||
const positions = route.orderedLocations.map(
|
||||
(loc) => [loc.latitude, loc.longitude] as [number, number],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color: '#3498db',
|
||||
weight: 3,
|
||||
dashArray: '8, 6',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
{route.orderedLocations.map((loc, idx) => (
|
||||
<CircleMarker
|
||||
key={`route-${loc.id}`}
|
||||
center={[loc.latitude, loc.longitude]}
|
||||
radius={4}
|
||||
pathOptions={{
|
||||
color: '#3498db',
|
||||
fillColor: '#fff',
|
||||
fillOpacity: 1,
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -6]} permanent={false}>
|
||||
Stop #{idx + 1}
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
admin/src/components/map/AddLocationMode.tsx
Normal file
38
admin/src/components/map/AddLocationMode.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMap, useMapEvents } from 'react-leaflet';
|
||||
import type { LeafletMouseEvent } from 'leaflet';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
onMapClick: (lat: number, lng: number) => void;
|
||||
}
|
||||
|
||||
function AddLocationHandler({ onMapClick }: { onMapClick: (lat: number, lng: number) => void }) {
|
||||
useMapEvents({
|
||||
click(e: LeafletMouseEvent) {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AddLocationMode({ active, onMapClick }: Props) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
map.getContainer().style.cursor = 'crosshair';
|
||||
map.doubleClickZoom.disable();
|
||||
} else {
|
||||
map.getContainer().style.cursor = '';
|
||||
map.doubleClickZoom.enable();
|
||||
}
|
||||
return () => {
|
||||
map.getContainer().style.cursor = '';
|
||||
map.doubleClickZoom.enable();
|
||||
};
|
||||
}, [active, map]);
|
||||
|
||||
if (!active) return null;
|
||||
return <AddLocationHandler onMapClick={onMapClick} />;
|
||||
}
|
||||
538
admin/src/components/map/AdminMapView.tsx
Normal file
538
admin/src/components/map/AdminMapView.tsx
Normal file
@ -0,0 +1,538 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { Spin, Checkbox, Button, Typography, message } from 'antd';
|
||||
import { EditOutlined, DragOutlined } from '@ant-design/icons';
|
||||
import { MapContainer, CircleMarker, Popup, Marker, useMap, useMapEvents } from 'react-leaflet';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import L from 'leaflet';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Location, MapSettings, SupportLevel, Cut } from '@/types/api';
|
||||
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
||||
import { groupLocations, getMarkerColor } from './mapUtils';
|
||||
import MapLegend from './MapLegend';
|
||||
import MapControls from './MapControls';
|
||||
import AddLocationMode from './AddLocationMode';
|
||||
import MoveLocationMode from './MoveLocationMode';
|
||||
import CutOverlays from './CutOverlays';
|
||||
import CutOverlayControls from './CutOverlayControls';
|
||||
import DynamicTileLayer from './DynamicTileLayer';
|
||||
import TileLayerToggle from './TileLayerToggle';
|
||||
import { getPersistedTileLayer, persistTileLayer, getTileConfig } from './tileLayers';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ALL_LEVELS: (SupportLevel | 'NONE')[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4', 'NONE'];
|
||||
|
||||
const LEVEL_FILTER_LABELS: Record<string, string> = {
|
||||
LEVEL_1: 'Strong Support',
|
||||
LEVEL_2: 'Likely Support',
|
||||
LEVEL_3: 'Unsure',
|
||||
LEVEL_4: 'Opposition',
|
||||
NONE: 'No Level',
|
||||
};
|
||||
|
||||
const homeIcon = L.divIcon({
|
||||
html: '<div style="font-size:22px;text-align:center;line-height:30px">🏠</div>',
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 15],
|
||||
className: '',
|
||||
});
|
||||
|
||||
interface Props {
|
||||
locations: Location[];
|
||||
loading: boolean;
|
||||
onEditLocation: (location: Location) => void;
|
||||
onAddLocationAtPoint?: (lat: number, lng: number) => void;
|
||||
onMoveLocation?: (id: string, lat: number, lng: number) => void;
|
||||
onRefresh?: () => void;
|
||||
onMapMove?: (map: LeafletMap) => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function InvalidateSizeOnVisible({ visible }: { visible: boolean }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
const timer = setTimeout(() => map.invalidateSize(), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visible, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function FullscreenInvalidator() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
document.addEventListener('webkitfullscreenchange', handler);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handler);
|
||||
document.removeEventListener('webkitfullscreenchange', handler);
|
||||
};
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapEventsHandler({ onMove }: { onMove?: (map: LeafletMap) => void }) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => {
|
||||
// Only trigger if not animating to prevent Leaflet state corruption
|
||||
if (!map._animatingZoom && !map._moving) {
|
||||
onMove?.(map);
|
||||
}
|
||||
},
|
||||
zoomend: () => {
|
||||
// Wait a tick for Leaflet to finish internal zoom state updates
|
||||
setTimeout(() => {
|
||||
if (!map._animatingZoom && !map._moving) {
|
||||
onMove?.(map);
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
function FlyToPosition({ position }: { position: [number, number] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.flyTo(position, 17, { duration: 1.5 });
|
||||
}, [map, position]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AdminMapView({
|
||||
locations,
|
||||
loading,
|
||||
onEditLocation,
|
||||
onAddLocationAtPoint,
|
||||
onMoveLocation,
|
||||
onRefresh,
|
||||
onMapMove,
|
||||
visible,
|
||||
}: Props) {
|
||||
const [settings, setSettings] = useState<MapSettings | null>(null);
|
||||
const [visibleLevels, setVisibleLevels] = useState<Set<string>>(new Set(ALL_LEVELS));
|
||||
const [addMode, setAddMode] = useState(false);
|
||||
const [moveMode, setMoveMode] = useState<{ active: boolean; locationId: string | null }>({ active: false, locationId: null });
|
||||
const [showStartLocation, setShowStartLocation] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [userPosition, setUserPosition] = useState<[number, number] | null>(null);
|
||||
const [flyTo, setFlyTo] = useState<[number, number] | null>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [tileKey, setTileKey] = useState(getPersistedTileLayer);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const autoRefreshRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
|
||||
// Cuts state
|
||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
api.get<MapSettings>('/map/settings').then(({ data }) => setSettings(data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Fetch cuts
|
||||
useEffect(() => {
|
||||
api.get<{ cuts: Cut[] }>('/map/cuts', { params: { limit: 100 } })
|
||||
.then(({ data }) => {
|
||||
setCuts(data.cuts);
|
||||
setVisibleCutIds(new Set(data.cuts.map((c) => c.id)));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Auto-refresh
|
||||
useEffect(() => {
|
||||
if (autoRefresh && onRefresh) {
|
||||
autoRefreshRef.current = setInterval(onRefresh, 60000);
|
||||
}
|
||||
return () => clearInterval(autoRefreshRef.current);
|
||||
}, [autoRefresh, onRefresh]);
|
||||
|
||||
// Clear user position after 30s
|
||||
useEffect(() => {
|
||||
if (userPosition) {
|
||||
const timer = setTimeout(() => setUserPosition(null), 30000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [userPosition]);
|
||||
|
||||
// Track fullscreen state
|
||||
useEffect(() => {
|
||||
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
document.addEventListener('webkitfullscreenchange', handler);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handler);
|
||||
document.removeEventListener('webkitfullscreenchange', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const groups = useMemo(() => groupLocations(locations), [locations]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
return groups.filter((g) =>
|
||||
g.locations.some((loc) => {
|
||||
const level = loc.supportLevel || 'NONE';
|
||||
return visibleLevels.has(level);
|
||||
})
|
||||
);
|
||||
}, [groups, visibleLevels]);
|
||||
|
||||
const center: [number, number] = settings?.latitude && settings?.longitude
|
||||
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
|
||||
: [45.4215, -75.6972];
|
||||
const zoom = settings?.zoom ?? 12;
|
||||
|
||||
const toggleLevel = (level: string, checked: boolean) => {
|
||||
setVisibleLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (checked) next.add(level);
|
||||
else next.delete(level);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleGeolocate = useCallback(() => {
|
||||
if (!navigator.geolocation) {
|
||||
message.error('Geolocation not supported');
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const p: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||
setUserPosition(p);
|
||||
setFlyTo(p);
|
||||
// Clear flyTo after use
|
||||
setTimeout(() => setFlyTo(null), 2000);
|
||||
},
|
||||
() => message.error('Could not get your location'),
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
const requestFs = el.requestFullscreen || (el as any).webkitRequestFullscreen;
|
||||
if (requestFs) requestFs.call(el);
|
||||
} else {
|
||||
const exitFs = document.exitFullscreen || (document as any).webkitExitFullscreen;
|
||||
if (exitFs) exitFs.call(document);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMapClick = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
if (onAddLocationAtPoint) {
|
||||
onAddLocationAtPoint(lat, lng);
|
||||
setAddMode(false);
|
||||
}
|
||||
},
|
||||
[onAddLocationAtPoint]
|
||||
);
|
||||
|
||||
const startMoveMode = useCallback((locationId: string) => {
|
||||
setMoveMode({ active: true, locationId });
|
||||
setAddMode(false);
|
||||
}, []);
|
||||
|
||||
const handleConfirmMove = useCallback(
|
||||
(lat: number, lng: number) => {
|
||||
if (onMoveLocation && moveMode.locationId) {
|
||||
onMoveLocation(moveMode.locationId, lat, lng);
|
||||
}
|
||||
setMoveMode({ active: false, locationId: null });
|
||||
},
|
||||
[onMoveLocation, moveMode.locationId]
|
||||
);
|
||||
|
||||
const handleCancelMove = useCallback(() => {
|
||||
setMoveMode({ active: false, locationId: null });
|
||||
}, []);
|
||||
|
||||
const toggleCut = useCallback((id: string) => {
|
||||
setVisibleCutIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="admin-map-container"
|
||||
style={{ position: 'relative', width: '100%', height: 'calc(100vh - 340px)', minHeight: 500, background: '#1a1025' }}
|
||||
>
|
||||
{/* Support level filter overlay */}
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Support level filter"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: 10,
|
||||
zIndex: 1000,
|
||||
background: 'rgba(26, 16, 37, 0.92)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.65)', marginBottom: 4 }}>
|
||||
Filter
|
||||
</div>
|
||||
{ALL_LEVELS.map((level) => (
|
||||
<div key={level} style={{ marginBottom: 2 }}>
|
||||
<Checkbox
|
||||
checked={visibleLevels.has(level)}
|
||||
onChange={(e) => toggleLevel(level, e.target.checked)}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<span style={{ color: level === 'NONE' ? '#3498db' : SUPPORT_LEVEL_COLORS[level as SupportLevel] }}>
|
||||
{LEVEL_FILTER_LABELS[level]}
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mode indicators */}
|
||||
{(addMode || moveMode.active) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1001,
|
||||
background: addMode ? 'rgba(157, 78, 221, 0.9)' : 'rgba(255, 77, 79, 0.9)',
|
||||
borderRadius: 8,
|
||||
padding: '6px 16px',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{addMode ? 'Click map to add location' : 'Click map to move location'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapControls
|
||||
addMode={addMode}
|
||||
onToggleAddMode={() => {
|
||||
setAddMode((p) => !p);
|
||||
if (!addMode) setMoveMode({ active: false, locationId: null });
|
||||
}}
|
||||
moveMode={moveMode.active}
|
||||
onCancelMoveMode={handleCancelMove}
|
||||
showStartLocation={showStartLocation}
|
||||
onToggleStartLocation={() => setShowStartLocation((p) => !p)}
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={handleFullscreen}
|
||||
onGeolocate={handleGeolocate}
|
||||
onRefresh={() => onRefresh?.()}
|
||||
autoRefresh={autoRefresh}
|
||||
onToggleAutoRefresh={() => setAutoRefresh((p) => !p)}
|
||||
/>
|
||||
|
||||
<style>{`
|
||||
.admin-map .leaflet-popup-content-wrapper {
|
||||
background: #1a1025;
|
||||
color: rgba(255,255,255,0.85);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.admin-map .leaflet-popup-tip {
|
||||
background: #1a1025;
|
||||
}
|
||||
.admin-map .leaflet-popup-content {
|
||||
margin: 10px 14px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
style={{ width: '100%', height: '100%', borderRadius: 8 }}
|
||||
scrollWheelZoom={true}
|
||||
className="admin-map"
|
||||
>
|
||||
<InvalidateSizeOnVisible visible={visible} />
|
||||
<FullscreenInvalidator />
|
||||
<MapEventsHandler onMove={onMapMove} />
|
||||
{flyTo && <FlyToPosition position={flyTo} />}
|
||||
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
||||
|
||||
{/* Cut overlays (render beneath location markers) */}
|
||||
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
|
||||
|
||||
{/* Add location mode */}
|
||||
<AddLocationMode active={addMode && !moveMode.active} onMapClick={handleMapClick} />
|
||||
|
||||
{/* Move location mode */}
|
||||
<MoveLocationMode
|
||||
active={moveMode.active}
|
||||
onConfirmMove={handleConfirmMove}
|
||||
onCancel={handleCancelMove}
|
||||
/>
|
||||
|
||||
{/* Start location marker */}
|
||||
{showStartLocation && settings?.latitude && settings?.longitude && (
|
||||
<Marker
|
||||
position={[parseFloat(settings.latitude), parseFloat(settings.longitude)]}
|
||||
icon={homeIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User position */}
|
||||
{userPosition && (
|
||||
<CircleMarker
|
||||
center={userPosition}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
fillColor: '#2196F3',
|
||||
fillOpacity: 0.9,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Location markers */}
|
||||
{filteredGroups.map((group, idx) => {
|
||||
const color = getMarkerColor(group.dominantLevel);
|
||||
const radius = group.isMultiUnit ? 10 : 7;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={idx}
|
||||
center={[group.latitude, group.longitude]}
|
||||
radius={radius}
|
||||
pathOptions={{
|
||||
fillColor: color,
|
||||
fillOpacity: 0.8,
|
||||
color: '#fff',
|
||||
weight: group.isMultiUnit ? 2 : 1,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div style={{ minWidth: 200, maxWidth: 280 }}>
|
||||
{group.locations.map((loc, i) => {
|
||||
const name = [loc.firstName, loc.lastName].filter(Boolean).join(' ');
|
||||
return (
|
||||
<div
|
||||
key={loc.id}
|
||||
style={{
|
||||
marginBottom: i < group.locations.length - 1 ? 10 : 0,
|
||||
paddingBottom: i < group.locations.length - 1 ? 10 : 0,
|
||||
borderBottom: i < group.locations.length - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
|
||||
{loc.address || 'Unknown address'}
|
||||
{loc.unitNumber && <Text type="secondary" style={{ fontSize: 12 }}> Unit {loc.unitNumber}</Text>}
|
||||
</div>
|
||||
{name && <div style={{ fontSize: 12, marginBottom: 2 }}>{name}</div>}
|
||||
{loc.email && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{loc.email}</div>}
|
||||
{loc.phone && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{loc.phone}</div>}
|
||||
{loc.supportLevel && (
|
||||
<div style={{ fontSize: 12, marginTop: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getMarkerColor(loc.supportLevel),
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
{SUPPORT_LEVEL_LABELS[loc.supportLevel]}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)', marginTop: 2 }}>
|
||||
{loc.sign && <>Sign{loc.signSize ? ` (${loc.signSize})` : ''} · </>}
|
||||
{loc.geocodeConfidence != null && <>Confidence: {loc.geocodeConfidence}%</>}
|
||||
</div>
|
||||
{loc.notes && (
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 4, fontStyle: 'italic' }}>
|
||||
{loc.notes}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 4, display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditLocation(loc);
|
||||
}}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 12 }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{onMoveLocation && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<DragOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startMoveMode(loc.id);
|
||||
}}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 12 }}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
<MapLegend variant="admin" />
|
||||
|
||||
<TileLayerToggle
|
||||
activeKey={tileKey}
|
||||
onChange={(key) => { setTileKey(key); persistTileLayer(key); }}
|
||||
position="bottom-right"
|
||||
/>
|
||||
|
||||
{/* Cut overlay controls */}
|
||||
{cuts.length > 0 && (
|
||||
<CutOverlayControls
|
||||
cuts={cuts}
|
||||
visibleCutIds={visibleCutIds}
|
||||
onToggleCut={toggleCut}
|
||||
variant="admin"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
admin/src/components/map/CutDrawingMode.tsx
Normal file
145
admin/src/components/map/CutDrawingMode.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useMap, useMapEvents, CircleMarker, Polyline, Polygon, Tooltip } from 'react-leaflet';
|
||||
import type { LatLng, LeafletMouseEvent } from 'leaflet';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
color: string;
|
||||
opacity: number;
|
||||
onFinish: (geojson: string, bounds: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function DrawHandler({ onClickMap }: { onClickMap: (latlng: LatLng) => void }) {
|
||||
useMapEvents({
|
||||
click(e: LeafletMouseEvent) {
|
||||
onClickMap(e.latlng);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CutDrawingMode({ active, color, opacity, onFinish, onCancel }: Props) {
|
||||
const map = useMap();
|
||||
const [vertices, setVertices] = useState<LatLng[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
map.getContainer().style.cursor = 'crosshair';
|
||||
map.doubleClickZoom.disable();
|
||||
setVertices([]);
|
||||
} else {
|
||||
map.getContainer().style.cursor = '';
|
||||
map.doubleClickZoom.enable();
|
||||
setVertices([]);
|
||||
}
|
||||
return () => {
|
||||
map.getContainer().style.cursor = '';
|
||||
map.doubleClickZoom.enable();
|
||||
};
|
||||
}, [active, map]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
} else if (e.ctrlKey && e.key === 'z') {
|
||||
setVertices((prev) => prev.slice(0, -1));
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [active, onCancel]);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(latlng: LatLng) => {
|
||||
if (vertices.length >= 3) {
|
||||
// Check if close to first vertex
|
||||
const firstPx = map.latLngToContainerPoint(vertices[0]!);
|
||||
const clickPx = map.latLngToContainerPoint(latlng);
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(firstPx.x - clickPx.x, 2) + Math.pow(firstPx.y - clickPx.y, 2)
|
||||
);
|
||||
if (dist < 15) {
|
||||
// Close the polygon
|
||||
const coords = vertices.map((v) => [v.lng, v.lat]);
|
||||
// Close ring
|
||||
coords.push(coords[0]!);
|
||||
const geojson = JSON.stringify({
|
||||
type: 'Polygon',
|
||||
coordinates: [coords],
|
||||
});
|
||||
|
||||
const lats = vertices.map((v) => v.lat);
|
||||
const lngs = vertices.map((v) => v.lng);
|
||||
const bounds = JSON.stringify({
|
||||
minLat: Math.min(...lats),
|
||||
maxLat: Math.max(...lats),
|
||||
minLng: Math.min(...lngs),
|
||||
maxLng: Math.max(...lngs),
|
||||
});
|
||||
|
||||
onFinish(geojson, bounds);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setVertices((prev) => [...prev, latlng]);
|
||||
},
|
||||
[vertices, map, onFinish]
|
||||
);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
const positions = vertices.map((v) => [v.lat, v.lng] as [number, number]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawHandler onClickMap={handleClick} />
|
||||
|
||||
{/* Polygon preview (3+ vertices) */}
|
||||
{vertices.length >= 3 && (
|
||||
<Polygon
|
||||
positions={positions}
|
||||
pathOptions={{
|
||||
color,
|
||||
fillColor: color,
|
||||
fillOpacity: opacity,
|
||||
weight: 2,
|
||||
dashArray: '6 4',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Line between vertices (2+ vertices) */}
|
||||
{vertices.length >= 2 && vertices.length < 3 && (
|
||||
<Polyline
|
||||
positions={positions}
|
||||
pathOptions={{ color, weight: 2, dashArray: '6 4' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Vertex markers */}
|
||||
{vertices.map((v, i) => (
|
||||
<CircleMarker
|
||||
key={i}
|
||||
center={[v.lat, v.lng]}
|
||||
radius={i === 0 ? 8 : 5}
|
||||
pathOptions={{
|
||||
fillColor: i === 0 ? '#fff' : color,
|
||||
fillOpacity: 1,
|
||||
color: i === 0 ? color : '#fff',
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
{i === 0 && vertices.length >= 3 && (
|
||||
<Tooltip permanent direction="top" offset={[0, -10]}>
|
||||
Click to close
|
||||
</Tooltip>
|
||||
)}
|
||||
</CircleMarker>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
admin/src/components/map/CutEditorMap.tsx
Normal file
123
admin/src/components/map/CutEditorMap.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Space, Typography } from 'antd';
|
||||
import { MapContainer, useMap } from 'react-leaflet';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Cut, MapSettings } from '@/types/api';
|
||||
import CutDrawingMode from './CutDrawingMode';
|
||||
import CutOverlays from './CutOverlays';
|
||||
import DynamicTileLayer from './DynamicTileLayer';
|
||||
import TileLayerToggle from './TileLayerToggle';
|
||||
import { getPersistedTileLayer, persistTileLayer, getTileConfig } from './tileLayers';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
interface Props {
|
||||
cuts: Cut[];
|
||||
onFinishDraw: (geojson: string, bounds: string) => void;
|
||||
}
|
||||
|
||||
function InvalidateOnMount() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => map.invalidateSize(), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CutEditorMap({ cuts, onFinishDraw }: Props) {
|
||||
const [settings, setSettings] = useState<MapSettings | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [tileKey, setTileKey] = useState(getPersistedTileLayer);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<MapSettings>('/map/settings').then(({ data }) => setSettings(data)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const center: [number, number] = settings?.latitude && settings?.longitude
|
||||
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
|
||||
: [45.4215, -75.6972];
|
||||
const zoom = settings?.zoom ?? 12;
|
||||
|
||||
const allCutIds = new Set(cuts.map((c) => c.id));
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: 'calc(100vh - 200px)', minHeight: 400 }}>
|
||||
{/* Drawing toolbar */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1001,
|
||||
background: 'rgba(26, 16, 37, 0.95)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 16px',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
}}
|
||||
>
|
||||
{!drawing ? (
|
||||
<Button type="primary" onClick={() => setDrawing(true)}>
|
||||
Draw Cut
|
||||
</Button>
|
||||
) : (
|
||||
<Space>
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 12 }}>
|
||||
Click vertices on map. Click first vertex to close.
|
||||
</Typography.Text>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setDrawing(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
Esc: cancel | Ctrl+Z: undo
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.cut-editor-map .leaflet-popup-content-wrapper {
|
||||
background: #1a1025;
|
||||
color: rgba(255,255,255,0.85);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cut-editor-map .leaflet-popup-tip {
|
||||
background: #1a1025;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
style={{ width: '100%', height: '100%', borderRadius: 8 }}
|
||||
scrollWheelZoom={true}
|
||||
className="cut-editor-map"
|
||||
>
|
||||
<InvalidateOnMount />
|
||||
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
||||
<CutOverlays cuts={cuts} visibleCutIds={allCutIds} />
|
||||
<CutDrawingMode
|
||||
active={drawing}
|
||||
color="#3388ff"
|
||||
opacity={0.3}
|
||||
onFinish={(geojson, bounds) => {
|
||||
setDrawing(false);
|
||||
onFinishDraw(geojson, bounds);
|
||||
}}
|
||||
onCancel={() => setDrawing(false)}
|
||||
/>
|
||||
</MapContainer>
|
||||
|
||||
<TileLayerToggle
|
||||
activeKey={tileKey}
|
||||
onChange={(key) => { setTileKey(key); persistTileLayer(key); }}
|
||||
position="bottom-right"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
admin/src/components/map/CutOverlayControls.tsx
Normal file
89
admin/src/components/map/CutOverlayControls.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { Checkbox, Button, Space } from 'antd';
|
||||
import type { Cut, PublicCut } from '@/types/api';
|
||||
|
||||
const VARIANT_BG = {
|
||||
public: 'rgba(27, 40, 56, 0.92)',
|
||||
admin: 'rgba(26, 16, 37, 0.92)',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
cuts: (Cut | PublicCut)[];
|
||||
visibleCutIds: Set<string>;
|
||||
onToggleCut: (id: string) => void;
|
||||
variant?: 'admin' | 'public';
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, variant = 'admin', style }: Props) {
|
||||
const allVisible = cuts.every((c) => visibleCutIds.has(c.id));
|
||||
const noneVisible = cuts.every((c) => !visibleCutIds.has(c.id));
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
left: 12,
|
||||
zIndex: 1000,
|
||||
background: VARIANT_BG[variant],
|
||||
borderRadius: 8,
|
||||
padding: '10px 14px',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
minWidth: 140,
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#fff', marginBottom: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Cuts</span>
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={allVisible}
|
||||
onClick={() => cuts.forEach((c) => { if (!visibleCutIds.has(c.id)) onToggleCut(c.id); })}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 10 }}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
disabled={noneVisible}
|
||||
onClick={() => cuts.forEach((c) => { if (visibleCutIds.has(c.id)) onToggleCut(c.id); })}
|
||||
style={{ padding: 0, height: 'auto', fontSize: 10 }}
|
||||
>
|
||||
None
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{cuts.map((cut) => (
|
||||
<div key={cut.id} style={{ marginBottom: 3 }}>
|
||||
<Checkbox
|
||||
checked={visibleCutIds.has(cut.id)}
|
||||
onChange={() => onToggleCut(cut.id)}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 2,
|
||||
backgroundColor: cut.color,
|
||||
opacity: parseFloat(String(cut.opacity)) || 0.3,
|
||||
border: `1px solid ${cut.color}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'rgba(255,255,255,0.85)' }}>{cut.name}</span>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
admin/src/components/map/CutOverlays.tsx
Normal file
53
admin/src/components/map/CutOverlays.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react';
|
||||
import { GeoJSON, Tooltip } from 'react-leaflet';
|
||||
import type { Cut, PublicCut } from '@/types/api';
|
||||
|
||||
interface Props {
|
||||
cuts: (Cut | PublicCut)[];
|
||||
visibleCutIds: Set<string>;
|
||||
}
|
||||
|
||||
function CutLayer({ cut }: { cut: Cut | PublicCut }) {
|
||||
const geojsonData = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(cut.geojson) as GeoJSON.GeoJsonObject;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [cut.geojson]);
|
||||
|
||||
const opacity = parseFloat(String(cut.opacity));
|
||||
|
||||
if (!geojsonData) return null;
|
||||
|
||||
return (
|
||||
<GeoJSON
|
||||
key={cut.id}
|
||||
data={geojsonData}
|
||||
style={{
|
||||
color: cut.color,
|
||||
fillColor: cut.color,
|
||||
fillOpacity: isNaN(opacity) ? 0.3 : opacity,
|
||||
weight: 2,
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
<Tooltip sticky>{cut.name}</Tooltip>
|
||||
</GeoJSON>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CutOverlays({ cuts, visibleCutIds }: Props) {
|
||||
const visibleCuts = useMemo(
|
||||
() => cuts.filter((c) => visibleCutIds.has(c.id)),
|
||||
[cuts, visibleCutIds]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleCuts.map((cut) => (
|
||||
<CutLayer key={cut.id} cut={cut} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
admin/src/components/map/DynamicTileLayer.tsx
Normal file
17
admin/src/components/map/DynamicTileLayer.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { TileLayer } from 'react-leaflet';
|
||||
import type { TileLayerConfig } from './tileLayers';
|
||||
|
||||
interface Props {
|
||||
config: TileLayerConfig;
|
||||
}
|
||||
|
||||
export default function DynamicTileLayer({ config }: Props) {
|
||||
return (
|
||||
<TileLayer
|
||||
key={config.key}
|
||||
url={config.url}
|
||||
attribution={config.attribution}
|
||||
maxZoom={config.maxZoom}
|
||||
/>
|
||||
);
|
||||
}
|
||||
117
admin/src/components/map/MapControls.tsx
Normal file
117
admin/src/components/map/MapControls.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { Button, Space, Tooltip } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
AimOutlined,
|
||||
FullscreenOutlined,
|
||||
FullscreenExitOutlined,
|
||||
HomeOutlined,
|
||||
ReloadOutlined,
|
||||
DragOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface Props {
|
||||
addMode: boolean;
|
||||
onToggleAddMode: () => void;
|
||||
moveMode: boolean;
|
||||
onCancelMoveMode: () => void;
|
||||
showStartLocation: boolean;
|
||||
onToggleStartLocation: () => void;
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
onGeolocate: () => void;
|
||||
onRefresh: () => void;
|
||||
autoRefresh: boolean;
|
||||
onToggleAutoRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function MapControls({
|
||||
addMode,
|
||||
onToggleAddMode,
|
||||
moveMode,
|
||||
onCancelMoveMode,
|
||||
showStartLocation,
|
||||
onToggleStartLocation,
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
onGeolocate,
|
||||
onRefresh,
|
||||
autoRefresh,
|
||||
onToggleAutoRefresh,
|
||||
}: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 1000,
|
||||
background: 'rgba(26, 16, 37, 0.92)',
|
||||
borderRadius: 8,
|
||||
padding: 6,
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={4}>
|
||||
<Tooltip title={addMode ? 'Cancel Add' : 'Add Location'} placement="left">
|
||||
<Button
|
||||
type={addMode ? 'primary' : 'text'}
|
||||
icon={<PlusOutlined />}
|
||||
size="small"
|
||||
onClick={onToggleAddMode}
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{moveMode && (
|
||||
<Tooltip title="Cancel Move" placement="left">
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<DragOutlined />}
|
||||
size="small"
|
||||
onClick={onCancelMoveMode}
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="My Location" placement="left">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<AimOutlined />}
|
||||
size="small"
|
||||
onClick={onGeolocate}
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} placement="left">
|
||||
<Button
|
||||
type="text"
|
||||
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
size="small"
|
||||
onClick={onToggleFullscreen}
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={showStartLocation ? 'Hide Start' : 'Show Start'} placement="left">
|
||||
<Button
|
||||
type={showStartLocation ? 'primary' : 'text'}
|
||||
icon={<HomeOutlined />}
|
||||
size="small"
|
||||
onClick={onToggleStartLocation}
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={autoRefresh ? 'Auto-refresh ON' : 'Refresh'} placement="left">
|
||||
<Button
|
||||
type={autoRefresh ? 'primary' : 'text'}
|
||||
icon={<ReloadOutlined />}
|
||||
size="small"
|
||||
onClick={onRefresh}
|
||||
onDoubleClick={onToggleAutoRefresh}
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
admin/src/components/map/MapLegend.tsx
Normal file
72
admin/src/components/map/MapLegend.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
|
||||
import type { SupportLevel } from '@/types/api';
|
||||
import { NO_LEVEL_COLOR } from './mapUtils';
|
||||
|
||||
const entries: { level: SupportLevel; label: string; color: string }[] = [
|
||||
{ level: 'LEVEL_1', label: SUPPORT_LEVEL_LABELS.LEVEL_1, color: SUPPORT_LEVEL_COLORS.LEVEL_1 },
|
||||
{ level: 'LEVEL_2', label: SUPPORT_LEVEL_LABELS.LEVEL_2, color: SUPPORT_LEVEL_COLORS.LEVEL_2 },
|
||||
{ level: 'LEVEL_3', label: SUPPORT_LEVEL_LABELS.LEVEL_3, color: SUPPORT_LEVEL_COLORS.LEVEL_3 },
|
||||
{ level: 'LEVEL_4', label: SUPPORT_LEVEL_LABELS.LEVEL_4, color: SUPPORT_LEVEL_COLORS.LEVEL_4 },
|
||||
];
|
||||
|
||||
const VARIANT_BG = {
|
||||
public: 'rgba(27, 40, 56, 0.92)',
|
||||
admin: 'rgba(26, 16, 37, 0.92)',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
variant?: 'admin' | 'public';
|
||||
}
|
||||
|
||||
export default function MapLegend({ variant = 'public' }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
right: 12,
|
||||
zIndex: 1000,
|
||||
background: VARIANT_BG[variant],
|
||||
borderRadius: 8,
|
||||
padding: '10px 14px',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: '#fff', marginBottom: 6 }}>
|
||||
Support Level
|
||||
</div>
|
||||
{entries.map((e) => (
|
||||
<div key={e.level} style={{ display: 'flex', alignItems: 'center', marginBottom: 3 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: e.color,
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.85)' }}>{e.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: 2 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: NO_LEVEL_COLOR,
|
||||
marginRight: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.85)' }}>No Level</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
admin/src/components/map/MoveLocationMode.tsx
Normal file
76
admin/src/components/map/MoveLocationMode.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMap, useMapEvents, CircleMarker, Popup } from 'react-leaflet';
|
||||
import { Button, Space } from 'antd';
|
||||
import type { LeafletMouseEvent } from 'leaflet';
|
||||
|
||||
interface Props {
|
||||
active: boolean;
|
||||
onConfirmMove: (lat: number, lng: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function MoveHandler({ onClickMap }: { onClickMap: (lat: number, lng: number) => void }) {
|
||||
useMapEvents({
|
||||
click(e: LeafletMouseEvent) {
|
||||
onClickMap(e.latlng.lat, e.latlng.lng);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MoveLocationMode({ active, onConfirmMove, onCancel }: Props) {
|
||||
const map = useMap();
|
||||
const [target, setTarget] = useState<[number, number] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
map.getContainer().style.cursor = 'crosshair';
|
||||
map.doubleClickZoom.disable();
|
||||
setTarget(null);
|
||||
} else {
|
||||
map.getContainer().style.cursor = '';
|
||||
map.doubleClickZoom.enable();
|
||||
setTarget(null);
|
||||
}
|
||||
return () => {
|
||||
map.getContainer().style.cursor = '';
|
||||
map.doubleClickZoom.enable();
|
||||
};
|
||||
}, [active, map]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!target && <MoveHandler onClickMap={(lat, lng) => setTarget([lat, lng])} />}
|
||||
{target && (
|
||||
<CircleMarker
|
||||
center={target}
|
||||
radius={10}
|
||||
pathOptions={{ fillColor: '#9d4edd', fillOpacity: 0.9, color: '#fff', weight: 2 }}
|
||||
>
|
||||
<Popup autoClose={false} closeOnClick={false}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>Confirm move?</div>
|
||||
<div style={{ fontSize: 11, marginBottom: 8, color: 'rgba(255,255,255,0.65)' }}>
|
||||
{target[0].toFixed(5)}, {target[1].toFixed(5)}
|
||||
</div>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => onConfirmMove(target[0], target[1])}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button size="small" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
admin/src/components/map/NorthCompass.tsx
Normal file
34
admin/src/components/map/NorthCompass.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
interface Props {
|
||||
bearing?: number;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function NorthCompass({ bearing = 0, style }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 190,
|
||||
right: 14,
|
||||
zIndex: 1000,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width={36}
|
||||
height={36}
|
||||
viewBox="0 0 36 36"
|
||||
style={{ transform: `rotate(${bearing}deg)`, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.5))' }}
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle cx={18} cy={18} r={17} fill="rgba(0,0,0,0.7)" stroke="rgba(255,255,255,0.3)" strokeWidth={1} />
|
||||
{/* North arrow (red) */}
|
||||
<polygon points="18,4 14,18 18,15 22,18" fill="#e74c3c" />
|
||||
{/* South arrow (white) */}
|
||||
<polygon points="18,32 14,18 18,21 22,18" fill="rgba(255,255,255,0.7)" />
|
||||
{/* N label */}
|
||||
<text x={18} y={9} textAnchor="middle" fill="#fff" fontSize={7} fontWeight={700} fontFamily="Arial, sans-serif">N</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
admin/src/components/map/TileLayerToggle.tsx
Normal file
58
admin/src/components/map/TileLayerToggle.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import { GlobalOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { TILE_LAYERS } from './tileLayers';
|
||||
|
||||
interface Props {
|
||||
activeKey: string;
|
||||
onChange: (key: string) => void;
|
||||
position?: 'bottom-left' | 'bottom-right';
|
||||
}
|
||||
|
||||
const icons: Record<string, React.ReactNode> = {
|
||||
normal: <GlobalOutlined />,
|
||||
dark: <EyeOutlined />,
|
||||
satellite: <GlobalOutlined style={{ transform: 'rotate(45deg)' }} />,
|
||||
};
|
||||
|
||||
export default function TileLayerToggle({ activeKey, onChange, position = 'bottom-right' }: Props) {
|
||||
const posStyle = position === 'bottom-left'
|
||||
? { left: 10, bottom: 80 }
|
||||
: { right: 10, bottom: 80 };
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
...posStyle,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{TILE_LAYERS.map((layer) => (
|
||||
<button
|
||||
key={layer.key}
|
||||
onClick={() => onChange(layer.key)}
|
||||
title={layer.label}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
border: activeKey === layer.key ? '2px solid #1890ff' : '1px solid rgba(255,255,255,0.3)',
|
||||
background: activeKey === layer.key ? 'rgba(24, 144, 255, 0.25)' : 'rgba(0,0,0,0.6)',
|
||||
color: activeKey === layer.key ? '#1890ff' : 'rgba(255,255,255,0.8)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 16,
|
||||
backdropFilter: 'blur(4px)',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{icons[layer.key] ?? <GlobalOutlined />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
admin/src/components/map/mapUtils.ts
Normal file
57
admin/src/components/map/mapUtils.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type { Location, SupportLevel } from '@/types/api';
|
||||
import { SUPPORT_LEVEL_COLORS } from '@/types/api';
|
||||
|
||||
export const NO_LEVEL_COLOR = '#3498db';
|
||||
|
||||
export function getMarkerColor(level: SupportLevel | null): string {
|
||||
if (!level) return NO_LEVEL_COLOR;
|
||||
return SUPPORT_LEVEL_COLORS[level] ?? NO_LEVEL_COLOR;
|
||||
}
|
||||
|
||||
export interface LocationGroup {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
locations: Location[];
|
||||
isMultiUnit: boolean;
|
||||
dominantLevel: SupportLevel | null;
|
||||
}
|
||||
|
||||
export function groupLocations(locations: Location[]): LocationGroup[] {
|
||||
const groups = new Map<string, Location[]>();
|
||||
|
||||
for (const loc of locations) {
|
||||
if (loc.latitude == null || loc.longitude == null) continue;
|
||||
const key = `${parseFloat(loc.latitude).toFixed(6)},${parseFloat(loc.longitude).toFixed(6)}`;
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.push(loc);
|
||||
} else {
|
||||
groups.set(key, [loc]);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([key, locs]) => {
|
||||
const [lat, lng] = key.split(',');
|
||||
const levelCounts: Record<string, number> = {};
|
||||
for (const loc of locs) {
|
||||
const level = loc.supportLevel || 'NONE';
|
||||
levelCounts[level] = (levelCounts[level] || 0) + 1;
|
||||
}
|
||||
let dominant: SupportLevel | null = null;
|
||||
let maxCount = 0;
|
||||
for (const [level, count] of Object.entries(levelCounts)) {
|
||||
if (count > maxCount) {
|
||||
maxCount = count;
|
||||
dominant = level === 'NONE' ? null : (level as SupportLevel);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: parseFloat(lat!),
|
||||
longitude: parseFloat(lng!),
|
||||
locations: locs,
|
||||
isMultiUnit: locs.length > 1,
|
||||
dominantLevel: dominant,
|
||||
};
|
||||
});
|
||||
}
|
||||
53
admin/src/components/map/tileLayers.ts
Normal file
53
admin/src/components/map/tileLayers.ts
Normal file
@ -0,0 +1,53 @@
|
||||
export interface TileLayerConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
url: string;
|
||||
attribution: string;
|
||||
maxZoom?: number;
|
||||
}
|
||||
|
||||
export const TILE_LAYERS: TileLayerConfig[] = [
|
||||
{
|
||||
key: 'normal',
|
||||
label: 'Normal',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
label: 'Dark',
|
||||
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
maxZoom: 20,
|
||||
},
|
||||
{
|
||||
key: 'satellite',
|
||||
label: 'Satellite',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
attribution: '© Esri, Maxar, Earthstar Geographics',
|
||||
maxZoom: 18,
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'cmlite:tilePref';
|
||||
|
||||
export function getPersistedTileLayer(): string {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || 'dark';
|
||||
} catch {
|
||||
return 'dark';
|
||||
}
|
||||
}
|
||||
|
||||
export function persistTileLayer(key: string): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, key);
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
export function getTileConfig(key: string): TileLayerConfig {
|
||||
return TILE_LAYERS.find((t) => t.key === key) || TILE_LAYERS[1]!; // default dark
|
||||
}
|
||||
46
admin/src/hooks/useMkDocsBuild.ts
Normal file
46
admin/src/hooks/useMkDocsBuild.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, message } from 'antd';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import type { MkDocsBuildResult } from '@/types/api';
|
||||
|
||||
/**
|
||||
* Hook for triggering MkDocs static site builds with a confirmation modal.
|
||||
* Returns { building, confirmAndBuild } for use in any admin page.
|
||||
*/
|
||||
export function useMkDocsBuild() {
|
||||
const [building, setBuilding] = useState(false);
|
||||
const { user } = useAuthStore();
|
||||
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
|
||||
|
||||
const confirmAndBuild = useCallback(() => {
|
||||
if (!isSuperAdmin) {
|
||||
message.warning('Only super admins can trigger builds');
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: 'Build Static Site',
|
||||
content: 'This will rebuild the MkDocs static site. The dev server is unaffected. Continue?',
|
||||
okText: 'Build',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
setBuilding(true);
|
||||
try {
|
||||
const res = await api.post<MkDocsBuildResult>('/docs/build');
|
||||
if (res.data.success) {
|
||||
message.success(`Build succeeded in ${(res.data.duration / 1000).toFixed(1)}s`);
|
||||
} else {
|
||||
message.error('Build failed — check Docs Settings > Build for details');
|
||||
}
|
||||
} catch {
|
||||
message.error('Build request failed');
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
return { building, confirmAndBuild, isSuperAdmin };
|
||||
}
|
||||
78
admin/src/lib/api.ts
Normal file
78
admin/src/lib/api.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import axios from 'axios';
|
||||
import type { AuthResponse } from '@/types/api';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
});
|
||||
|
||||
// Token accessor — set by auth store on init to break circular dependency
|
||||
let getTokens: () => { accessToken: string | null; refreshToken: string | null } =
|
||||
() => ({ accessToken: null, refreshToken: null });
|
||||
let onTokenRefresh: (accessToken: string, refreshToken: string) => void = () => {};
|
||||
let onAuthFailure: () => void = () => {};
|
||||
|
||||
export function registerAuthCallbacks(callbacks: {
|
||||
getTokens: typeof getTokens;
|
||||
onTokenRefresh: typeof onTokenRefresh;
|
||||
onAuthFailure: typeof onAuthFailure;
|
||||
}) {
|
||||
getTokens = callbacks.getTokens;
|
||||
onTokenRefresh = callbacks.onTokenRefresh;
|
||||
onAuthFailure = callbacks.onAuthFailure;
|
||||
}
|
||||
|
||||
// Request interceptor: attach access token
|
||||
api.interceptors.request.use((config) => {
|
||||
const { accessToken } = getTokens();
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor: handle 401 with token refresh
|
||||
let refreshPromise: Promise<AuthResponse> | null = null;
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
const errorCode = error.response?.data?.error?.code;
|
||||
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
(errorCode === 'INVALID_TOKEN' || errorCode === 'AUTH_REQUIRED') &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const { refreshToken } = getTokens();
|
||||
if (!refreshToken) {
|
||||
onAuthFailure();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = api
|
||||
.post<AuthResponse>('/auth/refresh', { refreshToken })
|
||||
.then((res) => res.data)
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
const data = await refreshPromise;
|
||||
onTokenRefresh(data.accessToken, data.refreshToken);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return api(originalRequest);
|
||||
} catch {
|
||||
onAuthFailure();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
20
admin/src/lib/service-url.ts
Normal file
20
admin/src/lib/service-url.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Build an iframe-compatible URL for a platform service.
|
||||
*
|
||||
* All URLs route through nginx which strips X-Frame-Options headers:
|
||||
* - Real domain (app.cmlite.org) → //db.cmlite.org (subdomain via nginx port 80)
|
||||
* - Localhost dev → //localhost:EMBED_PORT (dedicated nginx proxy ports)
|
||||
*/
|
||||
export function buildServiceUrl(
|
||||
subdomain: string,
|
||||
domain: string,
|
||||
embedPort: number,
|
||||
): string {
|
||||
const hostname = window.location.hostname;
|
||||
// Real domain (contains a dot) — use subdomain via nginx
|
||||
if (hostname.includes('.')) {
|
||||
return `//${subdomain}.${domain}`;
|
||||
}
|
||||
// Localhost dev — use dedicated nginx embed proxy port
|
||||
return `//${hostname}:${embedPort}`;
|
||||
}
|
||||
10
admin/src/main.tsx
Normal file
10
admin/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
180
admin/src/pages/CampaignEmailsDrawer.tsx
Normal file
180
admin/src/pages/CampaignEmailsDrawer.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Drawer, Table, Tag, Select, Statistic, Row, Col, Card, message } from 'antd';
|
||||
import type { TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
Campaign,
|
||||
CampaignEmail,
|
||||
CampaignEmailStatus,
|
||||
CampaignEmailsListResponse,
|
||||
CampaignEmailStats,
|
||||
EmailMethod,
|
||||
} from '@/types/api';
|
||||
|
||||
interface Props {
|
||||
campaign: Campaign | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const statusColors: Record<CampaignEmailStatus, string> = {
|
||||
QUEUED: 'processing',
|
||||
SENT: 'success',
|
||||
FAILED: 'error',
|
||||
CLICKED: 'blue',
|
||||
USER_INFO_CAPTURED: 'purple',
|
||||
};
|
||||
|
||||
const statusOptions: { value: CampaignEmailStatus; label: string }[] = [
|
||||
{ value: 'QUEUED', label: 'Queued' },
|
||||
{ value: 'SENT', label: 'Sent' },
|
||||
{ value: 'FAILED', label: 'Failed' },
|
||||
{ value: 'CLICKED', label: 'Clicked (Mailto)' },
|
||||
{ value: 'USER_INFO_CAPTURED', label: 'User Info Captured' },
|
||||
];
|
||||
|
||||
export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props) {
|
||||
const [emails, setEmails] = useState<CampaignEmail[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<CampaignEmailStatus | undefined>();
|
||||
const [stats, setStats] = useState<CampaignEmailStats | null>(null);
|
||||
|
||||
const fetchEmails = useCallback(async (page = 1) => {
|
||||
if (!campaign) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<CampaignEmailsListResponse>(`/campaigns/${campaign.id}/emails`, {
|
||||
params: { page, limit: pagination.limit, status: statusFilter },
|
||||
});
|
||||
setEmails(data.emails);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load emails');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [campaign, pagination.limit, statusFilter]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!campaign) return;
|
||||
try {
|
||||
const { data } = await api.get<CampaignEmailStats>(`/campaigns/${campaign.id}/email-stats`);
|
||||
setStats(data);
|
||||
} catch {
|
||||
// Silently fail — stats are informational
|
||||
}
|
||||
}, [campaign]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && campaign) {
|
||||
fetchEmails(1);
|
||||
fetchStats();
|
||||
}
|
||||
}, [open, campaign, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||
fetchEmails(pag.current ?? 1);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Recipient',
|
||||
dataIndex: 'recipientEmail',
|
||||
key: 'recipientEmail',
|
||||
ellipsis: true,
|
||||
render: (email: string, record: CampaignEmail) =>
|
||||
record.recipientName ? `${record.recipientName} <${email}>` : email,
|
||||
},
|
||||
{
|
||||
title: 'Sender',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
ellipsis: true,
|
||||
render: (name: string | null, record: CampaignEmail) => name || record.userEmail || '--',
|
||||
},
|
||||
{
|
||||
title: 'Method',
|
||||
dataIndex: 'emailMethod',
|
||||
key: 'emailMethod',
|
||||
render: (method: EmailMethod) => (
|
||||
<Tag color={method === 'SMTP' ? 'geekblue' : 'orange'}>{method}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: CampaignEmailStatus) => (
|
||||
<Tag color={statusColors[status]}>{status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Sent',
|
||||
dataIndex: 'sentAt',
|
||||
key: 'sentAt',
|
||||
render: (date: string) => dayjs(date).format('MM-DD HH:mm'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={`Emails — ${campaign?.title || ''}`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={720}
|
||||
destroyOnClose
|
||||
>
|
||||
{stats && (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="Sent" value={stats.sent} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="Queued" value={stats.queued} valueStyle={{ color: '#1890ff' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="Failed" value={stats.failed} valueStyle={{ color: '#ff4d4f' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="Mailto" value={stats.clicked} valueStyle={{ color: '#fa8c16' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Select
|
||||
placeholder="Filter by status"
|
||||
options={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
style={{ width: 200, marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
<Table<CampaignEmail>
|
||||
columns={columns}
|
||||
dataSource={emails}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showTotal: (total) => `${total} emails`,
|
||||
size: 'small',
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
506
admin/src/pages/CampaignsPage.tsx
Normal file
506
admin/src/pages/CampaignsPage.tsx
Normal file
@ -0,0 +1,506 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Switch,
|
||||
Popconfirm,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
StarFilled,
|
||||
MailOutlined,
|
||||
LinkOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
Campaign,
|
||||
CampaignStatus,
|
||||
GovernmentLevel,
|
||||
CampaignsListResponse,
|
||||
CampaignsListParams,
|
||||
CreateCampaignPayload,
|
||||
UpdateCampaignPayload,
|
||||
} from '@/types/api';
|
||||
import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const statusColors: Record<CampaignStatus, string> = {
|
||||
DRAFT: 'default',
|
||||
ACTIVE: 'green',
|
||||
PAUSED: 'orange',
|
||||
ARCHIVED: 'gray',
|
||||
};
|
||||
|
||||
const statusOptions: { value: CampaignStatus; label: string }[] = [
|
||||
{ value: 'DRAFT', label: 'Draft' },
|
||||
{ value: 'ACTIVE', label: 'Active' },
|
||||
{ value: 'PAUSED', label: 'Paused' },
|
||||
{ value: 'ARCHIVED', label: 'Archived' },
|
||||
];
|
||||
|
||||
const govLevelOptions: { value: GovernmentLevel; label: string }[] = [
|
||||
{ value: 'FEDERAL', label: 'Federal' },
|
||||
{ value: 'PROVINCIAL', label: 'Provincial' },
|
||||
{ value: 'MUNICIPAL', label: 'Municipal' },
|
||||
{ value: 'SCHOOL_BOARD', label: 'School Board' },
|
||||
];
|
||||
|
||||
const govLevelColors: Record<GovernmentLevel, string> = {
|
||||
FEDERAL: 'blue',
|
||||
PROVINCIAL: 'purple',
|
||||
MUNICIPAL: 'cyan',
|
||||
SCHOOL_BOARD: 'magenta',
|
||||
};
|
||||
|
||||
export default function CampaignsPage() {
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const [statusFilter, setStatusFilter] = useState<CampaignStatus | undefined>();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingCampaign, setEditingCampaign] = useState<Campaign | null>(null);
|
||||
const [emailsDrawerOpen, setEmailsDrawerOpen] = useState(false);
|
||||
const [emailsCampaign, setEmailsCampaign] = useState<Campaign | null>(null);
|
||||
const [createForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(searchTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const fetchCampaigns = useCallback(async (params?: CampaignsListParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<CampaignsListResponse>('/campaigns', {
|
||||
params: {
|
||||
page: params?.page ?? pagination.page,
|
||||
limit: params?.limit ?? pagination.limit,
|
||||
search: params?.search ?? (debouncedSearch || undefined),
|
||||
status: params?.status ?? statusFilter,
|
||||
},
|
||||
});
|
||||
setCampaigns(data.campaigns);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load campaigns');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns({ page: 1 });
|
||||
}, [debouncedSearch, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||
fetchCampaigns({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||
};
|
||||
|
||||
const handleCreate = async (values: CreateCampaignPayload) => {
|
||||
try {
|
||||
await api.post('/campaigns', values);
|
||||
message.success('Campaign created');
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchCampaigns({ page: 1 });
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to create campaign';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (values: UpdateCampaignPayload) => {
|
||||
if (!editingCampaign) return;
|
||||
try {
|
||||
await api.put(`/campaigns/${editingCampaign.id}`, values);
|
||||
message.success('Campaign updated');
|
||||
setEditModalOpen(false);
|
||||
setEditingCampaign(null);
|
||||
editForm.resetFields();
|
||||
fetchCampaigns();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to update campaign';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/campaigns/${id}`);
|
||||
message.success('Campaign deleted');
|
||||
fetchCampaigns();
|
||||
} catch {
|
||||
message.error('Failed to delete campaign');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (campaign: Campaign) => {
|
||||
setEditingCampaign(campaign);
|
||||
editForm.setFieldsValue({
|
||||
title: campaign.title,
|
||||
description: campaign.description,
|
||||
emailSubject: campaign.emailSubject,
|
||||
emailBody: campaign.emailBody,
|
||||
callToAction: campaign.callToAction,
|
||||
status: campaign.status,
|
||||
targetGovernmentLevels: campaign.targetGovernmentLevels,
|
||||
allowSmtpEmail: campaign.allowSmtpEmail,
|
||||
allowMailtoLink: campaign.allowMailtoLink,
|
||||
collectUserInfo: campaign.collectUserInfo,
|
||||
showEmailCount: campaign.showEmailCount,
|
||||
showCallCount: campaign.showCallCount,
|
||||
allowEmailEditing: campaign.allowEmailEditing,
|
||||
allowCustomRecipients: campaign.allowCustomRecipients,
|
||||
showResponseWall: campaign.showResponseWall,
|
||||
highlightCampaign: campaign.highlightCampaign,
|
||||
coverPhoto: campaign.coverPhoto,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Campaign> = [
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (title: string, record: Campaign) => (
|
||||
<div>
|
||||
<Space>
|
||||
<span style={{ fontWeight: 500 }}>{title}</span>
|
||||
{record.highlightCampaign && <StarFilled style={{ color: '#faad14' }} />}
|
||||
</Space>
|
||||
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/campaign/{record.slug}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: CampaignStatus) => (
|
||||
<Tag color={statusColors[status]}>{status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Gov. Levels',
|
||||
dataIndex: 'targetGovernmentLevels',
|
||||
key: 'targetGovernmentLevels',
|
||||
render: (levels: GovernmentLevel[]) =>
|
||||
levels.length > 0
|
||||
? levels.map((l) => (
|
||||
<Tag key={l} color={govLevelColors[l]} style={{ fontSize: 11 }}>
|
||||
{l.replace('_', ' ')}
|
||||
</Tag>
|
||||
))
|
||||
: '--',
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Emails',
|
||||
key: 'emails',
|
||||
render: (_: unknown, record: Campaign) => record._count.emails,
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Responses',
|
||||
key: 'responses',
|
||||
render: (_: unknown, record: Campaign) => record._count.responses,
|
||||
responsive: ['lg'],
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: Campaign) => (
|
||||
<Space>
|
||||
{record.status === 'ACTIVE' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => window.open(`/campaign/${record.slug}`, '_blank')}
|
||||
title="View public page"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => {
|
||||
const url = `${window.location.origin}/campaign/${record.slug}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success('Campaign link copied');
|
||||
}}
|
||||
title="Copy public link"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<MailOutlined />}
|
||||
onClick={() => {
|
||||
setEmailsCampaign(record);
|
||||
setEmailsDrawerOpen(true);
|
||||
}}
|
||||
title="View emails"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
title="Edit campaign"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="Delete this campaign?"
|
||||
description="All associated emails and responses will also be deleted."
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const campaignFormFields = (
|
||||
<>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="Title"
|
||||
rules={[{ required: true, message: 'Title is required' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="emailSubject"
|
||||
label="Email Subject"
|
||||
rules={[{ required: true, message: 'Email subject is required' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="emailBody"
|
||||
label="Email Body"
|
||||
rules={[{ required: true, message: 'Email body is required' }]}
|
||||
>
|
||||
<TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item name="callToAction" label="Call to Action">
|
||||
<TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="Status" initialValue="DRAFT">
|
||||
<Select options={statusOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="targetGovernmentLevels" label="Government Levels" initialValue={[]}>
|
||||
<Select mode="multiple" options={govLevelOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="coverPhoto" label="Cover Photo URL">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left" plain>
|
||||
Feature Flags
|
||||
</Divider>
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="allowSmtpEmail" label="Allow SMTP Email" valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="allowMailtoLink" label="Allow Mailto Link" valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="collectUserInfo" label="Collect User Info" valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="showEmailCount" label="Show Email Count" valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="showCallCount" label="Show Call Count" valuePropName="checked" initialValue={true}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="allowEmailEditing" label="Allow Email Editing" valuePropName="checked" initialValue={false}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="allowCustomRecipients" label="Allow Custom Recipients" valuePropName="checked" initialValue={false}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="showResponseWall" label="Show Response Wall" valuePropName="checked" initialValue={false}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="highlightCampaign" label="Highlight Campaign" valuePropName="checked" initialValue={false}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Create Campaign
|
||||
</Button>
|
||||
), []);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Campaigns', actions: headerActions });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Input
|
||||
placeholder="Search by title or description"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={7} md={4}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
options={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table<Campaign>
|
||||
columns={columns}
|
||||
dataSource={campaigns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${total} campaigns`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
title="Create Campaign"
|
||||
open={createModalOpen}
|
||||
destroyOnHidden
|
||||
width={640}
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
onOk={() => createForm.submit()}
|
||||
okText="Create"
|
||||
>
|
||||
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
||||
{campaignFormFields}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
title="Edit Campaign"
|
||||
open={editModalOpen}
|
||||
destroyOnHidden
|
||||
width={640}
|
||||
onCancel={() => {
|
||||
setEditModalOpen(false);
|
||||
setEditingCampaign(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
onOk={() => editForm.submit()}
|
||||
okText="Save"
|
||||
>
|
||||
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
||||
{campaignFormFields}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Emails Drawer */}
|
||||
<CampaignEmailsDrawer
|
||||
campaign={emailsCampaign}
|
||||
open={emailsDrawerOpen}
|
||||
onClose={() => {
|
||||
setEmailsDrawerOpen(false);
|
||||
setEmailsCampaign(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
315
admin/src/pages/CanvassDashboardPage.tsx
Normal file
315
admin/src/pages/CanvassDashboardPage.tsx
Normal file
@ -0,0 +1,315 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Card, Row, Col, Statistic, Table, Tag, Typography, Spin, Progress, App, Button, Space, Grid } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
HomeOutlined,
|
||||
TeamOutlined,
|
||||
ThunderboltOutlined,
|
||||
CheckCircleOutlined,
|
||||
EnvironmentOutlined,
|
||||
HistoryOutlined,
|
||||
EyeOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
CanvassAdminStats,
|
||||
CanvassVisit,
|
||||
VolunteerSummary,
|
||||
VisitOutcome,
|
||||
} from '@/types/canvass';
|
||||
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
|
||||
import type { PaginationMeta, Cut, MapSettings } from '@/types/api';
|
||||
import type { SessionRoute } from '@/types/tracking';
|
||||
import AdminLiveMap from '@/components/canvass/AdminLiveMap';
|
||||
import HistoricalRoutesDrawer from '@/components/canvass/HistoricalRoutesDrawer';
|
||||
|
||||
export default function CanvassDashboardPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const isDesktop = !!screens.lg;
|
||||
|
||||
const [stats, setStats] = useState<CanvassAdminStats | null>(null);
|
||||
const [activity, setActivity] = useState<CanvassVisit[]>([]);
|
||||
const [activityPagination, setActivityPagination] = useState<PaginationMeta | null>(null);
|
||||
const [volunteers, setVolunteers] = useState<VolunteerSummary[]>([]);
|
||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||
const [mapSettings, setMapSettings] = useState<MapSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mapVisible, setMapVisible] = useState(true);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [_historyRoute, setHistoryRoute] = useState<SessionRoute | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Canvassing' });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [statsRes, activityRes, volunteersRes, cutsRes, settingsRes] = await Promise.all([
|
||||
api.get('/map/canvass/stats'),
|
||||
api.get('/map/canvass/activity', { params: { limit: 10 } }),
|
||||
api.get('/map/canvass/volunteers'),
|
||||
api.get('/map/cuts', { params: { limit: 100 } }),
|
||||
api.get('/map/settings').catch(() => ({ data: null })),
|
||||
]);
|
||||
setStats(statsRes.data);
|
||||
setActivity(activityRes.data.visits);
|
||||
setActivityPagination(activityRes.data.pagination);
|
||||
setVolunteers(volunteersRes.data);
|
||||
setCuts(cutsRes.data.cuts ?? cutsRes.data);
|
||||
if (settingsRes.data) setMapSettings(settingsRes.data);
|
||||
} catch {
|
||||
message.error('Failed to load canvass data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
const interval = setInterval(loadData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
const activityColumns: ColumnsType<CanvassVisit> = [
|
||||
{
|
||||
title: 'Volunteer',
|
||||
key: 'volunteer',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, r: CanvassVisit) => r.user?.name || r.user?.email || '—',
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
key: 'address',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, r: CanvassVisit) =>
|
||||
`${r.location?.address ?? '?'}${r.location?.unitNumber ? ` #${r.location.unitNumber}` : ''}`,
|
||||
},
|
||||
{
|
||||
title: 'Outcome',
|
||||
dataIndex: 'outcome',
|
||||
key: 'outcome',
|
||||
width: 100,
|
||||
render: (val: VisitOutcome) => (
|
||||
<Tag color={VISIT_OUTCOME_COLORS[val]}>{VISIT_OUTCOME_LABELS[val]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'When',
|
||||
dataIndex: 'visitedAt',
|
||||
key: 'visitedAt',
|
||||
width: 80,
|
||||
render: (val: string) => dayjs(val).format('h:mm A'),
|
||||
},
|
||||
];
|
||||
|
||||
const volunteerColumns: ColumnsType<VolunteerSummary> = [
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, r: VolunteerSummary) => r.name || r.email,
|
||||
},
|
||||
{ title: 'Visits', dataIndex: 'totalVisits', key: 'visits', width: 60, sorter: (a: VolunteerSummary, b: VolunteerSummary) => a.totalVisits - b.totalVisits },
|
||||
{ title: 'Sessions', dataIndex: 'sessions', key: 'sessions', width: 70 },
|
||||
{
|
||||
title: 'Last Active',
|
||||
dataIndex: 'lastActive',
|
||||
key: 'lastActive',
|
||||
width: 120,
|
||||
render: (val: string | null) => val ? dayjs(val).format('MMM D, h:mm A') : '—',
|
||||
},
|
||||
];
|
||||
|
||||
const cutColumns: ColumnsType<Cut> = [
|
||||
{ title: 'Cut', dataIndex: 'name', key: 'name', ellipsis: true },
|
||||
{
|
||||
title: 'Progress',
|
||||
key: 'progress',
|
||||
width: 120,
|
||||
render: (_: unknown, r: Cut) => (
|
||||
<Progress
|
||||
percent={r.completionPercentage}
|
||||
size="small"
|
||||
style={{ maxWidth: 100 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Last Canvassed',
|
||||
dataIndex: 'lastCanvassed',
|
||||
key: 'lastCanvassed',
|
||||
width: 100,
|
||||
render: (val: string | null) => val ? dayjs(val).format('MMM D') : 'Never',
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
// Convert cuts to PublicCut shape for AdminLiveMap
|
||||
const publicCuts = cuts.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
color: c.color,
|
||||
opacity: c.opacity,
|
||||
category: c.category,
|
||||
geojson: c.geojson,
|
||||
bounds: c.bounds,
|
||||
}));
|
||||
|
||||
const mapHeight = isMobile ? 350 : isDesktop ? 'calc(100vh - 180px)' : 500;
|
||||
|
||||
const mapCard = (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<EnvironmentOutlined />
|
||||
<span>Live Volunteer Map</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => setHistoryOpen(true)}
|
||||
>
|
||||
History
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={mapVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setMapVisible(!mapVisible)}
|
||||
>
|
||||
{mapVisible ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{mapVisible && (
|
||||
<div style={{ height: mapHeight }}>
|
||||
<AdminLiveMap cuts={publicCuts} mapSettings={mapSettings} />
|
||||
</div>
|
||||
)}
|
||||
{!mapVisible && (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '24px 0' }}>
|
||||
Map hidden to save resources. Click "Show" to enable.
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const rightPanel = (
|
||||
<div
|
||||
style={
|
||||
isDesktop
|
||||
? { position: 'sticky', top: 0, maxHeight: 'calc(100vh - 140px)', overflowY: 'auto', paddingRight: 4 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* Stats 2x2 grid */}
|
||||
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
|
||||
<Col span={12}>
|
||||
<Card size="small">
|
||||
<Statistic title="Total Visits" value={stats?.totalVisits ?? 0} prefix={<HomeOutlined />} valueStyle={{ fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card size="small">
|
||||
<Statistic title="Active Volunteers" value={stats?.activeVolunteers ?? 0} prefix={<TeamOutlined />} valueStyle={{ fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card size="small">
|
||||
<Statistic title="Active Sessions" value={stats?.activeSessions ?? 0} prefix={<ThunderboltOutlined />} valueStyle={{ fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card size="small">
|
||||
<Statistic title="Completion" value={stats?.overallCompletion ?? 0} suffix="%" prefix={<CheckCircleOutlined />} valueStyle={{ fontSize: 20 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="Recent Activity" size="small" style={{ marginBottom: 12 }}>
|
||||
<Table
|
||||
dataSource={activity}
|
||||
columns={activityColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
{activityPagination && activityPagination.total > 10 && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
|
||||
Showing 10 of {activityPagination.total} visits
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title="Cut Progress" size="small" style={{ marginBottom: 12 }}>
|
||||
<Table
|
||||
dataSource={cuts}
|
||||
columns={cutColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Volunteer Leaderboard" size="small">
|
||||
<Table
|
||||
dataSource={volunteers}
|
||||
columns={volunteerColumns}
|
||||
rowKey="userId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isMobile ? (
|
||||
// Mobile: stack vertically
|
||||
<>
|
||||
{mapCard}
|
||||
<div style={{ marginTop: 16 }}>{rightPanel}</div>
|
||||
</>
|
||||
) : (
|
||||
// Desktop/tablet: side-by-side
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={13} lg={15}>
|
||||
{mapCard}
|
||||
</Col>
|
||||
<Col xs={24} md={11} lg={9}>
|
||||
{rightPanel}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Historical Routes Drawer */}
|
||||
<HistoricalRoutesDrawer
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onSelectRoute={setHistoryRoute}
|
||||
volunteers={volunteers.map((v) => ({ userId: v.userId, name: v.name, email: v.email }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
admin/src/pages/CodeEditorPage.tsx
Normal file
122
admin/src/pages/CodeEditorPage.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
||||
import { ReloadOutlined, LinkOutlined, CodeOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { DocsStatus, DocsConfig } from '@/types/api';
|
||||
|
||||
export default function CodeEditorPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [codeServerPort, setCodeServerPort] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, configRes] = await Promise.all([
|
||||
api.get<DocsStatus>('/docs/status'),
|
||||
api.get<DocsConfig>('/docs/config'),
|
||||
]);
|
||||
setOnline(statusRes.data.codeServer.online);
|
||||
setCodeServerPort(configRes.data.codeServerPort);
|
||||
} catch {
|
||||
setOnline(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const codeServerUrl = codeServerPort
|
||||
? `//${window.location.hostname}:${codeServerPort}`
|
||||
: null;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Badge
|
||||
status={online === null ? 'processing' : online ? 'success' : 'error'}
|
||||
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
size="small"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{codeServerUrl && (
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
href={codeServerUrl}
|
||||
target="_blank"
|
||||
size="small"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
), [online, handleRefresh, codeServerUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Code Editor', actions: headerActions, fullBleed: true });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Result
|
||||
status="info"
|
||||
title="Desktop Required"
|
||||
subTitle="The code editor requires a desktop browser with a larger screen."
|
||||
icon={<CodeOutlined style={{ fontSize: 48 }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!online || !codeServerUrl) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="Code Server Unavailable"
|
||||
subTitle="Code Server is not running or could not be reached. Check that the code-server container is started."
|
||||
extra={
|
||||
<Button type="primary" onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={codeServerUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
title="Code Server"
|
||||
/>
|
||||
);
|
||||
}
|
||||
300
admin/src/pages/CutExportPage.tsx
Normal file
300
admin/src/pages/CutExportPage.tsx
Normal file
@ -0,0 +1,300 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Spin,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PrinterOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { Cut, Location, CutStatistics, SupportLevel } from '@/types/api';
|
||||
import {
|
||||
SUPPORT_LEVEL_LABELS,
|
||||
SUPPORT_LEVEL_COLORS,
|
||||
CUT_CATEGORY_LABELS,
|
||||
CUT_CATEGORY_COLORS,
|
||||
} from '@/types/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function CutExportPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [cut, setCut] = useState<Cut | null>(null);
|
||||
const [locations, setLocations] = useState<Location[]>([]);
|
||||
const [stats, setStats] = useState<CutStatistics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
(async () => {
|
||||
try {
|
||||
const [cutRes, locsRes, statsRes] = await Promise.all([
|
||||
api.get<Cut>(`/map/cuts/${id}`),
|
||||
api.get<Location[]>(`/map/cuts/${id}/locations`),
|
||||
api.get<CutStatistics>(`/map/cuts/${id}/statistics`),
|
||||
]);
|
||||
setCut(cutRes.data);
|
||||
setLocations(locsRes.data);
|
||||
setStats(statsRes.data);
|
||||
} catch {
|
||||
message.error('Failed to load cut data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/app/map/cuts')}
|
||||
>
|
||||
Back to Cuts
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
</Space>
|
||||
), [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: cut?.name || 'Cut Export', actions: headerActions });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions, cut?.name]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cut) {
|
||||
return <Text type="danger">Cut not found</Text>;
|
||||
}
|
||||
|
||||
const now = dayjs().format('YYYY-MM-DD HH:mm');
|
||||
|
||||
const withEmail = locations.filter((l) => l.email).length;
|
||||
const withPhone = locations.filter((l) => l.phone).length;
|
||||
|
||||
const columns: ColumnsType<Location> = [
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
render: (_: unknown, record: Location) =>
|
||||
[record.firstName, record.lastName].filter(Boolean).join(' ') || '--',
|
||||
},
|
||||
{
|
||||
title: 'Address',
|
||||
key: 'address',
|
||||
render: (_: unknown, record: Location) =>
|
||||
[record.address, record.unitNumber && `#${record.unitNumber}`].filter(Boolean).join(' ') || '--',
|
||||
},
|
||||
{
|
||||
title: 'Support',
|
||||
dataIndex: 'supportLevel',
|
||||
key: 'supportLevel',
|
||||
width: 120,
|
||||
render: (level: SupportLevel | null) =>
|
||||
level ? (
|
||||
<Tag color={SUPPORT_LEVEL_COLORS[level]}>{SUPPORT_LEVEL_LABELS[level]}</Tag>
|
||||
) : (
|
||||
<Tag>None</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Phone',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 120,
|
||||
render: (val: string | null) => val || '--',
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 180,
|
||||
render: (val: string | null) => val || '--',
|
||||
},
|
||||
{
|
||||
title: 'Sign',
|
||||
key: 'sign',
|
||||
width: 80,
|
||||
render: (_: unknown, record: Location) =>
|
||||
record.sign
|
||||
? `Yes${record.signSize ? ` (${record.signSize})` : ''}`
|
||||
: 'No',
|
||||
},
|
||||
{
|
||||
title: 'Notes',
|
||||
dataIndex: 'notes',
|
||||
key: 'notes',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (val: string | null) => val || '--',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body * { visibility: hidden !important; }
|
||||
.cut-export-print, .cut-export-print * { visibility: visible !important; }
|
||||
.cut-export-print {
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 100% !important;
|
||||
padding: 0.4in !important;
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
.cut-export-print .ant-table { font-size: 9px !important; }
|
||||
.cut-export-print .ant-table-thead > tr > th {
|
||||
background: #f0f0f0 !important;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
padding: 4px 6px !important;
|
||||
}
|
||||
.cut-export-print .ant-table-tbody > tr > td { padding: 3px 6px !important; }
|
||||
.cut-export-print .ant-tag {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
.cut-export-print .ant-card { box-shadow: none !important; border: 1px solid #ddd !important; }
|
||||
@page { size: letter landscape; margin: 0.25in; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Printable content */}
|
||||
<div className="cut-export-print">
|
||||
{/* Report header */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Title level={4} style={{ margin: 0 }}>{cut.name}</Title>
|
||||
<Space style={{ marginTop: 4 }}>
|
||||
{cut.category && (
|
||||
<Tag color={CUT_CATEGORY_COLORS[cut.category]}>
|
||||
{CUT_CATEGORY_LABELS[cut.category]}
|
||||
</Tag>
|
||||
)}
|
||||
{cut.assignedTo && <Text type="secondary">Assigned to: {cut.assignedTo}</Text>}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary">Generated: {now}</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
{stats && (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Total" value={stats.total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Strong"
|
||||
value={stats.byLevel.LEVEL_1 || 0}
|
||||
valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_1 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Likely"
|
||||
value={stats.byLevel.LEVEL_2 || 0}
|
||||
valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_2 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Unsure"
|
||||
value={stats.byLevel.LEVEL_3 || 0}
|
||||
valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_3 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Oppose"
|
||||
value={stats.byLevel.LEVEL_4 || 0}
|
||||
valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_4 }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="None" value={stats.byLevel.NONE || 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Signs" value={stats.withSign} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Email" value={withEmail} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={8} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Phone" value={withPhone} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Location table */}
|
||||
<Table<Location>
|
||||
columns={columns}
|
||||
dataSource={locations}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 10 }}>
|
||||
Generated by Changemaker Lite — {now}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
560
admin/src/pages/CutsPage.tsx
Normal file
560
admin/src/pages/CutsPage.tsx
Normal file
@ -0,0 +1,560 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Switch,
|
||||
Popconfirm,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Slider,
|
||||
Segmented,
|
||||
Drawer,
|
||||
Upload,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
EnvironmentOutlined,
|
||||
TableOutlined,
|
||||
PrinterOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
Cut,
|
||||
CutsListResponse,
|
||||
CutsListParams,
|
||||
CutCategory,
|
||||
} from '@/types/api';
|
||||
import { CUT_CATEGORY_LABELS, CUT_CATEGORY_COLORS } from '@/types/api';
|
||||
import CutEditorMap from '@/components/map/CutEditorMap';
|
||||
|
||||
const categoryOptions = Object.entries(CUT_CATEGORY_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
export default function CutsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const [categoryFilter, setCategoryFilter] = useState<CutCategory | undefined>();
|
||||
const [activeTab, setActiveTab] = useState('table');
|
||||
|
||||
// Create modal
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [pendingGeojson, setPendingGeojson] = useState<string | null>(null);
|
||||
const [pendingBounds, setPendingBounds] = useState<string | null>(null);
|
||||
const [createForm] = Form.useForm();
|
||||
|
||||
// Import modal
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
|
||||
// Edit drawer
|
||||
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
|
||||
const [editingCut, setEditingCut] = useState<Cut | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(searchTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const fetchCuts = useCallback(async (params?: CutsListParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<CutsListResponse>('/map/cuts', {
|
||||
params: {
|
||||
page: params?.page ?? pagination.page,
|
||||
limit: params?.limit ?? pagination.limit,
|
||||
search: params?.search ?? (debouncedSearch || undefined),
|
||||
category: params?.category ?? categoryFilter,
|
||||
},
|
||||
});
|
||||
setCuts(data.cuts);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load cuts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCuts({ page: 1 });
|
||||
}, [debouncedSearch, categoryFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||
fetchCuts({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||
};
|
||||
|
||||
const handleCreate = async (values: Record<string, unknown>) => {
|
||||
try {
|
||||
await api.post('/map/cuts', {
|
||||
...values,
|
||||
geojson: pendingGeojson,
|
||||
bounds: pendingBounds,
|
||||
});
|
||||
message.success('Cut created');
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
setPendingGeojson(null);
|
||||
setPendingBounds(null);
|
||||
fetchCuts({ page: 1 });
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to create cut';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (values: Record<string, unknown>) => {
|
||||
if (!editingCut) return;
|
||||
try {
|
||||
await api.put(`/map/cuts/${editingCut.id}`, values);
|
||||
message.success('Cut updated');
|
||||
setEditDrawerOpen(false);
|
||||
setEditingCut(null);
|
||||
editForm.resetFields();
|
||||
fetchCuts();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to update cut';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAll = async () => {
|
||||
try {
|
||||
const { data } = await api.get('/map/cuts/export-geojson', { responseType: 'blob' });
|
||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/geo+json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'cuts.geojson';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('GeoJSON exported');
|
||||
} catch {
|
||||
message.error('Export failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportSingle = async (id: string, name: string) => {
|
||||
try {
|
||||
const { data } = await api.get(`/map/cuts/${id}/export-geojson`);
|
||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/geo+json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${name.replace(/\s+/g, '-').toLowerCase()}.geojson`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
message.error('Export failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async (file: File) => {
|
||||
setImporting(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data } = await api.post('/map/cuts/import-geojson', formData);
|
||||
message.success(`Imported: ${data.success} of ${data.total} features`);
|
||||
if (data.failed > 0) {
|
||||
message.warning(`${data.failed} features failed to import`);
|
||||
}
|
||||
setImportModalOpen(false);
|
||||
fetchCuts({ page: 1 });
|
||||
} catch {
|
||||
message.error('Import failed');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/map/cuts/${id}`);
|
||||
message.success('Cut deleted');
|
||||
fetchCuts();
|
||||
} catch {
|
||||
message.error('Failed to delete cut');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (cut: Cut) => {
|
||||
setEditingCut(cut);
|
||||
editForm.setFieldsValue({
|
||||
name: cut.name,
|
||||
description: cut.description,
|
||||
color: cut.color,
|
||||
opacity: parseFloat(String(cut.opacity)),
|
||||
category: cut.category,
|
||||
isPublic: cut.isPublic,
|
||||
isOfficial: cut.isOfficial,
|
||||
showLocations: cut.showLocations,
|
||||
exportEnabled: cut.exportEnabled,
|
||||
assignedTo: cut.assignedTo,
|
||||
});
|
||||
setEditDrawerOpen(true);
|
||||
};
|
||||
|
||||
const handleFinishDraw = (geojson: string, bounds: string) => {
|
||||
setPendingGeojson(geojson);
|
||||
setPendingBounds(bounds);
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ color: '#3388ff', opacity: 0.3 });
|
||||
setCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Cut> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
|
||||
},
|
||||
{
|
||||
title: 'Color',
|
||||
dataIndex: 'color',
|
||||
key: 'color',
|
||||
width: 60,
|
||||
render: (color: string, record: Cut) => (
|
||||
<div
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
backgroundColor: color,
|
||||
opacity: parseFloat(String(record.opacity)) || 0.3,
|
||||
border: `2px solid ${color}`,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Category',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (cat: CutCategory | null) =>
|
||||
cat ? (
|
||||
<Tag color={CUT_CATEGORY_COLORS[cat]}>{CUT_CATEGORY_LABELS[cat]}</Tag>
|
||||
) : (
|
||||
<Tag>None</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Public',
|
||||
dataIndex: 'isPublic',
|
||||
key: 'isPublic',
|
||||
width: 80,
|
||||
render: (isPublic: boolean, record: Cut) => (
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
size="small"
|
||||
onChange={async (checked) => {
|
||||
try {
|
||||
await api.put(`/map/cuts/${record.id}`, { isPublic: checked });
|
||||
fetchCuts();
|
||||
} catch {
|
||||
message.error('Failed to update');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Assigned To',
|
||||
dataIndex: 'assignedTo',
|
||||
key: 'assignedTo',
|
||||
render: (val: string | null) => val || '--',
|
||||
responsive: ['lg'],
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||
responsive: ['xl'],
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (_: unknown, record: Cut) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); handleExportSingle(record.id, record.name); }}
|
||||
title="Download GeoJSON"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/app/map/cuts/${record.id}/export`); }}
|
||||
title="Export"
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
title="Edit"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="Delete this cut?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cutFormFields = (
|
||||
<>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Name"
|
||||
rules={[{ required: true, message: 'Name is required' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="color" label="Color">
|
||||
<Input type="color" style={{ width: 60, padding: 2, cursor: 'pointer' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Form.Item name="opacity" label="Opacity">
|
||||
<Slider min={0} max={1} step={0.05} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="category" label="Category">
|
||||
<Select options={categoryOptions} allowClear placeholder="Select category" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="isPublic" label="Public" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="isOfficial" label="Official" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="showLocations" label="Show Locations" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="assignedTo" label="Assigned To">
|
||||
<Input placeholder="e.g. Volunteer name" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Segmented
|
||||
value={activeTab}
|
||||
onChange={(val) => setActiveTab(val as string)}
|
||||
options={[
|
||||
{ value: 'table', icon: <TableOutlined />, label: 'Table' },
|
||||
{ value: 'map', icon: <EnvironmentOutlined />, label: 'Map' },
|
||||
]}
|
||||
size="middle"
|
||||
/>
|
||||
<Button icon={<ExportOutlined />} onClick={handleExportAll}>
|
||||
Export GeoJSON
|
||||
</Button>
|
||||
<Button icon={<ImportOutlined />} onClick={() => setImportModalOpen(true)}>
|
||||
Import GeoJSON
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setActiveTab('map')}
|
||||
>
|
||||
Draw New Cut
|
||||
</Button>
|
||||
</Space>
|
||||
), [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Cuts', actions: headerActions });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeTab === 'table' ? (
|
||||
<>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Input
|
||||
placeholder="Search name or description"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Select
|
||||
placeholder="Category"
|
||||
options={categoryOptions}
|
||||
value={categoryFilter}
|
||||
onChange={setCategoryFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table<Cut>
|
||||
columns={columns}
|
||||
dataSource={cuts}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${total} cuts`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
onRow={(record) => ({
|
||||
onClick: () => openEdit(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CutEditorMap
|
||||
cuts={cuts}
|
||||
onFinishDraw={handleFinishDraw}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
title="Create Cut"
|
||||
open={createModalOpen}
|
||||
destroyOnHidden
|
||||
width={500}
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
setPendingGeojson(null);
|
||||
setPendingBounds(null);
|
||||
}}
|
||||
onOk={() => createForm.submit()}
|
||||
okText="Create"
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
onFinish={handleCreate}
|
||||
layout="vertical"
|
||||
initialValues={{ color: '#3388ff', opacity: 0.3 }}
|
||||
>
|
||||
{cutFormFields}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Import GeoJSON Modal */}
|
||||
<Modal
|
||||
title="Import GeoJSON"
|
||||
open={importModalOpen}
|
||||
destroyOnHidden
|
||||
width={500}
|
||||
onCancel={() => setImportModalOpen(false)}
|
||||
footer={null}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
Upload a GeoJSON file containing Polygon or MultiPolygon features.
|
||||
Supported formats: Feature, FeatureCollection, or bare Polygon/MultiPolygon.
|
||||
Properties like name, color, fill-opacity, and category will be mapped automatically.
|
||||
</Typography.Paragraph>
|
||||
<Upload.Dragger
|
||||
accept=".geojson,.json"
|
||||
maxCount={1}
|
||||
showUploadList={false}
|
||||
customRequest={({ file }) => handleImport(file as File)}
|
||||
disabled={importing}
|
||||
>
|
||||
<p style={{ fontSize: 32, color: '#1890ff' }}>
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p>{importing ? 'Importing...' : 'Click or drag a .geojson file here'}</p>
|
||||
</Upload.Dragger>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Drawer */}
|
||||
<Drawer
|
||||
title="Edit Cut"
|
||||
open={editDrawerOpen}
|
||||
width={480}
|
||||
onClose={() => {
|
||||
setEditDrawerOpen(false);
|
||||
setEditingCut(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => editForm.submit()}>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
||||
{cutFormFields}
|
||||
</Form>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
admin/src/pages/DashboardPage.tsx
Normal file
66
admin/src/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { Row, Col, Card, Statistic, Alert, Typography } from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
SendOutlined,
|
||||
EnvironmentOutlined,
|
||||
MailOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user } = useAuthStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>
|
||||
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Total Users"
|
||||
value="--"
|
||||
prefix={<TeamOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Active Campaigns"
|
||||
value="--"
|
||||
prefix={<SendOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Map Locations"
|
||||
value="--"
|
||||
prefix={<EnvironmentOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Emails Sent"
|
||||
value="--"
|
||||
prefix={<MailOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Alert
|
||||
message="Dashboard analytics coming soon"
|
||||
description="Statistics and charts will be populated as additional modules are implemented."
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1169
admin/src/pages/DocsPage.tsx
Normal file
1169
admin/src/pages/DocsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
140
admin/src/pages/EmailQueuePage.tsx
Normal file
140
admin/src/pages/EmailQueuePage.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Card, Statistic, Row, Col, Button, Space, Tag, message } from 'antd';
|
||||
import {
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { QueueStats } from '@/types/api';
|
||||
|
||||
export default function EmailQueuePage() {
|
||||
const [stats, setStats] = useState<QueueStats | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<QueueStats>('/email-queue/stats');
|
||||
setStats(data);
|
||||
} catch {
|
||||
message.error('Failed to load queue stats');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
const interval = setInterval(fetchStats, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchStats]);
|
||||
|
||||
const handlePauseResume = useCallback(async () => {
|
||||
if (!stats) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const action = stats.paused ? 'resume' : 'pause';
|
||||
await api.post(`/email-queue/${action}`);
|
||||
message.success(`Queue ${action}d`);
|
||||
fetchStats();
|
||||
} catch {
|
||||
message.error('Action failed');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, [stats, fetchStats]);
|
||||
|
||||
const handleClean = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');
|
||||
message.success(`Cleaned ${data.cleaned} completed jobs`);
|
||||
fetchStats();
|
||||
} catch {
|
||||
message.error('Clean failed');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, [fetchStats]);
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
{stats && (
|
||||
<Tag color={stats.paused ? 'orange' : 'green'}>
|
||||
{stats.paused ? 'PAUSED' : 'RUNNING'}
|
||||
</Tag>
|
||||
)}
|
||||
<Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
|
||||
onClick={handlePauseResume}
|
||||
loading={actionLoading}
|
||||
>
|
||||
{stats?.paused ? 'Resume' : 'Pause'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleClean}
|
||||
loading={actionLoading}
|
||||
>
|
||||
Clean Old Jobs
|
||||
</Button>
|
||||
</Space>
|
||||
), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Email Queue', actions: headerActions });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Waiting"
|
||||
value={stats?.waiting ?? 0}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Active"
|
||||
value={stats?.active ?? 0}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Completed"
|
||||
value={stats?.completed ?? 0}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="Failed"
|
||||
value={stats?.failed ?? 0}
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
admin/src/pages/GiteaPage.tsx
Normal file
123
admin/src/pages/GiteaPage.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
||||
import { ReloadOutlined, LinkOutlined, BranchesOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { ServicesStatus, ServicesConfig } from '@/types/api';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
|
||||
export default function GiteaPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, configRes] = await Promise.all([
|
||||
api.get<ServicesStatus>('/services/status'),
|
||||
api.get<ServicesConfig>('/services/config'),
|
||||
]);
|
||||
setOnline(statusRes.data.gitea.online);
|
||||
setConfig(configRes.data);
|
||||
} catch {
|
||||
setOnline(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const serviceUrl = config
|
||||
? buildServiceUrl(config.giteaSubdomain, config.domain, config.giteaPort)
|
||||
: null;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Badge
|
||||
status={online === null ? 'processing' : online ? 'success' : 'error'}
|
||||
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
size="small"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{serviceUrl && (
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
href={serviceUrl}
|
||||
target="_blank"
|
||||
size="small"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
), [online, handleRefresh, serviceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Git Repository', actions: headerActions, fullBleed: true });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Result
|
||||
status="info"
|
||||
title="Desktop Required"
|
||||
subTitle="The Git repository browser requires a desktop browser with a larger screen."
|
||||
icon={<BranchesOutlined style={{ fontSize: 48 }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!online || !serviceUrl) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="Gitea Unavailable"
|
||||
subTitle="Gitea is not running or could not be reached. Check that the Gitea container is started."
|
||||
extra={
|
||||
<Button type="primary" onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={serviceUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
title="Gitea"
|
||||
/>
|
||||
);
|
||||
}
|
||||
465
admin/src/pages/LandingPagesPage.tsx
Normal file
465
admin/src/pages/LandingPagesPage.tsx
Normal file
@ -0,0 +1,465 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Radio,
|
||||
Checkbox,
|
||||
Divider,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
EyeOutlined,
|
||||
SettingOutlined,
|
||||
SyncOutlined,
|
||||
BuildOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
||||
import type { LandingPage, EditorMode, LandingPagesListResponse, LandingPagesListParams } from '@/types/api';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const publishedOptions = [
|
||||
{ value: 'true', label: 'Published' },
|
||||
{ value: 'false', label: 'Draft' },
|
||||
];
|
||||
|
||||
export default function LandingPagesPage() {
|
||||
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
||||
const [pages, setPages] = useState<LandingPage[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const [publishedFilter, setPublishedFilter] = useState<'true' | 'false' | undefined>();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
|
||||
const [createForm] = Form.useForm();
|
||||
const [settingsForm] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(searchTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const fetchPages = useCallback(async (params?: LandingPagesListParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<LandingPagesListResponse>('/pages', {
|
||||
params: {
|
||||
page: params?.page ?? pagination.page,
|
||||
limit: params?.limit ?? pagination.limit,
|
||||
search: params?.search ?? (debouncedSearch || undefined),
|
||||
published: params?.published ?? publishedFilter,
|
||||
},
|
||||
});
|
||||
setPages(data.pages);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load pages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, publishedFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPages({ page: 1 });
|
||||
}, [debouncedSearch, publishedFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||
fetchPages({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||
};
|
||||
|
||||
const handleCreate = async (values: { title: string; description?: string; editorMode?: EditorMode }) => {
|
||||
try {
|
||||
const { data } = await api.post<LandingPage>('/pages', values);
|
||||
message.success('Page created');
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
navigate(`/app/pages/${data.id}/edit`);
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to create page';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncOverrides = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const { data } = await api.post<{ imported: number; updated: number; stubs: number }>('/pages/sync');
|
||||
if (data.imported > 0 || data.updated > 0 || data.stubs > 0) {
|
||||
message.success(`Synced: ${data.imported} imported, ${data.updated} updated, ${data.stubs} stubs created`);
|
||||
fetchPages();
|
||||
} else {
|
||||
message.info('No new overrides to sync');
|
||||
}
|
||||
} catch {
|
||||
message.error('Failed to sync overrides');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingsSave = async (values: Record<string, unknown>) => {
|
||||
if (!editingPage) return;
|
||||
try {
|
||||
await api.put(`/pages/${editingPage.id}`, values);
|
||||
message.success('Page settings updated');
|
||||
setSettingsModalOpen(false);
|
||||
setEditingPage(null);
|
||||
settingsForm.resetFields();
|
||||
fetchPages();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to update page';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePublished = async (page: LandingPage) => {
|
||||
try {
|
||||
await api.put(`/pages/${page.id}`, { published: !page.published });
|
||||
message.success(page.published ? 'Page unpublished' : 'Page published');
|
||||
fetchPages();
|
||||
} catch {
|
||||
message.error('Failed to update page');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/pages/${id}`);
|
||||
message.success('Page deleted');
|
||||
fetchPages();
|
||||
} catch {
|
||||
message.error('Failed to delete page');
|
||||
}
|
||||
};
|
||||
|
||||
const openSettings = (page: LandingPage) => {
|
||||
setEditingPage(page);
|
||||
settingsForm.setFieldsValue({
|
||||
title: page.title,
|
||||
description: page.description,
|
||||
mkdocsPath: page.mkdocsPath,
|
||||
mkdocsExportMode: page.mkdocsExportMode,
|
||||
mkdocsHideNav: page.mkdocsHideNav,
|
||||
mkdocsHideToc: page.mkdocsHideToc,
|
||||
seoTitle: page.seoTitle,
|
||||
seoDescription: page.seoDescription,
|
||||
seoImage: page.seoImage,
|
||||
});
|
||||
setSettingsModalOpen(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LandingPage> = [
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (title: string, record: LandingPage) => (
|
||||
<div>
|
||||
<span style={{ fontWeight: 500 }}>{title}</span>
|
||||
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/p/{record.slug}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Editor',
|
||||
dataIndex: 'editorMode',
|
||||
key: 'editorMode',
|
||||
render: (mode: EditorMode) => (
|
||||
<Tag color={mode === 'VISUAL' ? 'green' : 'blue'}>
|
||||
{mode === 'VISUAL' ? 'Visual' : 'Code'}
|
||||
</Tag>
|
||||
),
|
||||
responsive: ['sm'],
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'published',
|
||||
key: 'published',
|
||||
render: (published: boolean) => (
|
||||
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'MkDocs',
|
||||
dataIndex: 'mkdocsPath',
|
||||
key: 'mkdocsPath',
|
||||
render: (_: string | null, record: LandingPage) => (
|
||||
<div>
|
||||
<div>{record.mkdocsPath || '--'}</div>
|
||||
{record.mkdocsStubPath && (
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>{record.mkdocsStubPath}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
responsive: ['lg'],
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Updated',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: LandingPage) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => navigate(`/app/pages/${record.id}/edit`)}
|
||||
title={record.editorMode === 'CODE' ? 'Edit code' : 'Edit in builder'}
|
||||
/>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => openSettings(record)}
|
||||
title="Page settings"
|
||||
/>
|
||||
{record.published && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => window.open(`/p/${record.slug}`, '_blank')}
|
||||
title="View page"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => handleTogglePublished(record)}
|
||||
title={record.published ? 'Unpublish' : 'Publish'}
|
||||
>
|
||||
{record.published ? 'Unpublish' : 'Publish'}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="Delete this page?"
|
||||
description="This action cannot be undone."
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Landing Pages
|
||||
</Title>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
icon={<BuildOutlined />}
|
||||
loading={building}
|
||||
onClick={confirmAndBuild}
|
||||
>
|
||||
Build Site
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
icon={<SyncOutlined spin={syncing} />}
|
||||
loading={syncing}
|
||||
onClick={handleSyncOverrides}
|
||||
>
|
||||
Sync Overrides
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Create Page
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Input
|
||||
placeholder="Search by title or description"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={7} md={4}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
options={publishedOptions}
|
||||
value={publishedFilter}
|
||||
onChange={setPublishedFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table<LandingPage>
|
||||
columns={columns}
|
||||
dataSource={pages}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${total} pages`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
title="Create Landing Page"
|
||||
open={createModalOpen}
|
||||
destroyOnHidden
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
onOk={() => createForm.submit()}
|
||||
okText="Create & Edit"
|
||||
>
|
||||
<Form form={createForm} onFinish={handleCreate} layout="vertical" initialValues={{ editorMode: 'VISUAL' }}>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="Title"
|
||||
rules={[{ required: true, message: 'Title is required' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item name="editorMode" label="Editor Mode">
|
||||
<Radio.Group>
|
||||
<Radio.Button value="VISUAL">Visual Editor</Radio.Button>
|
||||
<Radio.Button value="CODE">Code Editor</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<Modal
|
||||
title="Page Settings"
|
||||
open={settingsModalOpen}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
onCancel={() => {
|
||||
setSettingsModalOpen(false);
|
||||
setEditingPage(null);
|
||||
settingsForm.resetFields();
|
||||
}}
|
||||
onOk={() => settingsForm.submit()}
|
||||
okText="Save"
|
||||
>
|
||||
<Form form={settingsForm} onFinish={handleSettingsSave} layout="vertical">
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="Title"
|
||||
rules={[{ required: true, message: 'Title is required' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoTitle" label="SEO Title">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoDescription" label="SEO Description">
|
||||
<TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="seoImage" label="SEO Image URL">
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
|
||||
<Divider>MkDocs Integration</Divider>
|
||||
|
||||
<Form.Item name="mkdocsPath" label="Override Path">
|
||||
<Input placeholder="e.g. about.html" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="mkdocsExportMode"
|
||||
valuePropName="checked"
|
||||
getValueFromEvent={(e: { target: { checked: boolean } }) => e.target.checked ? 'STANDALONE' : 'THEMED'}
|
||||
getValueProps={(value: string) => ({ checked: value === 'STANDALONE' })}
|
||||
help="Publish as a full HTML page with no MkDocs header, footer, or theme (like lander.html)"
|
||||
>
|
||||
<Checkbox>Full page MkDocs</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mkdocsExportMode !== cur.mkdocsExportMode}>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('mkdocsExportMode') !== 'STANDALONE' && (
|
||||
<>
|
||||
<Form.Item name="mkdocsHideNav" valuePropName="checked">
|
||||
<Checkbox>Hide navigation sidebar</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="mkdocsHideToc" valuePropName="checked">
|
||||
<Checkbox>Hide table of contents</Checkbox>
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
395
admin/src/pages/ListmonkPage.tsx
Normal file
395
admin/src/pages/ListmonkPage.tsx
Normal file
@ -0,0 +1,395 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Badge,
|
||||
Descriptions,
|
||||
Table,
|
||||
Collapse,
|
||||
Popconfirm,
|
||||
App,
|
||||
Row,
|
||||
Col,
|
||||
Spin,
|
||||
Radio,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
SyncOutlined,
|
||||
LinkOutlined,
|
||||
ApiOutlined,
|
||||
ReloadOutlined,
|
||||
DesktopOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
ListmonkStatus,
|
||||
ListmonkStats,
|
||||
ListmonkSyncResult,
|
||||
ListmonkSyncAllResult,
|
||||
} from '@/types/api';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default function ListmonkPage() {
|
||||
const { message } = App.useApp();
|
||||
const [status, setStatus] = useState<ListmonkStatus | null>(null);
|
||||
const [stats, setStats] = useState<ListmonkStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState<Record<string, boolean>>({});
|
||||
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
|
||||
const [iframeLoading, setIframeLoading] = useState(false);
|
||||
const [iframeError, setIframeError] = useState<string | null>(null);
|
||||
const iframeInitialized = useRef(false);
|
||||
const [activeTab, setActiveTab] = useState<'management' | 'admin'>('management');
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<ListmonkStatus>('/listmonk');
|
||||
setStatus(res.data);
|
||||
} catch {
|
||||
// Status fetch failed — leave null
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<ListmonkStats>('/listmonk/stats');
|
||||
setStats(res.data);
|
||||
} catch {
|
||||
// Stats fetch may fail if not initialized
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchStatus(), fetchStats()]);
|
||||
setLoading(false);
|
||||
}, [fetchStatus, fetchStats]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
setSyncing(s => ({ ...s, test: true }));
|
||||
try {
|
||||
const res = await api.post<{ success: boolean; message: string }>('/listmonk/test-connection');
|
||||
if (res.data.success) {
|
||||
message.success(res.data.message);
|
||||
} else {
|
||||
message.warning(res.data.message);
|
||||
}
|
||||
await fetchStatus();
|
||||
} catch {
|
||||
message.error('Failed to test connection');
|
||||
} finally {
|
||||
setSyncing(s => ({ ...s, test: false }));
|
||||
}
|
||||
}, [fetchStatus]);
|
||||
|
||||
const handleSync = async (type: 'participants' | 'locations' | 'users') => {
|
||||
setSyncing(s => ({ ...s, [type]: true }));
|
||||
try {
|
||||
const res = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);
|
||||
if (res.data.success) {
|
||||
message.success(res.data.message);
|
||||
if (res.data.results && res.data.results.failed > 0) {
|
||||
message.warning(`${res.data.results.failed} failed — check logs for details`);
|
||||
}
|
||||
} else {
|
||||
message.error(res.data.message);
|
||||
}
|
||||
await Promise.all([fetchStatus(), fetchStats()]);
|
||||
} catch {
|
||||
message.error(`Failed to sync ${type}`);
|
||||
} finally {
|
||||
setSyncing(s => ({ ...s, [type]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncAll = async () => {
|
||||
setSyncing(s => ({ ...s, all: true }));
|
||||
try {
|
||||
const res = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');
|
||||
if (res.data.success) {
|
||||
message.success(res.data.message);
|
||||
if (res.data.results) {
|
||||
const { participants, locations, users } = res.data.results;
|
||||
const totalFailed = participants.failed + locations.failed + users.failed;
|
||||
if (totalFailed > 0) {
|
||||
message.warning(`${totalFailed} total failures — check logs for details`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
message.error(res.data.message);
|
||||
}
|
||||
await Promise.all([fetchStatus(), fetchStats()]);
|
||||
} catch {
|
||||
message.error('Failed to sync all');
|
||||
} finally {
|
||||
setSyncing(s => ({ ...s, all: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinitialize = async () => {
|
||||
setSyncing(s => ({ ...s, reinit: true }));
|
||||
try {
|
||||
await api.post('/listmonk/reinitialize');
|
||||
message.success('Lists reinitialized');
|
||||
await Promise.all([fetchStatus(), fetchStats()]);
|
||||
} catch {
|
||||
message.error('Failed to reinitialize lists');
|
||||
} finally {
|
||||
setSyncing(s => ({ ...s, reinit: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const loadIframe = useCallback(async () => {
|
||||
if (iframeInitialized.current && iframeSrc) return;
|
||||
setIframeLoading(true);
|
||||
setIframeError(null);
|
||||
try {
|
||||
const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
|
||||
const { port, token } = res.data;
|
||||
const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;
|
||||
setIframeSrc(url);
|
||||
iframeInitialized.current = true;
|
||||
} catch {
|
||||
setIframeError('Failed to load Listmonk admin — ensure the proxy is running');
|
||||
} finally {
|
||||
setIframeLoading(false);
|
||||
}
|
||||
}, [iframeSrc]);
|
||||
|
||||
const listmonkAdminUrl = `//${window.location.hostname}:9001`;
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Radio.Group
|
||||
value={activeTab}
|
||||
onChange={(e) => {
|
||||
const tab = e.target.value as 'management' | 'admin';
|
||||
setActiveTab(tab);
|
||||
if (tab === 'admin') loadIframe();
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="management"><SettingOutlined /> Management</Radio.Button>
|
||||
<Radio.Button value="admin"><DesktopOutlined /> Listmonk Admin</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Button
|
||||
icon={<ApiOutlined />}
|
||||
loading={syncing.test}
|
||||
onClick={handleTestConnection}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
href={listmonkAdminUrl}
|
||||
target="_blank"
|
||||
>
|
||||
Open Listmonk
|
||||
</Button>
|
||||
</Space>
|
||||
), [activeTab, syncing.test, handleTestConnection, listmonkAdminUrl, loadIframe]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Newsletter / Listmonk', actions: headerActions, fullBleed: activeTab === 'admin' });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions, activeTab]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{activeTab === 'management' && (
|
||||
<>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="Status" size="small">
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="Sync Enabled">
|
||||
<Badge
|
||||
status={status?.enabled ? 'success' : 'error'}
|
||||
text={status?.enabled ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Connection">
|
||||
<Badge
|
||||
status={status?.connected ? 'success' : status?.enabled ? 'warning' : 'default'}
|
||||
text={status?.connected ? 'Connected' : status?.enabled ? 'Disconnected' : 'N/A'}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Lists Initialized">
|
||||
<Badge
|
||||
status={status?.initialized ? 'success' : 'default'}
|
||||
text={status?.initialized ? 'Yes' : 'No'}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Sync">
|
||||
{status?.lastSyncAt ? dayjs(status.lastSyncAt).fromNow() : 'Never'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Last Error">
|
||||
{status?.lastError || 'None'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="Sync Actions" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Button
|
||||
block
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncing.participants}
|
||||
onClick={() => handleSync('participants')}
|
||||
disabled={!status?.enabled}
|
||||
>
|
||||
Sync Participants
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Button
|
||||
block
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncing.locations}
|
||||
onClick={() => handleSync('locations')}
|
||||
disabled={!status?.enabled}
|
||||
>
|
||||
Sync Locations
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Button
|
||||
block
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncing.users}
|
||||
onClick={() => handleSync('users')}
|
||||
disabled={!status?.enabled}
|
||||
>
|
||||
Sync Users
|
||||
</Button>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
loading={syncing.all}
|
||||
onClick={handleSyncAll}
|
||||
disabled={!status?.enabled}
|
||||
>
|
||||
Sync All
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="List Statistics" size="small" style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
dataSource={stats?.lists || []}
|
||||
rowKey="name"
|
||||
size="small"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: 'List Name', dataIndex: 'name', key: 'name' },
|
||||
{
|
||||
title: 'Subscribers',
|
||||
dataIndex: 'subscriberCount',
|
||||
key: 'subscriberCount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
},
|
||||
]}
|
||||
locale={{ emptyText: status?.initialized ? 'No lists found' : 'Lists not initialized — run a sync or reinitialize' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Collapse
|
||||
style={{ marginTop: 16 }}
|
||||
items={[
|
||||
{
|
||||
key: 'advanced',
|
||||
label: 'Advanced',
|
||||
children: (
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title="Reinitialize Lists"
|
||||
description="This will re-create any missing lists in Listmonk. Existing lists are preserved."
|
||||
onConfirm={handleReinitialize}
|
||||
okText="Reinitialize"
|
||||
>
|
||||
<Button icon={<ReloadOutlined />} loading={syncing.reinit} disabled={!status?.enabled}>
|
||||
Reinitialize Lists
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'admin' && (
|
||||
<div>
|
||||
{iframeLoading && (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)}
|
||||
{iframeError && (
|
||||
<Alert
|
||||
type="error"
|
||||
message={iframeError}
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" onClick={loadIframe}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
{iframeSrc && !iframeLoading && (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
title="Listmonk Admin"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1476
admin/src/pages/LocationsPage.tsx
Normal file
1476
admin/src/pages/LocationsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
99
admin/src/pages/LoginPage.tsx
Normal file
99
admin/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Form, Input, Button, Alert, Typography } from 'antd';
|
||||
import { MailOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { ADMIN_ROLES, type UserRole } from '@/types/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function getPostLoginPath(role?: UserRole): string {
|
||||
if (role && ADMIN_ROLES.includes(role)) return '/app';
|
||||
return '/volunteer';
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { login, isAuthenticated, isLoading, error, user } = useAuthStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
navigate(getPostLoginPath(user.role), { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, user, navigate]);
|
||||
|
||||
const handleSubmit = async (values: { email: string; password: string }) => {
|
||||
try {
|
||||
await login(values.email, values.password);
|
||||
const updatedUser = useAuthStore.getState().user;
|
||||
navigate(getPostLoginPath(updatedUser?.role), { replace: true });
|
||||
} catch {
|
||||
// Error is set in store
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
padding: 16,
|
||||
background: '#120b1a',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: '100%', maxWidth: 400 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={3} style={{ marginBottom: 4 }}>
|
||||
{settings?.organizationName ?? 'Changemaker Lite'}
|
||||
</Title>
|
||||
<Text type="secondary">{settings?.loginSubtitle ?? 'Admin'}</Text>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
message={error}
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Form form={form} onFinish={handleSubmit} layout="vertical" size="large">
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{ required: true, message: 'Please enter your email' },
|
||||
{ type: 'email', message: 'Please enter a valid email' },
|
||||
]}
|
||||
>
|
||||
<Input prefix={<MailOutlined />} placeholder="Email" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: 'Please enter your password' }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isLoading}
|
||||
block
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
admin/src/pages/MailHogPage.tsx
Normal file
123
admin/src/pages/MailHogPage.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
||||
import { ReloadOutlined, LinkOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { ServicesStatus, ServicesConfig } from '@/types/api';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
|
||||
export default function MailHogPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, configRes] = await Promise.all([
|
||||
api.get<ServicesStatus>('/services/status'),
|
||||
api.get<ServicesConfig>('/services/config'),
|
||||
]);
|
||||
setOnline(statusRes.data.mailhog.online);
|
||||
setConfig(configRes.data);
|
||||
} catch {
|
||||
setOnline(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const serviceUrl = config
|
||||
? buildServiceUrl(config.mailhogSubdomain, config.domain, config.mailhogPort)
|
||||
: null;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Badge
|
||||
status={online === null ? 'processing' : online ? 'success' : 'error'}
|
||||
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
size="small"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{serviceUrl && (
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
href={serviceUrl}
|
||||
target="_blank"
|
||||
size="small"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
), [online, handleRefresh, serviceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'MailHog', actions: headerActions, fullBleed: true });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Result
|
||||
status="info"
|
||||
title="Desktop Required"
|
||||
subTitle="MailHog requires a desktop browser with a larger screen."
|
||||
icon={<MailOutlined style={{ fontSize: 48 }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!online || !serviceUrl) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="MailHog Unavailable"
|
||||
subTitle="MailHog is not running or could not be reached. Check that the MailHog container is started."
|
||||
extra={
|
||||
<Button type="primary" onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={serviceUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
title="MailHog"
|
||||
/>
|
||||
);
|
||||
}
|
||||
432
admin/src/pages/MapSettingsPage.tsx
Normal file
432
admin/src/pages/MapSettingsPage.tsx
Normal file
@ -0,0 +1,432 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Button,
|
||||
Slider,
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Typography,
|
||||
Divider,
|
||||
message,
|
||||
Spin,
|
||||
Space,
|
||||
AutoComplete,
|
||||
} from 'antd';
|
||||
import { SaveOutlined, PrinterOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { MapSettings, GeocodeSearchResult } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function MapSettingsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [citySearch, setCitySearch] = useState('');
|
||||
const [cityOptions, setCityOptions] = useState<{ value: string; label: React.ReactNode; result: GeocodeSearchResult }[]>([]);
|
||||
const [citySearching, setCitySearching] = useState(false);
|
||||
const cityTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Watch all form values so the walk sheet preview updates live
|
||||
const watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get<MapSettings>('/map/settings');
|
||||
form.setFieldsValue({
|
||||
latitude: data.latitude ? parseFloat(data.latitude) : 45.4215,
|
||||
longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,
|
||||
zoom: data.zoom ?? 12,
|
||||
walkSheetTitle: data.walkSheetTitle,
|
||||
walkSheetSubtitle: data.walkSheetSubtitle,
|
||||
walkSheetFooter: data.walkSheetFooter,
|
||||
qrCode1Url: data.qrCode1Url,
|
||||
qrCode1Label: data.qrCode1Label,
|
||||
qrCode2Url: data.qrCode2Url,
|
||||
qrCode2Label: data.qrCode2Label,
|
||||
qrCode3Url: data.qrCode3Url,
|
||||
qrCode3Label: data.qrCode3Label,
|
||||
});
|
||||
} catch {
|
||||
message.error('Failed to load map settings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
const handleCitySearch = useCallback((value: string) => {
|
||||
setCitySearch(value);
|
||||
clearTimeout(cityTimerRef.current);
|
||||
if (value.length < 2) {
|
||||
setCityOptions([]);
|
||||
return;
|
||||
}
|
||||
cityTimerRef.current = setTimeout(async () => {
|
||||
setCitySearching(true);
|
||||
try {
|
||||
const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {
|
||||
params: { q: value, limit: 5 },
|
||||
});
|
||||
setCityOptions(data.map((r) => ({
|
||||
value: r.displayName,
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>
|
||||
{r.displayName}
|
||||
</span>
|
||||
<span style={{ color: '#888', fontSize: 11, marginLeft: 8, flexShrink: 0 }}>{r.type}</span>
|
||||
</div>
|
||||
),
|
||||
result: r,
|
||||
})));
|
||||
} catch {
|
||||
setCityOptions([]);
|
||||
} finally {
|
||||
setCitySearching(false);
|
||||
}
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
const handleCitySelect = useCallback((_value: string, option: { result: GeocodeSearchResult }) => {
|
||||
form.setFieldsValue({
|
||||
latitude: option.result.latitude,
|
||||
longitude: option.result.longitude,
|
||||
zoom: 12,
|
||||
});
|
||||
setCitySearch('');
|
||||
setCityOptions([]);
|
||||
message.success('Coordinates auto-filled. Fine-tune below.');
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(cityTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleSave = async (values: Record<string, unknown>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put('/map/settings', values);
|
||||
message.success('Map settings saved');
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to save settings';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
Print Walk Sheet
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={() => form.submit()}
|
||||
>
|
||||
Save Settings
|
||||
</Button>
|
||||
</Space>
|
||||
), [saving, form]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Map Settings', actions: headerActions });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// QR codes from live form values
|
||||
const qrCodes = [
|
||||
{ url: watched?.qrCode1Url, label: watched?.qrCode1Label },
|
||||
{ url: watched?.qrCode2Url, label: watched?.qrCode2Label },
|
||||
{ url: watched?.qrCode3Url, label: watched?.qrCode3Label },
|
||||
].filter((qr) => qr.url);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body * { visibility: hidden !important; }
|
||||
.walk-sheet-print, .walk-sheet-print * { visibility: visible !important; }
|
||||
.walk-sheet-print {
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
top: 0 !important;
|
||||
width: 8.5in !important;
|
||||
height: 11in !important;
|
||||
padding: 0.4in 0.5in !important;
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
@page { size: letter; margin: 0; }
|
||||
.walk-sheet-print img { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<Row gutter={24}>
|
||||
{/* Left column: Settings form */}
|
||||
<Col xs={24} lg={10}>
|
||||
<Form form={form} onFinish={handleSave} layout="vertical">
|
||||
<Card title="Map Center & Zoom" style={{ marginBottom: 24 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<AutoComplete
|
||||
value={citySearch}
|
||||
options={cityOptions}
|
||||
onSearch={handleCitySearch}
|
||||
onSelect={handleCitySelect as any}
|
||||
placeholder="Search for a city to auto-fill coordinates..."
|
||||
style={{ width: '100%' }}
|
||||
suffixIcon={citySearching ? <Spin size="small" /> : <SearchOutlined />}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
|
||||
Search for a city to auto-fill coordinates. Fine-tune below.
|
||||
</Text>
|
||||
</div>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item name="latitude" label="Latitude" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} step={0.0001} min={-90} max={90} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item name="longitude" label="Longitude" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} step={0.0001} min={-180} max={180} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item name="zoom" label="Zoom Level">
|
||||
<Slider min={2} max={19} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Text type="secondary">
|
||||
Set the default center point and zoom level for the public map view.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card title="Walk Sheet Configuration" style={{ marginBottom: 24 }}>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
Configure headers and footers for printed walk sheets. Changes preview live on the right.
|
||||
</Text>
|
||||
<Form.Item name="walkSheetTitle" label="Title">
|
||||
<Input placeholder="Walk Sheet Title" />
|
||||
</Form.Item>
|
||||
<Form.Item name="walkSheetSubtitle" label="Subtitle">
|
||||
<Input placeholder="Walk Sheet Subtitle" />
|
||||
</Form.Item>
|
||||
<Form.Item name="walkSheetFooter" label="Footer">
|
||||
<Input.TextArea rows={2} placeholder="Footer text for walk sheets" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left" plain>QR Codes</Divider>
|
||||
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Row gutter={12} key={i}>
|
||||
<Col xs={24} sm={16}>
|
||||
<Form.Item name={`qrCode${i}Url`} label={`QR Code ${i} URL`}>
|
||||
<Input placeholder="https://..." />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item name={`qrCode${i}Label`} label="Label">
|
||||
<Input placeholder={`QR ${i} Label`} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</Card>
|
||||
</Form>
|
||||
</Col>
|
||||
|
||||
{/* Right column: Live walk sheet preview */}
|
||||
<Col xs={24} lg={14}>
|
||||
<Card
|
||||
title="Walk Sheet Preview"
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PrinterOutlined />}
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 24 }}
|
||||
styles={{ body: { padding: 0, maxHeight: 'calc(100vh - 200px)', overflow: 'auto' } }}
|
||||
>
|
||||
<div
|
||||
ref={printRef}
|
||||
className="walk-sheet-print"
|
||||
style={{
|
||||
background: '#fff',
|
||||
color: '#000',
|
||||
padding: 32,
|
||||
maxWidth: '8.5in',
|
||||
margin: '0 auto',
|
||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ borderBottom: '2px solid #000', paddingBottom: 6, marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>
|
||||
{watched?.walkSheetTitle || 'Walk Sheet'}
|
||||
</div>
|
||||
{watched?.walkSheetSubtitle && (
|
||||
<div style={{ fontSize: 12, textAlign: 'center', color: '#333', marginTop: 2 }}>
|
||||
{watched.walkSheetSubtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR Codes */}
|
||||
{qrCodes.length > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginBottom: 10 }}>
|
||||
{qrCodes.map((qr, i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}
|
||||
alt={qr.label || `QR Code ${i + 1}`}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
{qr.label && (
|
||||
<div style={{ fontSize: 9, marginTop: 2 }}>{qr.label}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact entry blocks */}
|
||||
{[1, 2].map((blockNum) => (
|
||||
<div key={blockNum} style={{ marginBottom: 6 }}>
|
||||
{blockNum > 1 && (
|
||||
<div style={{ borderTop: '1px dashed #999', marginBottom: 8 }} />
|
||||
)}
|
||||
<FormRow left="First Name" right="Last Name" />
|
||||
<FormRow left="Email" right="Phone" />
|
||||
<FormRow left="Address" right="Unit Number" />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 6 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Support Level</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{[1, 2, 3, 4].map((n) => (
|
||||
<span key={n} style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
|
||||
<Circle /><span style={{ fontSize: 10 }}>{n}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Sign Request</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
|
||||
<Circle /><span style={{ fontSize: 10 }}>Y</span>
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
|
||||
<Circle /><span style={{ fontSize: 10 }}>N</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 6 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Sign Size</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{['R', 'L', 'U'].map((s) => (
|
||||
<span key={s} style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
|
||||
<Circle /><span style={{ fontSize: 10 }}>{s}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Visited Date</div>
|
||||
<div style={{ borderBottom: '1px solid #999', height: 18 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Notes */}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Notes & Comments</div>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #999',
|
||||
borderRadius: 3,
|
||||
minHeight: 80,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{watched?.walkSheetFooter && (
|
||||
<div style={{ marginTop: 10, textAlign: 'center', fontSize: 9, color: '#666' }}>
|
||||
{watched.walkSheetFooter}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FormRow({ left, right }: { left: string; right: string }) {
|
||||
return (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 6 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>{left}</div>
|
||||
<div style={{ borderBottom: '1px solid #999', height: 18 }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>{right}</div>
|
||||
<div style={{ borderBottom: '1px solid #999', height: 18 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Circle() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 13,
|
||||
height: 13,
|
||||
borderRadius: '50%',
|
||||
border: '1.5px solid #333',
|
||||
flexShrink: 0,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1346
admin/src/pages/MkDocsSettingsPage.tsx
Normal file
1346
admin/src/pages/MkDocsSettingsPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
123
admin/src/pages/N8nPage.tsx
Normal file
123
admin/src/pages/N8nPage.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
||||
import { ReloadOutlined, LinkOutlined, ApiOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { ServicesStatus, ServicesConfig } from '@/types/api';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
|
||||
export default function N8nPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, configRes] = await Promise.all([
|
||||
api.get<ServicesStatus>('/services/status'),
|
||||
api.get<ServicesConfig>('/services/config'),
|
||||
]);
|
||||
setOnline(statusRes.data.n8n.online);
|
||||
setConfig(configRes.data);
|
||||
} catch {
|
||||
setOnline(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const serviceUrl = config
|
||||
? buildServiceUrl(config.n8nSubdomain, config.domain, config.n8nPort)
|
||||
: null;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Badge
|
||||
status={online === null ? 'processing' : online ? 'success' : 'error'}
|
||||
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
size="small"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{serviceUrl && (
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
href={serviceUrl}
|
||||
target="_blank"
|
||||
size="small"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
), [online, handleRefresh, serviceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Workflow Automation', actions: headerActions, fullBleed: true });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Result
|
||||
status="info"
|
||||
title="Desktop Required"
|
||||
subTitle="The workflow editor requires a desktop browser with a larger screen."
|
||||
icon={<ApiOutlined style={{ fontSize: 48 }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!online || !serviceUrl) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="n8n Unavailable"
|
||||
subTitle="n8n is not running or could not be reached. Check that the n8n container is started."
|
||||
extra={
|
||||
<Button type="primary" onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={serviceUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
title="n8n Workflows"
|
||||
/>
|
||||
);
|
||||
}
|
||||
123
admin/src/pages/NocoDBPage.tsx
Normal file
123
admin/src/pages/NocoDBPage.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
|
||||
import { ReloadOutlined, LinkOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { ServicesStatus, ServicesConfig } from '@/types/api';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
|
||||
export default function NocoDBPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const [statusRes, configRes] = await Promise.all([
|
||||
api.get<ServicesStatus>('/services/status'),
|
||||
api.get<ServicesConfig>('/services/config'),
|
||||
]);
|
||||
setOnline(statusRes.data.nocodb.online);
|
||||
setConfig(configRes.data);
|
||||
} catch {
|
||||
setOnline(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const serviceUrl = config
|
||||
? buildServiceUrl(config.nocodbSubdomain, config.domain, config.nocodbPort)
|
||||
: null;
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
fetchStatus();
|
||||
}, [fetchStatus]);
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Badge
|
||||
status={online === null ? 'processing' : online ? 'success' : 'error'}
|
||||
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleRefresh}
|
||||
size="small"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
{serviceUrl && (
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
href={serviceUrl}
|
||||
target="_blank"
|
||||
size="small"
|
||||
>
|
||||
Open in New Tab
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
), [online, handleRefresh, serviceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Database Browser', actions: headerActions, fullBleed: true });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Result
|
||||
status="info"
|
||||
title="Desktop Required"
|
||||
subTitle="The database browser requires a desktop browser with a larger screen."
|
||||
icon={<DatabaseOutlined style={{ fontSize: 48 }} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!online || !serviceUrl) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="NocoDB Unavailable"
|
||||
subTitle="NocoDB is not running or could not be reached. Check that the NocoDB container is started."
|
||||
extra={
|
||||
<Button type="primary" onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={serviceUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 64px)',
|
||||
border: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
title="NocoDB"
|
||||
/>
|
||||
);
|
||||
}
|
||||
236
admin/src/pages/PageEditorPage.tsx
Normal file
236
admin/src/pages/PageEditorPage.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Button, Switch, Space, Typography, message, Spin, Tag, Grid, Result, theme } from 'antd';
|
||||
import { ArrowLeftOutlined, SaveOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { api } from '@/lib/api';
|
||||
import GrapesJSEditor from '@/components/GrapesJSEditor';
|
||||
import type { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
|
||||
import type { LandingPage, PageBlock } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function PageEditorPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState<LandingPage | null>(null);
|
||||
const [blocks, setBlocks] = useState<PageBlock[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [codeContent, setCodeContent] = useState('');
|
||||
const editorRef = useRef<GrapesJSEditorHandle>(null);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const isCodeMode = page?.editorMode === 'CODE';
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
if (isCodeMode) {
|
||||
// CODE mode only needs page data
|
||||
const pageRes = await api.get<LandingPage>(`/pages/${id}`);
|
||||
setPage(pageRes.data);
|
||||
setCodeContent(pageRes.data.htmlOutput || '');
|
||||
} else {
|
||||
const [pageRes, blocksRes] = await Promise.all([
|
||||
api.get<LandingPage>(`/pages/${id}`),
|
||||
api.get<PageBlock[]>('/page-blocks'),
|
||||
]);
|
||||
setPage(pageRes.data);
|
||||
setBlocks(blocksRes.data);
|
||||
setCodeContent(pageRes.data.htmlOutput || '');
|
||||
}
|
||||
} catch {
|
||||
message.error('Failed to load page');
|
||||
navigate('/app/pages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSaveVisual = useCallback(async (data: { projectData: Record<string, unknown>; html: string; css: string }) => {
|
||||
if (!page) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
|
||||
blocks: data.projectData,
|
||||
htmlOutput: data.html,
|
||||
cssOutput: data.css,
|
||||
});
|
||||
setPage(updated);
|
||||
message.success('Page saved');
|
||||
} catch {
|
||||
message.error('Failed to save page');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
const handleSaveCode = useCallback(async () => {
|
||||
if (!page) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
|
||||
htmlOutput: codeContent,
|
||||
});
|
||||
setPage(updated);
|
||||
message.success('Page saved');
|
||||
} catch {
|
||||
message.error('Failed to save page');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [page, codeContent]);
|
||||
|
||||
const handleTogglePublished = async () => {
|
||||
if (!page) return;
|
||||
try {
|
||||
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
|
||||
published: !page.published,
|
||||
});
|
||||
setPage(updated);
|
||||
message.success(updated.published ? 'Page published' : 'Page unpublished');
|
||||
} catch {
|
||||
message.error('Failed to update page');
|
||||
}
|
||||
};
|
||||
|
||||
// Ctrl+S handler for CODE mode
|
||||
useEffect(() => {
|
||||
if (!isCodeMode) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSaveCode();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [isCodeMode, handleSaveCode]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 24, background: token.colorBgBase }}>
|
||||
<Result
|
||||
status="info"
|
||||
title="Desktop Required"
|
||||
subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/app/pages')}>
|
||||
Back to Pages
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: token.colorBgBase }}>
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
background: token.colorBgBase,
|
||||
zIndex: 10,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/app/pages')}
|
||||
aria-label="Back to pages list"
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
<Text strong style={{ color: '#fff', fontSize: 16 }}>{page.title}</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 12 }}>/p/{page.slug}</Text>
|
||||
<Tag color={isCodeMode ? 'blue' : 'green'}>{isCodeMode ? 'Code' : 'Visual'}</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Space size={4}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>Published</Text>
|
||||
<Switch
|
||||
checked={page.published}
|
||||
onChange={handleTogglePublished}
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
{page.published && (
|
||||
<Tag color="green">Live</Tag>
|
||||
)}
|
||||
{page.published && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => window.open(`/p/${page.slug}`, '_blank')}
|
||||
>
|
||||
Preview
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SaveOutlined />}
|
||||
loading={saving}
|
||||
onClick={() => {
|
||||
if (isCodeMode) {
|
||||
handleSaveCode();
|
||||
} else {
|
||||
editorRef.current?.triggerSave();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Editor — conditional on mode */}
|
||||
{isCodeMode ? (
|
||||
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="html"
|
||||
theme="vs-dark"
|
||||
value={codeContent}
|
||||
onChange={(value) => setCodeContent(value ?? '')}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<GrapesJSEditor
|
||||
ref={editorRef}
|
||||
initialData={page.blocks as Record<string, unknown>}
|
||||
onSave={handleSaveVisual}
|
||||
customBlocks={blocks}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
admin/src/pages/PangolinPage.tsx
Normal file
250
admin/src/pages/PangolinPage.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, Popconfirm, App,
|
||||
} from 'antd';
|
||||
import {
|
||||
CloudServerOutlined, SyncOutlined, DeleteOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
RocketOutlined, CopyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { PangolinStatus, PangolinConfig, PangolinResource } from '@/types/api';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
export default function PangolinPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [status, setStatus] = useState<PangolinStatus | null>(null);
|
||||
const [config, setConfig] = useState<PangolinConfig | null>(null);
|
||||
const [resources, setResources] = useState<PangolinResource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<Record<string, unknown> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Tunnel Management' });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statusRes, configRes] = await Promise.all([
|
||||
api.get<PangolinStatus>('/api/pangolin/status'),
|
||||
api.get<PangolinConfig>('/api/pangolin/config'),
|
||||
]);
|
||||
setStatus(statusRes.data);
|
||||
setConfig(configRes.data);
|
||||
|
||||
if (statusRes.data.configured) {
|
||||
try {
|
||||
const resourcesRes = await api.get<{ resources: PangolinResource[] }>('/api/pangolin/resources');
|
||||
setResources(resourcesRes.data.resources);
|
||||
} catch {
|
||||
// Resources may not load if site isn't set up
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('Failed to load Pangolin status');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
const handleSetup = async (values: { siteName?: string }) => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await api.post('/api/pangolin/setup', { siteName: values.siteName });
|
||||
setSetupResult(res.data);
|
||||
message.success('Setup complete! See credentials below.');
|
||||
fetchData();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Setup failed';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await api.post<{ created: number; skipped: number; errors: number }>('/api/pangolin/sync');
|
||||
message.success(`Sync complete: ${res.data.created} created, ${res.data.skipped} skipped`);
|
||||
fetchData();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Sync failed';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteResource = async (resourceId: string) => {
|
||||
try {
|
||||
await api.delete(`/api/pangolin/resource/${resourceId}`);
|
||||
message.success('Resource deleted');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('Failed to delete resource');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||
}
|
||||
|
||||
const isConfigured = status?.configured;
|
||||
const isHealthy = status?.healthy;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Status Card */}
|
||||
<Card title={<><CloudServerOutlined /> Pangolin Tunnel Status</>}>
|
||||
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
|
||||
<Descriptions.Item label="Configured">
|
||||
{isConfigured
|
||||
? <Tag icon={<CheckCircleOutlined />} color="success">Yes</Tag>
|
||||
: <Tag icon={<CloseCircleOutlined />} color="error">No</Tag>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Server Health">
|
||||
{isHealthy
|
||||
? <Tag icon={<CheckCircleOutlined />} color="success">Healthy</Tag>
|
||||
: <Tag icon={<CloseCircleOutlined />} color="error">Unreachable</Tag>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API URL">
|
||||
<Text copyable>{config?.pangolinApiUrl || 'Not set'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Newt">
|
||||
{status?.newtConfigured
|
||||
? <Tag color="success">Configured</Tag>
|
||||
: <Tag color="warning">Not configured</Tag>}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Organization ID">
|
||||
<Text code>{config?.orgId || 'Not set'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Site ID">
|
||||
<Text code>{config?.siteId || 'Not set'}</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* Setup Wizard — shown when not configured or no site */}
|
||||
{(!isConfigured || !config?.siteId) && (
|
||||
<Card title={<><RocketOutlined /> Initial Setup</>}>
|
||||
{!isConfigured ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="Pangolin Not Configured"
|
||||
description="Set PANGOLIN_API_URL, PANGOLIN_API_KEY, and PANGOLIN_ORG_ID in your .env file, then restart the API container."
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : (
|
||||
<Form layout="inline" onFinish={handleSetup}>
|
||||
<Form.Item name="siteName" label="Site Name">
|
||||
<Input placeholder={`changemaker-${config?.domain || 'cmlite.org'}`} style={{ width: 300 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={actionLoading} icon={<RocketOutlined />}>
|
||||
Create Site + Resources
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{setupResult && (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
closable
|
||||
onClose={() => setSetupResult(null)}
|
||||
message="Setup Complete"
|
||||
description={
|
||||
<div>
|
||||
<Paragraph>Add these to your <Text code>.env</Text> file:</Paragraph>
|
||||
<pre style={{ background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 6, fontSize: 12, overflow: 'auto' }}>
|
||||
{((setupResult as Record<string, unknown>).instructions as string[] || []).slice(1).join('\n')}
|
||||
</pre>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
const text = ((setupResult as Record<string, unknown>).instructions as string[] || []).slice(1).join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Resource Management — shown when configured */}
|
||||
{isConfigured && config?.siteId && (
|
||||
<Card
|
||||
title="Tunnel Resources"
|
||||
extra={
|
||||
<Button icon={<SyncOutlined />} loading={actionLoading} onClick={handleSync}>
|
||||
Sync Resources
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={resources}
|
||||
rowKey="resourceId"
|
||||
size="small"
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Domain',
|
||||
key: 'domain',
|
||||
render: (_, r: PangolinResource) => (
|
||||
<Text copyable>{r.fullDomain || r.subdomain || '(root)'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'SSL',
|
||||
dataIndex: 'ssl',
|
||||
key: 'ssl',
|
||||
render: (ssl: boolean) => ssl ? <Tag color="green">Yes</Tag> : <Tag>No</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Active',
|
||||
dataIndex: 'active',
|
||||
key: 'active',
|
||||
render: (active: boolean) => active !== false
|
||||
? <Tag color="success">Active</Tag>
|
||||
: <Tag color="error">Inactive</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_, r: PangolinResource) => (
|
||||
<Popconfirm title="Delete this resource?" onConfirm={() => handleDeleteResource(r.resourceId)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
386
admin/src/pages/RepresentativesPage.tsx
Normal file
386
admin/src/pages/RepresentativesPage.tsx
Normal file
@ -0,0 +1,386 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Statistic,
|
||||
Descriptions,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
Representative,
|
||||
RepresentativesListResponse,
|
||||
RepresentativesListParams,
|
||||
RepresentativeLookupResponse,
|
||||
CacheStats,
|
||||
} from '@/types/api';
|
||||
|
||||
export default function RepresentativesPage() {
|
||||
const [representatives, setRepresentatives] = useState<Representative[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const [postalCodeFilter, setPostalCodeFilter] = useState('');
|
||||
const [debouncedPostalCode, setDebouncedPostalCode] = useState('');
|
||||
const postalTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const [lookupInput, setLookupInput] = useState('');
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
|
||||
const [stats, setStats] = useState<CacheStats | null>(null);
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedRep, setSelectedRep] = useState<Representative | null>(null);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
|
||||
};
|
||||
|
||||
const handlePostalCodeFilterChange = (value: string) => {
|
||||
setPostalCodeFilter(value);
|
||||
clearTimeout(postalTimerRef.current);
|
||||
postalTimerRef.current = setTimeout(() => setDebouncedPostalCode(value), 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
clearTimeout(postalTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchRepresentatives = useCallback(async (params?: RepresentativesListParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<RepresentativesListResponse>('/representatives', {
|
||||
params: {
|
||||
page: params?.page ?? pagination.page,
|
||||
limit: params?.limit ?? pagination.limit,
|
||||
search: params?.search ?? (debouncedSearch || undefined),
|
||||
postalCode: params?.postalCode ?? (debouncedPostalCode || undefined),
|
||||
},
|
||||
});
|
||||
setRepresentatives(data.representatives);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load representatives');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, debouncedPostalCode]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get<CacheStats>('/representatives/cache-stats');
|
||||
setStats(data);
|
||||
} catch {
|
||||
// Stats are non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRepresentatives({ page: 1 });
|
||||
}, [debouncedSearch, debouncedPostalCode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||
fetchRepresentatives({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||
};
|
||||
|
||||
const handleLookup = useCallback(async () => {
|
||||
const code = lookupInput.replace(/\s/g, '').toUpperCase();
|
||||
if (!code) return;
|
||||
setLookupLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<RepresentativeLookupResponse>(
|
||||
`/representatives/by-postal/${encodeURIComponent(code)}`,
|
||||
{ params: { refresh: 'true' } }
|
||||
);
|
||||
message.success(
|
||||
`Found ${data.representatives.length} representative(s) for ${code} (source: ${data.source})`
|
||||
);
|
||||
// Refresh table and stats
|
||||
fetchRepresentatives({ page: 1 });
|
||||
fetchStats();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Lookup failed';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
}, [lookupInput, fetchRepresentatives, fetchStats]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/representatives/${id}`);
|
||||
message.success('Representative removed from cache');
|
||||
fetchRepresentatives();
|
||||
fetchStats();
|
||||
} catch {
|
||||
message.error('Failed to delete representative');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearPostalCode = async (postalCode: string) => {
|
||||
try {
|
||||
const { data } = await api.delete<{ deleted: number; postalCode: string }>(
|
||||
`/representatives/by-postal/${encodeURIComponent(postalCode)}`
|
||||
);
|
||||
message.success(`Cleared ${data.deleted} cached rep(s) for ${postalCode}`);
|
||||
fetchRepresentatives();
|
||||
fetchStats();
|
||||
} catch {
|
||||
message.error('Failed to clear cache');
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = (rep: Representative) => {
|
||||
setSelectedRep(rep);
|
||||
setDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Representative> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string | null) => name || '--',
|
||||
},
|
||||
{
|
||||
title: 'Postal Code',
|
||||
dataIndex: 'postalCode',
|
||||
key: 'postalCode',
|
||||
render: (code: string) => <Tag color="blue">{code}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Elected Office',
|
||||
dataIndex: 'electedOffice',
|
||||
key: 'electedOffice',
|
||||
render: (office: string | null) => office || '--',
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Party',
|
||||
dataIndex: 'partyName',
|
||||
key: 'partyName',
|
||||
render: (party: string | null) => party || '--',
|
||||
responsive: ['lg'],
|
||||
},
|
||||
{
|
||||
title: 'District',
|
||||
dataIndex: 'districtName',
|
||||
key: 'districtName',
|
||||
render: (district: string | null) => district || '--',
|
||||
responsive: ['lg'],
|
||||
},
|
||||
{
|
||||
title: 'Cached',
|
||||
dataIndex: 'cachedAt',
|
||||
key: 'cachedAt',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: Representative) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => openDetail(record)}
|
||||
/>
|
||||
{record.id && (
|
||||
<Popconfirm
|
||||
title="Remove from cache?"
|
||||
onConfirm={() => handleDelete(record.id!)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="Postal code (e.g. T2P1N3)"
|
||||
value={lookupInput}
|
||||
onChange={(e) => setLookupInput(e.target.value)}
|
||||
onPressEnter={handleLookup}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={lookupLoading}
|
||||
onClick={handleLookup}
|
||||
>
|
||||
Lookup / Refresh
|
||||
</Button>
|
||||
</Space>
|
||||
), [lookupInput, lookupLoading, handleLookup]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Representatives Cache', actions: headerActions });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{stats && (
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Cached Representatives" value={stats.totalRepresentatives} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Postal Codes with Reps" value={stats.postalCodesWithRepresentatives} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Postal Codes with Geo Data" value={stats.totalPostalCodes} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Input
|
||||
placeholder="Search name, email, or district"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={7} md={4}>
|
||||
<Input
|
||||
placeholder="Filter by postal code"
|
||||
value={postalCodeFilter}
|
||||
onChange={(e) => handlePostalCodeFilterChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table<Representative>
|
||||
columns={columns}
|
||||
dataSource={representatives}
|
||||
rowKey={(r) => r.id || `${r.postalCode}-${r.name}-${r.electedOffice}`}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${total} representatives`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
title="Representative Details"
|
||||
open={detailModalOpen}
|
||||
onCancel={() => {
|
||||
setDetailModalOpen(false);
|
||||
setSelectedRep(null);
|
||||
}}
|
||||
footer={
|
||||
selectedRep?.postalCode ? (
|
||||
<Space>
|
||||
<Popconfirm
|
||||
title={`Clear all cached reps for ${selectedRep.postalCode}?`}
|
||||
onConfirm={() => {
|
||||
handleClearPostalCode(selectedRep.postalCode);
|
||||
setDetailModalOpen(false);
|
||||
setSelectedRep(null);
|
||||
}}
|
||||
>
|
||||
<Button danger>Clear Postal Code Cache</Button>
|
||||
</Popconfirm>
|
||||
<Button onClick={() => { setDetailModalOpen(false); setSelectedRep(null); }}>
|
||||
Close
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
width={640}
|
||||
>
|
||||
{selectedRep && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="Name">{selectedRep.name || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Email">{selectedRep.email || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Postal Code">{selectedRep.postalCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="Elected Office">{selectedRep.electedOffice || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Party">{selectedRep.partyName || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="District">{selectedRep.districtName || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Representative Set">{selectedRep.representativeSetName || '--'}</Descriptions.Item>
|
||||
<Descriptions.Item label="URL">
|
||||
{selectedRep.url ? (
|
||||
<a href={selectedRep.url} target="_blank" rel="noopener noreferrer">
|
||||
{selectedRep.url}
|
||||
</a>
|
||||
) : '--'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Photo URL">
|
||||
{selectedRep.photoUrl ? (
|
||||
<a href={selectedRep.photoUrl} target="_blank" rel="noopener noreferrer">
|
||||
{selectedRep.photoUrl}
|
||||
</a>
|
||||
) : '--'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Offices">
|
||||
{selectedRep.offices && Array.isArray(selectedRep.offices) && selectedRep.offices.length > 0
|
||||
? (
|
||||
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap' }}>
|
||||
{JSON.stringify(selectedRep.offices, null, 2)}
|
||||
</pre>
|
||||
)
|
||||
: '--'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Cached At">
|
||||
{dayjs(selectedRep.cachedAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
400
admin/src/pages/ResponsesPage.tsx
Normal file
400
admin/src/pages/ResponsesPage.tsx
Normal file
@ -0,0 +1,400 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
Input,
|
||||
Select,
|
||||
Popconfirm,
|
||||
message,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
Row,
|
||||
Col,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
MailOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
RepresentativeResponse,
|
||||
ResponsesListResponse,
|
||||
ResponseStatus,
|
||||
CampaignsListResponse,
|
||||
Campaign,
|
||||
PaginationMeta,
|
||||
} from '@/types/api';
|
||||
import {
|
||||
RESPONSE_TYPE_LABELS,
|
||||
RESPONSE_STATUS_COLORS,
|
||||
GOVERNMENT_LEVEL_COLORS,
|
||||
GOVERNMENT_LEVEL_LABELS,
|
||||
} from '@/types/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function ResponsesPage() {
|
||||
const [responses, setResponses] = useState<RepresentativeResponse[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<ResponseStatus | undefined>();
|
||||
const [campaignFilter, setCampaignFilter] = useState<string | undefined>();
|
||||
const [search, setSearch] = useState('');
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [detailResponse, setDetailResponse] = useState<RepresentativeResponse | null>(null);
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const fetchResponses = useCallback(async (page = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string | number> = { page, limit: 20 };
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (campaignFilter) params.campaignId = campaignFilter;
|
||||
if (search) params.search = search;
|
||||
|
||||
const { data } = await api.get<ResponsesListResponse>('/responses', { params });
|
||||
setResponses(data.responses);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load responses');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter, campaignFilter, search]);
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
try {
|
||||
const { data } = await api.get<CampaignsListResponse>('/campaigns', { params: { limit: 100 } });
|
||||
setCampaigns(data.campaigns);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResponses(1);
|
||||
}, [fetchResponses]);
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
setSearch(value);
|
||||
}, 400);
|
||||
};
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await api.patch(`/responses/${id}/status`, { status: 'APPROVED' });
|
||||
message.success('Response approved');
|
||||
fetchResponses(pagination.page);
|
||||
} catch {
|
||||
message.error('Failed to approve response');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await api.patch(`/responses/${id}/status`, { status: 'REJECTED' });
|
||||
message.success('Response rejected');
|
||||
fetchResponses(pagination.page);
|
||||
} catch {
|
||||
message.error('Failed to reject response');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await api.post(`/responses/${id}/resend-verification`);
|
||||
message.success('Verification email sent');
|
||||
} catch {
|
||||
message.error('Failed to send verification email');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
setActionLoading(id);
|
||||
try {
|
||||
await api.delete(`/responses/${id}`);
|
||||
message.success('Response deleted');
|
||||
fetchResponses(pagination.page);
|
||||
} catch {
|
||||
message.error('Failed to delete response');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<RepresentativeResponse> = [
|
||||
{
|
||||
title: 'Representative',
|
||||
dataIndex: 'representativeName',
|
||||
key: 'representativeName',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Level',
|
||||
dataIndex: 'representativeLevel',
|
||||
key: 'level',
|
||||
width: 120,
|
||||
render: (level) => (
|
||||
<Tag color={GOVERNMENT_LEVEL_COLORS[level as keyof typeof GOVERNMENT_LEVEL_COLORS]}>
|
||||
{GOVERNMENT_LEVEL_LABELS[level as keyof typeof GOVERNMENT_LEVEL_LABELS]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'responseType',
|
||||
key: 'type',
|
||||
width: 110,
|
||||
render: (type) => <Tag>{RESPONSE_TYPE_LABELS[type as keyof typeof RESPONSE_TYPE_LABELS]}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Campaign',
|
||||
key: 'campaign',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_, record) => record.campaign?.title || '—',
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status) => (
|
||||
<Tag color={RESPONSE_STATUS_COLORS[status as keyof typeof RESPONSE_STATUS_COLORS]}>
|
||||
{status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Verified',
|
||||
key: 'verified',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
responsive: ['md'] as any,
|
||||
render: (_, record) =>
|
||||
record.isVerified ? (
|
||||
<SafetyCertificateOutlined style={{ color: '#16a34a', fontSize: 16 }} />
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
title: 'Upvotes',
|
||||
dataIndex: 'upvoteCount',
|
||||
key: 'upvotes',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
responsive: ['md'] as any,
|
||||
},
|
||||
{
|
||||
title: 'Submitted',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 120,
|
||||
responsive: ['sm'] as any,
|
||||
render: (date) => dayjs(date).format('MMM D, YYYY'),
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space size="small" wrap>
|
||||
{record.status !== 'APPROVED' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<CheckCircleOutlined />}
|
||||
style={{ color: '#16a34a' }}
|
||||
loading={actionLoading === record.id}
|
||||
onClick={() => handleApprove(record.id)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
{record.status !== 'REJECTED' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<CloseCircleOutlined />}
|
||||
danger
|
||||
loading={actionLoading === record.id}
|
||||
onClick={() => handleReject(record.id)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
{record.representativeEmail && (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<MailOutlined />}
|
||||
loading={actionLoading === record.id}
|
||||
onClick={() => handleResendVerification(record.id)}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm title="Delete this response?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button size="small" type="link" icon={<DeleteOutlined />} danger loading={actionLoading === record.id} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Button icon={<ReloadOutlined />} onClick={() => fetchResponses(pagination.page)}>
|
||||
Refresh
|
||||
</Button>
|
||||
), [fetchResponses, pagination.page]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Responses', actions: headerActions });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Input
|
||||
placeholder="Search responses..."
|
||||
prefix={<SearchOutlined />}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={5}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={statusFilter}
|
||||
onChange={(v) => setStatusFilter(v)}
|
||||
options={[
|
||||
{ value: 'PENDING', label: 'Pending' },
|
||||
{ value: 'APPROVED', label: 'Approved' },
|
||||
{ value: 'REJECTED', label: 'Rejected' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={7}>
|
||||
<Select
|
||||
placeholder="Campaign"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
value={campaignFilter}
|
||||
onChange={(v) => setCampaignFilter(v)}
|
||||
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={responses}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 900 }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => setDetailResponse(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: false,
|
||||
onChange: (page) => fetchResponses(page),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="Response Details"
|
||||
open={!!detailResponse}
|
||||
onClose={() => setDetailResponse(null)}
|
||||
width={520}
|
||||
destroyOnClose
|
||||
>
|
||||
{detailResponse && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="Representative">
|
||||
{detailResponse.representativeName}
|
||||
{detailResponse.representativeTitle && ` — ${detailResponse.representativeTitle}`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Level">
|
||||
<Tag color={GOVERNMENT_LEVEL_COLORS[detailResponse.representativeLevel]}>
|
||||
{GOVERNMENT_LEVEL_LABELS[detailResponse.representativeLevel]}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Type">
|
||||
<Tag>{RESPONSE_TYPE_LABELS[detailResponse.responseType]}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Campaign">{detailResponse.campaign?.title || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Status">
|
||||
<Tag color={RESPONSE_STATUS_COLORS[detailResponse.status]}>{detailResponse.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Verified">
|
||||
{detailResponse.isVerified ? (
|
||||
<Space>
|
||||
<SafetyCertificateOutlined style={{ color: '#16a34a' }} />
|
||||
{detailResponse.verifiedBy && `by ${detailResponse.verifiedBy}`}
|
||||
{detailResponse.verifiedAt && ` on ${dayjs(detailResponse.verifiedAt).format('MMM D, YYYY')}`}
|
||||
</Space>
|
||||
) : 'No'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Upvotes">{detailResponse.upvoteCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="Response Text">
|
||||
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 300, overflow: 'auto' }}>
|
||||
{detailResponse.responseText}
|
||||
</div>
|
||||
</Descriptions.Item>
|
||||
{detailResponse.userComment && (
|
||||
<Descriptions.Item label="User Comment">
|
||||
<div style={{ whiteSpace: 'pre-wrap' }}>{detailResponse.userComment}</div>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="Submitted By">
|
||||
{detailResponse.isAnonymous
|
||||
? 'Anonymous'
|
||||
: `${detailResponse.submittedByName || '—'} (${detailResponse.submittedByEmail || '—'})`}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Submitted">
|
||||
{dayjs(detailResponse.createdAt).format('MMM D, YYYY h:mm A')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
419
admin/src/pages/SettingsPage.tsx
Normal file
419
admin/src/pages/SettingsPage.tsx
Normal file
@ -0,0 +1,419 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
Tabs,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Switch,
|
||||
Button,
|
||||
ColorPicker,
|
||||
Alert,
|
||||
Divider,
|
||||
Space,
|
||||
Segmented,
|
||||
Tag,
|
||||
message,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import {
|
||||
SettingOutlined,
|
||||
SaveOutlined,
|
||||
ThunderboltOutlined,
|
||||
SendOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { SmtpTestResult, SmtpSendTestResult } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
const [connectionResult, setConnectionResult] = useState<SmtpTestResult | null>(null);
|
||||
const [sendingTest, setSendingTest] = useState(false);
|
||||
const [sendResult, setSendResult] = useState<SmtpSendTestResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Settings' });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAdminSettings();
|
||||
}, [fetchAdminSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
form.setFieldsValue(settings);
|
||||
}
|
||||
}, [settings, form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = form.getFieldsValue();
|
||||
|
||||
// Convert ColorPicker values to hex strings
|
||||
const colorFields = [
|
||||
'adminColorPrimary',
|
||||
'adminColorBgBase',
|
||||
'publicColorPrimary',
|
||||
'publicColorBgBase',
|
||||
'publicColorBgContainer',
|
||||
] as const;
|
||||
|
||||
for (const field of colorFields) {
|
||||
const val = values[field];
|
||||
if (val && typeof val === 'object' && 'toHexString' in val) {
|
||||
values[field] = val.toHexString();
|
||||
}
|
||||
}
|
||||
|
||||
await updateSettings(values);
|
||||
setConnectionResult(null);
|
||||
setSendResult(null);
|
||||
message.success('Settings saved successfully');
|
||||
} catch {
|
||||
message.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestingConnection(true);
|
||||
setConnectionResult(null);
|
||||
try {
|
||||
const { data } = await api.post<SmtpTestResult>('/settings/email/test-connection');
|
||||
setConnectionResult(data);
|
||||
} catch {
|
||||
setConnectionResult({ success: false, message: 'Request failed' });
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTest = async () => {
|
||||
setSendingTest(true);
|
||||
setSendResult(null);
|
||||
try {
|
||||
const to = form.getFieldValue('testEmailRecipient');
|
||||
const { data } = await api.post<SmtpSendTestResult>('/settings/email/test-send', { to });
|
||||
setSendResult(data);
|
||||
} catch {
|
||||
setSendResult({ success: false, testMode: false, recipient: '' });
|
||||
} finally {
|
||||
setSendingTest(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProviderToggle = async (value: string | number) => {
|
||||
const provider = value as 'mailhog' | 'production';
|
||||
try {
|
||||
await updateSettings({ smtpActiveProvider: provider });
|
||||
form.setFieldsValue({ smtpActiveProvider: provider });
|
||||
message.success(`Switched to ${provider === 'mailhog' ? 'MailHog' : 'Production'} SMTP`);
|
||||
setConnectionResult(null);
|
||||
setSendResult(null);
|
||||
} catch {
|
||||
message.error('Failed to switch SMTP provider');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'organization',
|
||||
label: 'Organization',
|
||||
icon: <SettingOutlined />,
|
||||
children: (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
<Form.Item label="Organization Name" name="organizationName">
|
||||
<Input placeholder="Changemaker Lite" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Short Name" name="organizationShortName" extra="Shown when sidebar is collapsed (max 10 chars)">
|
||||
<Input placeholder="CML" maxLength={10} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Logo URL" name="organizationLogoUrl">
|
||||
<Input placeholder="https://example.com/logo.png" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Favicon URL" name="organizationFaviconUrl">
|
||||
<Input placeholder="https://example.com/favicon.ico" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Footer Text" name="footerText">
|
||||
<Input placeholder="Powered by Changemaker Lite" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Login Subtitle" name="loginSubtitle" extra="Shown below the organization name on the login page">
|
||||
<Input placeholder="Admin" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
label: 'Theme Colors',
|
||||
children: (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>Admin Theme</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item label="Primary Color" name="adminColorPrimary">
|
||||
<ColorPicker format="hex" showText />
|
||||
</Form.Item>
|
||||
<Form.Item label="Background Color" name="adminColorBgBase">
|
||||
<ColorPicker format="hex" showText />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>Public Theme</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item label="Primary Color" name="publicColorPrimary">
|
||||
<ColorPicker format="hex" showText />
|
||||
</Form.Item>
|
||||
<Form.Item label="Background Color" name="publicColorBgBase">
|
||||
<ColorPicker format="hex" showText />
|
||||
</Form.Item>
|
||||
<Form.Item label="Container Color" name="publicColorBgContainer">
|
||||
<ColorPicker format="hex" showText />
|
||||
</Form.Item>
|
||||
<Form.Item label="Header Gradient" name="publicHeaderGradient" extra="CSS gradient string, e.g. linear-gradient(135deg, #005a9c 0%, #007acc 100%)">
|
||||
<Input placeholder="linear-gradient(135deg, #005a9c 0%, #007acc 100%)" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{settings && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>Preview</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<Swatch label="Admin Primary" color={settings.adminColorPrimary} />
|
||||
<Swatch label="Admin BG" color={settings.adminColorBgBase} />
|
||||
<Swatch label="Public Primary" color={settings.publicColorPrimary} />
|
||||
<Swatch label="Public BG" color={settings.publicColorBgBase} />
|
||||
<Swatch label="Public Container" color={settings.publicColorBgContainer} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: '12px 24px',
|
||||
background: settings.publicHeaderGradient,
|
||||
borderRadius: 8,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Header Gradient Preview
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
children: (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
{/* Sender */}
|
||||
<Text strong style={{ fontSize: 15 }}>Sender</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item label="From Name" name="emailFromName" extra="The sender name used in outgoing emails">
|
||||
<Input placeholder="Changemaker Lite" />
|
||||
</Form.Item>
|
||||
<Form.Item label="From Address" name="smtpFromAddress" extra="The sender email address (leave empty to use env default)">
|
||||
<Input placeholder="noreply@cmlite.org" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Active SMTP Provider */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>Active SMTP Provider</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Segmented
|
||||
value={settings?.smtpActiveProvider || 'mailhog'}
|
||||
options={[
|
||||
{ label: 'MailHog', value: 'mailhog' },
|
||||
{ label: 'Production', value: 'production' },
|
||||
]}
|
||||
onChange={handleProviderToggle}
|
||||
size="large"
|
||||
/>
|
||||
<Tag
|
||||
color="green"
|
||||
style={{ marginLeft: 12, verticalAlign: 'middle' }}
|
||||
>
|
||||
{(settings?.smtpActiveProvider || 'mailhog') === 'mailhog' ? 'MailHog Active' : 'Production Active'}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MailHog Info */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>MailHog (Testing)</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.04)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<div><Text type="secondary">Host:</Text> <Text code>mailhog-changemaker</Text></div>
|
||||
<div style={{ marginTop: 4 }}><Text type="secondary">Port:</Text> <Text code>1025</Text></div>
|
||||
<div style={{ marginTop: 4 }}><Text type="secondary">Auth:</Text> <Text type="secondary">None required</Text></div>
|
||||
<div style={{ marginTop: 4 }}><Text type="secondary">Web UI:</Text> <Text code>http://localhost:8025</Text></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Production SMTP */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>Production SMTP</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item label="SMTP Host" name="smtpHost" extra="Production mail server hostname">
|
||||
<Input placeholder="smtp.protonmail.ch" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SMTP Port" name="smtpPort" extra="Common ports: 587 (STARTTLS), 465 (SSL)">
|
||||
<InputNumber min={0} max={65535} style={{ width: '100%' }} placeholder="587" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SMTP User" name="smtpUser">
|
||||
<Input placeholder="user@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SMTP Password" name="smtpPass">
|
||||
<Input.Password placeholder="SMTP password or app-specific password" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Test Mode */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>Test Mode</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Form.Item
|
||||
label="Enable Test Mode"
|
||||
name="emailTestMode"
|
||||
valuePropName="checked"
|
||||
extra="When enabled, all emails are redirected to the test recipient"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Test Recipient" name="testEmailRecipient" extra="All emails will be sent to this address when test mode is on">
|
||||
<Input placeholder="admin@cmlite.org" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Test Actions */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Text strong style={{ fontSize: 15 }}>Test Actions</Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Alert
|
||||
type="info"
|
||||
message="Tests run against the active provider. Save production credentials before switching."
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={testingConnection}
|
||||
onClick={handleTestConnection}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button
|
||||
icon={<SendOutlined />}
|
||||
loading={sendingTest}
|
||||
onClick={handleSendTest}
|
||||
>
|
||||
Send Test Email
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{connectionResult && (
|
||||
<Alert
|
||||
type={connectionResult.success ? 'success' : 'error'}
|
||||
message={connectionResult.message}
|
||||
icon={connectionResult.success ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sendResult && (
|
||||
<Alert
|
||||
type={sendResult.success ? 'success' : 'error'}
|
||||
message={
|
||||
sendResult.success
|
||||
? `Test email sent to ${sendResult.recipient}${sendResult.testMode ? ' (test mode)' : ''}`
|
||||
: 'Failed to send test email'
|
||||
}
|
||||
description={sendResult.messageId ? `Message ID: ${sendResult.messageId}` : undefined}
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'features',
|
||||
label: 'Feature Toggles',
|
||||
children: (
|
||||
<div style={{ maxWidth: 600 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
message="Disabling a module hides it from navigation but does not delete data."
|
||||
showIcon
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
<Form.Item label="Enable Influence" name="enableInfluence" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Enable Map" name="enableMap" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Enable Newsletter" name="enableNewsletter" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label="Enable Landing Pages" name="enableLandingPages" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Form form={form} layout="vertical">
|
||||
<Tabs items={items} />
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Button type="primary" icon={<SaveOutlined />} size="large" onClick={handleSave}>
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function Swatch({ label, color }: { label: string; color: string }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div
|
||||
style={{
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 8,
|
||||
background: color,
|
||||
border: '2px solid rgba(255,255,255,0.2)',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 11 }}>{label}</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
756
admin/src/pages/ShiftsPage.tsx
Normal file
756
admin/src/pages/ShiftsPage.tsx
Normal file
@ -0,0 +1,756 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
Switch,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
Progress,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
InputNumber,
|
||||
Drawer,
|
||||
Card,
|
||||
Statistic,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
CalendarOutlined,
|
||||
MailOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
StopOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type {
|
||||
Shift,
|
||||
ShiftSignup,
|
||||
ShiftsListResponse,
|
||||
ShiftsListParams,
|
||||
ShiftStats,
|
||||
ShiftStatus,
|
||||
Cut,
|
||||
} from '@/types/api';
|
||||
import { SHIFT_STATUS_COLORS, SHIFT_STATUS_LABELS, SIGNUP_SOURCE_COLORS } from '@/types/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const statusOptions = Object.entries(SHIFT_STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
export default function ShiftsPage() {
|
||||
const [shifts, setShifts] = useState<Shift[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();
|
||||
const [stats, setStats] = useState<ShiftStats | null>(null);
|
||||
|
||||
// Create modal
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [createForm] = Form.useForm();
|
||||
|
||||
// Edit drawer
|
||||
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
|
||||
const [editingShift, setEditingShift] = useState<Shift | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
// Signups drawer
|
||||
const [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);
|
||||
const [signupsShift, setSignupsShift] = useState<Shift | null>(null);
|
||||
const [signups, setSignups] = useState<ShiftSignup[]>([]);
|
||||
const [signupsLoading, setSignupsLoading] = useState(false);
|
||||
const [addEmail, setAddEmail] = useState('');
|
||||
const [addName, setAddName] = useState('');
|
||||
|
||||
// Cuts for area dropdown
|
||||
const [cuts, setCuts] = useState<Cut[]>([]);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(searchTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get<ShiftStats>('/map/shifts/stats');
|
||||
setStats(data);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchCuts = useCallback(async () => {
|
||||
try {
|
||||
const { data } = await api.get('/map/cuts', { params: { limit: 100 } });
|
||||
setCuts(data.cuts ?? data);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchShifts = useCallback(async (params?: ShiftsListParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<ShiftsListResponse>('/map/shifts', {
|
||||
params: {
|
||||
page: params?.page ?? pagination.page,
|
||||
limit: params?.limit ?? pagination.limit,
|
||||
search: params?.search ?? (debouncedSearch || undefined),
|
||||
status: params?.status ?? statusFilter,
|
||||
},
|
||||
});
|
||||
setShifts(data.shifts);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load shifts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShifts({ page: 1 });
|
||||
fetchStats();
|
||||
fetchCuts();
|
||||
}, [debouncedSearch, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||
fetchShifts({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||
};
|
||||
|
||||
const handleCreate = async (values: Record<string, unknown>) => {
|
||||
try {
|
||||
const payload = {
|
||||
title: values.title,
|
||||
description: values.description || undefined,
|
||||
date: dayjs(values.date as string).format('YYYY-MM-DD'),
|
||||
startTime: dayjs(values.startTime as string).format('HH:mm'),
|
||||
endTime: dayjs(values.endTime as string).format('HH:mm'),
|
||||
location: values.location || undefined,
|
||||
maxVolunteers: values.maxVolunteers,
|
||||
isPublic: values.isPublic || false,
|
||||
cutId: values.cutId || undefined,
|
||||
};
|
||||
await api.post('/map/shifts', payload);
|
||||
message.success('Shift created');
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchShifts({ page: 1 });
|
||||
fetchStats();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to create shift';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (values: Record<string, unknown>) => {
|
||||
if (!editingShift) return;
|
||||
try {
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (values.title !== undefined) payload.title = values.title;
|
||||
if (values.description !== undefined) payload.description = values.description || null;
|
||||
if (values.date !== undefined) payload.date = dayjs(values.date as string).format('YYYY-MM-DD');
|
||||
if (values.startTime !== undefined) payload.startTime = dayjs(values.startTime as string).format('HH:mm');
|
||||
if (values.endTime !== undefined) payload.endTime = dayjs(values.endTime as string).format('HH:mm');
|
||||
if (values.location !== undefined) payload.location = values.location || null;
|
||||
if (values.maxVolunteers !== undefined) payload.maxVolunteers = values.maxVolunteers;
|
||||
if (values.isPublic !== undefined) payload.isPublic = values.isPublic;
|
||||
if (values.status !== undefined) payload.status = values.status;
|
||||
if (values.cutId !== undefined) payload.cutId = values.cutId || null;
|
||||
|
||||
await api.put(`/map/shifts/${editingShift.id}`, payload);
|
||||
message.success('Shift updated');
|
||||
setEditDrawerOpen(false);
|
||||
setEditingShift(null);
|
||||
editForm.resetFields();
|
||||
fetchShifts();
|
||||
fetchStats();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to update shift';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/map/shifts/${id}`);
|
||||
message.success('Shift deleted');
|
||||
fetchShifts();
|
||||
fetchStats();
|
||||
} catch {
|
||||
message.error('Failed to delete shift');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (shift: Shift) => {
|
||||
setEditingShift(shift);
|
||||
editForm.setFieldsValue({
|
||||
title: shift.title,
|
||||
description: shift.description,
|
||||
date: dayjs(shift.date),
|
||||
startTime: dayjs(shift.startTime, 'HH:mm'),
|
||||
endTime: dayjs(shift.endTime, 'HH:mm'),
|
||||
location: shift.location,
|
||||
maxVolunteers: shift.maxVolunteers,
|
||||
isPublic: shift.isPublic,
|
||||
status: shift.status,
|
||||
cutId: shift.cutId,
|
||||
});
|
||||
setEditDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openSignups = async (shift: Shift) => {
|
||||
setSignupsShift(shift);
|
||||
setSignupsDrawerOpen(true);
|
||||
await fetchSignups(shift.id);
|
||||
};
|
||||
|
||||
const fetchSignups = async (shiftId: string) => {
|
||||
setSignupsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<Shift>(`/map/shifts/${shiftId}`);
|
||||
setSignups(data.signups || []);
|
||||
setSignupsShift(data);
|
||||
} catch {
|
||||
message.error('Failed to load signups');
|
||||
} finally {
|
||||
setSignupsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSignup = async () => {
|
||||
if (!signupsShift || !addEmail.trim()) return;
|
||||
try {
|
||||
await api.post(`/map/shifts/${signupsShift.id}/signups`, {
|
||||
userEmail: addEmail.trim(),
|
||||
userName: addName.trim() || undefined,
|
||||
});
|
||||
message.success('Volunteer added');
|
||||
setAddEmail('');
|
||||
setAddName('');
|
||||
fetchSignups(signupsShift.id);
|
||||
fetchShifts();
|
||||
fetchStats();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to add volunteer';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSignup = async (signupId: string) => {
|
||||
if (!signupsShift) return;
|
||||
try {
|
||||
await api.delete(`/map/shifts/${signupsShift.id}/signups/${signupId}`);
|
||||
message.success('Volunteer removed');
|
||||
fetchSignups(signupsShift.id);
|
||||
fetchShifts();
|
||||
fetchStats();
|
||||
} catch {
|
||||
message.error('Failed to remove volunteer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEmailAll = async () => {
|
||||
if (!signupsShift) return;
|
||||
try {
|
||||
const { data } = await api.post<{ sent: number; failed: number }>(
|
||||
`/map/shifts/${signupsShift.id}/email-details`
|
||||
);
|
||||
message.success(`Emailed ${data.sent} volunteer(s)${data.failed ? `, ${data.failed} failed` : ''}`);
|
||||
} catch {
|
||||
message.error('Failed to email volunteers');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Shift> = [
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
render: (title: string) => <span style={{ fontWeight: 500 }}>{title}</span>,
|
||||
},
|
||||
{
|
||||
title: 'Date',
|
||||
dataIndex: 'date',
|
||||
key: 'date',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||
},
|
||||
{
|
||||
title: 'Time',
|
||||
key: 'time',
|
||||
render: (_: unknown, record: Shift) => `${record.startTime} — ${record.endTime}`,
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Location',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
render: (loc: string | null) => loc || '--',
|
||||
responsive: ['lg'],
|
||||
},
|
||||
{
|
||||
title: 'Area',
|
||||
key: 'cut',
|
||||
render: (_: unknown, record: Shift) => record.cut?.name || '--',
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Volunteers',
|
||||
key: 'volunteers',
|
||||
width: 140,
|
||||
render: (_: unknown, record: Shift) => {
|
||||
const confirmed = record._count?.signups ?? record.currentVolunteers;
|
||||
const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;
|
||||
return (
|
||||
<div>
|
||||
<Progress
|
||||
percent={pct}
|
||||
size="small"
|
||||
status={pct >= 100 ? 'exception' : 'active'}
|
||||
format={() => `${confirmed}/${record.maxVolunteers}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (status: ShiftStatus) => (
|
||||
<Tag color={SHIFT_STATUS_COLORS[status]}>{SHIFT_STATUS_LABELS[status]}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Public',
|
||||
dataIndex: 'isPublic',
|
||||
key: 'isPublic',
|
||||
width: 70,
|
||||
render: (isPublic: boolean) =>
|
||||
isPublic ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : null,
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (_: unknown, record: Shift) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(record); }}
|
||||
title="Edit"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="Delete this shift?"
|
||||
onConfirm={(e) => { e?.stopPropagation(); handleDelete(record.id); }}
|
||||
onCancel={(e) => e?.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Delete"
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const signupColumns: ColumnsType<ShiftSignup> = [
|
||||
{
|
||||
title: 'Email',
|
||||
dataIndex: 'userEmail',
|
||||
key: 'userEmail',
|
||||
},
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
render: (name: string | null) => name || '--',
|
||||
},
|
||||
{
|
||||
title: 'Phone',
|
||||
key: 'phone',
|
||||
render: (_: unknown, record: ShiftSignup) =>
|
||||
record.userPhone || record.user?.phone || '--',
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Source',
|
||||
dataIndex: 'signupSource',
|
||||
key: 'signupSource',
|
||||
width: 100,
|
||||
render: (source: string) => (
|
||||
<Tag color={SIGNUP_SOURCE_COLORS[source as keyof typeof SIGNUP_SOURCE_COLORS]}>
|
||||
{source}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Date',
|
||||
dataIndex: 'signupDate',
|
||||
key: 'signupDate',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||
responsive: ['lg'],
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'remove',
|
||||
width: 60,
|
||||
render: (_: unknown, record: ShiftSignup) =>
|
||||
record.status === 'CONFIRMED' ? (
|
||||
<Popconfirm title="Remove this volunteer?" onConfirm={() => handleRemoveSignup(record.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Remove" />
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Tag color="red">Cancelled</Tag>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const cutOptions = cuts.map((c) => ({ value: c.id, label: c.name }));
|
||||
|
||||
const shiftFormFields = (isEdit = false) => (
|
||||
<>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="Title"
|
||||
rules={[{ required: true, message: 'Title is required' }]}
|
||||
>
|
||||
<Input placeholder="e.g. Door Knocking, Phone Banking" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={3} placeholder="Shift details and instructions" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Form.Item
|
||||
name="date"
|
||||
label="Date"
|
||||
rules={[{ required: true, message: 'Date is required' }]}
|
||||
>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Form.Item
|
||||
name="startTime"
|
||||
label="Start Time"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label="End Time"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="location" label="Location">
|
||||
<Input placeholder="e.g. Campaign HQ, 123 Main St" />
|
||||
</Form.Item>
|
||||
<Form.Item name="cutId" label="Area (Cut)">
|
||||
<Select
|
||||
options={cutOptions}
|
||||
placeholder="Assign a canvass area..."
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col xs={12} sm={8}>
|
||||
<Form.Item
|
||||
name="maxVolunteers"
|
||||
label="Max Volunteers"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={8}>
|
||||
<Form.Item name="isPublic" label="Public" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{isEdit && (
|
||||
<Col xs={12} sm={8}>
|
||||
<Form.Item name="status" label="Status">
|
||||
<Select options={statusOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
|
||||
const headerActions = useMemo(() => (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Shift
|
||||
</Button>
|
||||
), [createForm]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({ title: 'Shifts', actions: headerActions });
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader, headerActions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Stats Row */}
|
||||
{stats && (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Total" value={stats.total} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Open" value={stats.open} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Full" value={stats.full} valueStyle={{ color: '#faad14' }} prefix={<StopOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Cancelled" value={stats.cancelled} valueStyle={{ color: '#ff4d4f' }} prefix={<StopOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Upcoming" value={stats.upcoming} prefix={<ClockCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="Signups" value={stats.totalSignups} prefix={<TeamOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Input
|
||||
placeholder="Search title or location"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
options={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Table */}
|
||||
<Table<Shift>
|
||||
columns={columns}
|
||||
dataSource={shifts}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${total} shifts`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
onRow={(record) => ({
|
||||
onClick: () => openSignups(record),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
title="Create Shift"
|
||||
open={createModalOpen}
|
||||
destroyOnHidden
|
||||
width={560}
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
onOk={() => createForm.submit()}
|
||||
okText="Create"
|
||||
>
|
||||
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
||||
{shiftFormFields(false)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Drawer */}
|
||||
<Drawer
|
||||
title="Edit Shift"
|
||||
open={editDrawerOpen}
|
||||
width={520}
|
||||
onClose={() => {
|
||||
setEditDrawerOpen(false);
|
||||
setEditingShift(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => editForm.submit()}>
|
||||
Save
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
||||
{shiftFormFields(true)}
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
{/* Signups Drawer */}
|
||||
<Drawer
|
||||
title={
|
||||
<Space>
|
||||
<TeamOutlined />
|
||||
<span>Signups — {signupsShift?.title}</span>
|
||||
</Space>
|
||||
}
|
||||
open={signupsDrawerOpen}
|
||||
width={640}
|
||||
onClose={() => {
|
||||
setSignupsDrawerOpen(false);
|
||||
setSignupsShift(null);
|
||||
setSignups([]);
|
||||
setAddEmail('');
|
||||
setAddName('');
|
||||
}}
|
||||
extra={
|
||||
<Button
|
||||
icon={<MailOutlined />}
|
||||
onClick={handleEmailAll}
|
||||
disabled={signups.filter((s) => s.status === 'CONFIRMED').length === 0}
|
||||
>
|
||||
Email All
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{signupsShift && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Text type="secondary">Date</Text>
|
||||
<br />
|
||||
<Text strong>{dayjs(signupsShift.date).format('YYYY-MM-DD')}</Text>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Text type="secondary">Time</Text>
|
||||
<br />
|
||||
<Text strong>{signupsShift.startTime} — {signupsShift.endTime}</Text>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Text type="secondary">Volunteers</Text>
|
||||
<br />
|
||||
<Text strong>
|
||||
{signupsShift._count?.signups ?? signupsShift.currentVolunteers} / {signupsShift.maxVolunteers}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Table<ShiftSignup>
|
||||
columns={signupColumns}
|
||||
dataSource={signups.filter((s) => s.status === 'CONFIRMED')}
|
||||
rowKey="id"
|
||||
loading={signupsLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
|
||||
<Input
|
||||
placeholder="Email"
|
||||
value={addEmail}
|
||||
onChange={(e) => setAddEmail(e.target.value)}
|
||||
style={{ flex: 2 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Name (optional)"
|
||||
value={addName}
|
||||
onChange={(e) => setAddName(e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddSignup}
|
||||
disabled={!addEmail.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
463
admin/src/pages/UsersPage.tsx
Normal file
463
admin/src/pages/UsersPage.tsx
Normal file
@ -0,0 +1,463 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
Modal,
|
||||
Form,
|
||||
InputNumber,
|
||||
Popconfirm,
|
||||
message,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
DatePicker,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
User,
|
||||
UserRole,
|
||||
UserStatus,
|
||||
UsersListResponse,
|
||||
UsersListParams,
|
||||
CreateUserPayload,
|
||||
UpdateUserPayload,
|
||||
} from '@/types/api';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const roleColors: Record<UserRole, string> = {
|
||||
SUPER_ADMIN: 'red',
|
||||
INFLUENCE_ADMIN: 'volcano',
|
||||
MAP_ADMIN: 'orange',
|
||||
USER: 'blue',
|
||||
TEMP: 'default',
|
||||
};
|
||||
|
||||
const statusColors: Record<UserStatus, string> = {
|
||||
ACTIVE: 'green',
|
||||
INACTIVE: 'default',
|
||||
SUSPENDED: 'red',
|
||||
EXPIRED: 'orange',
|
||||
};
|
||||
|
||||
const roleOptions: { value: UserRole; label: string }[] = [
|
||||
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
|
||||
{ value: 'INFLUENCE_ADMIN', label: 'Influence Admin' },
|
||||
{ value: 'MAP_ADMIN', label: 'Map Admin' },
|
||||
{ value: 'USER', label: 'User' },
|
||||
{ value: 'TEMP', label: 'Temp' },
|
||||
];
|
||||
|
||||
const statusOptions: { value: UserStatus; label: string }[] = [
|
||||
{ value: 'ACTIVE', label: 'Active' },
|
||||
{ value: 'INACTIVE', label: 'Inactive' },
|
||||
{ value: 'SUSPENDED', label: 'Suspended' },
|
||||
{ value: 'EXPIRED', label: 'Expired' },
|
||||
];
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const [roleFilter, setRoleFilter] = useState<UserRole | undefined>();
|
||||
const [statusFilter, setStatusFilter] = useState<UserStatus | undefined>();
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [createForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(searchTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const fetchUsers = useCallback(async (params?: UsersListParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<UsersListResponse>('/users', {
|
||||
params: {
|
||||
page: params?.page ?? pagination.page,
|
||||
limit: params?.limit ?? pagination.limit,
|
||||
search: params?.search ?? (debouncedSearch || undefined),
|
||||
role: params?.role ?? roleFilter,
|
||||
status: params?.status ?? statusFilter,
|
||||
},
|
||||
});
|
||||
setUsers(data.users);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers({ page: 1 });
|
||||
}, [debouncedSearch, roleFilter, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTableChange = (pag: TablePaginationConfig) => {
|
||||
fetchUsers({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
|
||||
};
|
||||
|
||||
const handleCreate = async (values: CreateUserPayload & { expiresAtDate?: dayjs.Dayjs }) => {
|
||||
try {
|
||||
const payload: CreateUserPayload = { ...values };
|
||||
if (values.expiresAtDate) {
|
||||
payload.expiresAt = values.expiresAtDate.toISOString();
|
||||
}
|
||||
delete (payload as unknown as Record<string, unknown>).expiresAtDate;
|
||||
await api.post('/users', payload);
|
||||
message.success('User created');
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchUsers({ page: 1 });
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to create user';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (values: UpdateUserPayload & { expiresAtDate?: dayjs.Dayjs | null }) => {
|
||||
if (!editingUser) return;
|
||||
try {
|
||||
const payload: UpdateUserPayload = { ...values };
|
||||
if (values.expiresAtDate) {
|
||||
payload.expiresAt = values.expiresAtDate.toISOString();
|
||||
} else if (values.expiresAtDate === null) {
|
||||
payload.expiresAt = null;
|
||||
}
|
||||
delete (payload as unknown as Record<string, unknown>).expiresAtDate;
|
||||
// Remove empty password
|
||||
if (!payload.password) delete payload.password;
|
||||
await api.put(`/users/${editingUser.id}`, payload);
|
||||
message.success('User updated');
|
||||
setEditModalOpen(false);
|
||||
setEditingUser(null);
|
||||
editForm.resetFields();
|
||||
fetchUsers();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Failed to update user';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/users/${id}`);
|
||||
message.success('User deleted');
|
||||
fetchUsers();
|
||||
} catch {
|
||||
message.error('Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (user: User) => {
|
||||
setEditingUser(user);
|
||||
editForm.setFieldsValue({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
phone: user.phone,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
expireDays: user.expireDays,
|
||||
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<User> = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string | null) => name || '--',
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
},
|
||||
{
|
||||
title: 'Role',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: UserRole) => (
|
||||
<Tag color={roleColors[role]}>{role.replace('_', ' ')}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: UserStatus) => (
|
||||
<Tag color={statusColors[status]}>{status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Created',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
|
||||
responsive: ['md'],
|
||||
},
|
||||
{
|
||||
title: 'Last Login',
|
||||
dataIndex: 'lastLoginAt',
|
||||
key: 'lastLoginAt',
|
||||
render: (date: string | null) =>
|
||||
date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '--',
|
||||
responsive: ['lg'],
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: unknown, record: User) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEdit(record)}
|
||||
/>
|
||||
<Popconfirm
|
||||
title="Delete this user?"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const createRoleValue = Form.useWatch('role', createForm);
|
||||
const editRoleValue = Form.useWatch('role', editForm);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
Users
|
||||
</Title>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={10} md={8}>
|
||||
<Input
|
||||
placeholder="Search by name or email"
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={7} md={4}>
|
||||
<Select
|
||||
placeholder="Role"
|
||||
options={roleOptions}
|
||||
value={roleFilter}
|
||||
onChange={setRoleFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={7} md={4}>
|
||||
<Select
|
||||
placeholder="Status"
|
||||
options={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table<User>
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.limit,
|
||||
total: pagination.total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `${total} users`,
|
||||
}}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
title="Create User"
|
||||
open={createModalOpen}
|
||||
destroyOnHidden
|
||||
onCancel={() => {
|
||||
setCreateModalOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
onOk={() => createForm.submit()}
|
||||
okText="Create"
|
||||
>
|
||||
<Form form={createForm} onFinish={handleCreate} layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="Email"
|
||||
rules={[
|
||||
{ required: true, message: 'Email is required' },
|
||||
{ type: 'email', message: 'Invalid email' },
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="Password"
|
||||
rules={[
|
||||
{ required: true, message: 'Password is required' },
|
||||
{ min: 8, message: 'Minimum 8 characters' },
|
||||
]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="Phone">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="role" label="Role" initialValue="USER">
|
||||
<Select options={roleOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="Status" initialValue="ACTIVE">
|
||||
<Select options={statusOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
{createRoleValue === 'TEMP' && (
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="expiresAtDate" label="Expires At">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="expireDays" label="Expire Days">
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal
|
||||
title="Edit User"
|
||||
open={editModalOpen}
|
||||
destroyOnHidden
|
||||
onCancel={() => {
|
||||
setEditModalOpen(false);
|
||||
setEditingUser(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
onOk={() => editForm.submit()}
|
||||
okText="Save"
|
||||
>
|
||||
<Form form={editForm} onFinish={handleEdit} layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="Email"
|
||||
rules={[
|
||||
{ required: true, message: 'Email is required' },
|
||||
{ type: 'email', message: 'Invalid email' },
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="Password"
|
||||
help="Leave blank to keep current password"
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="Name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="Phone">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="role" label="Role">
|
||||
<Select options={roleOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="Status">
|
||||
<Select options={statusOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
{editRoleValue === 'TEMP' && (
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="expiresAtDate" label="Expires At">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="expireDays" label="Expire Days">
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
187
admin/src/pages/WalkSheetPage.tsx
Normal file
187
admin/src/pages/WalkSheetPage.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Typography, Spin, App } from 'antd';
|
||||
import { PrinterOutlined } from '@ant-design/icons';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import type { MapSettings } from '@/types/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export default function WalkSheetPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
const { message } = App.useApp();
|
||||
const [settings, setSettings] = useState<MapSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setPageHeader({
|
||||
title: 'Walk Sheet',
|
||||
actions: (
|
||||
<Button icon={<PrinterOutlined />} onClick={() => window.print()}>
|
||||
Print
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
return () => setPageHeader(null);
|
||||
}, [setPageHeader]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/map/settings')
|
||||
.then(({ data }) => setSettings(data))
|
||||
.catch(() => message.error('Failed to load settings'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
const qrCodes = [
|
||||
{ url: settings?.qrCode1Url, label: settings?.qrCode1Label },
|
||||
{ url: settings?.qrCode2Url, label: settings?.qrCode2Label },
|
||||
{ url: settings?.qrCode3Url, label: settings?.qrCode3Label },
|
||||
].filter((q) => q.url);
|
||||
|
||||
const rows = Array.from({ length: 12 }, (_, i) => i);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body * { visibility: hidden; }
|
||||
.walk-sheet-print, .walk-sheet-print * { visibility: visible; }
|
||||
.walk-sheet-print {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
font-size: 11px;
|
||||
}
|
||||
.walk-sheet-print .no-print { display: none !important; }
|
||||
}
|
||||
.walk-sheet-print table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.walk-sheet-print th,
|
||||
.walk-sheet-print td {
|
||||
border: 1px solid #555;
|
||||
padding: 4px 6px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
}
|
||||
.walk-sheet-print th {
|
||||
background: rgba(255,255,255,0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
.support-circle {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1.5px solid rgba(255,255,255,0.4);
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
line-height: 14px;
|
||||
font-size: 9px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="walk-sheet-print">
|
||||
{/* Header */}
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<Title level={3} style={{ marginBottom: 2 }}>
|
||||
{settings?.walkSheetTitle || 'Walk Sheet'}
|
||||
</Title>
|
||||
{settings?.walkSheetSubtitle && (
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>{settings.walkSheetSubtitle}</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR Codes */}
|
||||
{qrCodes.length > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginBottom: 16 }}>
|
||||
{qrCodes.map((qr, idx) => (
|
||||
<div key={idx} style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url!)}&size=100`}
|
||||
alt={qr.label || 'QR'}
|
||||
style={{ width: 80, height: 80 }}
|
||||
/>
|
||||
{qr.label && (
|
||||
<div style={{ fontSize: 10, marginTop: 2 }}>
|
||||
<Text type="secondary">{qr.label}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Volunteer info line */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong>Volunteer: </Text>
|
||||
<span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 200 }}> </span>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Date: </Text>
|
||||
<span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}> </span>
|
||||
</div>
|
||||
<div>
|
||||
<Text strong>Area/Cut: </Text>
|
||||
<span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact table */}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 20 }}>#</th>
|
||||
<th>Name</th>
|
||||
<th>Address / Unit</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th style={{ width: 70 }}>Support</th>
|
||||
<th style={{ width: 50 }}>Sign</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ textAlign: 'center' }}>{i + 1}</td>
|
||||
<td style={{ height: 28 }}> </td>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
<td> </td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span className="support-circle">1</span>
|
||||
<span className="support-circle">2</span>
|
||||
<span className="support-circle">3</span>
|
||||
<span className="support-circle">4</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<span className="support-circle">R</span>
|
||||
<span className="support-circle">L</span>
|
||||
</td>
|
||||
<td> </td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Footer */}
|
||||
{settings?.walkSheetFooter && (
|
||||
<div style={{ marginTop: 16, fontSize: 11, textAlign: 'center' }}>
|
||||
<Text type="secondary">{settings.walkSheetFooter}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
613
admin/src/pages/public/CampaignPage.tsx
Normal file
613
admin/src/pages/public/CampaignPage.tsx
Normal file
@ -0,0 +1,613 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
Input,
|
||||
Button,
|
||||
Card,
|
||||
Space,
|
||||
Tag,
|
||||
Avatar,
|
||||
message,
|
||||
Spin,
|
||||
Result,
|
||||
Grid,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
SendOutlined,
|
||||
MailOutlined,
|
||||
UserOutlined,
|
||||
MessageOutlined,
|
||||
CopyOutlined,
|
||||
CheckCircleOutlined,
|
||||
ArrowRightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Campaign,
|
||||
Representative,
|
||||
RepresentativeLookupResponse,
|
||||
} from '@/types/api';
|
||||
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
|
||||
import { mapRepSetToLevel } from '@/utils/representatives';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const apiBase = '/api';
|
||||
|
||||
type Step = 'info' | 'reps' | 'send';
|
||||
|
||||
export default function CampaignPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Step tracking
|
||||
const [currentStep, setCurrentStep] = useState<Step>('info');
|
||||
|
||||
// Step 1: User info
|
||||
const [postalCode, setPostalCode] = useState('');
|
||||
const [userName, setUserName] = useState('');
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
|
||||
// Step 2: Representatives
|
||||
const [representatives, setRepresentatives] = useState<Representative[]>([]);
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
|
||||
// Step 3: Send email
|
||||
const [sendingTo, setSendingTo] = useState<string | null>(null);
|
||||
const [sentTo, setSentTo] = useState<Set<string>>(new Set());
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaign();
|
||||
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchCampaign = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.get<Campaign>(`${apiBase}/campaigns/${slug}/details`);
|
||||
setCampaign(data);
|
||||
setEditSubject(data.emailSubject);
|
||||
setEditBody(data.emailBody);
|
||||
} catch {
|
||||
setError('Campaign not found or is no longer active.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLookup = async () => {
|
||||
if (!postalCode.trim()) {
|
||||
message.warning('Please enter your postal code');
|
||||
return;
|
||||
}
|
||||
setLookupLoading(true);
|
||||
try {
|
||||
const code = postalCode.replace(/\s/g, '').toUpperCase();
|
||||
const { data } = await axios.get<RepresentativeLookupResponse>(
|
||||
`${apiBase}/representatives/by-postal/${code}`
|
||||
);
|
||||
let reps = data.representatives;
|
||||
if (campaign?.targetGovernmentLevels && campaign.targetGovernmentLevels.length > 0) {
|
||||
const targetLevels = new Set(campaign.targetGovernmentLevels);
|
||||
reps = reps.filter((r) => {
|
||||
const level = mapRepSetToLevel(r.representativeSetName);
|
||||
return level && targetLevels.has(level);
|
||||
});
|
||||
}
|
||||
setRepresentatives(reps);
|
||||
setCurrentStep('reps');
|
||||
} catch {
|
||||
message.error('Could not look up representatives for this postal code');
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendSmtp = async (rep: Representative) => {
|
||||
if (!campaign) return;
|
||||
if (campaign.collectUserInfo && (!userName.trim() || !userEmail.trim())) {
|
||||
message.warning('Please enter your name and email in Step 1');
|
||||
setCurrentStep('info');
|
||||
return;
|
||||
}
|
||||
if (!rep.email) return;
|
||||
|
||||
setSendingTo(rep.email);
|
||||
try {
|
||||
await axios.post(`${apiBase}/campaigns/${slug}/send-email`, {
|
||||
userEmail: userEmail || 'anonymous@cmlite.org',
|
||||
userName: userName || 'Anonymous',
|
||||
postalCode: postalCode.replace(/\s/g, '').toUpperCase(),
|
||||
recipientEmail: rep.email,
|
||||
recipientName: rep.name,
|
||||
recipientTitle: rep.electedOffice,
|
||||
recipientLevel: mapRepSetToLevel(rep.representativeSetName),
|
||||
emailMethod: 'SMTP',
|
||||
customEmailSubject: campaign.allowEmailEditing ? editSubject : undefined,
|
||||
customEmailBody: campaign.allowEmailEditing ? editBody : undefined,
|
||||
});
|
||||
message.success(`Email sent to ${rep.name}`);
|
||||
setSentTo((prev) => new Set(prev).add(rep.email!));
|
||||
} catch {
|
||||
message.error('Failed to send email');
|
||||
} finally {
|
||||
setSendingTo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMailto = async (rep: Representative) => {
|
||||
if (!campaign || !rep.email) return;
|
||||
const subject = encodeURIComponent(campaign.allowEmailEditing ? editSubject : campaign.emailSubject);
|
||||
const body = encodeURIComponent(campaign.allowEmailEditing ? editBody : campaign.emailBody);
|
||||
window.open(`mailto:${rep.email}?subject=${subject}&body=${body}`, '_blank');
|
||||
try {
|
||||
await axios.post(`${apiBase}/campaigns/${slug}/track-mailto`, {
|
||||
recipientEmail: rep.email,
|
||||
recipientName: rep.name,
|
||||
recipientTitle: rep.electedOffice,
|
||||
recipientLevel: mapRepSetToLevel(rep.representativeSetName),
|
||||
userEmail: userEmail || undefined,
|
||||
userName: userName || undefined,
|
||||
postalCode: postalCode.replace(/\s/g, '').toUpperCase() || undefined,
|
||||
});
|
||||
} catch { /* tracking is non-critical */ }
|
||||
setSentTo((prev) => new Set(prev).add(rep.email!));
|
||||
};
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const url = `${window.location.origin}/campaign/${slug}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
setLinkCopied(true);
|
||||
setTimeout(() => setLinkCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
if (error || !campaign) {
|
||||
return <Result status="404" title="Campaign Not Found" subTitle={error} />;
|
||||
}
|
||||
|
||||
const stepDefs: { key: Step; label: string; shortLabel: string }[] = [
|
||||
{ key: 'info', label: 'Enter Your Info', shortLabel: 'Info' },
|
||||
{ key: 'reps', label: 'Find Representatives', shortLabel: 'Reps' },
|
||||
{ key: 'send', label: 'Send Messages', shortLabel: 'Send' },
|
||||
];
|
||||
|
||||
const stepIndex = stepDefs.findIndex((s) => s.key === currentStep);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero Section */}
|
||||
<div
|
||||
style={{
|
||||
background: campaign.coverPhoto
|
||||
? `linear-gradient(rgba(0,0,0,0.55), rgba(0,0,0,0.55)), url(${campaign.coverPhoto}) center/cover`
|
||||
: 'linear-gradient(135deg, #3498db, #2c3e50)',
|
||||
borderRadius: 12,
|
||||
padding: isMobile ? '32px 16px' : '48px 32px',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Title level={isMobile ? 3 : 2} style={{ color: '#fff', margin: 0, textShadow: '0 2px 4px rgba(0,0,0,0.3)' }}>
|
||||
{campaign.title}
|
||||
</Title>
|
||||
{campaign.description && (
|
||||
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', fontSize: isMobile ? 14 : 16, margin: '12px auto 0', maxWidth: 600 }}>
|
||||
{campaign.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{/* Stats Circles */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: isMobile ? 12 : 20, marginTop: 24, flexWrap: 'wrap' }}>
|
||||
{campaign.showEmailCount && (
|
||||
<div style={getStatCircleStyle(isMobile)}>
|
||||
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: '#fff', lineHeight: 1 }}>{campaign._count.emails}</span>
|
||||
<span style={{ fontSize: isMobile ? 9 : 10, color: 'rgba(255,255,255,0.7)', textTransform: 'uppercase', letterSpacing: 1 }}>Emails</span>
|
||||
</div>
|
||||
)}
|
||||
{campaign.showResponseWall && (
|
||||
<div style={getStatCircleStyle(isMobile)}>
|
||||
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: '#fff', lineHeight: 1 }}>{campaign._count.responses}</span>
|
||||
<span style={{ fontSize: isMobile ? 9 : 10, color: 'rgba(255,255,255,0.7)', textTransform: 'uppercase', letterSpacing: 1 }}>Responses</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share Buttons */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, marginTop: 20 }}>
|
||||
<Button
|
||||
icon={linkCopied ? <CheckCircleOutlined /> : <CopyOutlined />}
|
||||
onClick={handleCopyLink}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
border: '1px solid rgba(255,255,255,0.25)',
|
||||
color: '#fff',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
{linkCopied ? 'Copied!' : 'Copy Link'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Call to Action */}
|
||||
{campaign.callToAction && (
|
||||
<Card
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
background: '#1b2838',
|
||||
border: `1px solid ${token.colorPrimary}40`,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 16, color: 'rgba(255,255,255,0.85)' }}>{campaign.callToAction}</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
background: '#1b2838',
|
||||
borderRadius: 8,
|
||||
padding: isMobile ? '8px 8px' : '12px 16px',
|
||||
marginBottom: 24,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
>
|
||||
{stepDefs.map((step, i) => {
|
||||
const isActive = step.key === currentStep;
|
||||
const isCompleted = i < stepIndex;
|
||||
const isClickable = (step.key === 'info') ||
|
||||
(step.key === 'reps' && representatives.length > 0) ||
|
||||
(step.key === 'send' && representatives.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.key}
|
||||
role="button"
|
||||
tabIndex={isClickable ? 0 : -1}
|
||||
onClick={() => isClickable && setCurrentStep(step.key)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && isClickable) {
|
||||
e.preventDefault();
|
||||
setCurrentStep(step.key);
|
||||
}
|
||||
}}
|
||||
aria-label={`Step ${i + 1}: ${step.label}${isCompleted ? ' (completed)' : ''}${isActive ? ' (current)' : ''}`}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
padding: isMobile ? '6px 2px' : '8px 4px',
|
||||
cursor: isClickable ? 'pointer' : 'default',
|
||||
color: isActive ? token.colorPrimary : isCompleted ? token.colorSuccess : 'rgba(255,255,255,0.4)',
|
||||
fontWeight: isActive ? 700 : 400,
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
position: 'relative',
|
||||
transition: 'color 0.2s',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{isCompleted && <CheckCircleOutlined style={{ marginRight: isMobile ? 4 : 6 }} />}
|
||||
{isMobile ? step.shortLabel : step.label}
|
||||
{i < stepDefs.length - 1 && (
|
||||
<ArrowRightOutlined
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -4,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
fontSize: 11,
|
||||
color: 'rgba(255,255,255,0.2)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Your Information */}
|
||||
{currentStep === 'info' && (
|
||||
<Card
|
||||
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
|
||||
>
|
||||
<Title level={4} style={{ color: '#fff', marginBottom: 4 }}>Your Information</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 20 }}>
|
||||
We need some basic information to find your representatives and track campaign engagement.
|
||||
</Text>
|
||||
|
||||
<div style={{ maxWidth: 500 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||||
Your Postal Code *
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="e.g. T5K 2M5"
|
||||
value={postalCode}
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
onPressEnter={handleLookup}
|
||||
size="large"
|
||||
style={{ textTransform: 'uppercase' }}
|
||||
disabled={lookupLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{campaign.collectUserInfo && (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||||
Your Name
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Your full name"
|
||||
value={userName}
|
||||
onChange={(e) => setUserName(e.target.value)}
|
||||
size="large"
|
||||
disabled={lookupLoading}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
||||
Your Email (Optional - If you would like a reply)
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="your@email.com"
|
||||
value={userEmail}
|
||||
onChange={(e) => setUserEmail(e.target.value)}
|
||||
size="large"
|
||||
type="email"
|
||||
disabled={lookupLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleLookup}
|
||||
loading={lookupLoading}
|
||||
style={{ marginTop: 8, background: '#005a9c' }}
|
||||
>
|
||||
Find My Representatives
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 2: Representatives */}
|
||||
{currentStep === 'reps' && (
|
||||
<Card
|
||||
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
|
||||
>
|
||||
<Title level={4} style={{ color: '#fff', marginBottom: 16 }}>
|
||||
Your Representatives ({representatives.length})
|
||||
</Title>
|
||||
|
||||
{representatives.length === 0 ? (
|
||||
<Result
|
||||
status="info"
|
||||
title="No Representatives Found"
|
||||
subTitle="No representatives matched the target levels for this campaign."
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{representatives.map((rep, idx) => {
|
||||
const level = mapRepSetToLevel(rep.representativeSetName);
|
||||
const isSent = rep.email ? sentTo.has(rep.email) : false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rep.id || idx}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center',
|
||||
gap: isMobile ? 12 : 16,
|
||||
padding: '16px',
|
||||
background: '#0d1b2a',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Avatar src={rep.photoUrl} icon={!rep.photoUrl ? <UserOutlined /> : undefined} size={isMobile ? 48 : 56} />
|
||||
<div style={{ flex: 1, minWidth: 150 }}>
|
||||
<Text strong style={{ color: '#fff', fontSize: 15 }}>{rep.name || 'Unknown'}</Text>
|
||||
<br />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}>{rep.electedOffice}</Text>
|
||||
{rep.districtName && (
|
||||
<>
|
||||
<br />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.4)', fontSize: 12 }}>{rep.districtName}</Text>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{level && <Tag color={GOVERNMENT_LEVEL_COLORS[level]} style={{ fontSize: 11 }}>{GOVERNMENT_LEVEL_LABELS[level]}</Tag>}
|
||||
{rep.partyName && <Tag style={{ fontSize: 11 }}>{rep.partyName}</Tag>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', width: isMobile ? '100%' : 'auto' }}>
|
||||
{rep.email && !isSent && (
|
||||
<>
|
||||
{campaign.allowSmtpEmail && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SendOutlined />}
|
||||
loading={sendingTo === rep.email}
|
||||
onClick={() => handleSendSmtp(rep)}
|
||||
style={{ background: '#005a9c' }}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
{campaign.allowMailtoLink && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<MailOutlined />}
|
||||
onClick={() => handleMailto(rep)}
|
||||
>
|
||||
Email App
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isSent && (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />} style={{ margin: 0, padding: '4px 12px' }}>
|
||||
Sent
|
||||
</Tag>
|
||||
)}
|
||||
{!rep.email && (
|
||||
<Text style={{ color: 'rgba(255,255,255,0.3)', fontSize: 12 }}>No email available</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{representatives.length > 0 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="link" onClick={() => setCurrentStep('send')} style={{ padding: 0, color: token.colorPrimary }}>
|
||||
View / Edit Email Message <ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Step 3: Email Preview */}
|
||||
{currentStep === 'send' && campaign && (
|
||||
<Card
|
||||
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
|
||||
>
|
||||
<Title level={4} style={{ color: '#fff', marginBottom: 4 }}>
|
||||
<MailOutlined style={{ marginRight: 8 }} />Email Preview
|
||||
</Title>
|
||||
{campaign.allowEmailEditing && (
|
||||
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 16 }}>
|
||||
You can edit this message before sending to your representatives:
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, display: 'block', marginBottom: 4 }}>Subject</Text>
|
||||
{campaign.allowEmailEditing ? (
|
||||
<Input
|
||||
value={editSubject}
|
||||
onChange={(e) => setEditSubject(e.target.value)}
|
||||
size="large"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '10px 14px', background: '#0d1b2a', borderRadius: 6, border: '1px solid rgba(255,255,255,0.08)', color: '#fff', fontWeight: 600 }}>
|
||||
{campaign.emailSubject}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, display: 'block', marginBottom: 4 }}>Message</Text>
|
||||
{campaign.allowEmailEditing ? (
|
||||
<Input.TextArea
|
||||
value={editBody}
|
||||
onChange={(e) => setEditBody(e.target.value)}
|
||||
rows={10}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
padding: '14px',
|
||||
background: '#0d1b2a',
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
whiteSpace: 'pre-wrap',
|
||||
maxHeight: 400,
|
||||
overflow: 'auto',
|
||||
lineHeight: 1.7,
|
||||
}}>
|
||||
{campaign.emailBody}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="link" onClick={() => setCurrentStep('reps')} style={{ padding: 0, color: token.colorPrimary }}>
|
||||
Back to Representatives
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Response Wall CTA */}
|
||||
{campaign.showResponseWall && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: isMobile ? '32px 16px' : '40px 24px',
|
||||
background: '#1b2838',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
>
|
||||
<MessageOutlined style={{ fontSize: 28, color: '#667eea', marginBottom: 8 }} />
|
||||
<Title level={4} style={{ color: '#fff' }}>See What People Are Saying</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 20 }}>
|
||||
Check out responses to people who have taken action on this campaign
|
||||
</Text>
|
||||
<Link to={`/campaign/${slug}/responses`}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
border: 'none',
|
||||
borderRadius: 50,
|
||||
padding: '0 32px',
|
||||
height: 48,
|
||||
fontWeight: 600,
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
View Response Wall
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatCircleStyle(isMobile: boolean): React.CSSProperties {
|
||||
const size = isMobile ? 64 : 80;
|
||||
return {
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
border: '2px solid rgba(255,255,255,0.25)',
|
||||
borderRadius: '50%',
|
||||
width: size,
|
||||
height: size,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(10px)',
|
||||
};
|
||||
}
|
||||
|
||||
565
admin/src/pages/public/CampaignsListPage.tsx
Normal file
565
admin/src/pages/public/CampaignsListPage.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Spin,
|
||||
Empty,
|
||||
Statistic,
|
||||
Result,
|
||||
Button,
|
||||
Input,
|
||||
Avatar,
|
||||
Space,
|
||||
Divider,
|
||||
Grid,
|
||||
Tooltip,
|
||||
message,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
MailOutlined,
|
||||
MessageOutlined,
|
||||
StarFilled,
|
||||
SearchOutlined,
|
||||
UserOutlined,
|
||||
PhoneOutlined,
|
||||
GlobalOutlined,
|
||||
ArrowRightOutlined,
|
||||
CopyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import type { Campaign, GovernmentLevel, Representative, RepresentativeLookupResponse } from '@/types/api';
|
||||
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
|
||||
import { groupRepsByLevel, GOVERNMENT_LEVEL_DISPLAY_COLORS } from '@/utils/representatives';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
export default function CampaignsListPage() {
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const { token } = theme.useToken();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { settings: siteSettings } = useSettingsStore();
|
||||
|
||||
// Rep lookup state
|
||||
const [postalCode, setPostalCode] = useState('');
|
||||
const [lookupLoading, setLookupLoading] = useState(false);
|
||||
const [representatives, setRepresentatives] = useState<Representative[] | null>(null);
|
||||
const [lookupLocation, setLookupLocation] = useState<{ city: string | null; province: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns();
|
||||
}, []);
|
||||
|
||||
const fetchCampaigns = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const { data } = await axios.get<Campaign[]>('/api/campaigns/public');
|
||||
setCampaigns(data);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLookup = async () => {
|
||||
if (!postalCode.trim()) {
|
||||
message.warning('Please enter your postal code');
|
||||
return;
|
||||
}
|
||||
setLookupLoading(true);
|
||||
try {
|
||||
const code = postalCode.replace(/\s/g, '').toUpperCase();
|
||||
const { data } = await axios.get<RepresentativeLookupResponse>(
|
||||
`/api/representatives/by-postal/${code}`
|
||||
);
|
||||
setRepresentatives(data.representatives);
|
||||
setLookupLocation(data.location);
|
||||
} catch {
|
||||
message.error('Could not look up representatives for this postal code');
|
||||
} finally {
|
||||
setLookupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 80 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Result
|
||||
status="500"
|
||||
title="Failed to Load Campaigns"
|
||||
subTitle="There was an error loading campaigns. Please try again later."
|
||||
extra={
|
||||
<Button type="primary" onClick={fetchCampaigns}>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const featured = campaigns.filter(c => c.highlightCampaign);
|
||||
const nonFeatured = campaigns.filter(c => !c.highlightCampaign);
|
||||
|
||||
// Group representatives by government level
|
||||
const repsByLevel = representatives ? groupRepsByLevel(representatives) : null;
|
||||
const locationText = lookupLocation
|
||||
? [lookupLocation.city, lookupLocation.province].filter(Boolean).join(', ')
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero Banner */}
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: isMobile ? '36px 16px 32px' : '48px 24px 40px',
|
||||
marginBottom: 32,
|
||||
background: siteSettings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 50%, #0099ff 100%)',
|
||||
borderRadius: 12,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Title level={2} style={{ color: '#fff', margin: 0, fontSize: isMobile ? 26 : 32, textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
|
||||
{siteSettings?.organizationName ?? 'Changemaker Lite'}
|
||||
</Title>
|
||||
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', fontSize: isMobile ? 14 : 16, margin: '12px 0 0' }}>
|
||||
Connect with your elected representatives across all levels of government
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Find Your Representatives Section */}
|
||||
<div style={{ marginBottom: 32 }}>
|
||||
<Title level={3} style={{ color: '#fff', textAlign: 'center', marginBottom: 16 }}>
|
||||
Find Your Representatives
|
||||
</Title>
|
||||
<Card
|
||||
style={{
|
||||
background: 'rgba(27, 40, 56, 0.8)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 12,
|
||||
maxWidth: 600,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', display: 'block', textAlign: 'center', marginBottom: 12 }}>
|
||||
Enter your postal code:
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Input
|
||||
placeholder="e.g. T5K 2M5"
|
||||
value={postalCode}
|
||||
onChange={(e) => setPostalCode(e.target.value)}
|
||||
onPressEnter={handleLookup}
|
||||
size="large"
|
||||
style={{ textTransform: 'uppercase', flex: 1 }}
|
||||
disabled={lookupLoading}
|
||||
prefix={lookupLoading ? <Spin size="small" /> : undefined}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={handleLookup}
|
||||
loading={lookupLoading}
|
||||
style={{ background: '#005a9c' }}
|
||||
>
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Representative Results */}
|
||||
{repsByLevel && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
{locationText && (
|
||||
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', textAlign: 'center', marginBottom: 16 }}>
|
||||
Showing representatives for {locationText}
|
||||
</Text>
|
||||
)}
|
||||
{representatives!.length === 0 ? (
|
||||
<Result
|
||||
status="info"
|
||||
title="No Representatives Found"
|
||||
subTitle="No representatives were found for this postal code."
|
||||
/>
|
||||
) : (
|
||||
Object.entries(repsByLevel).map(([level, reps]) => (
|
||||
<div key={level} style={{ marginBottom: 24 }}>
|
||||
<Title level={4} style={{ color: GOVERNMENT_LEVEL_DISPLAY_COLORS[level] || '#94a3b8', marginBottom: 12 }}>
|
||||
{GOVERNMENT_LEVEL_LABELS[level as GovernmentLevel] || level} Representatives
|
||||
</Title>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
{reps.map((rep, idx) => (
|
||||
<Card
|
||||
key={rep.id || idx}
|
||||
style={{
|
||||
background: '#1b2838',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 10,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16, flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
<Avatar src={rep.photoUrl} icon={!rep.photoUrl ? <UserOutlined /> : undefined} size={isMobile ? 64 : 80} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text strong style={{ color: token.colorPrimary, fontSize: 17 }}>
|
||||
{rep.name || 'Unknown'}
|
||||
</Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{rep.electedOffice && (
|
||||
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Office:</Text> {rep.electedOffice}</Text></div>
|
||||
)}
|
||||
{rep.districtName && (
|
||||
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>District:</Text> {rep.districtName}</Text></div>
|
||||
)}
|
||||
{rep.partyName && (
|
||||
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Party:</Text> {rep.partyName}</Text></div>
|
||||
)}
|
||||
{rep.email && (
|
||||
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Email:</Text> {rep.email}</Text></div>
|
||||
)}
|
||||
{rep.offices?.map((office, oi) => (
|
||||
office.tel && (
|
||||
<div key={oi}><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Phone:</Text> {office.tel}{office.type ? ` (${office.type})` : ''}</Text></div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 12 }}>
|
||||
{rep.email && (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<MailOutlined />}
|
||||
onClick={() => window.open(`mailto:${rep.email}`, '_blank')}
|
||||
style={{ background: '#005a9c' }}
|
||||
>
|
||||
Send Email
|
||||
</Button>
|
||||
)}
|
||||
{rep.offices?.some(o => o.tel) && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PhoneOutlined />}
|
||||
style={{ background: '#16a34a', border: 'none', color: '#fff' }}
|
||||
onClick={() => {
|
||||
const phone = rep.offices?.find(o => o.tel)?.tel;
|
||||
if (phone) window.open(`tel:${phone.replace(/\s/g, '')}`, '_blank');
|
||||
}}
|
||||
>
|
||||
Call
|
||||
</Button>
|
||||
)}
|
||||
{rep.url && (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<GlobalOutlined />}
|
||||
onClick={() => window.open(rep.url!, '_blank')}
|
||||
>
|
||||
View Profile
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rep.offices && rep.offices.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
|
||||
{rep.offices.map((office, oi) => (
|
||||
office.postal && (
|
||||
<Tag key={oi} color="processing" style={{ fontSize: 11 }}>
|
||||
{office.type || 'Office'}: {office.postal}
|
||||
</Tag>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
{/* Featured Campaign */}
|
||||
{featured.map((campaign) => (
|
||||
<div key={campaign.id} style={{ marginBottom: 24 }}>
|
||||
<Link to={`/campaign/${campaign.slug}`} style={{ display: 'block' }}>
|
||||
<Card
|
||||
hoverable
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #1b2838 0%, #1a3a5c 100%)',
|
||||
border: '1px solid rgba(255,215,0,0.3)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<StarFilled style={{ color: '#ffd700', fontSize: 16 }} />
|
||||
<Tag color="gold" style={{ margin: 0, fontWeight: 600, fontSize: 11 }}>FEATURED</Tag>
|
||||
</div>
|
||||
<Title level={3} style={{ color: '#fff', margin: '4px 0 8px' }}>{campaign.title}</Title>
|
||||
{campaign.description && (
|
||||
<Paragraph style={{ color: 'rgba(255,255,255,0.7)', margin: '0 0 12px' }} ellipsis={{ rows: 2 }}>
|
||||
{campaign.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{campaign.showEmailCount && (
|
||||
<Statistic
|
||||
value={campaign._count.emails}
|
||||
suffix="emails sent"
|
||||
valueStyle={{ color: token.colorPrimary, fontSize: 18, fontWeight: 700 }}
|
||||
/>
|
||||
)}
|
||||
{campaign.showResponseWall && campaign._count.responses > 0 && (
|
||||
<Statistic
|
||||
value={campaign._count.responses}
|
||||
suffix="responses"
|
||||
valueStyle={{ color: token.colorSuccess, fontSize: 18, fontWeight: 700 }}
|
||||
/>
|
||||
)}
|
||||
{campaign.targetGovernmentLevels.map((l: GovernmentLevel) => (
|
||||
<Tag key={l} color={GOVERNMENT_LEVEL_COLORS[l]}>{GOVERNMENT_LEVEL_LABELS[l]}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
<ShareButtons campaign={campaign} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Campaigns Grid */}
|
||||
<Title level={3} style={{ color: 'rgba(255,255,255,0.85)', textAlign: 'center', marginBottom: 8 }}>
|
||||
Active Campaigns
|
||||
</Title>
|
||||
<Paragraph style={{ color: 'rgba(255,255,255,0.55)', textAlign: 'center', marginBottom: 24 }}>
|
||||
Join ongoing campaigns to make your voice heard on important issues
|
||||
</Paragraph>
|
||||
|
||||
{campaigns.length === 0 ? (
|
||||
<Empty
|
||||
description={<Text style={{ color: 'rgba(255,255,255,0.45)' }}>No active campaigns</Text>}
|
||||
style={{ padding: 60 }}
|
||||
/>
|
||||
) : nonFeatured.length === 0 ? (
|
||||
<Empty
|
||||
description={<Text style={{ color: 'rgba(255,255,255,0.45)' }}>Check out the featured campaign above</Text>}
|
||||
style={{ padding: 60 }}
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={[20, 20]}>
|
||||
{nonFeatured.map((campaign) => (
|
||||
<Col xs={24} sm={12} lg={8} key={campaign.id}>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<Card
|
||||
style={{
|
||||
flex: 1,
|
||||
background: '#1b2838',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 10,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' } }}
|
||||
>
|
||||
<Link to={`/campaign/${campaign.slug}`} style={{ display: 'block', flex: 1, padding: 20 }}>
|
||||
{campaign.coverPhoto ? (
|
||||
<div
|
||||
style={{
|
||||
height: 140,
|
||||
margin: '-20px -20px 16px -20px',
|
||||
backgroundImage: `url(${campaign.coverPhoto})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ color: '#fff', margin: 0, textShadow: '0 2px 8px rgba(0,0,0,0.6)', textAlign: 'center', padding: '0 16px' }} ellipsis={{ rows: 2 }}>
|
||||
{campaign.title}
|
||||
</Title>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: 140,
|
||||
margin: '-20px -20px 16px -20px',
|
||||
background: 'linear-gradient(135deg, #1a3a5c 0%, #1b2838 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Title level={4} style={{ color: '#fff', margin: 0, textAlign: 'center', padding: '0 16px' }} ellipsis={{ rows: 2 }}>
|
||||
{campaign.title}
|
||||
</Title>
|
||||
</div>
|
||||
)}
|
||||
{campaign.description && (
|
||||
<Paragraph
|
||||
style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13, margin: '0 0 12px' }}
|
||||
ellipsis={{ rows: 2 }}
|
||||
>
|
||||
{campaign.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{campaign.targetGovernmentLevels.map((l: GovernmentLevel) => (
|
||||
<Tag key={l} color={GOVERNMENT_LEVEL_COLORS[l]} style={{ fontSize: 11 }}>
|
||||
{GOVERNMENT_LEVEL_LABELS[l]}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{campaign.showEmailCount && (
|
||||
<span style={{ color: token.colorPrimary, fontSize: 13 }}>
|
||||
<MailOutlined style={{ marginRight: 4 }} />
|
||||
{campaign._count.emails} emails sent
|
||||
</span>
|
||||
)}
|
||||
{campaign.showResponseWall && (
|
||||
<span style={{ color: token.colorSuccess, fontSize: 13 }}>
|
||||
<MessageOutlined style={{ marginRight: 4 }} />
|
||||
{campaign._count.responses} responses
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', padding: '8px 20px' }}>
|
||||
<ShareButtons campaign={campaign} compact />
|
||||
<Link to={`/campaign/${campaign.slug}`} style={{ display: 'block', marginTop: 4 }}>
|
||||
<Button type="link" style={{ padding: 0, color: token.colorPrimary, fontSize: 13 }}>
|
||||
Learn More & Participate <ArrowRightOutlined />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Share Buttons Component ---
|
||||
|
||||
function ShareButtons({ campaign, compact }: { campaign: Campaign; compact?: boolean }) {
|
||||
const url = `${window.location.origin}/campaign/${campaign.slug}`;
|
||||
const text = `${campaign.title} - Take action now!`;
|
||||
|
||||
const shareLinks = [
|
||||
{
|
||||
label: 'Share on X',
|
||||
shortLabel: 'X',
|
||||
icon: <span style={{ fontWeight: 700, fontSize: compact ? 12 : 14 }}>𝕏</span>,
|
||||
href: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`,
|
||||
},
|
||||
{
|
||||
label: 'Share on Facebook',
|
||||
shortLabel: 'Facebook',
|
||||
icon: <span style={{ fontWeight: 700, fontSize: compact ? 12 : 14 }}>f</span>,
|
||||
href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
|
||||
},
|
||||
{
|
||||
label: 'Share on LinkedIn',
|
||||
shortLabel: 'LinkedIn',
|
||||
icon: <span style={{ fontWeight: 700, fontSize: compact ? 11 : 13 }}>in</span>,
|
||||
href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
|
||||
},
|
||||
{
|
||||
label: 'Share on Reddit',
|
||||
shortLabel: 'Reddit',
|
||||
icon: <span style={{ fontWeight: 700, fontSize: compact ? 12 : 14 }}>r/</span>,
|
||||
href: `https://reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(campaign.title)}`,
|
||||
},
|
||||
{
|
||||
label: 'Share via Email',
|
||||
shortLabel: 'Email',
|
||||
icon: <MailOutlined style={{ fontSize: compact ? 12 : 14 }} />,
|
||||
href: `mailto:?subject=${encodeURIComponent(campaign.title)}&body=${encodeURIComponent(`Check out this campaign: ${url}`)}`,
|
||||
},
|
||||
];
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(url);
|
||||
message.success('Link copied!');
|
||||
};
|
||||
|
||||
const size = compact ? 30 : 34;
|
||||
const btnStyle: React.CSSProperties = {
|
||||
width: size,
|
||||
height: size,
|
||||
minWidth: size,
|
||||
padding: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
background: 'transparent',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: compact ? 6 : 8, marginTop: compact ? 0 : 8, flexWrap: 'wrap' }}>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: compact ? 12 : 13 }}>Share:</Text>
|
||||
{shareLinks.map((link) => (
|
||||
<Tooltip key={link.shortLabel} title={link.shortLabel}>
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={btnStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label={link.label}
|
||||
>
|
||||
{link.icon}
|
||||
</a>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Tooltip title="Copy Link">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); handleCopy(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleCopy();
|
||||
}
|
||||
}}
|
||||
style={btnStyle}
|
||||
aria-label="Copy link to campaign"
|
||||
>
|
||||
<CopyOutlined style={{ fontSize: compact ? 12 : 14 }} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
admin/src/pages/public/LandingPage.tsx
Normal file
67
admin/src/pages/public/LandingPage.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Spin, Result } from 'antd';
|
||||
import axios from 'axios';
|
||||
import type { LandingPage as LandingPageType } from '@/types/api';
|
||||
|
||||
export default function PublicLandingPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const [page, setPage] = useState<LandingPageType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPage = async () => {
|
||||
try {
|
||||
const { data } = await axios.get<LandingPageType>(`/api/pages/${slug}/view`);
|
||||
setPage(data);
|
||||
|
||||
// Set SEO meta
|
||||
document.title = data.seoTitle || data.title;
|
||||
const metaDesc = document.querySelector('meta[name="description"]');
|
||||
if (metaDesc && data.seoDescription) {
|
||||
metaDesc.setAttribute('content', data.seoDescription);
|
||||
}
|
||||
const metaOgImage = document.querySelector('meta[property="og:image"]');
|
||||
if (metaOgImage && data.seoImage) {
|
||||
metaOgImage.setAttribute('content', data.seoImage);
|
||||
}
|
||||
} catch {
|
||||
setNotFound(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPage();
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#0d1b2a' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (notFound || !page) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#0d1b2a' }}>
|
||||
<Result
|
||||
status="404"
|
||||
title="Page Not Found"
|
||||
subTitle="The page you're looking for doesn't exist or hasn't been published yet."
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).
|
||||
// Only authenticated admins can create/edit pages, so XSS risk is accepted.
|
||||
return (
|
||||
<>
|
||||
{page.cssOutput && <style dangerouslySetInnerHTML={{ __html: page.cssOutput }} />}
|
||||
<div dangerouslySetInnerHTML={{ __html: page.htmlOutput || '' }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
411
admin/src/pages/public/MapPage.tsx
Normal file
411
admin/src/pages/public/MapPage.tsx
Normal file
@ -0,0 +1,411 @@
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { ConfigProvider, Spin, Typography, theme, Button, Tooltip, message } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeftOutlined, AimOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import axios from 'axios';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import type { MapSettings, Location, SupportLevel, PublicCut } from '@/types/api';
|
||||
import { SUPPORT_LEVEL_LABELS } from '@/types/api';
|
||||
import { groupLocations, getMarkerColor } from '@/components/map/mapUtils';
|
||||
import MapLegend from '@/components/map/MapLegend';
|
||||
import CutOverlays from '@/components/map/CutOverlays';
|
||||
import CutOverlayControls from '@/components/map/CutOverlayControls';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
|
||||
type BoundsQuery = {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
};
|
||||
|
||||
const HEADER_HEIGHT = 48;
|
||||
|
||||
function FlyToPosition({ position }: { position: [number, number] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.flyTo(position, 17, { duration: 1.5 });
|
||||
}, [map, position]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function FullscreenInvalidator() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
document.addEventListener('webkitfullscreenchange', handler);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handler);
|
||||
document.removeEventListener('webkitfullscreenchange', handler);
|
||||
};
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function MapEventsHandler({ onMove }: { onMove: (map: LeafletMap) => void }) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => {
|
||||
// Only trigger if not animating to prevent Leaflet state corruption
|
||||
if (!map._animatingZoom && !map._moving) {
|
||||
onMove(map);
|
||||
}
|
||||
},
|
||||
zoomend: () => {
|
||||
// Wait a tick for Leaflet to finish internal zoom state updates
|
||||
setTimeout(() => {
|
||||
if (!map._animatingZoom && !map._moving) {
|
||||
onMove(map);
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function MapPage() {
|
||||
const [settings, setSettings] = useState<MapSettings | null>(null);
|
||||
const [locations, setLocations] = useState<Location[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingLocations, setLoadingLocations] = useState(false);
|
||||
const [cuts, setCuts] = useState<PublicCut[]>([]);
|
||||
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
|
||||
const [userPosition, setUserPosition] = useState<[number, number] | null>(null);
|
||||
const [flyTo, setFlyTo] = useState<[number, number] | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [bounds, setBounds] = useState<BoundsQuery | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const { settings: siteSettings } = useSettingsStore();
|
||||
|
||||
const fetchLocations = useCallback(async (b: BoundsQuery) => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
setLoadingLocations(true);
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
minLat: b.minLat.toString(),
|
||||
maxLat: b.maxLat.toString(),
|
||||
minLng: b.minLng.toString(),
|
||||
maxLng: b.maxLng.toString(),
|
||||
});
|
||||
const res = await axios.get<Location[]>(`/api/map/locations/public?${params}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// Check if we hit the safety limit
|
||||
const limitHit = res.headers['x-location-limit-hit'] === 'true';
|
||||
if (limitHit) {
|
||||
message.warning('Too many locations in view. Zoom in for more detail.', 3);
|
||||
}
|
||||
|
||||
setLocations(res.data);
|
||||
setBounds(b);
|
||||
} catch (err) {
|
||||
if (!axios.isCancel(err)) {
|
||||
message.error('Failed to load locations');
|
||||
}
|
||||
} finally {
|
||||
setLoadingLocations(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMapMove = useCallback((map: LeafletMap) => {
|
||||
const b = map.getBounds();
|
||||
const newBounds = {
|
||||
minLat: b.getSouth(),
|
||||
maxLat: b.getNorth(),
|
||||
minLng: b.getWest(),
|
||||
maxLng: b.getEast(),
|
||||
};
|
||||
|
||||
clearTimeout(fetchTimerRef.current);
|
||||
fetchTimerRef.current = setTimeout(() => {
|
||||
fetchLocations(newBounds);
|
||||
}, 800); // Increased debounce to 800ms to allow zoom animations to complete
|
||||
}, [fetchLocations]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [settingsRes, cutsRes] = await Promise.all([
|
||||
axios.get<MapSettings>('/api/map/settings'),
|
||||
axios.get<PublicCut[]>('/api/map/cuts/public'),
|
||||
]);
|
||||
setSettings(settingsRes.data);
|
||||
setCuts(cutsRes.data);
|
||||
setVisibleCutIds(new Set(cutsRes.data.map((c) => c.id)));
|
||||
} catch {
|
||||
// Still render map with defaults
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Clear user position after 30s
|
||||
useEffect(() => {
|
||||
if (userPosition) {
|
||||
const timer = setTimeout(() => setUserPosition(null), 30000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [userPosition]);
|
||||
|
||||
// Track fullscreen state
|
||||
useEffect(() => {
|
||||
const handler = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
document.addEventListener('webkitfullscreenchange', handler);
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handler);
|
||||
document.removeEventListener('webkitfullscreenchange', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const groups = useMemo(() => groupLocations(locations), [locations]);
|
||||
|
||||
const center: [number, number] = settings?.latitude && settings?.longitude
|
||||
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
|
||||
: [45.4215, -75.6972];
|
||||
const zoom = settings?.zoom ?? 12;
|
||||
|
||||
const handleGeolocate = useCallback(() => {
|
||||
if (!navigator.geolocation) {
|
||||
message.error('Geolocation not supported');
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
const p: [number, number] = [pos.coords.latitude, pos.coords.longitude];
|
||||
setUserPosition(p);
|
||||
setFlyTo(p);
|
||||
setTimeout(() => setFlyTo(null), 2000);
|
||||
},
|
||||
() => message.error('Could not get your location'),
|
||||
{ enableHighAccuracy: true, timeout: 10000 }
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleFullscreen = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
const requestFs = el.requestFullscreen || (el as any).webkitRequestFullscreen;
|
||||
if (requestFs) requestFs.call(el);
|
||||
} else {
|
||||
const exitFs = document.exitFullscreen || (document as any).webkitExitFullscreen;
|
||||
if (exitFs) exitFs.call(document);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCut = useCallback((id: string) => {
|
||||
setVisibleCutIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: siteSettings?.publicColorPrimary ?? '#3498db',
|
||||
colorBgBase: siteSettings?.publicColorBgBase ?? '#0d1b2a',
|
||||
colorBgContainer: siteSettings?.publicColorBgContainer ?? '#1b2838',
|
||||
colorBgElevated: siteSettings?.publicColorBgContainer ?? '#1b2838',
|
||||
colorBorder: 'rgba(255,255,255,0.1)',
|
||||
colorBorderSecondary: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 8,
|
||||
colorLink: siteSettings?.publicColorPrimary ?? '#3498db',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div ref={containerRef} style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: siteSettings?.publicColorBgBase ?? '#0d1b2a' }}>
|
||||
{/* Thin header */}
|
||||
<div
|
||||
style={{
|
||||
height: HEADER_HEIGHT,
|
||||
background: siteSettings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ArrowLeftOutlined style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }} />
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13 }}>
|
||||
Back to Campaigns
|
||||
</Typography.Text>
|
||||
</Link>
|
||||
<Typography.Text strong style={{ fontSize: 16, color: '#fff' }}>
|
||||
{siteSettings?.organizationName ?? 'Changemaker Lite'}
|
||||
</Typography.Text>
|
||||
<div style={{ width: 140 }} /> {/* Spacer for centering */}
|
||||
</div>
|
||||
|
||||
{/* Full-viewport map */}
|
||||
{loading ? (
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
{/* Floating controls */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 10,
|
||||
right: 10,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="My Location" placement="left">
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={<AimOutlined />}
|
||||
onClick={handleGeolocate}
|
||||
style={{ background: 'rgba(27, 40, 56, 0.9)', borderColor: 'rgba(255,255,255,0.2)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} placement="left">
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={handleFullscreen}
|
||||
style={{ background: 'rgba(27, 40, 56, 0.9)', borderColor: 'rgba(255,255,255,0.2)' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{loadingLocations && (
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 1000 }}>
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
scrollWheelZoom={true}
|
||||
>
|
||||
<FullscreenInvalidator />
|
||||
<MapEventsHandler onMove={handleMapMove} />
|
||||
{flyTo && <FlyToPosition position={flyTo} />}
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Cut overlays (render beneath markers) */}
|
||||
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
|
||||
|
||||
{/* User position */}
|
||||
{userPosition && (
|
||||
<CircleMarker
|
||||
center={userPosition}
|
||||
radius={8}
|
||||
pathOptions={{
|
||||
fillColor: '#2196F3',
|
||||
fillOpacity: 0.9,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
opacity: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{groups.map((group, idx) => {
|
||||
const color = getMarkerColor(group.dominantLevel);
|
||||
const radius = group.isMultiUnit ? 10 : 7;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={idx}
|
||||
center={[group.latitude, group.longitude]}
|
||||
radius={radius}
|
||||
pathOptions={{
|
||||
fillColor: color,
|
||||
fillOpacity: 0.8,
|
||||
color: '#fff',
|
||||
weight: group.isMultiUnit ? 2 : 1,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div style={{ minWidth: 140 }}>
|
||||
{group.locations.map((loc, i) => (
|
||||
<div key={loc.id} style={{ marginBottom: i < group.locations.length - 1 ? 8 : 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>
|
||||
{loc.address || 'Unknown address'}
|
||||
</div>
|
||||
{loc.unitNumber && (
|
||||
<div style={{ fontSize: 12, color: '#666' }}>Unit {loc.unitNumber}</div>
|
||||
)}
|
||||
{loc.supportLevel && (
|
||||
<div style={{ fontSize: 12 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getMarkerColor(loc.supportLevel as SupportLevel),
|
||||
marginRight: 4,
|
||||
}}
|
||||
/>
|
||||
{SUPPORT_LEVEL_LABELS[loc.supportLevel as SupportLevel]}
|
||||
</div>
|
||||
)}
|
||||
{loc.sign && (
|
||||
<div style={{ fontSize: 11, color: '#888' }}>
|
||||
Sign{loc.signSize ? ` (${loc.signSize})` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
<MapLegend variant="public" />
|
||||
|
||||
{/* Cut overlay controls */}
|
||||
{cuts.length > 0 && (
|
||||
<CutOverlayControls
|
||||
cuts={cuts}
|
||||
visibleCutIds={visibleCutIds}
|
||||
onToggleCut={toggleCut}
|
||||
variant="public"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
491
admin/src/pages/public/ResponseWallPage.tsx
Normal file
491
admin/src/pages/public/ResponseWallPage.tsx
Normal file
@ -0,0 +1,491 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Select,
|
||||
Statistic,
|
||||
Spin,
|
||||
Result,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Checkbox,
|
||||
message,
|
||||
Pagination,
|
||||
Divider,
|
||||
Grid,
|
||||
Tooltip,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
LikeOutlined,
|
||||
LikeFilled,
|
||||
SafetyCertificateOutlined,
|
||||
ArrowLeftOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import type {
|
||||
Campaign,
|
||||
RepresentativeResponse,
|
||||
ResponseStats,
|
||||
GovernmentLevel,
|
||||
} from '@/types/api';
|
||||
import {
|
||||
GOVERNMENT_LEVEL_COLORS,
|
||||
GOVERNMENT_LEVEL_LABELS,
|
||||
RESPONSE_TYPE_LABELS,
|
||||
} from '@/types/api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const apiBase = '/api';
|
||||
|
||||
export default function ResponseWallPage() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||
const [responses, setResponses] = useState<RepresentativeResponse[]>([]);
|
||||
const [stats, setStats] = useState<ResponseStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [sort, setSort] = useState<'recent' | 'upvotes' | 'verified'>('recent');
|
||||
const [levelFilter, setLevelFilter] = useState<GovernmentLevel | undefined>();
|
||||
const [submitModalOpen, setSubmitModalOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [upvotedIds, setUpvotedIds] = useState<Set<string>>(new Set());
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaign();
|
||||
fetchStats();
|
||||
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchCampaign = async () => {
|
||||
try {
|
||||
const { data } = await axios.get<Campaign>(`${apiBase}/campaigns/${slug}/details`);
|
||||
setCampaign(data);
|
||||
} catch {
|
||||
setError('Campaign not found or is no longer active.');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const { data } = await axios.get<ResponseStats>(`${apiBase}/campaigns/${slug}/response-stats`);
|
||||
setStats(data);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
};
|
||||
|
||||
const fetchResponses = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string | number> = { page, limit: 20, sort };
|
||||
if (levelFilter) params.level = levelFilter;
|
||||
|
||||
const { data } = await axios.get(`${apiBase}/campaigns/${slug}/responses`, { params });
|
||||
setResponses(data.responses);
|
||||
setTotal(data.pagination.total);
|
||||
} catch {
|
||||
message.error('Failed to load responses');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [slug, page, sort, levelFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slug) fetchResponses();
|
||||
}, [fetchResponses, slug]);
|
||||
|
||||
const handleUpvote = async (responseId: string) => {
|
||||
if (upvotedIds.has(responseId)) {
|
||||
// Remove upvote
|
||||
try {
|
||||
await axios.delete(`${apiBase}/responses/${responseId}/upvote`);
|
||||
setUpvotedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(responseId);
|
||||
return next;
|
||||
});
|
||||
setResponses((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === responseId ? { ...r, upvoteCount: Math.max(0, r.upvoteCount - 1) } : r
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const { data } = await axios.post(`${apiBase}/responses/${responseId}/upvote`);
|
||||
if (data.alreadyUpvoted) {
|
||||
message.info('You have already upvoted this response');
|
||||
setUpvotedIds((prev) => new Set(prev).add(responseId));
|
||||
} else {
|
||||
setUpvotedIds((prev) => new Set(prev).add(responseId));
|
||||
setResponses((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === responseId ? { ...r, upvoteCount: r.upvoteCount + 1 } : r
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await axios.post(`${apiBase}/campaigns/${slug}/responses`, {
|
||||
representativeName: values.representativeName,
|
||||
representativeLevel: values.representativeLevel,
|
||||
responseType: values.responseType,
|
||||
responseText: values.responseText,
|
||||
representativeTitle: values.representativeTitle || undefined,
|
||||
representativeEmail: values.representativeEmail || undefined,
|
||||
userComment: values.userComment || undefined,
|
||||
submittedByName: values.submittedByName || undefined,
|
||||
submittedByEmail: values.submittedByEmail || undefined,
|
||||
isAnonymous: values.isAnonymous || false,
|
||||
sendVerification: values.sendVerification || false,
|
||||
});
|
||||
message.success('Response submitted! It will appear after moderation.');
|
||||
setSubmitModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchResponses();
|
||||
fetchStats();
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.error?.message || 'Failed to submit response';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <Result status="404" title="Campaign Not Found" subTitle={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back link + Header */}
|
||||
<Link to={`/campaign/${slug}`}>
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 16 }}>
|
||||
Back to Campaign
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
{campaign && (
|
||||
<Link to={`/campaign/${slug}`} style={{ textDecoration: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3498db, #2c3e50)',
|
||||
borderRadius: 12,
|
||||
padding: isMobile ? '24px 16px' : '32px 24px',
|
||||
textAlign: 'center',
|
||||
marginBottom: 24,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Title level={3} style={{ color: '#fff', margin: 0, textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
|
||||
{campaign.title}
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 15 }}>Community Response Wall</Text>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Stats Banner */}
|
||||
{stats && (
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Responses" value={stats.total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Verified"
|
||||
value={stats.verified}
|
||||
prefix={<SafetyCertificateOutlined style={{ color: token.colorSuccess }} />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Upvotes" value={stats.totalUpvotes} prefix={<LikeOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<Row gutter={[12, 12]} align="middle" style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={(v) => { setSort(v); setPage(1); }}
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'recent', label: 'Most Recent' },
|
||||
{ value: 'upvotes', label: 'Most Upvotes' },
|
||||
{ value: 'verified', label: 'Verified First' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Select
|
||||
placeholder="Filter by level"
|
||||
allowClear
|
||||
value={levelFilter}
|
||||
onChange={(v) => { setLevelFilter(v); setPage(1); }}
|
||||
style={{ width: '100%' }}
|
||||
options={Object.entries(GOVERNMENT_LEVEL_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} style={{ textAlign: 'right' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setSubmitModalOpen(true)}
|
||||
>
|
||||
Share a Response
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Response Cards */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : responses.length === 0 ? (
|
||||
<Result
|
||||
status="info"
|
||||
title="No Responses Yet"
|
||||
subTitle="Be the first to share a representative's response!"
|
||||
extra={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setSubmitModalOpen(true)}>
|
||||
Share a Response
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
{responses.map((response) => (
|
||||
<Card key={response.id} size="small">
|
||||
<Row justify="space-between" align="top">
|
||||
<Col flex="1">
|
||||
<Space wrap>
|
||||
<Text strong style={{ fontSize: 15 }}>
|
||||
{response.representativeName}
|
||||
</Text>
|
||||
{response.representativeTitle && (
|
||||
<Text type="secondary">
|
||||
{response.representativeTitle}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<br />
|
||||
<Space size={4} style={{ marginTop: 4 }}>
|
||||
<Tag color={GOVERNMENT_LEVEL_COLORS[response.representativeLevel]}>
|
||||
{GOVERNMENT_LEVEL_LABELS[response.representativeLevel]}
|
||||
</Tag>
|
||||
<Tag>{RESPONSE_TYPE_LABELS[response.responseType]}</Tag>
|
||||
{response.isVerified && (
|
||||
<Tooltip title="This response was verified by emailing the representative">
|
||||
<Tag icon={<SafetyCertificateOutlined />} color="success">
|
||||
Verified
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
type={upvotedIds.has(response.id) ? 'primary' : 'default'}
|
||||
size="small"
|
||||
icon={upvotedIds.has(response.id) ? <LikeFilled /> : <LikeOutlined />}
|
||||
onClick={() => handleUpvote(response.id)}
|
||||
aria-label={upvotedIds.has(response.id) ? `Remove upvote (${response.upvoteCount} total)` : `Upvote response (${response.upvoteCount} total)`}
|
||||
>
|
||||
{response.upvoteCount}
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Paragraph style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{response.responseText}
|
||||
</Paragraph>
|
||||
|
||||
{response.userComment && (
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: 6,
|
||||
borderLeft: `3px solid ${token.colorPrimary}`,
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>Comment: </Text>
|
||||
<Text style={{ fontSize: 13 }}>{response.userComment}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{response.isAnonymous ? 'Anonymous' : response.submittedByName || 'Anonymous'}
|
||||
{' '}·{' '}
|
||||
{dayjs(response.createdAt).format('MMM D, YYYY')}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||
<Pagination
|
||||
current={page}
|
||||
total={total}
|
||||
pageSize={20}
|
||||
onChange={(p) => setPage(p)}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Submit Response Modal */}
|
||||
<Modal
|
||||
title="Share a Representative's Response"
|
||||
open={submitModalOpen}
|
||||
onCancel={() => setSubmitModalOpen(false)}
|
||||
footer={null}
|
||||
width={isMobile ? '100%' : 560}
|
||||
style={{ maxWidth: '95vw' }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="representativeName"
|
||||
label="Representative Name"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<Input placeholder="e.g. John Smith" autoFocus />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name="representativeLevel"
|
||||
label="Government Level"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Select level"
|
||||
options={Object.entries(GOVERNMENT_LEVEL_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name="responseType"
|
||||
label="Response Type"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="How did they respond?"
|
||||
options={Object.entries(RESPONSE_TYPE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="representativeTitle" label="Representative Title">
|
||||
<Input placeholder="e.g. MP, MPP, City Councillor" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="responseText"
|
||||
label="Response Text"
|
||||
rules={[{ required: true, message: 'Required' }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
placeholder="What did the representative say or write?"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="userComment" label="Your Comment (optional)">
|
||||
<Input.TextArea rows={2} placeholder="Any additional context?" />
|
||||
</Form.Item>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="submittedByName" label="Your Name">
|
||||
<Input placeholder="Optional" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item name="submittedByEmail" label="Your Email">
|
||||
<Input placeholder="Optional" type="email" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="representativeEmail" label="Representative's Email">
|
||||
<Input placeholder="For verification (optional)" type="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Space>
|
||||
<Form.Item name="isAnonymous" valuePropName="checked" noStyle>
|
||||
<Checkbox>Submit anonymously</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="sendVerification" valuePropName="checked" noStyle>
|
||||
<Checkbox>Send verification to representative</Checkbox>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
|
||||
<div style={{ marginTop: 24, textAlign: 'right' }}>
|
||||
<Space>
|
||||
<Button onClick={() => setSubmitModalOpen(false)}>Cancel</Button>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
Submit Response
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
343
admin/src/pages/public/ShiftsPage.tsx
Normal file
343
admin/src/pages/public/ShiftsPage.tsx
Normal file
@ -0,0 +1,343 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
Progress,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Spin,
|
||||
Result,
|
||||
Grid,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
EnvironmentOutlined,
|
||||
TeamOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const apiBase = '/api';
|
||||
|
||||
interface PublicShift {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
maxVolunteers: number;
|
||||
currentVolunteers: number;
|
||||
status: 'OPEN' | 'FULL' | 'CANCELLED';
|
||||
}
|
||||
|
||||
export default function PublicShiftsPage() {
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const [shifts, setShifts] = useState<PublicShift[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [signupModalOpen, setSignupModalOpen] = useState(false);
|
||||
const [selectedShift, setSelectedShift] = useState<PublicShift | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [successShift, setSuccessShift] = useState<PublicShift | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
fetchShifts();
|
||||
}, []);
|
||||
|
||||
const fetchShifts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await axios.get<PublicShift[]>(`${apiBase}/map/shifts/public`);
|
||||
setShifts(data);
|
||||
} catch {
|
||||
message.error('Failed to load volunteer opportunities');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = async (values: { name: string; email: string; phone?: string }) => {
|
||||
if (!selectedShift) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await axios.post(`${apiBase}/map/shifts/public/${selectedShift.id}/signup`, {
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
phone: values.phone || undefined,
|
||||
});
|
||||
setSuccessShift(selectedShift);
|
||||
setSignupModalOpen(false);
|
||||
form.resetFields();
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.error?.message || 'Failed to sign up';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openSignup = (shift: PublicShift) => {
|
||||
setSelectedShift(shift);
|
||||
form.resetFields();
|
||||
setSignupModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Hero */}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #3498db, #2c3e50)',
|
||||
borderRadius: 12,
|
||||
padding: isMobile ? '32px 16px' : '48px 24px',
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<Title level={2} style={{ color: '#fff', margin: 0, textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
|
||||
Volunteer Opportunities
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 16 }}>
|
||||
Sign up for upcoming shifts and make a difference
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : shifts.length === 0 ? (
|
||||
<Result
|
||||
status="info"
|
||||
title="No Upcoming Shifts"
|
||||
subTitle="Check back later for volunteer opportunities."
|
||||
/>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{shifts.map((shift) => {
|
||||
const spotsLeft = shift.maxVolunteers - shift.currentVolunteers;
|
||||
const isFull = shift.status === 'FULL' || spotsLeft <= 0;
|
||||
const pct = shift.maxVolunteers > 0
|
||||
? Math.round((shift.currentVolunteers / shift.maxVolunteers) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Col key={shift.id} xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
hoverable={!isFull}
|
||||
style={{
|
||||
height: '100%',
|
||||
opacity: isFull ? 0.7 : 1,
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={5} style={{ margin: '0 0 8px 0' }}>
|
||||
{shift.title}
|
||||
</Title>
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{dayjs(shift.date).format('dddd, MMMM D, YYYY')}</Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{shift.startTime} — {shift.endTime}</Text>
|
||||
</div>
|
||||
{shift.location && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<EnvironmentOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{shift.location}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shift.description && (
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
ellipsis={{ rows: 2 }}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
{shift.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text type="secondary">
|
||||
<TeamOutlined style={{ marginRight: 4 }} />
|
||||
Volunteers
|
||||
</Text>
|
||||
<Text type="secondary">{shift.currentVolunteers}/{shift.maxVolunteers}</Text>
|
||||
</div>
|
||||
<Progress
|
||||
percent={pct}
|
||||
size="small"
|
||||
status={isFull ? 'exception' : 'active'}
|
||||
showInfo={false}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{isFull ? 'No spots available' : `${spotsLeft} spot${spotsLeft !== 1 ? 's' : ''} remaining`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="large"
|
||||
disabled={isFull}
|
||||
onClick={() => openSignup(shift)}
|
||||
>
|
||||
{isFull ? 'Shift Full' : 'Sign Up'}
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Signup Modal */}
|
||||
<Modal
|
||||
title={
|
||||
<span>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
Sign Up — {selectedShift?.title}
|
||||
</span>
|
||||
}
|
||||
open={signupModalOpen}
|
||||
onCancel={() => setSignupModalOpen(false)}
|
||||
footer={null}
|
||||
width={isMobile ? '100%' : 440}
|
||||
style={{ maxWidth: '95vw' }}
|
||||
destroyOnClose
|
||||
>
|
||||
{selectedShift && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{dayjs(selectedShift.date).format('dddd, MMMM D, YYYY')}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<ClockCircleOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{selectedShift.startTime} — {selectedShift.endTime}</Text>
|
||||
</div>
|
||||
{selectedShift.location && (
|
||||
<div>
|
||||
<EnvironmentOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{selectedShift.location}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form form={form} layout="vertical" onFinish={handleSignup}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="Your Name"
|
||||
rules={[{ required: true, message: 'Name is required' }]}
|
||||
>
|
||||
<Input placeholder="Jane Doe" autoFocus />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="Email Address"
|
||||
rules={[
|
||||
{ required: true, message: 'Email is required' },
|
||||
{ type: 'email', message: 'Enter a valid email' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="jane@example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="Phone (optional)">
|
||||
<Input placeholder="555-123-4567" />
|
||||
</Form.Item>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button onClick={() => setSignupModalOpen(false)} style={{ marginRight: 8 }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit" loading={submitting}>
|
||||
Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Success Modal */}
|
||||
<Modal
|
||||
open={!!successShift}
|
||||
onCancel={() => setSuccessShift(null)}
|
||||
footer={
|
||||
<Button type="primary" onClick={() => setSuccessShift(null)}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
width={isMobile ? '100%' : 440}
|
||||
style={{ maxWidth: '95vw' }}
|
||||
>
|
||||
{successShift && (
|
||||
<Result
|
||||
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
||||
title="You're signed up!"
|
||||
subTitle="A confirmation email has been sent with your shift details."
|
||||
extra={
|
||||
<div style={{
|
||||
textAlign: 'left',
|
||||
padding: '16px',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: 8,
|
||||
}}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<Text strong>{successShift.title}</Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{dayjs(successShift.date).format('dddd, MMMM D, YYYY')}</Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{successShift.startTime} — {successShift.endTime}</Text>
|
||||
</div>
|
||||
{successShift.location && (
|
||||
<div>
|
||||
<EnvironmentOutlined style={{ marginRight: 8 }} />
|
||||
<Text>{successShift.location}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
admin/src/pages/volunteer/MyActivityPage.tsx
Normal file
136
admin/src/pages/volunteer/MyActivityPage.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Row, Col, Statistic, Table, Tag, Typography, Spin, Grid, App } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
import type { MyCanvassStats, CanvassVisit } from '@/types/canvass';
|
||||
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS, type VisitOutcome } from '@/types/canvass';
|
||||
import type { PaginationMeta } from '@/types/api';
|
||||
|
||||
export default function MyActivityPage() {
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const [stats, setStats] = useState<MyCanvassStats | null>(null);
|
||||
const [visits, setVisits] = useState<CanvassVisit[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsRes, visitsRes] = await Promise.all([
|
||||
api.get('/map/canvass/my/stats'),
|
||||
api.get('/map/canvass/my/visits', { params: { page, limit: 20 } }),
|
||||
]);
|
||||
setStats(statsRes.data);
|
||||
setVisits(visitsRes.data.visits);
|
||||
setPagination(visitsRes.data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load activity');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const columns: ColumnsType<CanvassVisit> = [
|
||||
{
|
||||
title: 'Address',
|
||||
key: 'address',
|
||||
render: (_: unknown, record: CanvassVisit) => (
|
||||
<span>
|
||||
{record.location?.address ?? 'Unknown'}
|
||||
{record.location?.unitNumber && ` #${record.location.unitNumber}`}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Outcome',
|
||||
dataIndex: 'outcome',
|
||||
key: 'outcome',
|
||||
render: (outcome: VisitOutcome) => (
|
||||
<Tag color={VISIT_OUTCOME_COLORS[outcome]}>
|
||||
{VISIT_OUTCOME_LABELS[outcome]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'When',
|
||||
dataIndex: 'visitedAt',
|
||||
key: 'visitedAt',
|
||||
render: (val: string) => dayjs(val).format('MMM D, h:mm A'),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading && !stats) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
// Outcome breakdown
|
||||
const outcomeEntries = stats?.byOutcome
|
||||
? Object.entries(stats.byOutcome).sort((a, b) => b[1] - a[1])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 16 }}>My Activity</Typography.Title>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={12} md={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Today" value={stats?.todayVisits ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Total Doors" value={stats?.totalVisits ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card size="small">
|
||||
<Statistic title="Sessions" value={stats?.sessions ?? 0} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{outcomeEntries.length > 0 && (
|
||||
<Card size="small" title="Outcome Breakdown" style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{outcomeEntries.map(([key, count]) => (
|
||||
<Tag
|
||||
key={key}
|
||||
color={VISIT_OUTCOME_COLORS[key as VisitOutcome]}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
{VISIT_OUTCOME_LABELS[key as VisitOutcome]}: {count}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Visit History" size="small">
|
||||
<Table
|
||||
dataSource={visits}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 500 } : undefined}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total: pagination?.total ?? 0,
|
||||
pageSize: 20,
|
||||
showSizeChanger: false,
|
||||
onChange: setPage,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
274
admin/src/pages/volunteer/MyRoutesPage.tsx
Normal file
274
admin/src/pages/volunteer/MyRoutesPage.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Card, Row, Col, Statistic, Table, Button, Typography, Spin, Grid, App, theme } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||
import { MapContainer, TileLayer, Polyline, CircleMarker, useMap } from 'react-leaflet';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import dayjs from 'dayjs';
|
||||
import duration from 'dayjs/plugin/duration';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TrackingSessionSummary, SessionRoute, TrackPointEvent } from '@/types/tracking';
|
||||
import type { PaginationMeta } from '@/types/api';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
const EVENT_COLORS: Record<TrackPointEvent, string> = {
|
||||
SESSION_STARTED: '#52c41a',
|
||||
SESSION_ENDED: '#ff4d4f',
|
||||
VISIT_RECORDED: '#1890ff',
|
||||
LOCATION_ADDED: '#fa8c16',
|
||||
};
|
||||
|
||||
const EVENT_LABELS: Record<TrackPointEvent, string> = {
|
||||
SESSION_STARTED: 'Start',
|
||||
SESSION_ENDED: 'End',
|
||||
VISIT_RECORDED: 'Visit',
|
||||
LOCATION_ADDED: 'Location Added',
|
||||
};
|
||||
|
||||
function FitBounds({ coordinates }: { coordinates: [number, number][] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (coordinates.length > 0) {
|
||||
const bounds = coordinates.map((c) => [c[0], c[1]] as [number, number]);
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}
|
||||
}, [coordinates, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDuration(startedAt: string, endedAt: string | null): string {
|
||||
if (!endedAt) return 'Active';
|
||||
const dur = dayjs.duration(dayjs(endedAt).diff(dayjs(startedAt)));
|
||||
const h = Math.floor(dur.asHours());
|
||||
const m = dur.minutes();
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`;
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
|
||||
export default function MyRoutesPage() {
|
||||
const { message } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const [sessions, setSessions] = useState<TrackingSessionSummary[]>([]);
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [route, setRoute] = useState<SessionRoute | null>(null);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get('/map/tracking/my/sessions', {
|
||||
params: { page, limit: 15 },
|
||||
});
|
||||
setSessions(data.sessions);
|
||||
setPagination(data.pagination);
|
||||
} catch {
|
||||
message.error('Failed to load routes');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleView = async (sessionId: string) => {
|
||||
if (selectedId === sessionId) {
|
||||
setSelectedId(null);
|
||||
setRoute(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedId(sessionId);
|
||||
setRouteLoading(true);
|
||||
try {
|
||||
const { data } = await api.get(`/map/tracking/my/sessions/${sessionId}/route`);
|
||||
setRoute(data);
|
||||
} catch {
|
||||
message.error('Failed to load route');
|
||||
setSelectedId(null);
|
||||
} finally {
|
||||
setRouteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Aggregate stats
|
||||
const totalSessions = pagination?.total ?? 0;
|
||||
const totalDistance = sessions.reduce((sum, s) => sum + s.totalDistanceM, 0);
|
||||
const totalTime = sessions.reduce((sum, s) => {
|
||||
if (!s.endedAt) return sum;
|
||||
return sum + dayjs(s.endedAt).diff(dayjs(s.startedAt), 'minute');
|
||||
}, 0);
|
||||
|
||||
const columns: ColumnsType<TrackingSessionSummary> = [
|
||||
{
|
||||
title: 'Date',
|
||||
dataIndex: 'startedAt',
|
||||
key: 'startedAt',
|
||||
render: (val: string) => dayjs(val).format('MMM D, h:mm A'),
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
key: 'duration',
|
||||
render: (_: unknown, r: TrackingSessionSummary) => formatDuration(r.startedAt, r.endedAt),
|
||||
},
|
||||
{
|
||||
title: 'Distance',
|
||||
dataIndex: 'totalDistanceM',
|
||||
key: 'distance',
|
||||
render: (val: number) => formatDistance(val),
|
||||
},
|
||||
{
|
||||
title: 'Points',
|
||||
dataIndex: 'totalPoints',
|
||||
key: 'points',
|
||||
responsive: ['md'] as any,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 80,
|
||||
render: (_: unknown, r: TrackingSessionSummary) => (
|
||||
<Button
|
||||
type={selectedId === r.id ? 'primary' : 'text'}
|
||||
icon={selectedId === r.id ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
size="small"
|
||||
loading={routeLoading && selectedId === r.id}
|
||||
onClick={() => handleView(r.id)}
|
||||
>
|
||||
{isMobile ? '' : selectedId === r.id ? 'Hide' : 'View'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading && sessions.length === 0) {
|
||||
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 16 }}>My Routes</Typography.Title>
|
||||
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={8}>
|
||||
<Card size="small"><Statistic title="Sessions" value={totalSessions} /></Card>
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Card size="small"><Statistic title="Distance" value={formatDistance(totalDistance)} /></Card>
|
||||
</Col>
|
||||
<Col xs={8}>
|
||||
<Card size="small"><Statistic title="Time" value={totalTime > 60 ? `${Math.floor(totalTime / 60)}h ${totalTime % 60}m` : `${totalTime}m`} /></Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Map */}
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginBottom: 24, overflow: 'hidden' }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<div style={{ height: 300 }}>
|
||||
<MapContainer
|
||||
center={[45.42, -75.69]}
|
||||
zoom={12}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={mapRef}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://carto.com">CARTO</a>'
|
||||
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
|
||||
/>
|
||||
{route && route.coordinates.length > 0 && (
|
||||
<>
|
||||
<Polyline
|
||||
positions={route.coordinates}
|
||||
pathOptions={{ color: token.colorPrimary, weight: 3, opacity: 0.8 }}
|
||||
/>
|
||||
{route.events.map((evt, i) => (
|
||||
<CircleMarker
|
||||
key={i}
|
||||
center={[evt.latitude, evt.longitude]}
|
||||
radius={6}
|
||||
pathOptions={{
|
||||
color: EVENT_COLORS[evt.eventType],
|
||||
fillColor: EVENT_COLORS[evt.eventType],
|
||||
fillOpacity: 0.9,
|
||||
weight: 2,
|
||||
}}
|
||||
>
|
||||
</CircleMarker>
|
||||
))}
|
||||
<FitBounds coordinates={route.coordinates} />
|
||||
</>
|
||||
)}
|
||||
{!route && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
zIndex: 1000,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
Select a session to view its route
|
||||
</div>
|
||||
)}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Legend */}
|
||||
{route && (
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
{Object.entries(EVENT_COLORS).map(([key, color]) => (
|
||||
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: '50%', background: color }} />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{EVENT_LABELS[key as TrackPointEvent]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card title="Sessions" size="small">
|
||||
<Table
|
||||
dataSource={sessions}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={isMobile ? { x: 400 } : undefined}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
total: pagination?.total ?? 0,
|
||||
pageSize: 15,
|
||||
showSizeChanger: false,
|
||||
onChange: setPage,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
548
admin/src/pages/volunteer/VolunteerMapPage.tsx
Normal file
548
admin/src/pages/volunteer/VolunteerMapPage.tsx
Normal file
@ -0,0 +1,548 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Drawer, Spin, App, ConfigProvider, theme } from 'antd';
|
||||
import { MapContainer, useMapEvents } from 'react-leaflet';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useCanvassStore } from '@/stores/canvass.store';
|
||||
import { useTrackingStore } from '@/stores/tracking.store';
|
||||
import { useAuthStore } from '@/stores/auth.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { MAP_ADMIN_ROLES } from '@/types/api';
|
||||
import type { RecordVisitPayload } from '@/types/canvass';
|
||||
import VolunteerSessionBar from '@/components/canvass/VolunteerMapHeader';
|
||||
import VolunteerMapDrawer from '@/components/canvass/VolunteerMapDrawer';
|
||||
import MapCrosshair from '@/components/canvass/MapCrosshair';
|
||||
import CanvassMarker from '@/components/canvass/CanvassMarker';
|
||||
import WalkingRouteLine from '@/components/canvass/WalkingRouteLine';
|
||||
import GPSTracker from '@/components/canvass/GPSTracker';
|
||||
import CanvassBottomToolbar from '@/components/canvass/CanvassBottomToolbar';
|
||||
import CanvassLegend from '@/components/canvass/CanvassLegend';
|
||||
import VisitRecordingForm from '@/components/canvass/VisitRecordingForm';
|
||||
import LocationEditDrawer from '@/components/canvass/LocationEditDrawer';
|
||||
import AddLocationDrawer from '@/components/canvass/AddLocationDrawer';
|
||||
import CutOverlays from '@/components/map/CutOverlays';
|
||||
import CutOverlayControls from '@/components/map/CutOverlayControls';
|
||||
import DynamicTileLayer from '@/components/map/DynamicTileLayer';
|
||||
import TileLayerToggle from '@/components/map/TileLayerToggle';
|
||||
import NorthCompass from '@/components/map/NorthCompass';
|
||||
import AddressSearchOverlay from '@/components/canvass/AddressSearchOverlay';
|
||||
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
|
||||
import { getPersistedTileLayer, persistTileLayer, getTileConfig } from '@/components/map/tileLayers';
|
||||
const DEFAULT_CENTER: [number, number] = [45.42, -75.69];
|
||||
const DEFAULT_ZOOM = 13;
|
||||
|
||||
function MapEventsHandler({ onMove }: { onMove: (map: LeafletMap) => void }) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => {
|
||||
// Only trigger if not animating to prevent Leaflet state corruption
|
||||
if (!map._animatingZoom && !map._moving) {
|
||||
onMove(map);
|
||||
}
|
||||
},
|
||||
zoomend: () => {
|
||||
// Wait a tick for Leaflet to finish internal zoom state updates
|
||||
setTimeout(() => {
|
||||
if (!map._animatingZoom && !map._moving) {
|
||||
onMove(map);
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function VolunteerMapPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { message, modal } = App.useApp();
|
||||
const { user } = useAuthStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const trackingStore = useTrackingStore();
|
||||
|
||||
const {
|
||||
mode, activeCutId, session,
|
||||
locations,
|
||||
cuts, mapSettings,
|
||||
route, routeVisible, loadRoute, toggleRoute,
|
||||
selectedLocationId, selectLocation,
|
||||
gpsFollowing, setGpsFollowing,
|
||||
loadAllLocations, loadCuts, loadMapSettings, loadSession,
|
||||
enterSessionMode, exitSessionMode,
|
||||
recordVisit, reset,
|
||||
} = useCanvassStore();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
|
||||
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
|
||||
const [addDrawerOpen, setAddDrawerOpen] = useState(false);
|
||||
const [addLatLng, setAddLatLng] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [endingSession, setEndingSession] = useState(false);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
|
||||
const [tileKey, setTileKey] = useState(getPersistedTileLayer);
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
const gpsPositionRef = useRef<{ lat: number; lng: number } | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const userRole = user?.role ?? 'TEMP';
|
||||
const isAdmin = MAP_ADMIN_ROLES.includes(userRole);
|
||||
const sessionActive = mode === 'session' && !!session;
|
||||
|
||||
// Footer nav height + session bar height for positioning
|
||||
const FOOTER_HEIGHT = 56;
|
||||
const SESSION_BAR_GAP = 4; // gap between session bar and footer
|
||||
const SESSION_BAR_HEIGHT = sessionActive ? 40 + SESSION_BAR_GAP : 0;
|
||||
const toolbarBottom = FOOTER_HEIGHT + SESSION_BAR_HEIGHT + 16;
|
||||
|
||||
// ─── Initialize ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([loadAllLocations(), loadCuts(), loadMapSettings()]);
|
||||
|
||||
// Check for existing active session
|
||||
await loadSession();
|
||||
const state = useCanvassStore.getState();
|
||||
|
||||
// Handle URL query params
|
||||
const cutIdParam = searchParams.get('cutId');
|
||||
const shiftIdParam = searchParams.get('shiftId') ?? undefined;
|
||||
if (cutIdParam && !state.session) {
|
||||
await enterSessionMode(cutIdParam, shiftIdParam);
|
||||
} else if (state.session) {
|
||||
// Re-load cut-filtered locations for existing session
|
||||
const store = useCanvassStore.getState();
|
||||
if (store.session) {
|
||||
await useCanvassStore.getState().loadLocations(store.session.cutId);
|
||||
useCanvassStore.setState({ mode: 'session', activeCutId: store.session.cutId });
|
||||
}
|
||||
}
|
||||
|
||||
// Start GPS tracking (restore existing or create new)
|
||||
await trackingStore.restoreSession();
|
||||
if (!useTrackingStore.getState().isTracking) {
|
||||
await trackingStore.startTracking();
|
||||
}
|
||||
} catch {
|
||||
message.error('Failed to load map data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
trackingStore.stopTracking();
|
||||
reset();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Initialize visible cuts when cuts load
|
||||
useEffect(() => {
|
||||
if (cuts.length > 0) {
|
||||
setVisibleCutIds(new Set(cuts.map((c) => c.id)));
|
||||
}
|
||||
}, [cuts]);
|
||||
|
||||
// Fit map to cut bounds in session mode
|
||||
useEffect(() => {
|
||||
if (!sessionActive || !activeCutId || !mapRef.current) return;
|
||||
const cut = cuts.find((c) => c.id === activeCutId);
|
||||
if (!cut?.bounds) return;
|
||||
try {
|
||||
const bounds = JSON.parse(cut.bounds);
|
||||
mapRef.current.fitBounds(
|
||||
[[bounds.minLat, bounds.minLng], [bounds.maxLat, bounds.maxLng]],
|
||||
{ padding: [20, 20] },
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}, [sessionActive, activeCutId, cuts]);
|
||||
|
||||
// Center map on settings when in free mode on load
|
||||
useEffect(() => {
|
||||
if (loading || sessionActive || !mapSettings || !mapRef.current) return;
|
||||
const lat = mapSettings.latitude ? parseFloat(mapSettings.latitude) : null;
|
||||
const lng = mapSettings.longitude ? parseFloat(mapSettings.longitude) : null;
|
||||
const zoom = mapSettings.zoom ?? DEFAULT_ZOOM;
|
||||
if (lat && lng) {
|
||||
mapRef.current.setView([lat, lng], zoom);
|
||||
}
|
||||
}, [loading, sessionActive, mapSettings]);
|
||||
|
||||
// ─── Map Move Handler (Viewport Loading) ──────────────────────────
|
||||
const handleMapMove = useCallback((map: LeafletMap) => {
|
||||
const b = map.getBounds();
|
||||
const newBounds = {
|
||||
minLat: b.getSouth(),
|
||||
maxLat: b.getNorth(),
|
||||
minLng: b.getWest(),
|
||||
maxLng: b.getEast(),
|
||||
};
|
||||
|
||||
clearTimeout(fetchTimerRef.current);
|
||||
fetchTimerRef.current = setTimeout(() => {
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
if (mode === 'session' && activeCutId) {
|
||||
useCanvassStore.getState().loadLocations(activeCutId, newBounds);
|
||||
} else {
|
||||
useCanvassStore.getState().loadAllLocations(newBounds);
|
||||
}
|
||||
}, 800); // Increased debounce to 800ms to allow zoom animations to complete
|
||||
}, [mode, activeCutId]);
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────────────
|
||||
|
||||
const handleMarkerClick = useCallback((locationId: string) => {
|
||||
selectLocation(locationId);
|
||||
setBottomSheetOpen(true);
|
||||
}, [selectLocation]);
|
||||
|
||||
const handleRecordVisit = useCallback(async (payload: RecordVisitPayload) => {
|
||||
setRecording(true);
|
||||
try {
|
||||
await recordVisit(payload);
|
||||
// Track event point for visit
|
||||
const pos = gpsPositionRef.current;
|
||||
if (pos) trackingStore.addEventPoint(pos.lat, pos.lng, 'VISIT_RECORDED');
|
||||
message.success('Visit recorded');
|
||||
setBottomSheetOpen(false);
|
||||
} catch {
|
||||
message.error('Failed to record visit');
|
||||
} finally {
|
||||
setRecording(false);
|
||||
}
|
||||
}, [recordVisit, message, trackingStore]);
|
||||
|
||||
const handleNextDoor = useCallback(() => {
|
||||
const unvisited = locations.filter((l) => !l.lastVisit);
|
||||
if (unvisited.length === 0) {
|
||||
message.info('All locations visited!');
|
||||
return;
|
||||
}
|
||||
|
||||
let target = unvisited[0]!;
|
||||
if (gpsPositionRef.current) {
|
||||
let nearest = Infinity;
|
||||
for (const loc of unvisited) {
|
||||
const dx = loc.latitude - gpsPositionRef.current.lat;
|
||||
const dy = loc.longitude - gpsPositionRef.current.lng;
|
||||
const dist = dx * dx + dy * dy;
|
||||
if (dist < nearest) {
|
||||
nearest = dist;
|
||||
target = loc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectLocation(target.id);
|
||||
setBottomSheetOpen(true);
|
||||
mapRef.current?.setView([target.latitude, target.longitude], 18, { animate: true });
|
||||
}, [locations, selectLocation, message]);
|
||||
|
||||
const handleGpsPositionChange = useCallback((lat: number, lng: number) => {
|
||||
gpsPositionRef.current = { lat, lng };
|
||||
}, []);
|
||||
|
||||
const handleEndSession = useCallback(async () => {
|
||||
modal.confirm({
|
||||
title: 'End Canvass Session?',
|
||||
content: 'Your progress will be saved. You can start a new session later.',
|
||||
okText: 'End Session',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
setEndingSession(true);
|
||||
try {
|
||||
const pos = gpsPositionRef.current;
|
||||
if (pos) trackingStore.addEventPoint(pos.lat, pos.lng, 'SESSION_ENDED');
|
||||
await exitSessionMode();
|
||||
message.success('Session ended');
|
||||
if (mapSettings && mapRef.current) {
|
||||
const lat = mapSettings.latitude ? parseFloat(mapSettings.latitude) : null;
|
||||
const lng = mapSettings.longitude ? parseFloat(mapSettings.longitude) : null;
|
||||
if (lat && lng) {
|
||||
mapRef.current.setView([lat, lng], mapSettings.zoom ?? DEFAULT_ZOOM);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
message.error('Failed to end session');
|
||||
} finally {
|
||||
setEndingSession(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [exitSessionMode, message, modal, mapSettings]);
|
||||
|
||||
const handleStartSession = useCallback(async (cutId: string, shiftId?: string) => {
|
||||
try {
|
||||
const pos = gpsPositionRef.current;
|
||||
await enterSessionMode(cutId, shiftId, pos?.lat, pos?.lng);
|
||||
// Link canvass session to tracking + add event
|
||||
const canvassSession = useCanvassStore.getState().session;
|
||||
if (canvassSession) {
|
||||
await trackingStore.linkCanvassSession(canvassSession.id);
|
||||
if (pos) trackingStore.addEventPoint(pos.lat, pos.lng, 'SESSION_STARTED');
|
||||
}
|
||||
message.success('Session started');
|
||||
} catch {
|
||||
message.error('Failed to start session');
|
||||
}
|
||||
}, [enterSessionMode, message, trackingStore]);
|
||||
|
||||
const handleTileChange = useCallback((key: string) => {
|
||||
setTileKey(key);
|
||||
persistTileLayer(key);
|
||||
}, []);
|
||||
|
||||
const handleSearchFlyTo = useCallback((lat: number, lng: number) => {
|
||||
mapRef.current?.flyTo([lat, lng], 18);
|
||||
setGpsFollowing(false);
|
||||
}, [setGpsFollowing]);
|
||||
|
||||
const handleToggleCut = useCallback((id: string) => {
|
||||
setVisibleCutIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// "+" tapped — read map center from the crosshair position
|
||||
const handleAddAtCenter = useCallback(() => {
|
||||
if (!mapRef.current) return;
|
||||
const center = mapRef.current.getCenter();
|
||||
setAddLatLng({ lat: center.lat, lng: center.lng });
|
||||
setAddDrawerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleToggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!document.fullscreenElement) {
|
||||
(containerRef.current.requestFullscreen?.() ??
|
||||
(containerRef.current as unknown as { webkitRequestFullscreen?: () => Promise<void> }).webkitRequestFullscreen?.());
|
||||
setFullscreen(true);
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
setFullscreen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => { if (!document.fullscreenElement) setFullscreen(false); };
|
||||
document.addEventListener('fullscreenchange', handler);
|
||||
return () => document.removeEventListener('fullscreenchange', handler);
|
||||
}, []);
|
||||
|
||||
const selectedLocation = locations.find((l) => l.id === selectedLocationId) ?? null;
|
||||
const visitedCount = locations.filter((l) => l.lastVisit).length;
|
||||
|
||||
const center: [number, number] = mapSettings?.latitude && mapSettings?.longitude
|
||||
? [parseFloat(mapSettings.latitude), parseFloat(mapSettings.longitude)]
|
||||
: DEFAULT_CENTER;
|
||||
const zoom = mapSettings?.zoom ?? DEFAULT_ZOOM;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#0d1b2a' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
algorithm: theme.darkAlgorithm,
|
||||
token: {
|
||||
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
|
||||
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
|
||||
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
|
||||
colorBgElevated: settings?.publicColorBgContainer ?? '#1b2838',
|
||||
borderRadius: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<App>
|
||||
<div ref={containerRef} style={{ position: 'relative', width: '100vw', height: '100vh' }}>
|
||||
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={zoom}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
zoomControl={false}
|
||||
ref={mapRef}
|
||||
>
|
||||
<MapEventsHandler onMove={handleMapMove} />
|
||||
<DynamicTileLayer config={getTileConfig(tileKey)} />
|
||||
|
||||
{/* Cut polygon overlays */}
|
||||
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
|
||||
|
||||
{/* Location markers */}
|
||||
{locations.map((loc) => (
|
||||
<CanvassMarker
|
||||
key={loc.id}
|
||||
location={loc}
|
||||
isSelected={loc.id === selectedLocationId}
|
||||
onClick={() => handleMarkerClick(loc.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Walking route (session mode) */}
|
||||
{sessionActive && routeVisible && route && <WalkingRouteLine route={route} />}
|
||||
|
||||
{/* GPS tracker */}
|
||||
<GPSTracker
|
||||
following={gpsFollowing}
|
||||
onPositionChange={handleGpsPositionChange}
|
||||
/>
|
||||
</MapContainer>
|
||||
|
||||
{/* Persistent crosshair at map center — tap center dot to add location */}
|
||||
<MapCrosshair onClick={handleAddAtCenter} />
|
||||
|
||||
{/* Legend — top-right, clear of bottom controls */}
|
||||
<CanvassLegend />
|
||||
|
||||
{/* North compass — below legend */}
|
||||
<NorthCompass />
|
||||
|
||||
{/* Tile layer toggle — bottom-left */}
|
||||
<TileLayerToggle activeKey={tileKey} onChange={handleTileChange} position="bottom-left" />
|
||||
|
||||
{/* Address search overlay */}
|
||||
<AddressSearchOverlay onFlyTo={handleSearchFlyTo} />
|
||||
|
||||
{/* Cut overlay controls — top-left to avoid footer collision */}
|
||||
{cuts.length > 0 && (
|
||||
<CutOverlayControls
|
||||
cuts={cuts}
|
||||
visibleCutIds={visibleCutIds}
|
||||
onToggleCut={handleToggleCut}
|
||||
variant="public"
|
||||
style={{ top: 10, bottom: 'auto', left: 10 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating toolbar — above footer + session bar */}
|
||||
<CanvassBottomToolbar
|
||||
visitedCount={visitedCount}
|
||||
totalCount={locations.length}
|
||||
routeVisible={routeVisible}
|
||||
gpsFollowing={gpsFollowing}
|
||||
onNextDoor={handleNextDoor}
|
||||
onToggleRoute={() => {
|
||||
if (!routeVisible && activeCutId) {
|
||||
const pos = gpsPositionRef.current;
|
||||
loadRoute(activeCutId, true, pos?.lat, pos?.lng);
|
||||
}
|
||||
toggleRoute();
|
||||
}}
|
||||
onToggleGps={() => setGpsFollowing(!gpsFollowing)}
|
||||
sessionActive={sessionActive}
|
||||
onAddAtCenter={handleAddAtCenter}
|
||||
fullscreen={fullscreen}
|
||||
onToggleFullscreen={handleToggleFullscreen}
|
||||
onMenuOpen={() => setDrawerOpen(true)}
|
||||
bottomOffset={toolbarBottom}
|
||||
/>
|
||||
|
||||
{/* Session bar — above footer nav, only when session active */}
|
||||
{sessionActive && (
|
||||
<VolunteerSessionBar
|
||||
sessionCutName={session?.cut?.name}
|
||||
sessionStartedAt={session?.startedAt}
|
||||
onEndSession={handleEndSession}
|
||||
endingSession={endingSession}
|
||||
headerGradient={settings?.publicHeaderGradient}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Footer nav — bottom of screen */}
|
||||
<VolunteerFooterNav
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Left drawer — menu, stats, assignments, session picker */}
|
||||
<VolunteerMapDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
cuts={cuts}
|
||||
onStartSession={handleStartSession}
|
||||
/>
|
||||
|
||||
{/* Bottom sheet — visit recording */}
|
||||
<Drawer
|
||||
placement="bottom"
|
||||
open={bottomSheetOpen && !!selectedLocation}
|
||||
onClose={() => { setBottomSheetOpen(false); selectLocation(null); }}
|
||||
height="auto"
|
||||
styles={{
|
||||
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },
|
||||
header: { display: 'none' },
|
||||
}}
|
||||
maskClosable
|
||||
>
|
||||
{selectedLocation && (
|
||||
<>
|
||||
<VisitRecordingForm
|
||||
location={selectedLocation}
|
||||
sessionId={session?.id}
|
||||
shiftId={session?.shiftId ?? undefined}
|
||||
onRecord={handleRecordVisit}
|
||||
recording={recording}
|
||||
userRole={userRole}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: 8, textAlign: 'center' }}>
|
||||
<a
|
||||
onClick={() => { setBottomSheetOpen(false); setEditDrawerOpen(true); }}
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
Edit Location Details
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
{/* Bottom sheet — location editing (admin only) */}
|
||||
<LocationEditDrawer
|
||||
open={editDrawerOpen}
|
||||
onClose={() => setEditDrawerOpen(false)}
|
||||
location={selectedLocation}
|
||||
/>
|
||||
|
||||
{/* Bottom sheet — add new location + record visit */}
|
||||
<AddLocationDrawer
|
||||
open={addDrawerOpen}
|
||||
onClose={() => { setAddDrawerOpen(false); setAddLatLng(null); }}
|
||||
lat={addLatLng?.lat ?? null}
|
||||
lng={addLatLng?.lng ?? null}
|
||||
userRole={userRole}
|
||||
sessionId={session?.id}
|
||||
shiftId={session?.shiftId ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</App>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
311
admin/src/pages/volunteer/VolunteerShiftsPage.tsx
Normal file
311
admin/src/pages/volunteer/VolunteerShiftsPage.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Row,
|
||||
Col,
|
||||
Progress,
|
||||
Segmented,
|
||||
Spin,
|
||||
Result,
|
||||
Grid,
|
||||
App,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
EnvironmentOutlined,
|
||||
TeamOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
interface VolunteerShift {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
maxVolunteers: number;
|
||||
currentVolunteers: number;
|
||||
status: 'OPEN' | 'FULL' | 'CANCELLED';
|
||||
isSignedUp: boolean;
|
||||
}
|
||||
|
||||
interface MySignup {
|
||||
id: string;
|
||||
shiftId: string;
|
||||
shiftTitle: string;
|
||||
signupDate: string;
|
||||
shift: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
maxVolunteers: number;
|
||||
currentVolunteers: number;
|
||||
status: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Tab = 'upcoming' | 'my-signups';
|
||||
|
||||
export default function VolunteerShiftsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const [tab, setTab] = useState<Tab>('upcoming');
|
||||
const [shifts, setShifts] = useState<VolunteerShift[]>([]);
|
||||
const [signups, setSignups] = useState<MySignup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'upcoming') fetchUpcoming();
|
||||
else fetchSignups();
|
||||
}, [tab]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchUpcoming = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<VolunteerShift[]>('/map/shifts/volunteer/upcoming');
|
||||
setShifts(data);
|
||||
} catch {
|
||||
message.error('Failed to load shifts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSignups = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<MySignup[]>('/map/shifts/volunteer/my-signups');
|
||||
setSignups(data);
|
||||
} catch {
|
||||
message.error('Failed to load signups');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = (shift: VolunteerShift) => {
|
||||
modal.confirm({
|
||||
title: 'Sign Up',
|
||||
content: (
|
||||
<div>
|
||||
<Text>Sign up for <Text strong>{shift.title}</Text>?</Text>
|
||||
<div style={{ marginTop: 8, padding: '8px 12px', background: 'rgba(255,255,255,0.04)', borderRadius: 6 }}>
|
||||
<div><CalendarOutlined style={{ marginRight: 6 }} />{dayjs(shift.date).format('dddd, MMMM D, YYYY')}</div>
|
||||
<div><ClockCircleOutlined style={{ marginRight: 6 }} />{shift.startTime} — {shift.endTime}</div>
|
||||
{shift.location && <div><EnvironmentOutlined style={{ marginRight: 6 }} />{shift.location}</div>}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
okText: 'Sign Up',
|
||||
onOk: async () => {
|
||||
setActionLoading(shift.id);
|
||||
try {
|
||||
await api.post(`/map/shifts/volunteer/${shift.id}/signup`);
|
||||
message.success('Signed up successfully!');
|
||||
fetchUpcoming();
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.error?.message || 'Failed to sign up';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancel = (shiftId: string, shiftTitle: string) => {
|
||||
modal.confirm({
|
||||
title: 'Cancel Signup',
|
||||
content: `Cancel your signup for "${shiftTitle}"?`,
|
||||
okText: 'Cancel Signup',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
setActionLoading(shiftId);
|
||||
try {
|
||||
await api.delete(`/map/shifts/volunteer/${shiftId}/signup`);
|
||||
message.success('Signup cancelled');
|
||||
if (tab === 'upcoming') fetchUpcoming();
|
||||
else fetchSignups();
|
||||
} catch (err: any) {
|
||||
const msg = err.response?.data?.error?.message || 'Failed to cancel signup';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderShiftCard = (shift: VolunteerShift) => {
|
||||
const spotsLeft = shift.maxVolunteers - shift.currentVolunteers;
|
||||
const isFull = shift.status === 'FULL' || spotsLeft <= 0;
|
||||
const pct = shift.maxVolunteers > 0
|
||||
? Math.round((shift.currentVolunteers / shift.maxVolunteers) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Col key={shift.id} xs={24} sm={12}>
|
||||
<Card
|
||||
hoverable={!isFull && !shift.isSignedUp}
|
||||
style={{ height: '100%', opacity: isFull && !shift.isSignedUp ? 0.7 : 1 }}
|
||||
styles={{ body: { display: 'flex', flexDirection: 'column', height: '100%' } }}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={5} style={{ margin: '0 0 8px 0' }}>{shift.title}</Title>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{dayjs(shift.date).format('ddd, MMM D, YYYY')}</Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{shift.startTime} — {shift.endTime}</Text>
|
||||
</div>
|
||||
{shift.location && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<EnvironmentOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{shift.location}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shift.description && (
|
||||
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 12 }}>
|
||||
{shift.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text type="secondary"><TeamOutlined style={{ marginRight: 4 }} />Volunteers</Text>
|
||||
<Text type="secondary">{shift.currentVolunteers}/{shift.maxVolunteers}</Text>
|
||||
</div>
|
||||
<Progress percent={pct} size="small" status={isFull ? 'exception' : 'active'} showInfo={false} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{isFull ? 'No spots available' : `${spotsLeft} spot${spotsLeft !== 1 ? 's' : ''} remaining`}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shift.isSignedUp ? (
|
||||
<div>
|
||||
<Button type="primary" block icon={<CheckCircleOutlined />} style={{ marginBottom: 8 }}>
|
||||
Signed Up
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
block
|
||||
size="small"
|
||||
loading={actionLoading === shift.id}
|
||||
onClick={() => handleCancel(shift.id, shift.title)}
|
||||
>
|
||||
Cancel Signup
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
disabled={isFull}
|
||||
loading={actionLoading === shift.id}
|
||||
onClick={() => handleSignup(shift)}
|
||||
>
|
||||
{isFull ? 'Shift Full' : 'Sign Up'}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSignupCard = (signup: MySignup) => {
|
||||
const { shift } = signup;
|
||||
return (
|
||||
<Col key={signup.id} xs={24} sm={12}>
|
||||
<Card style={{ height: '100%' }} styles={{ body: { display: 'flex', flexDirection: 'column', height: '100%' } }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Title level={5} style={{ margin: '0 0 8px 0' }}>{shift.title}</Title>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<CalendarOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{dayjs(shift.date).format('ddd, MMM D, YYYY')}</Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<ClockCircleOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{shift.startTime} — {shift.endTime}</Text>
|
||||
</div>
|
||||
{shift.location && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<EnvironmentOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
|
||||
<Text>{shift.location}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shift.description && (
|
||||
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 12 }}>
|
||||
{shift.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
block
|
||||
loading={actionLoading === shift.id}
|
||||
onClick={() => handleCancel(shift.id, shift.title)}
|
||||
>
|
||||
Cancel Signup
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 16 }}>Shifts</Typography.Title>
|
||||
|
||||
<Segmented
|
||||
value={tab}
|
||||
onChange={(val) => setTab(val as Tab)}
|
||||
options={[
|
||||
{ label: 'Upcoming', value: 'upcoming' },
|
||||
{ label: 'My Signups', value: 'my-signups' },
|
||||
]}
|
||||
block={isMobile}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 60 }}><Spin size="large" /></div>
|
||||
) : tab === 'upcoming' ? (
|
||||
shifts.length === 0 ? (
|
||||
<Result status="info" title="No Upcoming Shifts" subTitle="Check back later for volunteer opportunities." />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>{shifts.map(renderShiftCard)}</Row>
|
||||
)
|
||||
) : signups.length === 0 ? (
|
||||
<Result status="info" title="No Signups" subTitle="You haven't signed up for any shifts yet." />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>{signups.map(renderSignupCard)}</Row>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
admin/src/stores/auth.store.ts
Normal file
154
admin/src/stores/auth.store.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { api, registerAuthCallbacks } from '@/lib/api';
|
||||
import type { User, AuthResponse } from '@/types/api';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface AuthActions {
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
fetchMe: () => Promise<void>;
|
||||
hydrate: () => Promise<void>;
|
||||
setTokens: (accessToken: string, refreshToken: string) => void;
|
||||
clearAuth: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState & AuthActions>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
set({ error: null, isLoading: true });
|
||||
try {
|
||||
const { data } = await api.post<AuthResponse>('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||
?.response?.data?.error?.message || 'Login failed';
|
||||
set({ error: message, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const { refreshToken } = get();
|
||||
try {
|
||||
if (refreshToken) {
|
||||
await api.post('/auth/logout', { refreshToken });
|
||||
}
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
get().clearAuth();
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
const { refreshToken } = get();
|
||||
if (!refreshToken) {
|
||||
get().clearAuth();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.post<AuthResponse>('/auth/refresh', {
|
||||
refreshToken,
|
||||
});
|
||||
set({
|
||||
user: data.user,
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
} catch {
|
||||
get().clearAuth();
|
||||
}
|
||||
},
|
||||
|
||||
fetchMe: async () => {
|
||||
try {
|
||||
const { data } = await api.get<User>('/auth/me');
|
||||
set({ user: data, isAuthenticated: true, isLoading: false });
|
||||
} catch {
|
||||
get().clearAuth();
|
||||
}
|
||||
},
|
||||
|
||||
hydrate: async () => {
|
||||
const { accessToken } = get();
|
||||
if (!accessToken) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await get().fetchMe();
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
setTokens: (accessToken: string, refreshToken: string) => {
|
||||
set({ accessToken, refreshToken });
|
||||
},
|
||||
|
||||
clearAuth: () => {
|
||||
set({
|
||||
user: null,
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'cml-auth',
|
||||
partialize: (state) => ({
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Register callbacks for the API interceptor (breaks circular dependency)
|
||||
registerAuthCallbacks({
|
||||
getTokens: () => {
|
||||
const state = useAuthStore.getState();
|
||||
return {
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
};
|
||||
},
|
||||
onTokenRefresh: (accessToken, refreshToken) => {
|
||||
useAuthStore.getState().setTokens(accessToken, refreshToken);
|
||||
},
|
||||
onAuthFailure: () => {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = '/login';
|
||||
},
|
||||
});
|
||||
346
admin/src/stores/canvass.store.ts
Normal file
346
admin/src/stores/canvass.store.ts
Normal file
@ -0,0 +1,346 @@
|
||||
import { create } from 'zustand';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type {
|
||||
CanvassLocation,
|
||||
WalkingRoute,
|
||||
CanvassSession,
|
||||
RecordVisitPayload,
|
||||
CanvassVisit,
|
||||
} from '@/types/canvass';
|
||||
import type { PublicCut, MapSettings, ReverseGeocodeResult, GeocodeResult } from '@/types/api';
|
||||
|
||||
interface CanvassState {
|
||||
// Mode
|
||||
mode: 'free' | 'session';
|
||||
activeCutId: string | null;
|
||||
|
||||
// Session
|
||||
session: CanvassSession | null;
|
||||
sessionLoading: boolean;
|
||||
|
||||
// Locations
|
||||
locations: CanvassLocation[];
|
||||
locationsLoading: boolean;
|
||||
|
||||
// Cuts & map settings
|
||||
cuts: PublicCut[];
|
||||
mapSettings: MapSettings | null;
|
||||
|
||||
// Walking route
|
||||
route: WalkingRoute | null;
|
||||
routeVisible: boolean;
|
||||
|
||||
// Selected location
|
||||
selectedLocationId: string | null;
|
||||
|
||||
// GPS
|
||||
gpsFollowing: boolean;
|
||||
|
||||
// Actions — data loading
|
||||
loadAllLocations: () => Promise<void>;
|
||||
loadCuts: () => Promise<void>;
|
||||
loadMapSettings: () => Promise<void>;
|
||||
loadSession: () => Promise<void>;
|
||||
loadRoute: (cutId: string, excludeVisited?: boolean, lat?: number, lng?: number) => Promise<void>;
|
||||
|
||||
// Actions — session management
|
||||
enterSessionMode: (cutId: string, shiftId?: string, lat?: number, lng?: number) => Promise<void>;
|
||||
exitSessionMode: () => Promise<void>;
|
||||
startSession: (cutId: string, shiftId?: string, lat?: number, lng?: number) => Promise<CanvassSession>;
|
||||
endSession: () => Promise<void>;
|
||||
|
||||
// Actions — location management
|
||||
loadLocations: (cutId: string) => Promise<void>;
|
||||
recordVisit: (payload: RecordVisitPayload) => Promise<CanvassVisit>;
|
||||
updateLocationFields: (id: string, data: Record<string, unknown>) => Promise<void>;
|
||||
addLocation: (data: Record<string, unknown>) => Promise<CanvassLocation>;
|
||||
reverseGeocode: (lat: number, lng: number) => Promise<ReverseGeocodeResult>;
|
||||
geocodeSearch: (address: string) => Promise<GeocodeResult>;
|
||||
|
||||
// Actions — UI
|
||||
selectLocation: (id: string | null) => void;
|
||||
toggleRoute: () => void;
|
||||
setGpsFollowing: (v: boolean) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useCanvassStore = create<CanvassState>((set, get) => ({
|
||||
mode: 'free',
|
||||
activeCutId: null,
|
||||
session: null,
|
||||
sessionLoading: false,
|
||||
locations: [],
|
||||
locationsLoading: false,
|
||||
cuts: [],
|
||||
mapSettings: null,
|
||||
route: null,
|
||||
routeVisible: false,
|
||||
selectedLocationId: null,
|
||||
gpsFollowing: true,
|
||||
|
||||
// ─── Data Loading ──────────────────────────────────────────────────
|
||||
|
||||
loadAllLocations: async (bounds?: {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
}) => {
|
||||
set({ locationsLoading: true });
|
||||
try {
|
||||
let url = '/map/canvass/locations';
|
||||
if (bounds) {
|
||||
const params = new URLSearchParams({
|
||||
minLat: bounds.minLat.toString(),
|
||||
maxLat: bounds.maxLat.toString(),
|
||||
minLng: bounds.minLng.toString(),
|
||||
maxLng: bounds.maxLng.toString(),
|
||||
});
|
||||
url += `?${params}`;
|
||||
}
|
||||
const { data } = await api.get(url);
|
||||
set({ locations: data });
|
||||
} finally {
|
||||
set({ locationsLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadCuts: async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/map/cuts/public');
|
||||
set({ cuts: data });
|
||||
} catch {
|
||||
// Non-critical — cuts overlay just won't show
|
||||
}
|
||||
},
|
||||
|
||||
loadMapSettings: async () => {
|
||||
try {
|
||||
const { data } = await axios.get('/api/map/settings');
|
||||
set({ mapSettings: data });
|
||||
} catch {
|
||||
// Non-critical — use default center/zoom
|
||||
}
|
||||
},
|
||||
|
||||
loadSession: async () => {
|
||||
set({ sessionLoading: true });
|
||||
try {
|
||||
const { data } = await api.get('/map/canvass/my/session');
|
||||
if (data) {
|
||||
set({ session: data, mode: 'session', activeCutId: data.cutId });
|
||||
} else {
|
||||
set({ session: null });
|
||||
}
|
||||
} catch {
|
||||
set({ session: null });
|
||||
} finally {
|
||||
set({ sessionLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadLocations: async (
|
||||
cutId,
|
||||
bounds?: {
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
minLng: number;
|
||||
maxLng: number;
|
||||
}
|
||||
) => {
|
||||
set({ locationsLoading: true });
|
||||
try {
|
||||
let url = `/map/canvass/cuts/${cutId}/locations`;
|
||||
if (bounds) {
|
||||
const params = new URLSearchParams({
|
||||
minLat: bounds.minLat.toString(),
|
||||
maxLat: bounds.maxLat.toString(),
|
||||
minLng: bounds.minLng.toString(),
|
||||
maxLng: bounds.maxLng.toString(),
|
||||
});
|
||||
url += `?${params}`;
|
||||
}
|
||||
const { data } = await api.get(url);
|
||||
set({ locations: data });
|
||||
} finally {
|
||||
set({ locationsLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadRoute: async (cutId, excludeVisited = true, lat, lng) => {
|
||||
const params = new URLSearchParams();
|
||||
if (excludeVisited) params.set('excludeVisited', 'true');
|
||||
if (lat !== undefined) params.set('startLatitude', lat.toString());
|
||||
if (lng !== undefined) params.set('startLongitude', lng.toString());
|
||||
const { data } = await api.get(`/map/canvass/cuts/${cutId}/route?${params}`);
|
||||
set({ route: data });
|
||||
},
|
||||
|
||||
// ─── Session Management ────────────────────────────────────────────
|
||||
|
||||
enterSessionMode: async (cutId, shiftId, lat, lng) => {
|
||||
const { session } = get();
|
||||
// End any existing session for a different cut
|
||||
if (session && session.cutId !== cutId) {
|
||||
await api.post(`/map/canvass/sessions/${session.id}/end`);
|
||||
}
|
||||
|
||||
// Start new session if none or different cut
|
||||
if (!session || session.cutId !== cutId) {
|
||||
const { data } = await api.post('/map/canvass/sessions', {
|
||||
cutId,
|
||||
shiftId,
|
||||
startLatitude: lat,
|
||||
startLongitude: lng,
|
||||
});
|
||||
set({ session: data });
|
||||
}
|
||||
|
||||
// Load cut-filtered locations
|
||||
set({ locationsLoading: true, mode: 'session', activeCutId: cutId });
|
||||
try {
|
||||
const { data } = await api.get(`/map/canvass/cuts/${cutId}/locations`);
|
||||
set({ locations: data });
|
||||
} finally {
|
||||
set({ locationsLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
exitSessionMode: async () => {
|
||||
const { session } = get();
|
||||
if (session) {
|
||||
await api.post(`/map/canvass/sessions/${session.id}/end`);
|
||||
}
|
||||
set({
|
||||
session: null,
|
||||
mode: 'free',
|
||||
activeCutId: null,
|
||||
route: null,
|
||||
routeVisible: false,
|
||||
});
|
||||
// Reload all locations
|
||||
await get().loadAllLocations();
|
||||
},
|
||||
|
||||
startSession: async (cutId, shiftId, lat, lng) => {
|
||||
const { data } = await api.post('/map/canvass/sessions', {
|
||||
cutId,
|
||||
shiftId,
|
||||
startLatitude: lat,
|
||||
startLongitude: lng,
|
||||
});
|
||||
set({ session: data });
|
||||
return data;
|
||||
},
|
||||
|
||||
endSession: async () => {
|
||||
const { session } = get();
|
||||
if (!session) return;
|
||||
await api.post(`/map/canvass/sessions/${session.id}/end`);
|
||||
set({ session: null });
|
||||
},
|
||||
|
||||
// ─── Location Management ───────────────────────────────────────────
|
||||
|
||||
recordVisit: async (payload) => {
|
||||
const { data } = await api.post('/map/canvass/visits', payload);
|
||||
|
||||
// Optimistic update: mark the location as visited in local state
|
||||
set((state) => ({
|
||||
locations: state.locations.map((loc) =>
|
||||
loc.id === payload.locationId
|
||||
? {
|
||||
...loc,
|
||||
lastVisit: {
|
||||
outcome: payload.outcome,
|
||||
visitedAt: new Date().toISOString(),
|
||||
visitorName: null,
|
||||
isMyVisit: true,
|
||||
},
|
||||
}
|
||||
: loc,
|
||||
),
|
||||
selectedLocationId: null,
|
||||
}));
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
updateLocationFields: async (id, data) => {
|
||||
await api.put(`/map/canvass/locations/${id}`, data);
|
||||
|
||||
// Optimistic update in locations array
|
||||
set((state) => ({
|
||||
locations: state.locations.map((loc) =>
|
||||
loc.id === id
|
||||
? {
|
||||
...loc,
|
||||
...data,
|
||||
// Keep lastVisit, latitude, longitude from existing
|
||||
}
|
||||
: loc,
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
addLocation: async (data) => {
|
||||
const { data: newLoc } = await api.post('/map/canvass/locations', data);
|
||||
|
||||
// Build a CanvassLocation from the response
|
||||
const canvassLoc: CanvassLocation = {
|
||||
id: newLoc.id,
|
||||
latitude: Number(newLoc.latitude),
|
||||
longitude: Number(newLoc.longitude),
|
||||
address: newLoc.address,
|
||||
unitNumber: newLoc.unitNumber,
|
||||
firstName: newLoc.firstName,
|
||||
lastName: newLoc.lastName,
|
||||
supportLevel: newLoc.supportLevel,
|
||||
sign: newLoc.sign,
|
||||
signSize: newLoc.signSize,
|
||||
notes: newLoc.notes,
|
||||
lastVisit: null,
|
||||
};
|
||||
|
||||
set((state) => ({ locations: [canvassLoc, ...state.locations] }));
|
||||
return canvassLoc;
|
||||
},
|
||||
|
||||
reverseGeocode: async (lat, lng) => {
|
||||
const { data } = await api.post('/map/canvass/reverse-geocode', {
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
geocodeSearch: async (address) => {
|
||||
const { data } = await api.post('/map/canvass/geocode-search', { address });
|
||||
return data;
|
||||
},
|
||||
|
||||
// ─── UI ────────────────────────────────────────────────────────────
|
||||
|
||||
selectLocation: (id) => set({ selectedLocationId: id }),
|
||||
|
||||
toggleRoute: () => set((state) => ({ routeVisible: !state.routeVisible })),
|
||||
|
||||
setGpsFollowing: (v) => set({ gpsFollowing: v }),
|
||||
|
||||
reset: () =>
|
||||
set({
|
||||
mode: 'free',
|
||||
activeCutId: null,
|
||||
session: null,
|
||||
sessionLoading: false,
|
||||
locations: [],
|
||||
locationsLoading: false,
|
||||
cuts: [],
|
||||
mapSettings: null,
|
||||
route: null,
|
||||
routeVisible: false,
|
||||
selectedLocationId: null,
|
||||
gpsFollowing: true,
|
||||
}),
|
||||
}));
|
||||
44
admin/src/stores/settings.store.ts
Normal file
44
admin/src/stores/settings.store.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { create } from 'zustand';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { SiteSettings } from '@/types/api';
|
||||
|
||||
interface SettingsState {
|
||||
settings: SiteSettings | null;
|
||||
loading: boolean;
|
||||
fetchSettings: () => Promise<void>;
|
||||
fetchAdminSettings: () => Promise<void>;
|
||||
updateSettings: (data: Partial<SiteSettings>) => Promise<SiteSettings>;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||
settings: null,
|
||||
loading: true,
|
||||
|
||||
fetchSettings: async () => {
|
||||
try {
|
||||
// Use axios directly (not { api }) — this is a public endpoint needed before auth
|
||||
const { data } = await axios.get<SiteSettings>('/api/settings');
|
||||
set({ settings: data, loading: false });
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
/** Fetch full settings including SMTP credentials (SUPER_ADMIN only) */
|
||||
fetchAdminSettings: async () => {
|
||||
try {
|
||||
const { data } = await api.get<SiteSettings>('/settings/admin');
|
||||
set({ settings: data, loading: false });
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
updateSettings: async (data) => {
|
||||
// Use authenticated api client for writes
|
||||
const { data: updated } = await api.put<SiteSettings>('/settings', data);
|
||||
set({ settings: updated });
|
||||
return updated;
|
||||
},
|
||||
}));
|
||||
131
admin/src/stores/tracking.store.ts
Normal file
131
admin/src/stores/tracking.store.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '@/lib/api';
|
||||
import type { TrackPointPayload, TrackPointEvent } from '@/types/tracking';
|
||||
|
||||
interface TrackingState {
|
||||
trackingSessionId: string | null;
|
||||
isTracking: boolean;
|
||||
pendingPoints: TrackPointPayload[];
|
||||
|
||||
startTracking(canvassSessionId?: string, lat?: number, lng?: number): Promise<void>;
|
||||
stopTracking(): Promise<void>;
|
||||
addPoint(point: TrackPointPayload): void;
|
||||
flushPoints(): Promise<void>;
|
||||
linkCanvassSession(canvassSessionId: string): Promise<void>;
|
||||
addEventPoint(lat: number, lng: number, eventType: TrackPointEvent): void;
|
||||
restoreSession(): Promise<void>;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export const useTrackingStore = create<TrackingState>((set, get) => ({
|
||||
trackingSessionId: null,
|
||||
isTracking: false,
|
||||
pendingPoints: [],
|
||||
|
||||
startTracking: async (canvassSessionId, lat, lng) => {
|
||||
if (get().isTracking) return;
|
||||
try {
|
||||
const { data } = await api.post('/map/tracking/sessions', {
|
||||
canvassSessionId,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
});
|
||||
set({ trackingSessionId: data.id, isTracking: true });
|
||||
} catch {
|
||||
// Non-critical — tracking just won't record
|
||||
}
|
||||
},
|
||||
|
||||
stopTracking: async () => {
|
||||
const { trackingSessionId, pendingPoints } = get();
|
||||
if (!trackingSessionId) {
|
||||
set({ isTracking: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Flush remaining points before ending
|
||||
if (pendingPoints.length > 0) {
|
||||
try {
|
||||
await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
|
||||
points: pendingPoints,
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await api.post(`/map/tracking/sessions/${trackingSessionId}/end`);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
set({ trackingSessionId: null, isTracking: false, pendingPoints: [] });
|
||||
},
|
||||
|
||||
addPoint: (point) => {
|
||||
set((state) => ({
|
||||
pendingPoints: [...state.pendingPoints, point],
|
||||
}));
|
||||
},
|
||||
|
||||
flushPoints: async () => {
|
||||
const { trackingSessionId, pendingPoints } = get();
|
||||
if (!trackingSessionId || pendingPoints.length === 0) return;
|
||||
|
||||
// Take a snapshot and clear the buffer optimistically
|
||||
const toSend = [...pendingPoints];
|
||||
set({ pendingPoints: [] });
|
||||
|
||||
try {
|
||||
await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
|
||||
points: toSend,
|
||||
});
|
||||
} catch {
|
||||
// Re-add failed points to the buffer
|
||||
set((state) => ({
|
||||
pendingPoints: [...toSend, ...state.pendingPoints],
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
linkCanvassSession: async (canvassSessionId) => {
|
||||
const { trackingSessionId } = get();
|
||||
if (!trackingSessionId) return;
|
||||
try {
|
||||
await api.post(`/map/tracking/sessions/${trackingSessionId}/link-canvass`, {
|
||||
canvassSessionId,
|
||||
});
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
},
|
||||
|
||||
addEventPoint: (lat, lng, eventType) => {
|
||||
get().addPoint({
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
recordedAt: new Date().toISOString(),
|
||||
eventType,
|
||||
});
|
||||
},
|
||||
|
||||
restoreSession: async () => {
|
||||
try {
|
||||
const { data } = await api.get('/map/tracking/my/session');
|
||||
if (data) {
|
||||
set({ trackingSessionId: data.id, isTracking: true });
|
||||
}
|
||||
} catch {
|
||||
// No active session
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
trackingSessionId: null,
|
||||
isTracking: false,
|
||||
pendingPoints: [],
|
||||
});
|
||||
},
|
||||
}));
|
||||
987
admin/src/types/api.ts
Normal file
987
admin/src/types/api.ts
Normal file
@ -0,0 +1,987 @@
|
||||
// TypeScript interfaces mirroring API response shapes
|
||||
|
||||
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'USER' | 'TEMP';
|
||||
|
||||
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED';
|
||||
|
||||
export type CreatedVia = 'ADMIN' | 'PUBLIC_SHIFT_SIGNUP' | 'STANDARD';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
permissions: Record<string, unknown> | null;
|
||||
createdVia: CreatedVia;
|
||||
expiresAt: string | null;
|
||||
expireDays: number | null;
|
||||
lastLoginAt: string | null;
|
||||
emailVerified: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface UsersListResponse {
|
||||
users: User[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: {
|
||||
message: string;
|
||||
code: string;
|
||||
details?: Record<string, string[]>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
expiresAt?: string;
|
||||
expireDays?: number;
|
||||
}
|
||||
|
||||
export interface UpdateUserPayload {
|
||||
email?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
expiresAt?: string | null;
|
||||
expireDays?: number;
|
||||
}
|
||||
|
||||
export interface UsersListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
role?: UserRole;
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
|
||||
|
||||
// --- Campaigns ---
|
||||
|
||||
export type CampaignStatus = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'ARCHIVED';
|
||||
|
||||
export type GovernmentLevel = 'FEDERAL' | 'PROVINCIAL' | 'MUNICIPAL' | 'SCHOOL_BOARD';
|
||||
|
||||
export interface Campaign {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
emailSubject: string;
|
||||
emailBody: string;
|
||||
callToAction: string | null;
|
||||
coverPhoto: string | null;
|
||||
status: CampaignStatus;
|
||||
allowSmtpEmail: boolean;
|
||||
allowMailtoLink: boolean;
|
||||
collectUserInfo: boolean;
|
||||
showEmailCount: boolean;
|
||||
showCallCount: boolean;
|
||||
allowEmailEditing: boolean;
|
||||
allowCustomRecipients: boolean;
|
||||
showResponseWall: boolean;
|
||||
highlightCampaign: boolean;
|
||||
targetGovernmentLevels: GovernmentLevel[];
|
||||
createdByUserId: string | null;
|
||||
createdByUserEmail: string | null;
|
||||
createdByUserName: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count: {
|
||||
emails: number;
|
||||
responses: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CampaignsListResponse {
|
||||
campaigns: Campaign[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface CreateCampaignPayload {
|
||||
title: string;
|
||||
emailSubject: string;
|
||||
emailBody: string;
|
||||
description?: string;
|
||||
callToAction?: string;
|
||||
status?: CampaignStatus;
|
||||
targetGovernmentLevels?: GovernmentLevel[];
|
||||
allowSmtpEmail?: boolean;
|
||||
allowMailtoLink?: boolean;
|
||||
collectUserInfo?: boolean;
|
||||
showEmailCount?: boolean;
|
||||
showCallCount?: boolean;
|
||||
allowEmailEditing?: boolean;
|
||||
allowCustomRecipients?: boolean;
|
||||
showResponseWall?: boolean;
|
||||
highlightCampaign?: boolean;
|
||||
coverPhoto?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCampaignPayload {
|
||||
title?: string;
|
||||
emailSubject?: string;
|
||||
emailBody?: string;
|
||||
description?: string | null;
|
||||
callToAction?: string | null;
|
||||
status?: CampaignStatus;
|
||||
targetGovernmentLevels?: GovernmentLevel[];
|
||||
allowSmtpEmail?: boolean;
|
||||
allowMailtoLink?: boolean;
|
||||
collectUserInfo?: boolean;
|
||||
showEmailCount?: boolean;
|
||||
showCallCount?: boolean;
|
||||
allowEmailEditing?: boolean;
|
||||
allowCustomRecipients?: boolean;
|
||||
showResponseWall?: boolean;
|
||||
highlightCampaign?: boolean;
|
||||
coverPhoto?: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignsListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: CampaignStatus;
|
||||
}
|
||||
|
||||
// --- Representatives ---
|
||||
|
||||
export interface RepresentativeOffice {
|
||||
type?: string;
|
||||
tel?: string;
|
||||
fax?: string;
|
||||
postal?: string;
|
||||
}
|
||||
|
||||
export interface Representative {
|
||||
id: string | null;
|
||||
postalCode: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
districtName: string | null;
|
||||
electedOffice: string | null;
|
||||
partyName: string | null;
|
||||
representativeSetName: string | null;
|
||||
url: string | null;
|
||||
photoUrl: string | null;
|
||||
offices: RepresentativeOffice[] | null;
|
||||
cachedAt: string;
|
||||
}
|
||||
|
||||
export interface RepresentativesListResponse {
|
||||
representatives: Representative[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface RepresentativeLookupResponse {
|
||||
source: 'cache' | 'api';
|
||||
postalCode: string;
|
||||
location: {
|
||||
city: string | null;
|
||||
province: string | null;
|
||||
};
|
||||
representatives: Representative[];
|
||||
}
|
||||
|
||||
export interface CacheStats {
|
||||
totalRepresentatives: number;
|
||||
postalCodesWithRepresentatives: number;
|
||||
totalPostalCodes: number;
|
||||
}
|
||||
|
||||
export interface RepresentativesListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
postalCode?: string;
|
||||
}
|
||||
|
||||
// --- Campaign Emails ---
|
||||
|
||||
export type EmailMethod = 'SMTP' | 'MAILTO';
|
||||
|
||||
export type CampaignEmailStatus = 'QUEUED' | 'SENT' | 'FAILED' | 'CLICKED' | 'USER_INFO_CAPTURED';
|
||||
|
||||
export interface CampaignEmail {
|
||||
id: string;
|
||||
userEmail: string | null;
|
||||
userName: string | null;
|
||||
userPostalCode: string | null;
|
||||
recipientEmail: string;
|
||||
recipientName: string | null;
|
||||
recipientLevel: GovernmentLevel | null;
|
||||
emailMethod: EmailMethod;
|
||||
subject: string;
|
||||
status: CampaignEmailStatus;
|
||||
sentAt: string;
|
||||
}
|
||||
|
||||
export interface CampaignEmailsListResponse {
|
||||
emails: CampaignEmail[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface CampaignEmailStats {
|
||||
total: number;
|
||||
queued: number;
|
||||
sent: number;
|
||||
failed: number;
|
||||
clicked: number;
|
||||
smtpCount: number;
|
||||
mailtoCount: number;
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
// --- Representative Responses ---
|
||||
|
||||
export type ResponseType = 'EMAIL' | 'LETTER' | 'PHONE_CALL' | 'MEETING' | 'SOCIAL_MEDIA' | 'OTHER';
|
||||
|
||||
export type ResponseStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
|
||||
|
||||
export interface RepresentativeResponse {
|
||||
id: string;
|
||||
representativeName: string;
|
||||
representativeTitle: string | null;
|
||||
representativeLevel: GovernmentLevel;
|
||||
representativeEmail: string | null;
|
||||
responseType: ResponseType;
|
||||
responseText: string;
|
||||
userComment: string | null;
|
||||
submittedByName: string | null;
|
||||
submittedByEmail: string | null;
|
||||
isAnonymous: boolean;
|
||||
status: ResponseStatus;
|
||||
isVerified: boolean;
|
||||
verifiedAt: string | null;
|
||||
verifiedBy: string | null;
|
||||
upvoteCount: number;
|
||||
createdAt: string;
|
||||
campaign?: {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResponsesListResponse {
|
||||
responses: RepresentativeResponse[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface ResponseStats {
|
||||
total: number;
|
||||
verified: number;
|
||||
totalUpvotes: number;
|
||||
byLevel: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SubmitResponsePayload {
|
||||
representativeName: string;
|
||||
representativeLevel: GovernmentLevel;
|
||||
responseType: ResponseType;
|
||||
responseText: string;
|
||||
representativeTitle?: string;
|
||||
representativeEmail?: string;
|
||||
userComment?: string;
|
||||
submittedByName?: string;
|
||||
submittedByEmail?: string;
|
||||
isAnonymous?: boolean;
|
||||
sendVerification?: boolean;
|
||||
}
|
||||
|
||||
export interface ResponsesListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: ResponseStatus;
|
||||
campaignId?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface PublicResponsesListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sort?: 'recent' | 'upvotes' | 'verified';
|
||||
level?: GovernmentLevel;
|
||||
}
|
||||
|
||||
export const RESPONSE_TYPE_LABELS: Record<ResponseType, string> = {
|
||||
EMAIL: 'Email',
|
||||
LETTER: 'Letter',
|
||||
PHONE_CALL: 'Phone Call',
|
||||
MEETING: 'Meeting',
|
||||
SOCIAL_MEDIA: 'Social Media',
|
||||
OTHER: 'Other',
|
||||
};
|
||||
|
||||
export const RESPONSE_STATUS_COLORS: Record<ResponseStatus, string> = {
|
||||
PENDING: 'orange',
|
||||
APPROVED: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
export const GOVERNMENT_LEVEL_COLORS: Record<GovernmentLevel, string> = {
|
||||
FEDERAL: 'blue',
|
||||
PROVINCIAL: 'purple',
|
||||
MUNICIPAL: 'green',
|
||||
SCHOOL_BOARD: 'orange',
|
||||
};
|
||||
|
||||
export const GOVERNMENT_LEVEL_LABELS: Record<GovernmentLevel, string> = {
|
||||
FEDERAL: 'Federal',
|
||||
PROVINCIAL: 'Provincial',
|
||||
MUNICIPAL: 'Municipal',
|
||||
SCHOOL_BOARD: 'School Board',
|
||||
};
|
||||
|
||||
// --- Map / Locations ---
|
||||
|
||||
export type SupportLevel = 'LEVEL_1' | 'LEVEL_2' | 'LEVEL_3' | 'LEVEL_4';
|
||||
|
||||
export type GeocodeProvider = 'MAPBOX' | 'NOMINATIM' | 'PHOTON' | 'LOCATIONIQ' | 'ARCGIS' | 'UNKNOWN';
|
||||
|
||||
export interface Location {
|
||||
id: string;
|
||||
latitude: string | null; // Prisma Decimal comes as string
|
||||
longitude: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
unitNumber: string | null;
|
||||
supportLevel: SupportLevel | null;
|
||||
address: string | null;
|
||||
sign: boolean;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
geocodeConfidence: number | null;
|
||||
geocodeProvider: GeocodeProvider | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LocationsListResponse {
|
||||
locations: Location[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface LocationStats {
|
||||
total: number;
|
||||
supportLevels: {
|
||||
LEVEL_1: number;
|
||||
LEVEL_2: number;
|
||||
LEVEL_3: number;
|
||||
LEVEL_4: number;
|
||||
NONE: number;
|
||||
};
|
||||
signs: number;
|
||||
geocoded: number;
|
||||
ungeocoded: number;
|
||||
}
|
||||
|
||||
export interface MapSettings {
|
||||
id: string;
|
||||
latitude: string | null;
|
||||
longitude: string | null;
|
||||
zoom: number | null;
|
||||
walkSheetTitle: string | null;
|
||||
walkSheetSubtitle: string | null;
|
||||
walkSheetFooter: string | null;
|
||||
qrCode1Url: string | null;
|
||||
qrCode1Label: string | null;
|
||||
qrCode2Url: string | null;
|
||||
qrCode2Label: string | null;
|
||||
qrCode3Url: string | null;
|
||||
qrCode3Label: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface GeocodeResult {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
confidence: number;
|
||||
provider: GeocodeProvider;
|
||||
formattedAddress?: string;
|
||||
}
|
||||
|
||||
export interface LocationsListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
supportLevel?: SupportLevel;
|
||||
hasSign?: boolean;
|
||||
sortBy?: 'createdAt' | 'address' | 'supportLevel';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export const SUPPORT_LEVEL_LABELS: Record<SupportLevel, string> = {
|
||||
LEVEL_1: 'Strong Support',
|
||||
LEVEL_2: 'Likely Support',
|
||||
LEVEL_3: 'Unsure',
|
||||
LEVEL_4: 'Opposition',
|
||||
};
|
||||
|
||||
export const SUPPORT_LEVEL_COLORS: Record<SupportLevel, string> = {
|
||||
LEVEL_1: '#27ae60',
|
||||
LEVEL_2: '#f1c40f',
|
||||
LEVEL_3: '#e67e22',
|
||||
LEVEL_4: '#e74c3c',
|
||||
};
|
||||
|
||||
export interface ReverseGeocodeResult {
|
||||
address: string;
|
||||
city?: string;
|
||||
province?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
// --- Map / Cuts ---
|
||||
|
||||
export type CutCategory = 'CUSTOM' | 'WARD' | 'NEIGHBORHOOD' | 'DISTRICT';
|
||||
|
||||
export interface Cut {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string;
|
||||
opacity: string; // Prisma Decimal comes as string
|
||||
category: CutCategory | null;
|
||||
isPublic: boolean;
|
||||
isOfficial: boolean;
|
||||
geojson: string;
|
||||
bounds: string | null;
|
||||
showLocations: boolean;
|
||||
exportEnabled: boolean;
|
||||
assignedTo: string | null;
|
||||
filterSettings: Record<string, unknown> | null;
|
||||
lastCanvassed: string | null;
|
||||
completionPercentage: number;
|
||||
createdByUserId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PublicCut {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
color: string;
|
||||
opacity: string;
|
||||
category: CutCategory | null;
|
||||
geojson: string;
|
||||
bounds: string | null;
|
||||
}
|
||||
|
||||
export interface CutsListResponse {
|
||||
cuts: Cut[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface CreateCutPayload {
|
||||
name: string;
|
||||
geojson: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
category?: CutCategory;
|
||||
isPublic?: boolean;
|
||||
isOfficial?: boolean;
|
||||
bounds?: string;
|
||||
showLocations?: boolean;
|
||||
exportEnabled?: boolean;
|
||||
assignedTo?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCutPayload {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
category?: CutCategory | null;
|
||||
isPublic?: boolean;
|
||||
isOfficial?: boolean;
|
||||
geojson?: string;
|
||||
bounds?: string | null;
|
||||
showLocations?: boolean;
|
||||
exportEnabled?: boolean;
|
||||
assignedTo?: string | null;
|
||||
lastCanvassed?: string | null;
|
||||
completionPercentage?: number;
|
||||
}
|
||||
|
||||
export interface CutsListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: CutCategory;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface CutStatistics {
|
||||
total: number;
|
||||
byLevel: Record<string, number>;
|
||||
withSign: number;
|
||||
}
|
||||
|
||||
export const CUT_CATEGORY_LABELS: Record<CutCategory, string> = {
|
||||
CUSTOM: 'Custom',
|
||||
WARD: 'Ward',
|
||||
NEIGHBORHOOD: 'Neighborhood',
|
||||
DISTRICT: 'District',
|
||||
};
|
||||
|
||||
export const CUT_CATEGORY_COLORS: Record<CutCategory, string> = {
|
||||
CUSTOM: 'default',
|
||||
WARD: 'blue',
|
||||
NEIGHBORHOOD: 'green',
|
||||
DISTRICT: 'purple',
|
||||
};
|
||||
|
||||
export const MAP_ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN'];
|
||||
|
||||
// --- Map / Shifts ---
|
||||
|
||||
export type ShiftStatus = 'OPEN' | 'FULL' | 'CANCELLED';
|
||||
|
||||
export type SignupStatus = 'CONFIRMED' | 'CANCELLED';
|
||||
|
||||
export type SignupSource = 'AUTHENTICATED' | 'PUBLIC' | 'ADMIN';
|
||||
|
||||
export interface Shift {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
maxVolunteers: number;
|
||||
currentVolunteers: number;
|
||||
status: ShiftStatus;
|
||||
isPublic: boolean;
|
||||
cutId: string | null;
|
||||
cut?: { id: string; name: string } | null;
|
||||
createdBy: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count?: {
|
||||
signups: number;
|
||||
};
|
||||
signups?: ShiftSignup[];
|
||||
}
|
||||
|
||||
export interface ShiftSignup {
|
||||
id: string;
|
||||
shiftId: string;
|
||||
shiftTitle: string | null;
|
||||
userId: string | null;
|
||||
userEmail: string;
|
||||
userName: string | null;
|
||||
userPhone: string | null;
|
||||
signupDate: string;
|
||||
status: SignupStatus;
|
||||
signupSource: SignupSource;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
phone: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ShiftsListResponse {
|
||||
shifts: Shift[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface ShiftStats {
|
||||
total: number;
|
||||
open: number;
|
||||
full: number;
|
||||
cancelled: number;
|
||||
upcoming: number;
|
||||
totalSignups: number;
|
||||
}
|
||||
|
||||
export interface ShiftsListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: ShiftStatus;
|
||||
upcoming?: boolean;
|
||||
sortBy?: 'date' | 'createdAt' | 'title';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface CreateShiftPayload {
|
||||
title: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location?: string;
|
||||
maxVolunteers: number;
|
||||
isPublic?: boolean;
|
||||
cutId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateShiftPayload {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
date?: string;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
location?: string | null;
|
||||
maxVolunteers?: number;
|
||||
isPublic?: boolean;
|
||||
status?: ShiftStatus;
|
||||
cutId?: string | null;
|
||||
}
|
||||
|
||||
export const SHIFT_STATUS_COLORS: Record<ShiftStatus, string> = {
|
||||
OPEN: 'green',
|
||||
FULL: 'orange',
|
||||
CANCELLED: 'red',
|
||||
};
|
||||
|
||||
export const SHIFT_STATUS_LABELS: Record<ShiftStatus, string> = {
|
||||
OPEN: 'Open',
|
||||
FULL: 'Full',
|
||||
CANCELLED: 'Cancelled',
|
||||
};
|
||||
|
||||
export const SIGNUP_SOURCE_COLORS: Record<SignupSource, string> = {
|
||||
AUTHENTICATED: 'blue',
|
||||
PUBLIC: 'green',
|
||||
ADMIN: 'purple',
|
||||
};
|
||||
|
||||
// --- Landing Pages ---
|
||||
|
||||
export type EditorMode = 'VISUAL' | 'CODE';
|
||||
|
||||
export type MkdocsExportMode = 'THEMED' | 'STANDALONE';
|
||||
|
||||
export interface LandingPage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
editorMode: EditorMode;
|
||||
blocks: unknown; // GrapesJS project data
|
||||
htmlOutput: string | null;
|
||||
cssOutput: string | null;
|
||||
mkdocsPath: string | null;
|
||||
mkdocsStubPath: string | null;
|
||||
mkdocsExportMode: MkdocsExportMode;
|
||||
mkdocsHideNav: boolean;
|
||||
mkdocsHideToc: boolean;
|
||||
published: boolean;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
seoImage: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PageBlock {
|
||||
id: string;
|
||||
type: string;
|
||||
label: string;
|
||||
schema: Record<string, unknown>;
|
||||
defaults: Record<string, unknown>;
|
||||
thumbnail: string | null;
|
||||
category: string | null;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LandingPagesListResponse {
|
||||
pages: LandingPage[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface LandingPagesListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
published?: 'true' | 'false';
|
||||
}
|
||||
|
||||
// --- Docs / Code Server ---
|
||||
|
||||
export interface DocsStatus {
|
||||
mkdocs: { online: boolean; url: string };
|
||||
codeServer: { online: boolean; url: string };
|
||||
siteServer: { online: boolean; url: string };
|
||||
}
|
||||
|
||||
export interface DocsConfig {
|
||||
codeServerPort: number;
|
||||
mkdocsPort: number;
|
||||
mkdocsSitePort: number;
|
||||
}
|
||||
|
||||
// --- Docs / File Tree ---
|
||||
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
// --- Platform Services ---
|
||||
|
||||
export interface ServicesStatus {
|
||||
nocodb: { online: boolean; url: string };
|
||||
n8n: { online: boolean; url: string };
|
||||
gitea: { online: boolean; url: string };
|
||||
mailhog: { online: boolean; url: string };
|
||||
}
|
||||
|
||||
export interface ServicesConfig {
|
||||
nocodbPort: number;
|
||||
n8nPort: number;
|
||||
giteaPort: number;
|
||||
mailhogPort: number;
|
||||
domain: string;
|
||||
nocodbSubdomain: string;
|
||||
n8nSubdomain: string;
|
||||
giteaSubdomain: string;
|
||||
mailhogSubdomain: string;
|
||||
}
|
||||
|
||||
// --- Site Settings ---
|
||||
|
||||
export interface SiteSettings {
|
||||
id: string;
|
||||
organizationName: string;
|
||||
organizationShortName: string;
|
||||
organizationLogoUrl: string | null;
|
||||
organizationFaviconUrl: string | null;
|
||||
adminColorPrimary: string;
|
||||
adminColorBgBase: string;
|
||||
publicColorPrimary: string;
|
||||
publicColorBgBase: string;
|
||||
publicColorBgContainer: string;
|
||||
publicHeaderGradient: string;
|
||||
footerText: string;
|
||||
loginSubtitle: string;
|
||||
emailFromName: string;
|
||||
// SMTP fields (only present from admin endpoint)
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpUser?: string;
|
||||
smtpPass?: string;
|
||||
smtpFromAddress?: string;
|
||||
smtpActiveProvider?: 'mailhog' | 'production';
|
||||
emailTestMode?: boolean;
|
||||
testEmailRecipient?: string;
|
||||
enableInfluence: boolean;
|
||||
enableMap: boolean;
|
||||
enableNewsletter: boolean;
|
||||
enableLandingPages: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// --- MkDocs Config ---
|
||||
|
||||
export interface MkDocsConfigResponse {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface MkDocsBuildResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// --- Pangolin ---
|
||||
|
||||
export interface PangolinStatus {
|
||||
configured: boolean;
|
||||
healthy: boolean;
|
||||
pangolinUrl: string | null;
|
||||
orgId: string | null;
|
||||
siteId: string | null;
|
||||
newtConfigured: boolean;
|
||||
}
|
||||
|
||||
export interface PangolinConfig {
|
||||
pangolinApiUrl: string | null;
|
||||
pangolinEndpoint: string | null;
|
||||
orgId: string | null;
|
||||
siteId: string | null;
|
||||
newtId: string | null;
|
||||
newtConfigured: boolean;
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface PangolinResource {
|
||||
resourceId: string;
|
||||
siteId: string;
|
||||
orgId: string;
|
||||
name: string;
|
||||
subdomain?: string;
|
||||
fullDomain?: string;
|
||||
ssl?: boolean;
|
||||
active?: boolean;
|
||||
proxyPort?: number;
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
// --- Listmonk ---
|
||||
|
||||
export interface ListmonkStatus {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
initialized: boolean;
|
||||
lastSyncAt: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface ListmonkListStat {
|
||||
name: string;
|
||||
subscriberCount: number;
|
||||
}
|
||||
|
||||
export interface ListmonkStats {
|
||||
lists: ListmonkListStat[];
|
||||
}
|
||||
|
||||
export interface ListmonkSyncResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
results?: {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// --- Geocoding ---
|
||||
|
||||
export interface GeocodeSearchResult {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
displayName: string;
|
||||
type: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
// --- Bulk Import ---
|
||||
|
||||
export interface BulkImportResult {
|
||||
total: number;
|
||||
created: number;
|
||||
skippedDuplicate: number;
|
||||
skippedOutOfBounds: number;
|
||||
skippedInvalid: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
// --- NAR Server Import ---
|
||||
|
||||
export interface NarDataset {
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
provinceAbbr: string;
|
||||
addressFiles: { filename: string; sizeFormatted: string; sizeBytes: number }[];
|
||||
locationFiles: { filename: string; sizeFormatted: string; sizeBytes: number }[];
|
||||
totalAddressSize: number;
|
||||
totalLocationSize: number;
|
||||
}
|
||||
|
||||
export interface NarDatasetsResponse {
|
||||
narDir: string | null;
|
||||
datasets: NarDataset[];
|
||||
}
|
||||
|
||||
export interface NarServerImportResult {
|
||||
provinceCode: string;
|
||||
provinceName: string;
|
||||
totalRows: number;
|
||||
created: number;
|
||||
skippedDuplicate: number;
|
||||
skippedOutOfBounds: number;
|
||||
skippedNonResidential: number;
|
||||
skippedInvalid: number;
|
||||
errors: string[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export interface NarImportProgress {
|
||||
status: 'loading-locations' | 'importing' | 'complete' | 'failed';
|
||||
totalRows: number;
|
||||
created: number;
|
||||
skippedDuplicate: number;
|
||||
skippedOutOfBounds: number;
|
||||
skippedNonResidential: number;
|
||||
skippedInvalid: number;
|
||||
currentFile: string;
|
||||
provinceName: string;
|
||||
error?: string;
|
||||
result?: NarServerImportResult;
|
||||
}
|
||||
|
||||
// --- SMTP Test Results ---
|
||||
|
||||
export interface SmtpTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SmtpSendTestResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
testMode: boolean;
|
||||
recipient: string;
|
||||
}
|
||||
|
||||
export interface ListmonkSyncAllResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
results?: {
|
||||
participants: { total: number; success: number; failed: number; errors: string[] };
|
||||
locations: { total: number; success: number; failed: number; errors: string[] };
|
||||
users: { total: number; success: number; failed: number; errors: string[] };
|
||||
};
|
||||
}
|
||||
152
admin/src/types/canvass.ts
Normal file
152
admin/src/types/canvass.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import type { SupportLevel } from './api';
|
||||
|
||||
// --- Enums ---
|
||||
|
||||
export type VisitOutcome =
|
||||
| 'NOT_HOME'
|
||||
| 'REFUSED'
|
||||
| 'MOVED'
|
||||
| 'ALREADY_VOTED'
|
||||
| 'SPOKE_WITH'
|
||||
| 'LEFT_LITERATURE'
|
||||
| 'COME_BACK_LATER';
|
||||
|
||||
export type CanvassSessionStatus = 'ACTIVE' | 'COMPLETED' | 'ABANDONED';
|
||||
|
||||
export const VISIT_OUTCOME_LABELS: Record<VisitOutcome, string> = {
|
||||
NOT_HOME: 'Not Home',
|
||||
REFUSED: 'Refused',
|
||||
MOVED: 'Moved',
|
||||
ALREADY_VOTED: 'Already Voted',
|
||||
SPOKE_WITH: 'Spoke With',
|
||||
LEFT_LITERATURE: 'Left Literature',
|
||||
COME_BACK_LATER: 'Come Back Later',
|
||||
};
|
||||
|
||||
export const VISIT_OUTCOME_COLORS: Record<VisitOutcome, string> = {
|
||||
NOT_HOME: '#f1c40f',
|
||||
REFUSED: '#e74c3c',
|
||||
MOVED: '#95a5a6',
|
||||
ALREADY_VOTED: '#9b59b6',
|
||||
SPOKE_WITH: '#27ae60',
|
||||
LEFT_LITERATURE: '#3498db',
|
||||
COME_BACK_LATER: '#e67e22',
|
||||
};
|
||||
|
||||
// --- Interfaces ---
|
||||
|
||||
export interface CanvassLocation {
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address: string | null;
|
||||
unitNumber: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
supportLevel: SupportLevel | null;
|
||||
sign: boolean;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
lastVisit: {
|
||||
outcome: VisitOutcome;
|
||||
visitedAt: string;
|
||||
visitorName: string | null;
|
||||
isMyVisit: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface WalkingRoute {
|
||||
orderedLocations: { id: string; latitude: number; longitude: number }[];
|
||||
totalDistanceMeters: number;
|
||||
estimatedMinutes: number;
|
||||
}
|
||||
|
||||
export interface CanvassSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
cutId: string;
|
||||
shiftId: string | null;
|
||||
status: CanvassSessionStatus;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
startLatitude: string | null;
|
||||
startLongitude: string | null;
|
||||
cut?: { id: string; name: string };
|
||||
shift?: { id: string; title: string } | null;
|
||||
}
|
||||
|
||||
export interface MyAssignment {
|
||||
shiftId: string;
|
||||
shiftTitle: string;
|
||||
shiftDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
location: string | null;
|
||||
cutId: string;
|
||||
cutName: string;
|
||||
completionPercentage: number;
|
||||
}
|
||||
|
||||
export interface MyCanvassStats {
|
||||
totalVisits: number;
|
||||
todayVisits: number;
|
||||
byOutcome: Record<string, number>;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface RecordVisitPayload {
|
||||
locationId: string;
|
||||
outcome: VisitOutcome;
|
||||
supportLevel?: SupportLevel;
|
||||
signRequested?: boolean;
|
||||
signSize?: string;
|
||||
notes?: string;
|
||||
durationSeconds?: number;
|
||||
sessionId?: string;
|
||||
shiftId?: string;
|
||||
updateLocation?: boolean;
|
||||
}
|
||||
|
||||
export interface CanvassVisit {
|
||||
id: string;
|
||||
locationId: string;
|
||||
userId: string;
|
||||
outcome: VisitOutcome;
|
||||
supportLevel: SupportLevel | null;
|
||||
signRequested: boolean;
|
||||
signSize: string | null;
|
||||
notes: string | null;
|
||||
durationSeconds: number | null;
|
||||
visitedAt: string;
|
||||
location?: { id: string; address: string | null; unitNumber: string | null };
|
||||
user?: { id: string; name: string | null; email: string };
|
||||
}
|
||||
|
||||
// --- Admin types ---
|
||||
|
||||
export interface CanvassAdminStats {
|
||||
totalVisits: number;
|
||||
todayVisits: number;
|
||||
activeSessions: number;
|
||||
activeVolunteers: number;
|
||||
overallCompletion: number;
|
||||
}
|
||||
|
||||
export interface CutCanvassStats {
|
||||
cutId: string;
|
||||
cutName: string;
|
||||
totalLocations: number;
|
||||
visitedLocations: number;
|
||||
completionPercentage: number;
|
||||
totalVisits: number;
|
||||
byOutcome: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface VolunteerSummary {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
totalVisits: number;
|
||||
sessions: number;
|
||||
lastActive: string | null;
|
||||
}
|
||||
45
admin/src/types/tracking.ts
Normal file
45
admin/src/types/tracking.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export type TrackPointEvent = 'LOCATION_ADDED' | 'VISIT_RECORDED' | 'SESSION_STARTED' | 'SESSION_ENDED';
|
||||
|
||||
export interface TrackPointPayload {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
recordedAt: string;
|
||||
eventType?: TrackPointEvent;
|
||||
}
|
||||
|
||||
export interface LiveVolunteer {
|
||||
userId: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
lastRecordedAt: string;
|
||||
recentTrail: [number, number][];
|
||||
canvassSessionId: string | null;
|
||||
trackingSessionId: string;
|
||||
}
|
||||
|
||||
export interface TrackingSessionSummary {
|
||||
id: string;
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userEmail: string;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
totalPoints: number;
|
||||
totalDistanceM: number;
|
||||
canvassSessionId: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface SessionRoute {
|
||||
coordinates: [number, number][];
|
||||
events: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
eventType: TrackPointEvent;
|
||||
recordedAt: string;
|
||||
}[];
|
||||
totalPoints: number;
|
||||
}
|
||||
43
admin/src/utils/representatives.ts
Normal file
43
admin/src/utils/representatives.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import type { GovernmentLevel, Representative } from '@/types/api';
|
||||
|
||||
export function mapRepSetToLevel(repSetName: string | null): GovernmentLevel | null {
|
||||
if (!repSetName) return null;
|
||||
const lower = repSetName.toLowerCase();
|
||||
if (lower.includes('house of commons') || lower.includes('senate')) return 'FEDERAL';
|
||||
if (lower.includes('legislative') || lower.includes('national assembly') || lower.includes('provincial')) return 'PROVINCIAL';
|
||||
if (lower.includes('council') || lower.includes('city') || lower.includes('municipal') || lower.includes('mayor')) return 'MUNICIPAL';
|
||||
if (lower.includes('school') || lower.includes('board') || lower.includes('trustee')) return 'SCHOOL_BOARD';
|
||||
if (lower.includes('mp') || lower.includes('federal')) return 'FEDERAL';
|
||||
if (lower.includes('mpp') || lower.includes('mla') || lower.includes('mna')) return 'PROVINCIAL';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function groupRepsByLevel(reps: Representative[]): Record<string, Representative[]> {
|
||||
const groups: Record<string, Representative[]> = {};
|
||||
const levelOrder: GovernmentLevel[] = ['FEDERAL', 'PROVINCIAL', 'MUNICIPAL', 'SCHOOL_BOARD'];
|
||||
|
||||
for (const rep of reps) {
|
||||
const level = mapRepSetToLevel(rep.representativeSetName) || 'OTHER';
|
||||
if (!groups[level]) groups[level] = [];
|
||||
groups[level].push(rep);
|
||||
}
|
||||
|
||||
// Sort by level order
|
||||
const sorted: Record<string, Representative[]> = {};
|
||||
for (const level of levelOrder) {
|
||||
if (groups[level]) sorted[level] = groups[level];
|
||||
}
|
||||
if (groups['OTHER']) sorted['OTHER'] = groups['OTHER'];
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Ant Design tag color names to actual hex colors for use in inline styles.
|
||||
*/
|
||||
export const GOVERNMENT_LEVEL_DISPLAY_COLORS: Record<string, string> = {
|
||||
FEDERAL: '#3b82f6',
|
||||
PROVINCIAL: '#a855f7',
|
||||
MUNICIPAL: '#22c55e',
|
||||
SCHOOL_BOARD: '#f97316',
|
||||
OTHER: '#94a3b8',
|
||||
};
|
||||
13
admin/src/vite-env.d.ts
vendored
Normal file
13
admin/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'grapesjs-tabs' {
|
||||
import { Plugin } from 'grapesjs';
|
||||
const plugin: Plugin;
|
||||
export default plugin;
|
||||
}
|
||||
|
||||
declare module 'grapesjs-touch' {
|
||||
import { Plugin } from 'grapesjs';
|
||||
const plugin: Plugin;
|
||||
export default plugin;
|
||||
}
|
||||
24
admin/tsconfig.json
Normal file
24
admin/tsconfig.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user