Compare commits
5 Commits
8dc2248f1f
...
feature/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90fa628cb5 | ||
|
|
7f389830f8 | ||
|
|
30909f3268 | ||
|
|
df45a6f5d7 | ||
|
|
156ad09232 |
655
README.md
655
README.md
@@ -1,257 +1,470 @@
|
|||||||
# SmoothSchedule - Multi-Tenant Scheduling Platform
|
# SmoothSchedule - Multi-Tenant Scheduling Platform
|
||||||
|
|
||||||
A production-ready multi-tenant SaaS platform for resource scheduling and orchestration.
|
A production-ready multi-tenant SaaS platform for resource scheduling, appointments, and business management.
|
||||||
|
|
||||||
## 🎯 Features
|
## Features
|
||||||
|
|
||||||
- ✅ **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
|
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
|
||||||
- ✅ **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
|
- **8-Tier Role Hierarchy**: SUPERUSER, PLATFORM_MANAGER, PLATFORM_SALES, PLATFORM_SUPPORT, TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF, CUSTOMER
|
||||||
- ✅ **Modern Stack**: Django 5.2 + React 18 + Vite
|
- **Modern Stack**: Django 5.2 + React 19 + TypeScript + Vite
|
||||||
- ✅ **Docker Ready**: Complete production & development Docker Compose setup
|
- **Real-time Updates**: Django Channels + WebSockets
|
||||||
- ✅ **Cloud Storage**: DigitalOcean Spaces (S3-compatible) for static/media files
|
- **Background Tasks**: Celery + Redis
|
||||||
- ✅ **Auto SSL**: Let's Encrypt certificates via Traefik reverse proxy
|
- **Auto SSL**: Let's Encrypt certificates via Traefik
|
||||||
- ✅ **Task Queue**: Celery + Redis for background jobs
|
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible)
|
||||||
- ✅ **Real-time**: Django Channels + WebSockets support
|
- **Docker Ready**: Complete Docker Compose setup for dev and production
|
||||||
- ✅ **Production Ready**: Fully configured for deployment
|
|
||||||
|
|
||||||
## 📚 Documentation
|
## Project Structure
|
||||||
|
|
||||||
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - **Manual step-by-step production deployment** (start here for fresh deployments)
|
```
|
||||||
- **[QUICK-REFERENCE.md](QUICK-REFERENCE.md)** - Common commands and quick start
|
smoothschedule2/
|
||||||
- **[PRODUCTION-READY.md](PRODUCTION-READY.md)** - Production deployment status
|
├── frontend/ # React + Vite + TypeScript
|
||||||
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
|
│ ├── src/
|
||||||
- **[CLAUDE.md](CLAUDE.md)** - Development guide and architecture
|
│ │ ├── api/ # API client and hooks
|
||||||
|
│ │ ├── components/ # Reusable UI components
|
||||||
|
│ │ ├── hooks/ # React Query hooks
|
||||||
|
│ │ ├── pages/ # Page components
|
||||||
|
│ │ └── types.ts # TypeScript interfaces
|
||||||
|
│ ├── nginx.conf # Production nginx config
|
||||||
|
│ └── Dockerfile.prod # Production frontend container
|
||||||
|
│
|
||||||
|
├── smoothschedule/ # Django backend
|
||||||
|
│ ├── config/ # Django settings
|
||||||
|
│ │ └── settings/
|
||||||
|
│ │ ├── base.py # Base settings
|
||||||
|
│ │ ├── local.py # Local development
|
||||||
|
│ │ └── production.py # Production settings
|
||||||
|
│ ├── smoothschedule/ # Django apps (domain-based)
|
||||||
|
│ │ ├── identity/ # Users, tenants, authentication
|
||||||
|
│ │ │ ├── core/ # Tenant, Domain, middleware
|
||||||
|
│ │ │ └── users/ # User model, MFA, auth
|
||||||
|
│ │ ├── scheduling/ # Core scheduling
|
||||||
|
│ │ │ ├── schedule/ # Resources, Events, Services
|
||||||
|
│ │ │ ├── contracts/ # E-signatures
|
||||||
|
│ │ │ └── analytics/ # Business analytics
|
||||||
|
│ │ ├── communication/ # Notifications, SMS, mobile
|
||||||
|
│ │ ├── commerce/ # Payments, tickets
|
||||||
|
│ │ └── platform/ # Admin, public API
|
||||||
|
│ ├── docker-compose.local.yml
|
||||||
|
│ └── docker-compose.production.yml
|
||||||
|
│
|
||||||
|
├── deploy.sh # Automated deployment script
|
||||||
|
└── CLAUDE.md # Development guide
|
||||||
|
```
|
||||||
|
|
||||||
## 🚀 Quick Start
|
---
|
||||||
|
|
||||||
### Local Development
|
## Local Development Setup
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- **Docker** and **Docker Compose** (for backend)
|
||||||
|
- **Node.js 22+** and **npm** (for frontend)
|
||||||
|
- **Git**
|
||||||
|
|
||||||
|
### Step 1: Clone the Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start backend (Django in Docker)
|
git clone https://github.com/your-repo/smoothschedule.git
|
||||||
cd smoothschedule
|
cd smoothschedule
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Start the Backend (Django in Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd smoothschedule
|
||||||
|
|
||||||
|
# Start all backend services
|
||||||
docker compose -f docker-compose.local.yml up -d
|
docker compose -f docker-compose.local.yml up -d
|
||||||
|
|
||||||
# Start frontend (React with Vite)
|
# Wait for services to initialize (first time takes longer)
|
||||||
cd ../frontend
|
sleep 30
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# Access the app
|
# Run database migrations
|
||||||
# Frontend: http://platform.lvh.me:5173
|
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||||
# Backend API: http://lvh.me:8000/api
|
|
||||||
|
# Create a superuser (optional)
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
See [CLAUDE.md](CLAUDE.md) for detailed development instructions.
|
### Step 3: Start the Frontend (React with Vite)
|
||||||
|
|
||||||
### Production Deployment
|
|
||||||
|
|
||||||
For **fresh deployments or complete reset**, follow [PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md) for manual step-by-step instructions.
|
|
||||||
|
|
||||||
For **routine updates**, use the automated script:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy to production server (code changes only)
|
cd ../frontend
|
||||||
./deploy.sh poduck@smoothschedule.com
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
See [PRODUCTION-READY.md](PRODUCTION-READY.md) for deployment checklist and [DEPLOYMENT.md](DEPLOYMENT.md) for detailed steps.
|
### Step 4: Access the Application
|
||||||
|
|
||||||
## 🏗️ Architecture
|
The application uses `lvh.me` (resolves to 127.0.0.1) for subdomain-based multi-tenancy:
|
||||||
|
|
||||||
|
| URL | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| http://platform.lvh.me:5173 | Platform admin dashboard |
|
||||||
|
| http://demo.lvh.me:5173 | Demo tenant (if created) |
|
||||||
|
| http://lvh.me:8000/api/ | Backend API |
|
||||||
|
| http://lvh.me:8000/admin/ | Django admin |
|
||||||
|
|
||||||
|
**Why `lvh.me`?** Browsers don't allow cookies with `domain=.localhost`, but `lvh.me` resolves to 127.0.0.1 and allows proper cookie sharing across subdomains.
|
||||||
|
|
||||||
|
### Local Development Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend commands (always use docker compose)
|
||||||
|
cd smoothschedule
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose -f docker-compose.local.yml logs -f django
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||||
|
|
||||||
|
# Django shell
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
docker compose -f docker-compose.local.yml exec django pytest
|
||||||
|
|
||||||
|
# Stop all services
|
||||||
|
docker compose -f docker-compose.local.yml down
|
||||||
|
|
||||||
|
# Frontend commands
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating a Test Tenant
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd smoothschedule
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from smoothschedule.identity.core.models import Tenant, Domain
|
||||||
|
|
||||||
|
# Create tenant
|
||||||
|
tenant = Tenant.objects.create(
|
||||||
|
name="Demo Business",
|
||||||
|
schema_name="demo",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create domain
|
||||||
|
Domain.objects.create(
|
||||||
|
domain="demo.lvh.me",
|
||||||
|
tenant=tenant,
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Created tenant: {tenant.name}")
|
||||||
|
print(f"Access at: http://demo.lvh.me:5173")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Ubuntu/Debian server with Docker and Docker Compose
|
||||||
|
- Domain name with DNS configured:
|
||||||
|
- `A` record: `yourdomain.com` → Server IP
|
||||||
|
- `A` record: `*.yourdomain.com` → Server IP (wildcard)
|
||||||
|
- SSH access to the server
|
||||||
|
|
||||||
|
### Quick Deploy (Existing Server)
|
||||||
|
|
||||||
|
For routine updates to an existing production server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From your local machine
|
||||||
|
./deploy.sh user@yourdomain.com
|
||||||
|
|
||||||
|
# Or deploy specific services
|
||||||
|
./deploy.sh user@yourdomain.com nginx
|
||||||
|
./deploy.sh user@yourdomain.com django
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fresh Server Deployment
|
||||||
|
|
||||||
|
#### Step 1: Server Setup
|
||||||
|
|
||||||
|
SSH into your server and install Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh user@yourdomain.com
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||||
|
sudo sh get-docker.sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
|
||||||
|
# Logout and login for group changes
|
||||||
|
exit
|
||||||
|
ssh user@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Clone Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~
|
||||||
|
git clone https://github.com/your-repo/smoothschedule.git smoothschedule
|
||||||
|
cd smoothschedule
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Configure Environment Variables
|
||||||
|
|
||||||
|
Create production environment files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p smoothschedule/.envs/.production
|
||||||
|
|
||||||
|
# Django configuration
|
||||||
|
cat > smoothschedule/.envs/.production/.django << 'EOF'
|
||||||
|
DJANGO_SECRET_KEY=your-random-secret-key-here
|
||||||
|
DJANGO_DEBUG=False
|
||||||
|
DJANGO_ALLOWED_HOSTS=yourdomain.com,*.yourdomain.com
|
||||||
|
|
||||||
|
DJANGO_ADMIN_URL=your-secret-admin-path/
|
||||||
|
FRONTEND_URL=https://platform.yourdomain.com
|
||||||
|
PLATFORM_BASE_URL=https://platform.yourdomain.com
|
||||||
|
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# DigitalOcean Spaces (or S3)
|
||||||
|
DJANGO_AWS_ACCESS_KEY_ID=your-access-key
|
||||||
|
DJANGO_AWS_SECRET_ACCESS_KEY=your-secret-key
|
||||||
|
DJANGO_AWS_STORAGE_BUCKET_NAME=your-bucket
|
||||||
|
DJANGO_AWS_S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com
|
||||||
|
DJANGO_AWS_S3_REGION_NAME=nyc3
|
||||||
|
|
||||||
|
# SSL
|
||||||
|
DJANGO_SECURE_SSL_REDIRECT=True
|
||||||
|
DJANGO_SESSION_COOKIE_SECURE=True
|
||||||
|
DJANGO_CSRF_COOKIE_SECURE=True
|
||||||
|
|
||||||
|
# Cloudflare (for wildcard SSL)
|
||||||
|
CF_DNS_API_TOKEN=your-cloudflare-api-token
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# PostgreSQL configuration
|
||||||
|
cat > smoothschedule/.envs/.production/.postgres << 'EOF'
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=smoothschedule
|
||||||
|
POSTGRES_USER=smoothschedule_user
|
||||||
|
POSTGRES_PASSWORD=your-secure-database-password
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Build and Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/smoothschedule/smoothschedule
|
||||||
|
|
||||||
|
# Build all images
|
||||||
|
docker compose -f docker-compose.production.yml build
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
docker compose -f docker-compose.production.yml up -d
|
||||||
|
|
||||||
|
# Wait for startup
|
||||||
|
sleep 30
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
docker compose -f docker-compose.production.yml exec django python manage.py migrate
|
||||||
|
|
||||||
|
# Collect static files
|
||||||
|
docker compose -f docker-compose.production.yml exec django python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# Create superuser
|
||||||
|
docker compose -f docker-compose.production.yml exec django python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 5: Verify Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check all containers are running
|
||||||
|
docker compose -f docker-compose.production.yml ps
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose -f docker-compose.production.yml logs -f
|
||||||
|
|
||||||
|
# Test endpoints
|
||||||
|
curl https://yourdomain.com/api/health/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production URLs
|
||||||
|
|
||||||
|
| URL | Purpose |
|
||||||
|
|-----|---------|
|
||||||
|
| https://yourdomain.com | Marketing site |
|
||||||
|
| https://platform.yourdomain.com | Platform admin |
|
||||||
|
| https://*.yourdomain.com | Tenant subdomains |
|
||||||
|
| https://api.yourdomain.com | API (if configured) |
|
||||||
|
| https://yourdomain.com:5555 | Flower (Celery monitoring) |
|
||||||
|
|
||||||
|
### Production Management Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh user@yourdomain.com
|
||||||
|
cd ~/smoothschedule/smoothschedule
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker compose -f docker-compose.production.yml logs -f django
|
||||||
|
|
||||||
|
# Restart services
|
||||||
|
docker compose -f docker-compose.production.yml restart
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
docker compose -f docker-compose.production.yml exec django python manage.py migrate
|
||||||
|
|
||||||
|
# Django shell
|
||||||
|
docker compose -f docker-compose.production.yml exec django python manage.py shell
|
||||||
|
|
||||||
|
# Database backup
|
||||||
|
docker compose -f docker-compose.production.yml exec postgres pg_dump -U smoothschedule_user smoothschedule > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
### Multi-Tenancy Model
|
### Multi-Tenancy Model
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────┐
|
PostgreSQL Database
|
||||||
│ PostgreSQL Database │
|
├── public (shared schema)
|
||||||
├─────────────────────────────────────────┤
|
│ ├── Tenants
|
||||||
│ public (shared schema) │
|
│ ├── Domains
|
||||||
│ ├─ Tenants │
|
│ ├── Users
|
||||||
│ ├─ Domains │
|
│ └── PermissionGrants
|
||||||
│ ├─ Users │
|
├── demo (tenant schema)
|
||||||
│ └─ PermissionGrants │
|
│ ├── Resources
|
||||||
├─────────────────────────────────────────┤
|
│ ├── Events
|
||||||
│ tenant_demo (schema for Demo Company) │
|
│ ├── Services
|
||||||
│ ├─ Appointments │
|
│ └── Customers
|
||||||
│ ├─ Resources │
|
└── acme (tenant schema)
|
||||||
│ └─ Customers │
|
├── Resources
|
||||||
├─────────────────────────────────────────┤
|
├── Events
|
||||||
│ tenant_acme (schema for Acme Corp) │
|
└── ...
|
||||||
│ ├─ Appointments │
|
|
||||||
│ ├─ Resources │
|
|
||||||
│ └─ Customers │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Role Hierarchy
|
### Role Hierarchy
|
||||||
|
|
||||||
| Role | Level | Access Scope |
|
| Role | Level | Access |
|
||||||
|---------------------|----------|---------------------------|
|
|------|-------|--------|
|
||||||
| SUPERUSER | Platform | All tenants (god mode) |
|
| SUPERUSER | Platform | All tenants (god mode) |
|
||||||
| PLATFORM_MANAGER | Platform | All tenants |
|
| PLATFORM_MANAGER | Platform | All tenants |
|
||||||
| PLATFORM_SALES | Platform | Demo accounts only |
|
| PLATFORM_SALES | Platform | Demo accounts only |
|
||||||
| PLATFORM_SUPPORT | Platform | Tenant users |
|
| PLATFORM_SUPPORT | Platform | Can masquerade as tenant users |
|
||||||
| TENANT_OWNER | Tenant | Own tenant (full access) |
|
| TENANT_OWNER | Tenant | Full tenant access |
|
||||||
| TENANT_MANAGER | Tenant | Own tenant |
|
| TENANT_MANAGER | Tenant | Most tenant features |
|
||||||
| TENANT_STAFF | Tenant | Own tenant (limited) |
|
| TENANT_STAFF | Tenant | Limited tenant access |
|
||||||
| CUSTOMER | Tenant | Own data only |
|
| CUSTOMER | Tenant | Own data only |
|
||||||
|
|
||||||
### Masquerading Matrix
|
### Request Flow
|
||||||
|
|
||||||
| Hijacker Role | Can Masquerade As |
|
|
||||||
|--------------------|----------------------------------|
|
|
||||||
| SUPERUSER | Anyone |
|
|
||||||
| PLATFORM_SUPPORT | Tenant users |
|
|
||||||
| PLATFORM_SALES | Demo accounts (`is_temporary=True`) |
|
|
||||||
| TENANT_OWNER | Staff in same tenant |
|
|
||||||
| Others | No one |
|
|
||||||
|
|
||||||
**Security Rules:**
|
|
||||||
- Cannot hijack yourself
|
|
||||||
- Cannot hijack SUPERUSERs (except by other SUPERUSERs)
|
|
||||||
- Maximum depth: 1 (no hijack chains)
|
|
||||||
- All attempts logged to `logs/masquerade.log`
|
|
||||||
|
|
||||||
## 📁 Project Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
smoothschedule/
|
Browser → Traefik (SSL) → nginx (frontend) or django (API)
|
||||||
├── config/
|
↓
|
||||||
│ └── settings.py # Multi-tenancy & security config
|
React SPA
|
||||||
├── core/
|
↓
|
||||||
│ ├── models.py # Tenant, Domain, PermissionGrant
|
/api/* → django:5000
|
||||||
│ ├── permissions.py # Hijack permission matrix
|
/ws/* → django:5000 (WebSocket)
|
||||||
│ ├── middleware.py # Masquerade audit logging
|
|
||||||
│ └── admin.py # Django admin for core models
|
|
||||||
├── users/
|
|
||||||
│ ├── models.py # Custom User with 8-tier roles
|
|
||||||
│ └── admin.py # User admin with hijack button
|
|
||||||
├── logs/
|
|
||||||
│ ├── security.log # General security events
|
|
||||||
│ └── masquerade.log # Hijack activity (JSON)
|
|
||||||
└── setup_project.sh # Automated setup script
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔐 Security Features
|
|
||||||
|
|
||||||
### Audit Logging
|
|
||||||
|
|
||||||
All masquerade activity is logged in JSON format:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2024-01-15T10:30:00Z",
|
|
||||||
"action": "HIJACK_START",
|
|
||||||
"hijacker_email": "support@smoothschedule.com",
|
|
||||||
"hijacked_email": "customer@demo.com",
|
|
||||||
"ip_address": "192.168.1.1",
|
|
||||||
"session_key": "abc123..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Permission Grants (30-Minute Window)
|
|
||||||
|
|
||||||
Time-limited elevated permissions:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from core.models import PermissionGrant
|
|
||||||
|
|
||||||
grant = PermissionGrant.create_grant(
|
|
||||||
grantor=admin_user,
|
|
||||||
grantee=support_user,
|
|
||||||
action="view_billing",
|
|
||||||
reason="Customer requested billing support",
|
|
||||||
duration_minutes=30,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if active
|
|
||||||
if grant.is_active():
|
|
||||||
# Perform privileged action
|
|
||||||
pass
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🧪 Testing Masquerading
|
|
||||||
|
|
||||||
1. Access Django Admin: `http://localhost:8000/admin/`
|
|
||||||
2. Create test users with different roles
|
|
||||||
3. Click "Hijack" button next to a user
|
|
||||||
4. Verify audit logs: `docker-compose exec django cat logs/masquerade.log`
|
|
||||||
|
|
||||||
## 📊 Admin Interface
|
|
||||||
|
|
||||||
- **Tenant Management**: View tenants, domains, subscription tiers
|
|
||||||
- **User Management**: Color-coded roles, masquerade buttons
|
|
||||||
- **Permission Grants**: Active/expired/revoked status, bulk revoke
|
|
||||||
- **Domain Verification**: AWS Route53 integration status
|
|
||||||
|
|
||||||
## 🛠️ Development
|
|
||||||
|
|
||||||
### Adding Tenant Apps
|
|
||||||
|
|
||||||
Edit `config/settings.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
TENANT_APPS = [
|
|
||||||
'django.contrib.contenttypes',
|
|
||||||
'appointments', # Your app
|
|
||||||
'resources', # Your app
|
|
||||||
'billing', # Your app
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Domain Setup
|
|
||||||
|
|
||||||
```python
|
|
||||||
domain = Domain.objects.create(
|
|
||||||
domain="app.customdomain.com",
|
|
||||||
tenant=tenant,
|
|
||||||
is_custom_domain=True,
|
|
||||||
route53_zone_id="Z1234567890ABC",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Then configure Route53 CNAME: `app.customdomain.com` → `smoothschedule.yourhost.com`
|
|
||||||
|
|
||||||
## 📖 Key Files Reference
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `setup_project.sh` | Automated project initialization |
|
|
||||||
| `config/settings.py` | Multi-tenancy, middleware, security config |
|
|
||||||
| `core/models.py` | Tenant, Domain, PermissionGrant models |
|
|
||||||
| `core/permissions.py` | Masquerading permission matrix |
|
|
||||||
| `core/middleware.py` | Audit logging for masquerading |
|
|
||||||
| `users/models.py` | Custom User with 8-tier roles |
|
|
||||||
|
|
||||||
## 📝 Important Notes
|
|
||||||
|
|
||||||
- **Django Admin**: The ONLY HTML interface (everything else is API)
|
|
||||||
- **Middleware Order**: `TenantMainMiddleware` must be first, `MasqueradeAuditMiddleware` after `HijackUserMiddleware`
|
|
||||||
- **Tenant Isolation**: Each tenant's data is in a separate PostgreSQL schema
|
|
||||||
- **Production**: Update `SECRET_KEY`, database credentials, and AWS keys via environment variables
|
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
|
||||||
|
|
||||||
**Cannot create tenant users:**
|
|
||||||
- Error: "Users with role TENANT_STAFF must be assigned to a tenant"
|
|
||||||
- Solution: Set `user.tenant = tenant_instance` before saving
|
|
||||||
|
|
||||||
**Hijack button doesn't appear:**
|
|
||||||
- Check `HIJACK_AUTHORIZATION_CHECK` in settings
|
|
||||||
- Verify `HijackUserAdminMixin` in `users/admin.py`
|
|
||||||
- Ensure user has permission per matrix rules
|
|
||||||
|
|
||||||
**Migrations fail:**
|
|
||||||
- Run shared migrations first: `migrate_schemas --shared`
|
|
||||||
- Then run tenant migrations: `migrate_schemas`
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
This is a production skeleton. Extend `TENANT_APPS` with your business logic.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Built with ❤️ for multi-tenant SaaS perfection**
|
## Configuration Files
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `smoothschedule/docker-compose.local.yml` | Local Docker services |
|
||||||
|
| `smoothschedule/docker-compose.production.yml` | Production Docker services |
|
||||||
|
| `smoothschedule/.envs/.local/` | Local environment variables |
|
||||||
|
| `smoothschedule/.envs/.production/` | Production environment variables |
|
||||||
|
| `smoothschedule/config/settings/` | Django settings |
|
||||||
|
| `smoothschedule/compose/production/traefik/traefik.yml` | Traefik routing config |
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `frontend/.env.development` | Local environment variables |
|
||||||
|
| `frontend/.env.production` | Production environment variables |
|
||||||
|
| `frontend/nginx.conf` | Production nginx config |
|
||||||
|
| `frontend/vite.config.ts` | Vite bundler config |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend won't start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Docker logs
|
||||||
|
docker compose -f docker-compose.local.yml logs django
|
||||||
|
|
||||||
|
# Common issues:
|
||||||
|
# - Database not ready: wait longer, then restart django
|
||||||
|
# - Missing migrations: run migrate command
|
||||||
|
# - Port conflict: check if 8000 is in use
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend can't connect to API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify backend is running
|
||||||
|
curl http://lvh.me:8000/api/
|
||||||
|
|
||||||
|
# Check CORS settings in Django
|
||||||
|
# Ensure CORS_ALLOWED_ORIGINS includes http://platform.lvh.me:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSockets disconnecting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check nginx has /ws/ proxy configured
|
||||||
|
# Verify django is running ASGI (Daphne)
|
||||||
|
# Check production traefik/nginx logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-tenant issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check tenant exists
|
||||||
|
docker compose exec django python manage.py shell
|
||||||
|
>>> from smoothschedule.identity.core.models import Tenant, Domain
|
||||||
|
>>> Tenant.objects.all()
|
||||||
|
>>> Domain.objects.all()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Documentation
|
||||||
|
|
||||||
|
- **[CLAUDE.md](CLAUDE.md)** - Development guide, coding standards, architecture details
|
||||||
|
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
|
||||||
|
- **[PRODUCTION_DEPLOYMENT.md](PRODUCTION_DEPLOYMENT.md)** - Step-by-step manual deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
@@ -63,6 +63,19 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Proxy WebSocket connections to Django (Daphne/ASGI)
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://django:5000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
# Proxy Static/Media to Django (if served by WhiteNoise/Django)
|
# Proxy Static/Media to Django (if served by WhiteNoise/Django)
|
||||||
location /static/ {
|
location /static/ {
|
||||||
proxy_pass http://django:5000;
|
proxy_pass http://django:5000;
|
||||||
|
|||||||
187
frontend/src/components/CurrencyInput.tsx
Normal file
187
frontend/src/components/CurrencyInput.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
|
||||||
|
interface CurrencyInputProps {
|
||||||
|
value: number; // Value in cents (integer)
|
||||||
|
onChange: (cents: number) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ATM-style currency input where digits are entered as cents.
|
||||||
|
* As more digits are entered, they shift from cents to dollars.
|
||||||
|
* Only accepts integer values (digits 0-9).
|
||||||
|
*
|
||||||
|
* Example: typing "1234" displays "$12.34"
|
||||||
|
* - Type "1" → $0.01
|
||||||
|
* - Type "2" → $0.12
|
||||||
|
* - Type "3" → $1.23
|
||||||
|
* - Type "4" → $12.34
|
||||||
|
*/
|
||||||
|
const CurrencyInput: React.FC<CurrencyInputProps> = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
placeholder = '$0.00',
|
||||||
|
className = '',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
}) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
|
// Ensure value is always an integer
|
||||||
|
const safeValue = Math.floor(Math.abs(value)) || 0;
|
||||||
|
|
||||||
|
// Format cents as dollars string (e.g., 1234 → "$12.34")
|
||||||
|
const formatCentsAsDollars = (cents: number): string => {
|
||||||
|
if (cents === 0 && !isFocused) return '';
|
||||||
|
const dollars = cents / 100;
|
||||||
|
return `$${dollars.toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayValue = safeValue > 0 || isFocused ? formatCentsAsDollars(safeValue) : '';
|
||||||
|
|
||||||
|
// Process a new digit being added
|
||||||
|
const addDigit = (digit: number) => {
|
||||||
|
let newValue = safeValue * 10 + digit;
|
||||||
|
|
||||||
|
// Enforce max if specified
|
||||||
|
if (max !== undefined && newValue > max) {
|
||||||
|
newValue = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove the last digit
|
||||||
|
const removeDigit = () => {
|
||||||
|
const newValue = Math.floor(safeValue / 10);
|
||||||
|
onChange(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
// Allow navigation keys without preventing default
|
||||||
|
if (
|
||||||
|
e.key === 'Tab' ||
|
||||||
|
e.key === 'Escape' ||
|
||||||
|
e.key === 'Enter' ||
|
||||||
|
e.key === 'ArrowLeft' ||
|
||||||
|
e.key === 'ArrowRight' ||
|
||||||
|
e.key === 'Home' ||
|
||||||
|
e.key === 'End'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle backspace/delete
|
||||||
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
|
e.preventDefault();
|
||||||
|
removeDigit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow digits 0-9
|
||||||
|
if (/^[0-9]$/.test(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
addDigit(parseInt(e.key, 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block everything else
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Catch input from mobile keyboards, IME, voice input, etc.
|
||||||
|
const handleBeforeInput = (e: React.FormEvent<HTMLInputElement>) => {
|
||||||
|
const inputEvent = e.nativeEvent as InputEvent;
|
||||||
|
const data = inputEvent.data;
|
||||||
|
|
||||||
|
// Always prevent default - we handle all input ourselves
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
// Extract only digits from the input
|
||||||
|
const digits = data.replace(/\D/g, '');
|
||||||
|
|
||||||
|
// Add each digit one at a time
|
||||||
|
for (const char of digits) {
|
||||||
|
addDigit(parseInt(char, 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsFocused(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsFocused(false);
|
||||||
|
// Enforce min on blur if specified
|
||||||
|
if (min !== undefined && safeValue < min && safeValue > 0) {
|
||||||
|
onChange(min);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle paste - extract digits only
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const pastedText = e.clipboardData.getData('text');
|
||||||
|
const digits = pastedText.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (digits) {
|
||||||
|
let newValue = parseInt(digits, 10);
|
||||||
|
if (max !== undefined && newValue > max) {
|
||||||
|
newValue = max;
|
||||||
|
}
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle drop - extract digits only
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const droppedText = e.dataTransfer.getData('text');
|
||||||
|
const digits = droppedText.replace(/\D/g, '');
|
||||||
|
|
||||||
|
if (digits) {
|
||||||
|
let newValue = parseInt(digits, 10);
|
||||||
|
if (max !== undefined && newValue > max) {
|
||||||
|
newValue = max;
|
||||||
|
}
|
||||||
|
onChange(newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={displayValue}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBeforeInput={handleBeforeInput}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onChange={() => {}} // Controlled via onKeyDown/onBeforeInput
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={className}
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
autoCapitalize="off"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CurrencyInput;
|
||||||
@@ -52,6 +52,14 @@ export const useAppointments = (filters?: AppointmentFilters) => {
|
|||||||
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
|
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
|
||||||
status: a.status as AppointmentStatus,
|
status: a.status as AppointmentStatus,
|
||||||
notes: a.notes || '',
|
notes: a.notes || '',
|
||||||
|
// Payment fields (amounts stored in cents, convert to dollars for display)
|
||||||
|
depositAmount: a.deposit_amount ? parseFloat(a.deposit_amount) / 100 : null,
|
||||||
|
depositTransactionId: a.deposit_transaction_id || '',
|
||||||
|
finalPrice: a.final_price ? parseFloat(a.final_price) / 100 : null,
|
||||||
|
finalChargeTransactionId: a.final_charge_transaction_id || '',
|
||||||
|
isVariablePricing: a.is_variable_pricing || false,
|
||||||
|
remainingBalance: a.remaining_balance ? parseFloat(a.remaining_balance) / 100 : null,
|
||||||
|
overpaidAmount: a.overpaid_amount ? parseFloat(a.overpaid_amount) / 100 : null,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -85,6 +93,14 @@ export const useAppointment = (id: string) => {
|
|||||||
durationMinutes: data.duration_minutes || calculateDuration(data.start_time, data.end_time),
|
durationMinutes: data.duration_minutes || calculateDuration(data.start_time, data.end_time),
|
||||||
status: data.status as AppointmentStatus,
|
status: data.status as AppointmentStatus,
|
||||||
notes: data.notes || '',
|
notes: data.notes || '',
|
||||||
|
// Payment fields (amounts stored in cents, convert to dollars for display)
|
||||||
|
depositAmount: data.deposit_amount ? parseFloat(data.deposit_amount) / 100 : null,
|
||||||
|
depositTransactionId: data.deposit_transaction_id || '',
|
||||||
|
finalPrice: data.final_price ? parseFloat(data.final_price) / 100 : null,
|
||||||
|
finalChargeTransactionId: data.final_charge_transaction_id || '',
|
||||||
|
isVariablePricing: data.is_variable_pricing || false,
|
||||||
|
remainingBalance: data.remaining_balance ? parseFloat(data.remaining_balance) / 100 : null,
|
||||||
|
overpaidAmount: data.overpaid_amount ? parseFloat(data.overpaid_amount) / 100 : null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
|
|||||||
@@ -70,8 +70,7 @@ export const useCreateResource = () => {
|
|||||||
const backendData: any = {
|
const backendData: any = {
|
||||||
name: resourceData.name,
|
name: resourceData.name,
|
||||||
type: resourceData.type,
|
type: resourceData.type,
|
||||||
user: resourceData.userId ? parseInt(resourceData.userId) : null,
|
user_id: resourceData.userId ? parseInt(resourceData.userId) : null,
|
||||||
timezone: 'UTC', // Default timezone
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (resourceData.maxConcurrentEvents !== undefined) {
|
if (resourceData.maxConcurrentEvents !== undefined) {
|
||||||
|
|||||||
@@ -1,29 +1,50 @@
|
|||||||
import React, { useState, useRef, useMemo } from 'react';
|
import React, { useState, useRef, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image, AlertTriangle } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image, AlertTriangle, Users, Bell, Mail, MessageSquare, Heart } from 'lucide-react';
|
||||||
import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices';
|
import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices';
|
||||||
|
import { useResources } from '../hooks/useResources';
|
||||||
import { Service, User, Business } from '../types';
|
import { Service, User, Business } from '../types';
|
||||||
import { getOverQuotaServiceIds } from '../utils/quotaUtils';
|
import { getOverQuotaServiceIds } from '../utils/quotaUtils';
|
||||||
|
import CurrencyInput from '../components/CurrencyInput';
|
||||||
|
|
||||||
interface ServiceFormData {
|
interface ServiceFormData {
|
||||||
name: string;
|
name: string;
|
||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
price: number;
|
price_cents: number; // Price in cents (e.g., 5000 = $50.00)
|
||||||
description: string;
|
description: string;
|
||||||
photos: string[];
|
photos: string[];
|
||||||
// Pricing fields
|
// Pricing fields
|
||||||
variable_pricing: boolean;
|
variable_pricing: boolean;
|
||||||
deposit_enabled: boolean;
|
deposit_enabled: boolean;
|
||||||
deposit_type: 'amount' | 'percent';
|
deposit_type: 'amount' | 'percent';
|
||||||
deposit_amount: number | null;
|
deposit_amount_cents: number | null; // Deposit in cents (e.g., 2500 = $25.00)
|
||||||
deposit_percent: number | null;
|
deposit_percent: number | null;
|
||||||
|
// Resource assignment fields
|
||||||
|
all_resources: boolean;
|
||||||
|
resource_ids: string[];
|
||||||
|
// Timing fields
|
||||||
|
prep_time: number;
|
||||||
|
takedown_time: number;
|
||||||
|
// Reminder notification fields
|
||||||
|
reminder_enabled: boolean;
|
||||||
|
reminder_hours_before: number;
|
||||||
|
reminder_email: boolean;
|
||||||
|
reminder_sms: boolean;
|
||||||
|
// Thank you email
|
||||||
|
thank_you_email_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to format cents as dollars for display
|
||||||
|
const formatCentsAsDollars = (cents: number): string => {
|
||||||
|
return (cents / 100).toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
const Services: React.FC = () => {
|
const Services: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useOutletContext<{ user: User, business: Business }>();
|
const { user } = useOutletContext<{ user: User, business: Business }>();
|
||||||
const { data: services, isLoading, error } = useServices();
|
const { data: services, isLoading, error } = useServices();
|
||||||
|
const { data: resources } = useResources({ type: 'STAFF' }); // Only STAFF resources for services
|
||||||
const createService = useCreateService();
|
const createService = useCreateService();
|
||||||
const updateService = useUpdateService();
|
const updateService = useUpdateService();
|
||||||
const deleteService = useDeleteService();
|
const deleteService = useDeleteService();
|
||||||
@@ -40,14 +61,26 @@ const Services: React.FC = () => {
|
|||||||
const [formData, setFormData] = useState<ServiceFormData>({
|
const [formData, setFormData] = useState<ServiceFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
durationMinutes: 60,
|
durationMinutes: 60,
|
||||||
price: 0,
|
price_cents: 0,
|
||||||
description: '',
|
description: '',
|
||||||
photos: [],
|
photos: [],
|
||||||
variable_pricing: false,
|
variable_pricing: false,
|
||||||
deposit_enabled: false,
|
deposit_enabled: false,
|
||||||
deposit_type: 'amount',
|
deposit_type: 'amount',
|
||||||
deposit_amount: null,
|
deposit_amount_cents: null,
|
||||||
deposit_percent: null,
|
deposit_percent: null,
|
||||||
|
all_resources: true,
|
||||||
|
resource_ids: [],
|
||||||
|
// Timing fields
|
||||||
|
prep_time: 0,
|
||||||
|
takedown_time: 0,
|
||||||
|
// Reminder notification fields
|
||||||
|
reminder_enabled: false,
|
||||||
|
reminder_hours_before: 24,
|
||||||
|
reminder_email: true,
|
||||||
|
reminder_sms: false,
|
||||||
|
// Thank you email
|
||||||
|
thank_you_email_enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Photo gallery state
|
// Photo gallery state
|
||||||
@@ -211,14 +244,23 @@ const Services: React.FC = () => {
|
|||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
durationMinutes: 60,
|
durationMinutes: 60,
|
||||||
price: 0,
|
price_cents: 0,
|
||||||
description: '',
|
description: '',
|
||||||
photos: [],
|
photos: [],
|
||||||
variable_pricing: false,
|
variable_pricing: false,
|
||||||
deposit_enabled: false,
|
deposit_enabled: false,
|
||||||
deposit_type: 'amount',
|
deposit_type: 'amount',
|
||||||
deposit_amount: null,
|
deposit_amount_cents: null,
|
||||||
deposit_percent: null,
|
deposit_percent: null,
|
||||||
|
all_resources: true,
|
||||||
|
resource_ids: [],
|
||||||
|
prep_time: 0,
|
||||||
|
takedown_time: 0,
|
||||||
|
reminder_enabled: false,
|
||||||
|
reminder_hours_before: 24,
|
||||||
|
reminder_email: true,
|
||||||
|
reminder_sms: false,
|
||||||
|
thank_you_email_enabled: false,
|
||||||
});
|
});
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -226,21 +268,30 @@ const Services: React.FC = () => {
|
|||||||
const openEditModal = (service: Service) => {
|
const openEditModal = (service: Service) => {
|
||||||
setEditingService(service);
|
setEditingService(service);
|
||||||
// Determine deposit configuration from existing data
|
// Determine deposit configuration from existing data
|
||||||
const hasDeposit = (service.deposit_amount && service.deposit_amount > 0) ||
|
const hasDeposit = (service.deposit_amount_cents && service.deposit_amount_cents > 0) ||
|
||||||
(service.deposit_percent && service.deposit_percent > 0);
|
(service.deposit_percent && service.deposit_percent > 0);
|
||||||
const depositType = service.deposit_percent && service.deposit_percent > 0 ? 'percent' : 'amount';
|
const depositType = service.deposit_percent && service.deposit_percent > 0 ? 'percent' : 'amount';
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
name: service.name,
|
name: service.name,
|
||||||
durationMinutes: service.durationMinutes,
|
durationMinutes: service.durationMinutes,
|
||||||
price: service.price,
|
price_cents: service.price_cents || 0,
|
||||||
description: service.description || '',
|
description: service.description || '',
|
||||||
photos: service.photos || [],
|
photos: service.photos || [],
|
||||||
variable_pricing: service.variable_pricing || false,
|
variable_pricing: service.variable_pricing || false,
|
||||||
deposit_enabled: hasDeposit,
|
deposit_enabled: hasDeposit,
|
||||||
deposit_type: depositType,
|
deposit_type: depositType,
|
||||||
deposit_amount: service.deposit_amount || null,
|
deposit_amount_cents: service.deposit_amount_cents || null,
|
||||||
deposit_percent: service.deposit_percent || null,
|
deposit_percent: service.deposit_percent || null,
|
||||||
|
all_resources: service.all_resources ?? true,
|
||||||
|
resource_ids: service.resource_ids || [],
|
||||||
|
prep_time: service.prep_time || 0,
|
||||||
|
takedown_time: service.takedown_time || 0,
|
||||||
|
reminder_enabled: service.reminder_enabled || false,
|
||||||
|
reminder_hours_before: service.reminder_hours_before || 24,
|
||||||
|
reminder_email: service.reminder_email ?? true,
|
||||||
|
reminder_sms: service.reminder_sms || false,
|
||||||
|
thank_you_email_enabled: service.thank_you_email_enabled || false,
|
||||||
});
|
});
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -257,17 +308,30 @@ const Services: React.FC = () => {
|
|||||||
const apiData = {
|
const apiData = {
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
durationMinutes: formData.durationMinutes,
|
durationMinutes: formData.durationMinutes,
|
||||||
price: formData.variable_pricing ? 0 : formData.price, // Price is 0 for variable pricing
|
price_cents: formData.variable_pricing ? 0 : formData.price_cents, // Price is 0 for variable pricing
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
photos: formData.photos,
|
photos: formData.photos,
|
||||||
variable_pricing: formData.variable_pricing,
|
variable_pricing: formData.variable_pricing,
|
||||||
// Only send deposit values if deposit is enabled
|
// Only send deposit values if deposit is enabled
|
||||||
deposit_amount: formData.deposit_enabled && formData.deposit_type === 'amount'
|
deposit_amount_cents: formData.deposit_enabled && formData.deposit_type === 'amount'
|
||||||
? formData.deposit_amount
|
? formData.deposit_amount_cents
|
||||||
: null,
|
: null,
|
||||||
deposit_percent: formData.deposit_enabled && formData.deposit_type === 'percent'
|
deposit_percent: formData.deposit_enabled && formData.deposit_type === 'percent'
|
||||||
? formData.deposit_percent
|
? formData.deposit_percent
|
||||||
: null,
|
: null,
|
||||||
|
// Resource assignment
|
||||||
|
all_resources: formData.all_resources,
|
||||||
|
resource_ids: formData.all_resources ? [] : formData.resource_ids,
|
||||||
|
// Timing fields
|
||||||
|
prep_time: formData.prep_time,
|
||||||
|
takedown_time: formData.takedown_time,
|
||||||
|
// Reminder fields - only send if enabled
|
||||||
|
reminder_enabled: formData.reminder_enabled,
|
||||||
|
reminder_hours_before: formData.reminder_enabled ? formData.reminder_hours_before : 24,
|
||||||
|
reminder_email: formData.reminder_enabled ? formData.reminder_email : true,
|
||||||
|
reminder_sms: formData.reminder_enabled ? formData.reminder_sms : false,
|
||||||
|
// Thank you email
|
||||||
|
thank_you_email_enabled: formData.thank_you_email_enabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -424,10 +488,10 @@ const Services: React.FC = () => {
|
|||||||
<DollarSign className="h-3.5 w-3.5" />
|
<DollarSign className="h-3.5 w-3.5" />
|
||||||
{service.variable_pricing ? (
|
{service.variable_pricing ? (
|
||||||
<>
|
<>
|
||||||
{t('services.fromPrice', 'From')} ${service.price.toFixed(2)}
|
{t('services.fromPrice', 'From')} ${service.price}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
`$${service.price.toFixed(2)}`
|
`$${service.price}`
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{service.variable_pricing && (
|
{service.variable_pricing && (
|
||||||
@@ -446,6 +510,19 @@ const Services: React.FC = () => {
|
|||||||
{service.photos.length}
|
{service.photos.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Resource assignment indicator */}
|
||||||
|
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1" title={
|
||||||
|
service.all_resources
|
||||||
|
? t('services.allResourcesAssigned', 'All resources can provide this service')
|
||||||
|
: service.resource_names && service.resource_names.length > 0
|
||||||
|
? service.resource_names.map(r => r.name).join(', ')
|
||||||
|
: t('services.noResourcesAssigned', 'No resources assigned')
|
||||||
|
}>
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{service.all_resources
|
||||||
|
? t('services.allResourcesBadge', 'All')
|
||||||
|
: service.resource_names?.length || 0}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,9 +574,9 @@ const Services: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
<span className="font-semibold text-brand-600 dark:text-brand-400">
|
<span className="font-semibold text-brand-600 dark:text-brand-400">
|
||||||
{service.variable_pricing ? (
|
{service.variable_pricing ? (
|
||||||
<>From ${service.price.toFixed(2)}</>
|
<>From ${service.price}</>
|
||||||
) : (
|
) : (
|
||||||
`$${service.price.toFixed(2)}`
|
`$${service.price}`
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{service.variable_pricing && service.deposit_display && (
|
{service.variable_pricing && service.deposit_display && (
|
||||||
@@ -530,7 +607,7 @@ const Services: React.FC = () => {
|
|||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] flex flex-col">
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] flex flex-col">
|
||||||
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
|
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{editingService
|
{editingService
|
||||||
@@ -599,7 +676,7 @@ const Services: React.FC = () => {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{t('services.duration', 'Duration (min)')} *
|
{t('services.duration', 'Duration (Minutes)')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -613,23 +690,25 @@ const Services: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{t('services.price', 'Price ($)')} {!formData.variable_pricing && '*'}
|
{t('services.price', 'Price')} {!formData.variable_pricing && '*'}
|
||||||
</label>
|
</label>
|
||||||
<input
|
{formData.variable_pricing ? (
|
||||||
type="number"
|
<input
|
||||||
value={formData.variable_pricing ? '' : formData.price}
|
type="text"
|
||||||
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
|
value=""
|
||||||
required={!formData.variable_pricing}
|
disabled
|
||||||
disabled={formData.variable_pricing}
|
placeholder={t('services.priceNA', 'N/A')}
|
||||||
min={0}
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-white cursor-not-allowed"
|
||||||
step={0.01}
|
/>
|
||||||
placeholder={formData.variable_pricing ? t('services.priceNA', 'N/A') : '0.00'}
|
) : (
|
||||||
className={`w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 ${
|
<CurrencyInput
|
||||||
formData.variable_pricing
|
value={formData.price_cents}
|
||||||
? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed'
|
onChange={(cents) => setFormData({ ...formData, price_cents: cents })}
|
||||||
: 'bg-white dark:bg-gray-700'
|
required={!formData.variable_pricing}
|
||||||
}`}
|
placeholder="$0.00"
|
||||||
/>
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{formData.variable_pricing && (
|
{formData.variable_pricing && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{t('services.variablePriceNote', 'Price determined after service')}
|
{t('services.variablePriceNote', 'Price determined after service')}
|
||||||
@@ -688,7 +767,7 @@ const Services: React.FC = () => {
|
|||||||
...formData,
|
...formData,
|
||||||
deposit_type: 'amount',
|
deposit_type: 'amount',
|
||||||
deposit_percent: null,
|
deposit_percent: null,
|
||||||
deposit_amount: formData.deposit_amount || 50,
|
deposit_amount_cents: formData.deposit_amount_cents || 5000,
|
||||||
})}
|
})}
|
||||||
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
|
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
|
||||||
/>
|
/>
|
||||||
@@ -704,7 +783,7 @@ const Services: React.FC = () => {
|
|||||||
onChange={() => setFormData({
|
onChange={() => setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
deposit_type: 'percent',
|
deposit_type: 'percent',
|
||||||
deposit_amount: null,
|
deposit_amount_cents: null,
|
||||||
deposit_percent: formData.deposit_percent || 25,
|
deposit_percent: formData.deposit_percent || 25,
|
||||||
})}
|
})}
|
||||||
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
|
className="w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
|
||||||
@@ -721,17 +800,15 @@ const Services: React.FC = () => {
|
|||||||
{(formData.variable_pricing || formData.deposit_type === 'amount') && (
|
{(formData.variable_pricing || formData.deposit_type === 'amount') && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{t('services.depositAmount', 'Deposit Amount ($)')} *
|
{t('services.depositAmount', 'Deposit Amount')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<CurrencyInput
|
||||||
type="number"
|
value={formData.deposit_amount_cents || 0}
|
||||||
value={formData.deposit_amount || ''}
|
onChange={(cents) => setFormData({ ...formData, deposit_amount_cents: cents || null })}
|
||||||
onChange={(e) => setFormData({ ...formData, deposit_amount: parseFloat(e.target.value) || null })}
|
|
||||||
required
|
required
|
||||||
min={1}
|
min={1}
|
||||||
step={0.01}
|
placeholder="$0.00"
|
||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
placeholder="50.00"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -753,11 +830,9 @@ const Services: React.FC = () => {
|
|||||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
placeholder="25"
|
placeholder="25"
|
||||||
/>
|
/>
|
||||||
{formData.deposit_percent && formData.price > 0 && (
|
{formData.deposit_percent && formData.price_cents > 0 && (
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
|
||||||
{t('services.depositCalculated', 'Deposit: ${amount}', {
|
= ${formatCentsAsDollars(Math.round(formData.price_cents * formData.deposit_percent / 100))}
|
||||||
amount: ((formData.price * formData.deposit_percent) / 100).toFixed(2)
|
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -770,6 +845,255 @@ const Services: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resource Assignment */}
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
<label className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||||
|
{t('services.resourceAssignment', 'Who Can Provide This Service?')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* All Resources Toggle */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
{t('services.allResources', 'All Resources')}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-300">
|
||||||
|
{t('services.allResourcesDescription', 'Any resource can be booked for this service')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({
|
||||||
|
...formData,
|
||||||
|
all_resources: !formData.all_resources,
|
||||||
|
resource_ids: !formData.all_resources ? [] : formData.resource_ids,
|
||||||
|
})}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
formData.all_resources ? 'bg-blue-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
formData.all_resources ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Specific Resource Selection */}
|
||||||
|
{!formData.all_resources && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-600">
|
||||||
|
<p className="text-xs text-blue-600 dark:text-blue-300 mb-2">
|
||||||
|
{t('services.selectSpecificResources', 'Select specific resources that can provide this service:')}
|
||||||
|
</p>
|
||||||
|
{resources && resources.length > 0 ? (
|
||||||
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<label
|
||||||
|
key={resource.id}
|
||||||
|
className="flex items-center gap-2 cursor-pointer p-2 rounded hover:bg-blue-100 dark:hover:bg-blue-800/30 transition-colors"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.resource_ids.includes(resource.id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
resource_ids: [...formData.resource_ids, resource.id],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
resource_ids: formData.resource_ids.filter(id => id !== resource.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-4 h-4 text-blue-600 border-blue-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-blue-900 dark:text-blue-100">
|
||||||
|
{resource.name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-blue-600 dark:text-blue-300 italic">
|
||||||
|
{t('services.noStaffResources', 'No resources available. Add resources first.')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!formData.all_resources && formData.resource_ids.length === 0 && resources && resources.length > 0 && (
|
||||||
|
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||||
|
{t('services.selectAtLeastOne', 'Select at least one resource, or enable "All Resources"')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prep Time and Takedown Time */}
|
||||||
|
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Clock className="h-4 w-4 text-gray-600 dark:text-gray-400" />
|
||||||
|
<label className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{t('services.bufferTime', 'Buffer Time')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('services.prepTime', 'Prep Time (Minutes)')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.prep_time}
|
||||||
|
onChange={(e) => setFormData({ ...formData, prep_time: parseInt(e.target.value) || 0 })}
|
||||||
|
min={0}
|
||||||
|
step={5}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('services.prepTimeHint', 'Time needed before the appointment')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
{t('services.takedownTime', 'Takedown Time (Minutes)')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.takedown_time}
|
||||||
|
onChange={(e) => setFormData({ ...formData, takedown_time: parseInt(e.target.value) || 0 })}
|
||||||
|
min={0}
|
||||||
|
step={5}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('services.takedownTimeHint', 'Time needed after the appointment')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reminder Notifications */}
|
||||||
|
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-700">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<label className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
|
{t('services.reminderNotifications', 'Reminder Notifications')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, reminder_enabled: !formData.reminder_enabled })}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
formData.reminder_enabled ? 'bg-amber-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
formData.reminder_enabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.reminder_enabled && (
|
||||||
|
<div className="space-y-4 pt-3 border-t border-amber-200 dark:border-amber-600">
|
||||||
|
{/* Reminder timing */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">
|
||||||
|
{t('services.reminderTiming', 'Send reminder')}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.reminder_hours_before}
|
||||||
|
onChange={(e) => setFormData({ ...formData, reminder_hours_before: parseInt(e.target.value) || 24 })}
|
||||||
|
min={1}
|
||||||
|
max={168}
|
||||||
|
className="w-20 px-3 py-2 border border-amber-300 dark:border-amber-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
{t('services.hoursBefore', 'hours before appointment')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reminder methods */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-amber-800 dark:text-amber-200 mb-2">
|
||||||
|
{t('services.reminderMethod', 'Send via')}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.reminder_email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, reminder_email: e.target.checked })}
|
||||||
|
className="w-4 h-4 text-amber-600 border-amber-300 rounded focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<Mail className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
{t('services.email', 'Email')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.reminder_sms}
|
||||||
|
onChange={(e) => setFormData({ ...formData, reminder_sms: e.target.checked })}
|
||||||
|
className="w-4 h-4 text-amber-600 border-amber-300 rounded focus:ring-amber-500"
|
||||||
|
/>
|
||||||
|
<MessageSquare className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
{t('services.sms', 'Text Message')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thank You Email */}
|
||||||
|
<div className="p-4 bg-pink-50 dark:bg-pink-900/20 rounded-lg border border-pink-200 dark:border-pink-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" />
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium text-pink-900 dark:text-pink-100">
|
||||||
|
{t('services.thankYouEmail', 'Thank You Email')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-pink-600 dark:text-pink-300">
|
||||||
|
{t('services.thankYouEmailDescription', 'Send a follow-up email after the appointment')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, thank_you_email_enabled: !formData.thank_you_email_enabled })}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
formData.thank_you_email_enabled ? 'bg-pink-600' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
formData.thank_you_email_enabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useRef } from 'react';
|
||||||
import { useOutletContext, Link } from 'react-router-dom';
|
import { useOutletContext, Link } from 'react-router-dom';
|
||||||
import { User, Business, Appointment } from '../../types';
|
import { User, Business, Appointment } from '../../types';
|
||||||
import { useAppointments, useUpdateAppointment } from '../../hooks/useAppointments';
|
import { useAppointments, useUpdateAppointment } from '../../hooks/useAppointments';
|
||||||
import { useServices } from '../../hooks/useServices';
|
import { useServices } from '../../hooks/useServices';
|
||||||
import { Calendar, Clock, MapPin, AlertTriangle, Loader2 } from 'lucide-react';
|
import { Calendar, Clock, X, FileText, Tag, Loader2, DollarSign, CreditCard, Printer, Receipt } from 'lucide-react';
|
||||||
|
import Portal from '../../components/Portal';
|
||||||
|
|
||||||
const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, business }) => {
|
const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, business }) => {
|
||||||
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
const [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
|
||||||
|
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
|
||||||
|
|
||||||
// Fetch appointments from API - backend filters for current customer
|
// Fetch appointments from API - backend filters for current customer
|
||||||
const { data: appointments = [], isLoading, error } = useAppointments();
|
const { data: appointments = [], isLoading, error } = useAppointments();
|
||||||
@@ -26,7 +28,7 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
|||||||
const hoursBefore = (new Date(appointment.startTime).getTime() - new Date().getTime()) / 3600000;
|
const hoursBefore = (new Date(appointment.startTime).getTime() - new Date().getTime()) / 3600000;
|
||||||
if (hoursBefore < business.cancellationWindowHours) {
|
if (hoursBefore < business.cancellationWindowHours) {
|
||||||
const service = services.find(s => s.id === appointment.serviceId);
|
const service = services.find(s => s.id === appointment.serviceId);
|
||||||
const fee = service ? (service.price * (business.lateCancellationFeePercent / 100)).toFixed(2) : 'a fee';
|
const fee = service ? (parseFloat(service.price) * (business.lateCancellationFeePercent / 100)).toFixed(2) : 'a fee';
|
||||||
if (!window.confirm(`Cancelling within the ${business.cancellationWindowHours}-hour window may incur a fee of $${fee}. Are you sure?`)) return;
|
if (!window.confirm(`Cancelling within the ${business.cancellationWindowHours}-hour window may incur a fee of $${fee}. Are you sure?`)) return;
|
||||||
} else {
|
} else {
|
||||||
if (!window.confirm("Are you sure you want to cancel this appointment?")) return;
|
if (!window.confirm("Are you sure you want to cancel this appointment?")) return;
|
||||||
@@ -39,6 +41,120 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to create receipt row elements
|
||||||
|
const createReceiptRow = (doc: Document, label: string, value: string, className?: string): HTMLDivElement => {
|
||||||
|
const row = doc.createElement('div');
|
||||||
|
row.className = 'receipt-row' + (className ? ' ' + className : '');
|
||||||
|
const labelSpan = doc.createElement('span');
|
||||||
|
labelSpan.className = 'label';
|
||||||
|
labelSpan.textContent = label;
|
||||||
|
const valueSpan = doc.createElement('span');
|
||||||
|
valueSpan.className = 'value' + (className ? ' ' + className : '');
|
||||||
|
valueSpan.textContent = value;
|
||||||
|
row.appendChild(labelSpan);
|
||||||
|
row.appendChild(valueSpan);
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrintReceipt = (appointment: Appointment) => {
|
||||||
|
const service = services.find(s => s.id === appointment.serviceId);
|
||||||
|
|
||||||
|
// Create an iframe for printing
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.style.position = 'absolute';
|
||||||
|
iframe.style.width = '0';
|
||||||
|
iframe.style.height = '0';
|
||||||
|
iframe.style.border = 'none';
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
|
||||||
|
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||||
|
if (!iframeDoc) {
|
||||||
|
alert('Unable to generate receipt. Please try again.');
|
||||||
|
document.body.removeChild(iframe);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build HTML structure using DOM methods
|
||||||
|
const style = iframeDoc.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
body { font-family: system-ui, -apple-system, sans-serif; padding: 40px; max-width: 600px; margin: 0 auto; }
|
||||||
|
h1 { font-size: 24px; margin-bottom: 8px; }
|
||||||
|
.business-name { color: #666; margin-bottom: 24px; }
|
||||||
|
.receipt-section { margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #eee; }
|
||||||
|
.receipt-row { display: flex; justify-content: space-between; margin-bottom: 8px; }
|
||||||
|
.label { color: #666; }
|
||||||
|
.value { font-weight: 600; }
|
||||||
|
.total { font-size: 18px; font-weight: bold; }
|
||||||
|
.paid { color: #16a34a; }
|
||||||
|
.due { color: #ea580c; }
|
||||||
|
.footer { margin-top: 32px; text-align: center; color: #999; font-size: 12px; }
|
||||||
|
`;
|
||||||
|
iframeDoc.head.appendChild(style);
|
||||||
|
|
||||||
|
const title = iframeDoc.createElement('title');
|
||||||
|
title.textContent = `Receipt - ${service?.name || 'Appointment'}`;
|
||||||
|
iframeDoc.head.appendChild(title);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const h1 = iframeDoc.createElement('h1');
|
||||||
|
h1.textContent = 'Receipt';
|
||||||
|
iframeDoc.body.appendChild(h1);
|
||||||
|
|
||||||
|
const businessName = iframeDoc.createElement('div');
|
||||||
|
businessName.className = 'business-name';
|
||||||
|
businessName.textContent = business.name;
|
||||||
|
iframeDoc.body.appendChild(businessName);
|
||||||
|
|
||||||
|
// Appointment Details Section
|
||||||
|
const detailsSection = iframeDoc.createElement('div');
|
||||||
|
detailsSection.className = 'receipt-section';
|
||||||
|
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Service', service?.name || 'Appointment'));
|
||||||
|
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Date', new Date(appointment.startTime).toLocaleDateString()));
|
||||||
|
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Time', new Date(appointment.startTime).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })));
|
||||||
|
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Duration', `${appointment.durationMinutes} minutes`));
|
||||||
|
detailsSection.appendChild(createReceiptRow(iframeDoc, 'Status', appointment.status.replace(/_/g, ' ')));
|
||||||
|
iframeDoc.body.appendChild(detailsSection);
|
||||||
|
|
||||||
|
// Payment Section
|
||||||
|
const paymentSection = iframeDoc.createElement('div');
|
||||||
|
paymentSection.className = 'receipt-section';
|
||||||
|
|
||||||
|
if (service?.price) {
|
||||||
|
const priceText = appointment.isVariablePricing ? 'Variable' : `$${service.price}`;
|
||||||
|
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Service Price', priceText));
|
||||||
|
}
|
||||||
|
if (appointment.depositAmount && appointment.depositAmount > 0) {
|
||||||
|
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Deposit Paid', `-$${appointment.depositAmount.toFixed(2)}`, 'paid'));
|
||||||
|
}
|
||||||
|
if (appointment.finalPrice && appointment.isVariablePricing) {
|
||||||
|
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Final Price', `$${appointment.finalPrice.toFixed(2)}`));
|
||||||
|
}
|
||||||
|
if (appointment.remainingBalance && appointment.remainingBalance > 0 && !['COMPLETED', 'PAID'].includes(appointment.status)) {
|
||||||
|
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Amount Due', `$${appointment.remainingBalance.toFixed(2)}`, 'total due'));
|
||||||
|
}
|
||||||
|
if (['COMPLETED', 'PAID'].includes(appointment.status) && (!appointment.remainingBalance || appointment.remainingBalance <= 0)) {
|
||||||
|
paymentSection.appendChild(createReceiptRow(iframeDoc, 'Status', 'Paid in Full', 'total paid'));
|
||||||
|
}
|
||||||
|
iframeDoc.body.appendChild(paymentSection);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
const footer = iframeDoc.createElement('div');
|
||||||
|
footer.className = 'footer';
|
||||||
|
const thankYou = iframeDoc.createElement('p');
|
||||||
|
thankYou.textContent = 'Thank you for your business!';
|
||||||
|
const appointmentId = iframeDoc.createElement('p');
|
||||||
|
appointmentId.textContent = `Appointment ID: ${appointment.id}`;
|
||||||
|
footer.appendChild(thankYou);
|
||||||
|
footer.appendChild(appointmentId);
|
||||||
|
iframeDoc.body.appendChild(footer);
|
||||||
|
|
||||||
|
// Print and cleanup
|
||||||
|
setTimeout(() => {
|
||||||
|
iframe.contentWindow?.print();
|
||||||
|
setTimeout(() => document.body.removeChild(iframe), 1000);
|
||||||
|
}, 250);
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-8 flex items-center justify-center py-12">
|
<div className="mt-8 flex items-center justify-center py-12">
|
||||||
@@ -66,20 +182,26 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
|||||||
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).map(apt => {
|
{(activeTab === 'upcoming' ? upcomingAppointments : pastAppointments).map(apt => {
|
||||||
const service = services.find(s => s.id === apt.serviceId);
|
const service = services.find(s => s.id === apt.serviceId);
|
||||||
return (
|
return (
|
||||||
<div key={apt.id} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
<div
|
||||||
|
key={apt.id}
|
||||||
|
onClick={() => setSelectedAppointment(apt)}
|
||||||
|
className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700 flex items-center justify-between cursor-pointer hover:border-brand-300 dark:hover:border-brand-600 hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold">{service?.name || 'Appointment'}</h3>
|
<h3 className="font-semibold">{service?.name || 'Appointment'}</h3>
|
||||||
<p className="text-sm text-gray-500">{new Date(apt.startTime).toLocaleString()}</p>
|
<p className="text-sm text-gray-500">{new Date(apt.startTime).toLocaleString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{activeTab === 'upcoming' && (
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
|
||||||
onClick={() => handleCancel(apt)}
|
apt.status === 'SCHEDULED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
|
||||||
disabled={updateAppointment.isPending}
|
apt.status === 'PENDING_DEPOSIT' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
|
||||||
className="text-sm font-medium text-red-600 hover:underline disabled:opacity-50"
|
apt.status === 'CANCELLED' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
|
||||||
>
|
apt.status === 'COMPLETED' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
|
||||||
{updateAppointment.isPending ? 'Cancelling...' : 'Cancel'}
|
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
</button>
|
}`}>
|
||||||
)}
|
{apt.status.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -89,6 +211,249 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Appointment Detail Modal */}
|
||||||
|
{selectedAppointment && (
|
||||||
|
<Portal>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||||
|
onClick={() => setSelectedAppointment(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
Appointment Details
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedAppointment(null)}
|
||||||
|
className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
{/* Service */}
|
||||||
|
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center">
|
||||||
|
<Tag size={20} className="text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">Service</p>
|
||||||
|
<p className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{services.find(s => s.id === selectedAppointment.serviceId)?.name || 'Appointment'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date, Time & Duration */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Calendar size={14} className="text-gray-400" />
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Date</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{new Date(selectedAppointment.startTime).toLocaleDateString(undefined, {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Clock size={14} className="text-gray-400" />
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Time</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{new Date(selectedAppointment.startTime).toLocaleTimeString(undefined, {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration & Status */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Duration</p>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{selectedAppointment.durationMinutes} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">Status</p>
|
||||||
|
<span className={`inline-flex text-xs font-medium px-2 py-1 rounded-full ${
|
||||||
|
selectedAppointment.status === 'SCHEDULED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
|
||||||
|
selectedAppointment.status === 'PENDING_DEPOSIT' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
|
||||||
|
selectedAppointment.status === 'CANCELLED' ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' :
|
||||||
|
selectedAppointment.status === 'COMPLETED' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' :
|
||||||
|
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}>
|
||||||
|
{selectedAppointment.status.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{selectedAppointment.notes && (
|
||||||
|
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<FileText size={14} className="text-gray-400" />
|
||||||
|
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Notes</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-200">{selectedAppointment.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Information */}
|
||||||
|
{(() => {
|
||||||
|
const service = services.find(s => s.id === selectedAppointment.serviceId);
|
||||||
|
const hasPaymentData = selectedAppointment.depositAmount || selectedAppointment.finalPrice ||
|
||||||
|
selectedAppointment.finalChargeTransactionId || service?.price;
|
||||||
|
|
||||||
|
if (!hasPaymentData) return null;
|
||||||
|
|
||||||
|
// Calculate payment made (final charge minus deposit that was already applied)
|
||||||
|
const paymentMade = selectedAppointment.finalChargeTransactionId && selectedAppointment.finalPrice
|
||||||
|
? (selectedAppointment.finalPrice - (selectedAppointment.depositAmount || 0))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<DollarSign size={16} className="text-green-600 dark:text-green-400" />
|
||||||
|
<p className="text-sm font-semibold text-green-800 dark:text-green-300">Payment Summary</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Service Price */}
|
||||||
|
{service?.price && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Service Price</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{selectedAppointment.isVariablePricing ? 'Variable' : `$${service.price}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Final Price (for variable pricing after completion) */}
|
||||||
|
{selectedAppointment.finalPrice && selectedAppointment.isVariablePricing && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400">Final Price</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
${selectedAppointment.finalPrice.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Deposit Paid */}
|
||||||
|
{selectedAppointment.depositAmount && selectedAppointment.depositAmount > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||||
|
<CreditCard size={12} />
|
||||||
|
Deposit Paid
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-green-600 dark:text-green-400">
|
||||||
|
${selectedAppointment.depositAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Payment Made (final charge) */}
|
||||||
|
{paymentMade && paymentMade > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||||
|
<CreditCard size={12} />
|
||||||
|
Payment Made
|
||||||
|
</span>
|
||||||
|
<span className="font-medium text-green-600 dark:text-green-400">
|
||||||
|
${paymentMade.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Amount Due */}
|
||||||
|
{selectedAppointment.remainingBalance && selectedAppointment.remainingBalance > 0 && (
|
||||||
|
<div className="flex justify-between text-sm pt-2 border-t border-green-200 dark:border-green-700">
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{['COMPLETED', 'PAID'].includes(selectedAppointment.status) ? 'Balance Due' : 'Amount Due at Service'}
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
${selectedAppointment.remainingBalance.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overpaid / Refund Due */}
|
||||||
|
{selectedAppointment.overpaidAmount && selectedAppointment.overpaidAmount > 0 && (
|
||||||
|
<div className="flex justify-between text-sm pt-2 border-t border-green-200 dark:border-green-700">
|
||||||
|
<span className="font-medium text-gray-700 dark:text-gray-300">Refund Due</span>
|
||||||
|
<span className="font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
${selectedAppointment.overpaidAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fully Paid indicator */}
|
||||||
|
{['COMPLETED', 'PAID'].includes(selectedAppointment.status) &&
|
||||||
|
(!selectedAppointment.remainingBalance || selectedAppointment.remainingBalance <= 0) && (
|
||||||
|
<div className="flex justify-between text-sm pt-2 border-t border-green-200 dark:border-green-700">
|
||||||
|
<span className="font-medium text-green-700 dark:text-green-300">Status</span>
|
||||||
|
<span className="font-bold text-green-600 dark:text-green-400">
|
||||||
|
Paid in Full
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* TODO: Add View Contracts button when contracts feature is linked to appointments */}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="pt-4 flex justify-between border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedAppointment(null)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePrintReceipt(selectedAppointment)}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Printer size={16} />
|
||||||
|
Print Receipt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{new Date(selectedAppointment.startTime) >= new Date() && selectedAppointment.status !== 'CANCELLED' && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleCancel(selectedAppointment);
|
||||||
|
setSelectedAppointment(null);
|
||||||
|
}}
|
||||||
|
disabled={updateAppointment.isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updateAppointment.isPending ? 'Cancelling...' : 'Cancel Appointment'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -310,14 +310,21 @@ class UserTenantFilteredMixin(SandboxFilteredQuerySetMixin):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def filter_queryset_for_tenant(self, queryset):
|
def filter_queryset_for_tenant(self, queryset):
|
||||||
"""Filter users by tenant foreign key."""
|
"""Filter users by tenant foreign key.
|
||||||
|
|
||||||
|
Uses request.tenant (from django-tenants middleware) rather than
|
||||||
|
request.user.tenant because platform-level users (owners) may have
|
||||||
|
tenant=None on their user record but still access tenant subdomains.
|
||||||
|
"""
|
||||||
queryset = super().filter_queryset_for_tenant(queryset)
|
queryset = super().filter_queryset_for_tenant(queryset)
|
||||||
|
|
||||||
user = self.request.user
|
# Use request.tenant (from middleware) - this is set based on the
|
||||||
if user.tenant:
|
# subdomain being accessed, not the user's tenant FK
|
||||||
queryset = queryset.filter(tenant=user.tenant)
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if request_tenant:
|
||||||
|
queryset = queryset.filter(tenant=request_tenant)
|
||||||
else:
|
else:
|
||||||
# User has no tenant - return empty for safety
|
# No tenant on request - return empty for safety
|
||||||
return queryset.none()
|
return queryset.none()
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|||||||
@@ -223,25 +223,51 @@ class ResourceSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
def _get_valid_user(self, user_id):
|
def _get_valid_user(self, user_id):
|
||||||
"""
|
"""
|
||||||
Get a user by ID, validating they belong to the same tenant as the request user.
|
Get a user by ID, validating they belong to the same tenant as the request.
|
||||||
Returns None if user doesn't exist or doesn't belong to the same tenant.
|
Returns None if user doesn't exist or doesn't belong to the same tenant.
|
||||||
|
|
||||||
CRITICAL: This prevents cross-tenant user linking (multi-tenancy security).
|
CRITICAL: This prevents cross-tenant user linking (multi-tenancy security).
|
||||||
|
|
||||||
|
Uses request.tenant (from django-tenants middleware) rather than request.user.tenant
|
||||||
|
because platform-level users (owners) may have tenant=None on their user record
|
||||||
|
but still access tenant subdomains.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
logger.warning(f"_get_valid_user called with user_id={user_id}")
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
logger.warning("_get_valid_user: user_id is empty, returning None")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
request = self.context.get('request')
|
request = self.context.get('request')
|
||||||
if not request or not request.user.is_authenticated:
|
if not request or not request.user.is_authenticated:
|
||||||
|
logger.warning(f"_get_valid_user: no request or not authenticated, returning None")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Use request.tenant (from django-tenants middleware) - this is set based on
|
||||||
|
# the subdomain being accessed, not the user's tenant FK
|
||||||
|
tenant = getattr(request, 'tenant', None)
|
||||||
|
logger.warning(f"_get_valid_user: request.tenant={tenant}, request.user={request.user}, request.user.tenant={getattr(request.user, 'tenant', 'N/A')}")
|
||||||
|
|
||||||
|
if not tenant:
|
||||||
|
logger.warning("_get_valid_user: no tenant on request, returning None")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
# Verify user belongs to the same tenant
|
logger.warning(f"_get_valid_user: found user {user.email}, user.tenant={user.tenant}, comparing to request.tenant={tenant}")
|
||||||
if request.user.tenant and user.tenant == request.user.tenant:
|
|
||||||
|
# Verify user belongs to the same tenant as the request
|
||||||
|
if user.tenant == tenant:
|
||||||
|
logger.warning(f"_get_valid_user: tenant match! Returning user")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
logger.warning(f"_get_valid_user: tenant mismatch, returning None")
|
||||||
return None
|
return None
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
|
logger.warning(f"_get_valid_user: user {user_id} not found")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _is_staff_type(self, resource_type=None, legacy_type=None):
|
def _is_staff_type(self, resource_type=None, legacy_type=None):
|
||||||
@@ -264,6 +290,10 @@ class ResourceSerializer(serializers.ModelSerializer):
|
|||||||
Validate that staff-type resources have a user assigned.
|
Validate that staff-type resources have a user assigned.
|
||||||
Staff resources MUST be linked to a staff member.
|
Staff resources MUST be linked to a staff member.
|
||||||
"""
|
"""
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.warning(f"ResourceSerializer.validate called with attrs={attrs}")
|
||||||
|
|
||||||
user_id = attrs.get('user_id')
|
user_id = attrs.get('user_id')
|
||||||
resource_type = attrs.get('resource_type')
|
resource_type = attrs.get('resource_type')
|
||||||
legacy_type = attrs.get('type')
|
legacy_type = attrs.get('type')
|
||||||
|
|||||||
Reference in New Issue
Block a user