5 Commits

Author SHA1 Message Date
poduck
90fa628cb5 feat: Add customer appointment details modal and ATM-style currency input
- Add appointment detail modal to CustomerDashboard with payment info display
  - Shows service, date/time, duration, status, and notes
  - Displays payment summary: service price, deposit paid, payment made, amount due
  - Print receipt functionality with secure DOM manipulation
  - Cancel appointment button for upcoming appointments

- Add CurrencyInput component for ATM-style price entry
  - Digits entered as cents, shift left as more digits added (e.g., "1234" → $12.34)
  - Robust input validation: handles keyboard, mobile, paste, drop, IME
  - Only allows integer digits (0-9)

- Update useAppointments hook to map payment fields from backend
  - Converts amounts from cents to dollars for display

- Update Services page to use CurrencyInput for price and deposit fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 12:46:10 -05:00
poduck
7f389830f8 docs: Update README with comprehensive setup and deployment guide
- Updated project structure to reflect current domain-based organization
- Added detailed local development setup with lvh.me explanation
- Added production deployment instructions (quick deploy and fresh server)
- Documented environment variables configuration
- Added architecture diagrams for multi-tenancy and request flow
- Included troubleshooting section for common issues
- Updated role hierarchy documentation
- Added configuration files reference table

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:53:02 -05:00
poduck
30909f3268 fix: Add WebSocket proxy configuration to nginx
The nginx.conf was missing a location block for /ws/ paths, causing
WebSocket connections to fall through to the SPA catch-all and return
index.html instead of proxying to Django/Daphne.

Added proper WebSocket proxy configuration with:
- HTTP/1.1 upgrade headers for WebSocket protocol
- 24-hour read timeout for long-lived connections
- Standard proxy headers for Django

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:46:37 -05:00
poduck
df45a6f5d7 fix: Use request.tenant for staff filtering in multi-tenant context
- UserTenantFilteredMixin now uses request.tenant (from django-tenants
  middleware) instead of request.user.tenant for filtering
- ResourceSerializer._get_valid_user uses request.tenant for validation
- Frontend useResources sends user_id instead of user field

This fixes 400 errors when creating staff resources because the tenant
context is now correctly derived from the subdomain being accessed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:31:18 -05:00
poduck
156ad09232 fix: Use request.tenant instead of request.user.tenant for user validation
Platform-level users (owners) may have tenant=None on their user record
but still access tenant subdomains. The _get_valid_user method now uses
request.tenant (from django-tenants middleware) which is set based on
the subdomain being accessed, not the user's tenant FK.

This fixes 400 Bad Request errors when platform users try to create
resources with staff assignments.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:11:38 -05:00
9 changed files with 1447 additions and 293 deletions

655
README.md
View File

@@ -1,257 +1,470 @@
# 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
- **8-Tier Role Hierarchy**: From SUPERUSER to CUSTOMER with strict permissions
- **Modern Stack**: Django 5.2 + React 18 + Vite
- **Docker Ready**: Complete production & development Docker Compose setup
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible) for static/media files
- **Auto SSL**: Let's Encrypt certificates via Traefik reverse proxy
- **Task Queue**: Celery + Redis for background jobs
- **Real-time**: Django Channels + WebSockets support
-**Production Ready**: Fully configured for deployment
- **Multi-Tenancy**: PostgreSQL schema-per-tenant using django-tenants
- **8-Tier Role Hierarchy**: SUPERUSER, PLATFORM_MANAGER, PLATFORM_SALES, PLATFORM_SUPPORT, TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF, CUSTOMER
- **Modern Stack**: Django 5.2 + React 19 + TypeScript + Vite
- **Real-time Updates**: Django Channels + WebSockets
- **Background Tasks**: Celery + Redis
- **Auto SSL**: Let's Encrypt certificates via Traefik
- **Cloud Storage**: DigitalOcean Spaces (S3-compatible)
- **Docker Ready**: Complete Docker Compose setup for dev and production
## 📚 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
- **[PRODUCTION-READY.md](PRODUCTION-READY.md)** - Production deployment status
- **[DEPLOYMENT.md](DEPLOYMENT.md)** - Comprehensive deployment guide
- **[CLAUDE.md](CLAUDE.md)** - Development guide and architecture
```
smoothschedule2/
├── frontend/ # React + Vite + TypeScript
│ ├── src/
│ │ ├── 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
# Start backend (Django in Docker)
git clone https://github.com/your-repo/smoothschedule.git
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
# Start frontend (React with Vite)
cd ../frontend
npm install
npm run dev
# Wait for services to initialize (first time takes longer)
sleep 30
# Access the app
# Frontend: http://platform.lvh.me:5173
# Backend API: http://lvh.me:8000/api
# Run database migrations
docker compose -f docker-compose.local.yml exec django python manage.py migrate
# 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.
### 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:
### Step 3: Start the Frontend (React with Vite)
```bash
# Deploy to production server (code changes only)
./deploy.sh poduck@smoothschedule.com
cd ../frontend
# 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
```
┌─────────────────────────────────────────┐
│ PostgreSQL Database │
├─────────────────────────────────────────┤
public (shared schema) │
├─ Tenants │
├─ Domains │
│ ├─ Users │
└─ PermissionGrants │
├─────────────────────────────────────────┤
tenant_demo (schema for Demo Company) │
├─ Appointments │
│ ├─ Resources │
└─ Customers │
├─────────────────────────────────────────┤
│ tenant_acme (schema for Acme Corp) │
│ ├─ Appointments │
│ ├─ Resources │
│ └─ Customers │
└─────────────────────────────────────────┘
PostgreSQL Database
├── public (shared schema)
│ ├── Tenants
├── Domains
├── Users
└── PermissionGrants
├── demo (tenant schema)
├── Resources
│ ├── Events
├── Services
└── Customers
└── acme (tenant schema)
├── Resources
├── Events
└── ...
```
### Role Hierarchy
| Role | Level | Access Scope |
|---------------------|----------|---------------------------|
| SUPERUSER | Platform | All tenants (god mode) |
| PLATFORM_MANAGER | Platform | All tenants |
| PLATFORM_SALES | Platform | Demo accounts only |
| PLATFORM_SUPPORT | Platform | Tenant users |
| TENANT_OWNER | Tenant | Own tenant (full access) |
| TENANT_MANAGER | Tenant | Own tenant |
| TENANT_STAFF | Tenant | Own tenant (limited) |
| CUSTOMER | Tenant | Own data only |
| Role | Level | Access |
|------|-------|--------|
| SUPERUSER | Platform | All tenants (god mode) |
| PLATFORM_MANAGER | Platform | All tenants |
| PLATFORM_SALES | Platform | Demo accounts only |
| PLATFORM_SUPPORT | Platform | Can masquerade as tenant users |
| TENANT_OWNER | Tenant | Full tenant access |
| TENANT_MANAGER | Tenant | Most tenant features |
| TENANT_STAFF | Tenant | Limited tenant access |
| CUSTOMER | Tenant | Own data only |
### Masquerading Matrix
| 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
### Request Flow
```
smoothschedule/
├── config/
└── settings.py # Multi-tenancy & security config
├── core/
├── models.py # Tenant, Domain, PermissionGrant
├── permissions.py # Hijack permission matrix
│ ├── 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
Browser → Traefik (SSL) → nginx (frontend) or django (API)
React SPA
/api/* → django:5000
/ws/* → django:5000 (WebSocket)
```
## 🔐 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

View File

@@ -63,6 +63,19 @@ http {
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)
location /static/ {
proxy_pass http://django:5000;

View 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;

View File

@@ -52,6 +52,14 @@ export const useAppointments = (filters?: AppointmentFilters) => {
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
status: a.status as AppointmentStatus,
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),
status: data.status as AppointmentStatus,
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,

View File

@@ -70,8 +70,7 @@ export const useCreateResource = () => {
const backendData: any = {
name: resourceData.name,
type: resourceData.type,
user: resourceData.userId ? parseInt(resourceData.userId) : null,
timezone: 'UTC', // Default timezone
user_id: resourceData.userId ? parseInt(resourceData.userId) : null,
};
if (resourceData.maxConcurrentEvents !== undefined) {

View File

@@ -1,29 +1,50 @@
import React, { useState, useRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
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 { useResources } from '../hooks/useResources';
import { Service, User, Business } from '../types';
import { getOverQuotaServiceIds } from '../utils/quotaUtils';
import CurrencyInput from '../components/CurrencyInput';
interface ServiceFormData {
name: string;
durationMinutes: number;
price: number;
price_cents: number; // Price in cents (e.g., 5000 = $50.00)
description: string;
photos: string[];
// Pricing fields
variable_pricing: boolean;
deposit_enabled: boolean;
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;
// 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 { t } = useTranslation();
const { user } = useOutletContext<{ user: User, business: Business }>();
const { data: services, isLoading, error } = useServices();
const { data: resources } = useResources({ type: 'STAFF' }); // Only STAFF resources for services
const createService = useCreateService();
const updateService = useUpdateService();
const deleteService = useDeleteService();
@@ -40,14 +61,26 @@ const Services: React.FC = () => {
const [formData, setFormData] = useState<ServiceFormData>({
name: '',
durationMinutes: 60,
price: 0,
price_cents: 0,
description: '',
photos: [],
variable_pricing: false,
deposit_enabled: false,
deposit_type: 'amount',
deposit_amount: null,
deposit_amount_cents: 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
@@ -211,14 +244,23 @@ const Services: React.FC = () => {
setFormData({
name: '',
durationMinutes: 60,
price: 0,
price_cents: 0,
description: '',
photos: [],
variable_pricing: false,
deposit_enabled: false,
deposit_type: 'amount',
deposit_amount: null,
deposit_amount_cents: 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);
};
@@ -226,21 +268,30 @@ const Services: React.FC = () => {
const openEditModal = (service: Service) => {
setEditingService(service);
// 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);
const depositType = service.deposit_percent && service.deposit_percent > 0 ? 'percent' : 'amount';
setFormData({
name: service.name,
durationMinutes: service.durationMinutes,
price: service.price,
price_cents: service.price_cents || 0,
description: service.description || '',
photos: service.photos || [],
variable_pricing: service.variable_pricing || false,
deposit_enabled: hasDeposit,
deposit_type: depositType,
deposit_amount: service.deposit_amount || null,
deposit_amount_cents: service.deposit_amount_cents || 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);
};
@@ -257,17 +308,30 @@ const Services: React.FC = () => {
const apiData = {
name: formData.name,
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,
photos: formData.photos,
variable_pricing: formData.variable_pricing,
// Only send deposit values if deposit is enabled
deposit_amount: formData.deposit_enabled && formData.deposit_type === 'amount'
? formData.deposit_amount
deposit_amount_cents: formData.deposit_enabled && formData.deposit_type === 'amount'
? formData.deposit_amount_cents
: null,
deposit_percent: formData.deposit_enabled && formData.deposit_type === 'percent'
? formData.deposit_percent
: 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 {
@@ -424,10 +488,10 @@ const Services: React.FC = () => {
<DollarSign className="h-3.5 w-3.5" />
{service.variable_pricing ? (
<>
{t('services.fromPrice', 'From')} ${service.price.toFixed(2)}
{t('services.fromPrice', 'From')} ${service.price}
</>
) : (
`$${service.price.toFixed(2)}`
`$${service.price}`
)}
</span>
{service.variable_pricing && (
@@ -446,6 +510,19 @@ const Services: React.FC = () => {
{service.photos.length}
</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>
@@ -497,9 +574,9 @@ const Services: React.FC = () => {
</span>
<span className="font-semibold text-brand-600 dark:text-brand-400">
{service.variable_pricing ? (
<>From ${service.price.toFixed(2)}</>
<>From ${service.price}</>
) : (
`$${service.price.toFixed(2)}`
`$${service.price}`
)}
</span>
{service.variable_pricing && service.deposit_display && (
@@ -530,7 +607,7 @@ const Services: React.FC = () => {
{/* Modal */}
{isModalOpen && (
<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">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{editingService
@@ -599,7 +676,7 @@ const Services: React.FC = () => {
<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.duration', 'Duration (min)')} *
{t('services.duration', 'Duration (Minutes)')} *
</label>
<input
type="number"
@@ -613,23 +690,25 @@ const Services: React.FC = () => {
</div>
<div>
<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>
<input
type="number"
value={formData.variable_pricing ? '' : formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
required={!formData.variable_pricing}
disabled={formData.variable_pricing}
min={0}
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 ${
formData.variable_pricing
? 'bg-gray-100 dark:bg-gray-900 cursor-not-allowed'
: 'bg-white dark:bg-gray-700'
}`}
/>
{formData.variable_pricing ? (
<input
type="text"
value=""
disabled
placeholder={t('services.priceNA', 'N/A')}
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"
/>
) : (
<CurrencyInput
value={formData.price_cents}
onChange={(cents) => setFormData({ ...formData, price_cents: cents })}
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 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.variablePriceNote', 'Price determined after service')}
@@ -688,7 +767,7 @@ const Services: React.FC = () => {
...formData,
deposit_type: 'amount',
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"
/>
@@ -704,7 +783,7 @@ const Services: React.FC = () => {
onChange={() => setFormData({
...formData,
deposit_type: 'percent',
deposit_amount: null,
deposit_amount_cents: null,
deposit_percent: formData.deposit_percent || 25,
})}
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') && (
<div>
<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>
<input
type="number"
value={formData.deposit_amount || ''}
onChange={(e) => setFormData({ ...formData, deposit_amount: parseFloat(e.target.value) || null })}
<CurrencyInput
value={formData.deposit_amount_cents || 0}
onChange={(cents) => setFormData({ ...formData, deposit_amount_cents: cents || null })}
required
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"
placeholder="50.00"
/>
</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"
placeholder="25"
/>
{formData.deposit_percent && formData.price > 0 && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.depositCalculated', 'Deposit: ${amount}', {
amount: ((formData.price * formData.deposit_percent) / 100).toFixed(2)
})}
{formData.deposit_percent && formData.price_cents > 0 && (
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
= ${formatCentsAsDollars(Math.round(formData.price_cents * formData.deposit_percent / 100))}
</p>
)}
</div>
@@ -770,6 +845,255 @@ const Services: React.FC = () => {
)}
</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 */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">

View File

@@ -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 { User, Business, Appointment } from '../../types';
import { useAppointments, useUpdateAppointment } from '../../hooks/useAppointments';
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 [activeTab, setActiveTab] = useState<'upcoming' | 'past'>('upcoming');
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
// Fetch appointments from API - backend filters for current customer
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;
if (hoursBefore < business.cancellationWindowHours) {
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;
} else {
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) {
return (
<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 => {
const service = services.find(s => s.id === apt.serviceId);
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>
<h3 className="font-semibold">{service?.name || 'Appointment'}</h3>
<p className="text-sm text-gray-500">{new Date(apt.startTime).toLocaleString()}</p>
</div>
{activeTab === 'upcoming' && (
<button
onClick={() => handleCancel(apt)}
disabled={updateAppointment.isPending}
className="text-sm font-medium text-red-600 hover:underline disabled:opacity-50"
>
{updateAppointment.isPending ? 'Cancelling...' : 'Cancel'}
</button>
)}
<div className="flex items-center gap-3">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${
apt.status === 'SCHEDULED' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' :
apt.status === 'PENDING_DEPOSIT' ? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400' :
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' :
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
}`}>
{apt.status.replace(/_/g, ' ')}
</span>
</div>
</div>
);
})}
@@ -89,6 +211,249 @@ const AppointmentList: React.FC<{ user: User, business: Business }> = ({ user, b
</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>
);
};

View File

@@ -310,14 +310,21 @@ class UserTenantFilteredMixin(SandboxFilteredQuerySetMixin):
"""
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)
user = self.request.user
if user.tenant:
queryset = queryset.filter(tenant=user.tenant)
# Use request.tenant (from middleware) - this is set based on the
# subdomain being accessed, not the user's tenant FK
request_tenant = getattr(self.request, 'tenant', None)
if request_tenant:
queryset = queryset.filter(tenant=request_tenant)
else:
# User has no tenant - return empty for safety
# No tenant on request - return empty for safety
return queryset.none()
return queryset

View File

@@ -223,25 +223,51 @@ class ResourceSerializer(serializers.ModelSerializer):
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.
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:
logger.warning("_get_valid_user: user_id is empty, returning None")
return None
request = self.context.get('request')
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
try:
user = User.objects.get(id=user_id)
# Verify user belongs to the same tenant
if request.user.tenant and user.tenant == request.user.tenant:
logger.warning(f"_get_valid_user: found user {user.email}, user.tenant={user.tenant}, comparing to request.tenant={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
logger.warning(f"_get_valid_user: tenant mismatch, returning None")
return None
except User.DoesNotExist:
logger.warning(f"_get_valid_user: user {user_id} not found")
return 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.
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')
resource_type = attrs.get('resource_type')
legacy_type = attrs.get('type')