feat: Add photo galleries to services, resource types management, and UI improvements
Major features: - Add drag-and-drop photo gallery to Service create/edit modals - Add Resource Types management section to Settings (CRUD for custom types) - Add edit icon consistency to Resources table (pencil icon in actions) - Improve Services page with drag-to-reorder and customer preview mockup Backend changes: - Add photos JSONField to Service model with migration - Add ResourceType model with category (STAFF/OTHER), description fields - Add ResourceTypeViewSet with CRUD operations - Add service reorder endpoint for display order Frontend changes: - Services page: two-column layout, drag-reorder, photo upload - Settings page: Resource Types tab with full CRUD modal - Resources page: Edit icon in actions column instead of row click - Sidebar: Payments link visibility based on role and paymentsEnabled - Update types.ts with Service.photos and ResourceTypeDefinition Note: Removed photos from ResourceType (kept only for Service) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
102
CLAUDE.md
Normal file
102
CLAUDE.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# SmoothSchedule - Multi-Tenant Scheduling Platform
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
/home/poduck/Desktop/smoothschedule2/
|
||||
├── frontend/ # React + Vite + TypeScript frontend
|
||||
│ └── CLAUDE.md # Frontend-specific docs
|
||||
│
|
||||
├── smoothschedule/ # Django backend (RUNS IN DOCKER!)
|
||||
│ └── CLAUDE.md # Backend-specific docs
|
||||
│
|
||||
└── legacy_reference/ # Old code for reference (do not modify)
|
||||
```
|
||||
|
||||
### Development URLs
|
||||
- **Frontend:** `http://demo.lvh.me:5173` (or any business subdomain)
|
||||
- **Platform Frontend:** `http://platform.lvh.me:5173`
|
||||
- **Backend API:** `http://lvh.me:8000/api/`
|
||||
|
||||
Note: `lvh.me` resolves to `127.0.0.1` - required for subdomain cookies to work.
|
||||
|
||||
## CRITICAL: Backend Runs in Docker
|
||||
|
||||
**NEVER run Django commands directly.** Always use Docker Compose:
|
||||
|
||||
```bash
|
||||
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
|
||||
|
||||
# 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
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.local.yml logs -f django
|
||||
|
||||
# Any management command
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py <command>
|
||||
```
|
||||
|
||||
## Key Configuration Files
|
||||
|
||||
### Backend (Django)
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `smoothschedule/docker-compose.local.yml` | Docker services config |
|
||||
| `smoothschedule/.envs/.local/.django` | Django env vars (SECRET_KEY, etc.) |
|
||||
| `smoothschedule/.envs/.local/.postgres` | Database credentials |
|
||||
| `smoothschedule/config/settings/local.py` | Local Django settings |
|
||||
| `smoothschedule/config/settings/base.py` | Base Django settings |
|
||||
| `smoothschedule/config/urls.py` | URL routing |
|
||||
|
||||
### Frontend (React)
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `frontend/.env.development` | Vite env vars |
|
||||
| `frontend/vite.config.ts` | Vite configuration |
|
||||
| `frontend/src/api/client.ts` | Axios API client |
|
||||
| `frontend/src/types.ts` | TypeScript interfaces |
|
||||
| `frontend/src/i18n/locales/en.json` | Translations |
|
||||
|
||||
## Key Django Apps
|
||||
|
||||
| App | Location | Purpose |
|
||||
|-----|----------|---------|
|
||||
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
|
||||
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
|
||||
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### After modifying Django models:
|
||||
```bash
|
||||
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py makemigrations
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||
```
|
||||
|
||||
### After modifying frontend:
|
||||
Frontend hot-reloads automatically. If issues, restart:
|
||||
```bash
|
||||
cd /home/poduck/Desktop/smoothschedule2/frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Debugging 500 errors:
|
||||
```bash
|
||||
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
|
||||
docker compose -f docker-compose.local.yml logs django --tail=100
|
||||
```
|
||||
|
||||
### Testing API directly:
|
||||
```bash
|
||||
curl -s "http://lvh.me:8000/api/resources/" | jq
|
||||
```
|
||||
|
||||
## Git Branch
|
||||
Currently on: `feature/platform-superuser-ui`
|
||||
Main branch: `main`
|
||||
354
IMPLEMENTATION_SUMMARY.md
Normal file
354
IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# Resource Types & Logo Upload - Implementation Summary
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### Backend Models
|
||||
|
||||
1. **ResourceType Model** (`smoothschedule/schedule/models.py`)
|
||||
- Custom resource type definitions (e.g., "Hair Stylist", "Massage Room")
|
||||
- Category field: STAFF (requires staff assignment) or OTHER
|
||||
- `is_default` flag to prevent deletion of core types
|
||||
- Validation to prevent deletion when in use
|
||||
- Database migration created and applied ✅
|
||||
|
||||
2. **Resource Model Updates** (`smoothschedule/schedule/models.py`)
|
||||
- Added `resource_type` ForeignKey to ResourceType model
|
||||
- Kept legacy `type` field for backwards compatibility
|
||||
- Migration created and applied ✅
|
||||
|
||||
3. **Tenant Model Updates** (`smoothschedule/core/models.py`)
|
||||
- Added `logo` ImageField for business logo upload
|
||||
- Added `logo_display_mode` field with choices:
|
||||
- `logo-only`: Show only logo in sidebar
|
||||
- `text-only`: Show only business name (default)
|
||||
- `logo-and-text`: Show both logo and name
|
||||
- Added `primary_color` and `secondary_color` fields
|
||||
- Database migration created and applied ✅
|
||||
|
||||
### Frontend Updates
|
||||
|
||||
1. **TypeScript Types** (`frontend/src/types.ts`)
|
||||
- Added `ResourceTypeDefinition` interface
|
||||
- Added `ResourceTypeCategory` type
|
||||
- Added `logoDisplayMode` to Business interface
|
||||
- Updated Resource interface with `typeId` field
|
||||
|
||||
2. **React Hooks** (`frontend/src/hooks/useResourceTypes.ts`)
|
||||
- `useResourceTypes()` - Fetch resource types
|
||||
- `useCreateResourceType()` - Create new type
|
||||
- `useUpdateResourceType()` - Update existing type
|
||||
- `useDeleteResourceType()` - Delete type (with validation)
|
||||
- Includes placeholder data for default types
|
||||
|
||||
3. **Sidebar Component** (`frontend/src/components/Sidebar.tsx`)
|
||||
- Updated to display logo based on `logoDisplayMode`
|
||||
- Shows logo image when mode is `logo-only` or `logo-and-text`
|
||||
- Hides business name text when mode is `logo-only`
|
||||
- Maintains fallback to initials when no logo
|
||||
|
||||
4. **Resource Modal** (`frontend/src/pages/Resources.tsx`)
|
||||
- Resource type dropdown now works (no longer disabled)
|
||||
- Staff autocomplete fully functional with keyboard navigation
|
||||
- Debounced search to reduce API calls
|
||||
- All three default types available (Staff, Room, Equipment)
|
||||
|
||||
## ⏳ Next Steps
|
||||
|
||||
### 1. Data Migration for Default Types
|
||||
Create a data migration to populate default ResourceType records for existing tenants:
|
||||
|
||||
```bash
|
||||
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py makemigrations schedule --empty --name create_default_resource_types
|
||||
```
|
||||
|
||||
Then edit the migration to add:
|
||||
```python
|
||||
def create_default_types(apps, schema_editor):
|
||||
ResourceType = apps.get_model('schedule', 'ResourceType')
|
||||
|
||||
# Create default types
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Staff',
|
||||
defaults={
|
||||
'category': 'STAFF',
|
||||
'is_default': True,
|
||||
'icon_name': 'user'
|
||||
}
|
||||
)
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Room',
|
||||
defaults={
|
||||
'category': 'OTHER',
|
||||
'is_default': True,
|
||||
'icon_name': 'home'
|
||||
}
|
||||
)
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Equipment',
|
||||
defaults={
|
||||
'category': 'OTHER',
|
||||
'is_default': True,
|
||||
'icon_name': 'wrench'
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 2. API Serializers & Views
|
||||
Create Django REST Framework serializers and viewsets:
|
||||
|
||||
**File**: `smoothschedule/schedule/serializers.py`
|
||||
```python
|
||||
class ResourceTypeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ResourceType
|
||||
fields = ['id', 'name', 'category', 'is_default', 'icon_name', 'created_at']
|
||||
read_only_fields = ['id', 'created_at']
|
||||
|
||||
def validate_delete(self, instance):
|
||||
if instance.is_default:
|
||||
raise serializers.ValidationError("Cannot delete default resource types.")
|
||||
if instance.resources.exists():
|
||||
raise serializers.ValidationError(f"Cannot delete - in use by {instance.resources.count()} resources.")
|
||||
```
|
||||
|
||||
**File**: `smoothschedule/schedule/views.py`
|
||||
```python
|
||||
class ResourceTypeViewSet(viewsets.ModelViewSet):
|
||||
queryset = ResourceType.objects.all()
|
||||
serializer_class = ResourceTypeSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
self.serializer_class().validate_delete(instance)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
```
|
||||
|
||||
**File**: `smoothschedule/schedule/urls.py`
|
||||
```python
|
||||
router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype')
|
||||
```
|
||||
|
||||
### 3. Logo Upload API
|
||||
Add logo upload endpoint to tenant/business API:
|
||||
|
||||
**File**: `smoothschedule/core/api_views.py` (or similar)
|
||||
```python
|
||||
class TenantLogoUploadView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
def post(self, request):
|
||||
tenant = request.tenant # From django-tenants middleware
|
||||
logo_file = request.FILES.get('logo')
|
||||
|
||||
if not logo_file:
|
||||
return Response({'error': 'No logo file provided'}, status=400)
|
||||
|
||||
# Validate file size (5MB max)
|
||||
if logo_file.size > 5 * 1024 * 1024:
|
||||
return Response({'error': 'Logo file too large (max 5MB)'}, status=400)
|
||||
|
||||
# Validate file type
|
||||
if not logo_file.content_type.startswith('image/'):
|
||||
return Response({'error': 'File must be an image'}, status=400)
|
||||
|
||||
tenant.logo = logo_file
|
||||
tenant.save()
|
||||
|
||||
return Response({
|
||||
'logoUrl': tenant.logo.url,
|
||||
'message': 'Logo uploaded successfully'
|
||||
})
|
||||
|
||||
def delete(self, request):
|
||||
tenant = request.tenant
|
||||
tenant.logo.delete()
|
||||
tenant.logo = None
|
||||
tenant.save()
|
||||
return Response({'message': 'Logo removed successfully'})
|
||||
```
|
||||
|
||||
### 4. Settings Page UI Components
|
||||
Add to `frontend/src/pages/Settings.tsx`:
|
||||
|
||||
#### A. Resource Types Tab
|
||||
```tsx
|
||||
{activeTab === 'resources' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Resource Types</h3>
|
||||
<p className="text-gray-600">Customize how you categorize your bookable resources.</p>
|
||||
</div>
|
||||
|
||||
<ResourceTypesManager />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
#### B. Logo Upload in Branding Section
|
||||
```tsx
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold">Business Logo</h4>
|
||||
|
||||
{/* Preview */}
|
||||
{business.logoUrl ? (
|
||||
<div className="relative inline-block">
|
||||
<img src={business.logoUrl} alt="Logo" className="w-32 h-32 object-contain border rounded" />
|
||||
<button
|
||||
onClick={handleRemoveLogo}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-32 h-32 border-2 border-dashed rounded flex items-center justify-center text-gray-400">
|
||||
<Image size={32} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload */}
|
||||
<input
|
||||
type="file"
|
||||
id="logo-upload"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleLogoUpload}
|
||||
/>
|
||||
<label htmlFor="logo-upload" className="btn-secondary">
|
||||
<Upload size={16} />
|
||||
Upload Logo
|
||||
</label>
|
||||
|
||||
{/* Display Mode */}
|
||||
<div>
|
||||
<label className="block font-medium mb-2">Logo Display Mode</label>
|
||||
<select
|
||||
value={business.logoDisplayMode}
|
||||
onChange={(e) => handleUpdateBusiness({ logoDisplayMode: e.target.value })}
|
||||
className="form-select"
|
||||
>
|
||||
<option value="text-only">Text Only</option>
|
||||
<option value="logo-only">Logo Only</option>
|
||||
<option value="logo-and-text">Logo and Text</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Preview with menu background */}
|
||||
<div>
|
||||
<label className="block font-medium mb-2">Preview in Sidebar</label>
|
||||
<div
|
||||
className="w-64 p-4 rounded-lg"
|
||||
style={{ backgroundColor: business.primaryColor }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{business.logoUrl && (business.logoDisplayMode === 'logo-only' || business.logoDisplayMode === 'logo-and-text') ? (
|
||||
<img src={business.logoUrl} alt="Logo" className="w-10 h-10 object-contain" />
|
||||
) : (
|
||||
<div className="w-10 h-10 bg-white rounded-lg flex items-center justify-center text-brand-600 font-bold">
|
||||
{business.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{business.logoDisplayMode !== 'logo-only' && (
|
||||
<div>
|
||||
<h1 className="font-bold text-white truncate">{business.name}</h1>
|
||||
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5. Update Resources Page
|
||||
Modify `frontend/src/pages/Resources.tsx` to use custom resource types:
|
||||
|
||||
```tsx
|
||||
const { data: resourceTypes = [] } = useResourceTypes();
|
||||
|
||||
// In the modal:
|
||||
<select value={selectedTypeId} onChange={handleTypeChange}>
|
||||
{resourceTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Show staff selector if selected type is STAFF category */}
|
||||
{selectedType?.category === 'STAFF' && (
|
||||
<StaffAutocomplete />
|
||||
)}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Create a custom resource type (e.g., "Massage Therapist")
|
||||
- [ ] Delete a custom type (should work if not in use)
|
||||
- [ ] Try to delete a default type (should fail)
|
||||
- [ ] Try to delete a type in use (should fail)
|
||||
- [ ] Upload a business logo
|
||||
- [ ] Change logo display mode and verify sidebar updates
|
||||
- [ ] Remove logo
|
||||
- [ ] Create a resource using custom type
|
||||
- [ ] Verify staff assignment required for STAFF category types
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### Backend
|
||||
- ✅ `smoothschedule/schedule/models.py` - Added ResourceType model, updated Resource
|
||||
- ✅ `smoothschedule/core/models.py` - Added logo fields to Tenant
|
||||
- ✅ `smoothschedule/schedule/migrations/0007_*.py` - ResourceType migration
|
||||
- ✅ `smoothschedule/core/migrations/0003_*.py` - Tenant logo migration
|
||||
- ⏳ `smoothschedule/schedule/serializers.py` - Need ResourceTypeSerializer
|
||||
- ⏳ `smoothschedule/schedule/views.py` - Need ResourceTypeViewSet
|
||||
- ⏳ `smoothschedule/schedule/urls.py` - Need router registration
|
||||
- ⏳ `smoothschedule/core/api_views.py` - Need logo upload endpoint
|
||||
|
||||
### Frontend
|
||||
- ✅ `frontend/src/types.ts` - Added ResourceType interfaces
|
||||
- ✅ `frontend/src/hooks/useResourceTypes.ts` - CRUD hooks
|
||||
- ✅ `frontend/src/components/Sidebar.tsx` - Logo display logic
|
||||
- ✅ `frontend/src/pages/Resources.tsx` - Fixed dropdown, autocomplete
|
||||
- ⏳ `frontend/src/pages/Settings.tsx` - Need resource types tab & logo upload UI
|
||||
- ⏳ `frontend/src/components/ResourceTypesManager.tsx` - Need new component
|
||||
|
||||
## Database Schema
|
||||
|
||||
### ResourceType Table
|
||||
```sql
|
||||
CREATE TABLE schedule_resourcetype (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
category VARCHAR(10) NOT NULL DEFAULT 'OTHER',
|
||||
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
icon_name VARCHAR(50),
|
||||
created_at TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
### Tenant Table Updates
|
||||
```sql
|
||||
ALTER TABLE core_tenant ADD COLUMN logo VARCHAR(100);
|
||||
ALTER TABLE core_tenant ADD COLUMN logo_display_mode VARCHAR(20) DEFAULT 'text-only';
|
||||
ALTER TABLE core_tenant ADD COLUMN primary_color VARCHAR(7) DEFAULT '#2563eb';
|
||||
ALTER TABLE core_tenant ADD COLUMN secondary_color VARCHAR(7) DEFAULT '#0ea5e9';
|
||||
```
|
||||
|
||||
### Resource Table Updates
|
||||
```sql
|
||||
ALTER TABLE schedule_resource ADD COLUMN resource_type_id INTEGER REFERENCES schedule_resourcetype(id) ON DELETE PROTECT;
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Logo files stored in `MEDIA_ROOT/tenant_logos/`
|
||||
- Max file size: 5MB
|
||||
- Supported formats: PNG, JPG, SVG
|
||||
- Recommended dimensions: 500x500px (square) or 500x200px (wide)
|
||||
- Default resource types are created via data migration
|
||||
- Legacy `Resource.type` field kept for backwards compatibility
|
||||
281
RESOURCE_TYPES_PLAN.md
Normal file
281
RESOURCE_TYPES_PLAN.md
Normal file
@@ -0,0 +1,281 @@
|
||||
# Custom Resource Types & Logo Upload - Implementation Plan
|
||||
|
||||
## Overview
|
||||
Allow businesses to create custom resource types (e.g., "Stylist", "Massage Therapist", "Treatment Room") instead of hardcoded types. Also add logo upload to business branding.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Custom Resource Types
|
||||
- **Default Types**: Always include one "Staff" type (cannot be deleted)
|
||||
- **Custom Names**: Users can name types whatever they want (e.g., "Hair Stylist", "Nail Technician", "Massage Room")
|
||||
- **Categories**: Each type has a category:
|
||||
- `STAFF`: Requires staff member assignment
|
||||
- `OTHER`: No staff assignment needed
|
||||
- **Management**: Add, edit, delete custom types in Business Settings
|
||||
|
||||
### 2. Logo Upload
|
||||
- Upload business logo in branding section
|
||||
- Support PNG, JPG, SVG
|
||||
- Preview before saving
|
||||
- Used in customer-facing pages
|
||||
|
||||
## Database Changes
|
||||
|
||||
### Backend Models
|
||||
|
||||
```python
|
||||
# smoothschedule/schedule/models.py
|
||||
|
||||
class ResourceType(models.Model):
|
||||
"""Custom resource type definitions per business"""
|
||||
business = models.ForeignKey('tenants.Business', on_delete=models.CASCADE, related_name='resource_types')
|
||||
name = models.CharField(max_length=100) # "Stylist", "Treatment Room", etc.
|
||||
category = models.CharField(max_length=10, choices=[
|
||||
('STAFF', 'Staff'),
|
||||
('OTHER', 'Other'),
|
||||
])
|
||||
is_default = models.BooleanField(default=False) # Cannot be deleted
|
||||
icon_name = models.CharField(max_length=50, null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['business', 'name']
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.business.name} - {self.name}"
|
||||
|
||||
|
||||
class Resource(models.Model):
|
||||
"""Update existing Resource model"""
|
||||
# ... existing fields ...
|
||||
|
||||
# NEW FIELD - references custom resource type
|
||||
resource_type = models.ForeignKey(
|
||||
ResourceType,
|
||||
on_delete=models.PROTECT, # Cannot delete type if resources use it
|
||||
related_name='resources',
|
||||
null=True, # For migration
|
||||
blank=True
|
||||
)
|
||||
|
||||
# DEPRECATED - keep for backwards compatibility during migration
|
||||
type = models.CharField(
|
||||
max_length=20,
|
||||
choices=[('STAFF', 'Staff'), ('ROOM', 'Room'), ('EQUIPMENT', 'Equipment')],
|
||||
default='STAFF'
|
||||
)
|
||||
|
||||
|
||||
# smoothschedule/tenants/models.py
|
||||
|
||||
class Business(models.Model):
|
||||
# ... existing fields ...
|
||||
|
||||
# NEW FIELD - logo upload
|
||||
logo = models.ImageField(upload_to='business_logos/', null=True, blank=True)
|
||||
```
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. Create `ResourceType` model
|
||||
2. For each business, create default resource types:
|
||||
- "Staff" (category=STAFF, is_default=True)
|
||||
- "Room" (category=OTHER, is_default=True)
|
||||
- "Equipment" (category=OTHER, is_default=True)
|
||||
3. Migrate existing resources to use default types based on their `type` field
|
||||
4. Eventually deprecate `Resource.type` field
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Resource Types
|
||||
|
||||
```
|
||||
GET /api/resource-types/ # List business resource types
|
||||
POST /api/resource-types/ # Create new type
|
||||
PATCH /api/resource-types/{id}/ # Update type
|
||||
DELETE /api/resource-types/{id}/ # Delete type (if not in use & not default)
|
||||
```
|
||||
|
||||
### Logo Upload
|
||||
|
||||
```
|
||||
POST /api/business/logo/ # Upload logo
|
||||
DELETE /api/business/logo/ # Remove logo
|
||||
```
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### 1. Business Settings - Resource Types Tab
|
||||
|
||||
```tsx
|
||||
// Add new tab to Settings.tsx
|
||||
|
||||
<Tab name="Resource Types">
|
||||
<ResourceTypesManager />
|
||||
</Tab>
|
||||
```
|
||||
|
||||
### ResourceTypesManager Component
|
||||
|
||||
```tsx
|
||||
interface ResourceTypesManagerProps {}
|
||||
|
||||
const ResourceTypesManager: React.FC = () => {
|
||||
const { data: types = [] } = useResourceTypes();
|
||||
const createMutation = useCreateResourceType();
|
||||
const updateMutation = useUpdateResourceType();
|
||||
const deleteMutation = useDeleteResourceType();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>Resource Types</h3>
|
||||
<p>Customize how you categorize your bookable resources.</p>
|
||||
|
||||
{/* List of types */}
|
||||
{types.map(type => (
|
||||
<TypeCard
|
||||
key={type.id}
|
||||
type={type}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
canDelete={!type.isDefault && !type.inUse}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Add new type button */}
|
||||
<button onClick={openCreateModal}>
|
||||
+ Add Resource Type
|
||||
</button>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<ResourceTypeModal
|
||||
type={editingType}
|
||||
onSave={handleSave}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Logo Upload in Branding Section
|
||||
|
||||
```tsx
|
||||
// In Settings.tsx, Branding section
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4>Business Logo</h4>
|
||||
|
||||
{/* Logo preview */}
|
||||
{business.logoUrl && (
|
||||
<div className="relative w-32 h-32">
|
||||
<img src={business.logoUrl} alt="Business logo" />
|
||||
<button onClick={removeLogo}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload button */}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={handleLogoUpload}
|
||||
className="hidden"
|
||||
id="logo-upload"
|
||||
/>
|
||||
<label htmlFor="logo-upload">
|
||||
<Upload size={16} />
|
||||
Upload Logo
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 3. Update Resources.tsx to use custom types
|
||||
|
||||
```tsx
|
||||
// Resources.tsx
|
||||
|
||||
const Resources: React.FC = () => {
|
||||
const { data: resourceTypes = [] } = useResourceTypes();
|
||||
|
||||
return (
|
||||
<ResourceModal>
|
||||
<select name="resourceType">
|
||||
{resourceTypes.map(type => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Show staff selector if selected type is STAFF category */}
|
||||
{selectedType?.category === 'STAFF' && (
|
||||
<StaffSelector required />
|
||||
)}
|
||||
</ResourceModal>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Phase 1: Backend Foundation
|
||||
1. ✅ Create TypeScript types (DONE)
|
||||
2. ⏳ Create Django models (`ResourceType`, add `Business.logo`)
|
||||
3. ⏳ Create migrations with default data
|
||||
4. ⏳ Create serializers
|
||||
5. ⏳ Create API views and URLs
|
||||
6. ⏳ Add file upload handling for logos
|
||||
|
||||
### Phase 2: Frontend Hooks & Types
|
||||
1. ✅ Create `useResourceTypes` hook (DONE)
|
||||
2. ⏳ Create logo upload utilities
|
||||
3. ⏳ Update `Resource` type to include `typeId`
|
||||
|
||||
### Phase 3: UI Components
|
||||
1. ⏳ Add "Resource Types" tab to Settings
|
||||
2. ⏳ Create ResourceTypesManager component
|
||||
3. ⏳ Create ResourceTypeModal component
|
||||
4. ⏳ Add logo upload to Branding section
|
||||
5. ⏳ Update Resources page to use custom types
|
||||
|
||||
### Phase 4: Migration & Testing
|
||||
1. ⏳ Test creating/editing/deleting types
|
||||
2. ⏳ Test logo upload/removal
|
||||
3. ⏳ Ensure backwards compatibility
|
||||
4. ⏳ Test resource creation with custom types
|
||||
|
||||
## User Experience
|
||||
|
||||
### Creating a Custom Resource Type
|
||||
1. Navigate to Business Settings → Resource Types
|
||||
2. Click "+ Add Resource Type"
|
||||
3. Enter name (e.g., "Massage Therapist")
|
||||
4. Select category: Staff or Other
|
||||
5. Click Save
|
||||
6. New type appears in list and is available when creating resources
|
||||
|
||||
### Uploading a Logo
|
||||
1. Navigate to Business Settings → General → Branding
|
||||
2. Click "Upload Logo"
|
||||
3. Select image file (PNG, JPG, or SVG)
|
||||
4. Preview appears
|
||||
5. Click "Save Changes"
|
||||
6. Logo appears in header and customer-facing pages
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Flexibility**: Businesses can name types to match their industry
|
||||
2. **Clarity**: "Massage Therapist" is clearer than "Staff" for a spa
|
||||
3. **Scalability**: Add new types as business grows
|
||||
4. **Branding**: Logo upload improves professional appearance
|
||||
5. **User-Friendly**: Simple UI for non-technical users
|
||||
|
||||
## Notes
|
||||
|
||||
- Default "Staff" type cannot be deleted (ensures at least one staff type exists)
|
||||
- Cannot delete types that are in use by existing resources
|
||||
- Logo file size limit: 5MB
|
||||
- Logo recommended dimensions: 500x500px (square) or 500x200px (wide)
|
||||
@@ -1,5 +1,36 @@
|
||||
# SmoothSchedule Frontend Development Guide
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is the React frontend for SmoothSchedule, a multi-tenant scheduling platform.
|
||||
|
||||
**See also:** `/home/poduck/Desktop/smoothschedule2/smoothschedule/CLAUDE.md` for backend documentation.
|
||||
|
||||
## Key Paths
|
||||
|
||||
```
|
||||
/home/poduck/Desktop/smoothschedule2/
|
||||
├── frontend/ # This React frontend
|
||||
│ ├── src/
|
||||
│ │ ├── api/client.ts # Axios API client
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── hooks/ # React Query hooks (useResources, useAuth, etc.)
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── types.ts # TypeScript interfaces
|
||||
│ │ ├── i18n/locales/en.json # English translations
|
||||
│ │ └── utils/cookies.ts # Cookie utilities
|
||||
│ ├── .env.development # Frontend env vars
|
||||
│ └── vite.config.ts # Vite configuration
|
||||
│
|
||||
└── smoothschedule/ # Django backend (runs in Docker!)
|
||||
├── docker-compose.local.yml # Docker config
|
||||
├── .envs/.local/ # Backend env vars
|
||||
├── config/settings/ # Django settings
|
||||
└── smoothschedule/
|
||||
├── schedule/ # Core scheduling app
|
||||
└── users/ # User management
|
||||
```
|
||||
|
||||
## Local Development Domain Setup
|
||||
|
||||
### Why lvh.me instead of localhost?
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- button "Collapse sidebar" [ref=e6]:
|
||||
- generic [ref=e7]: DE
|
||||
- generic [ref=e8]:
|
||||
- heading "Demo Company" [level=1] [ref=e9]
|
||||
- paragraph [ref=e10]: demo.smoothschedule.com
|
||||
- navigation [ref=e11]:
|
||||
- link "Dashboard" [ref=e12] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e13]
|
||||
- generic [ref=e18]: Dashboard
|
||||
- link "Scheduler" [ref=e19] [cursor=pointer]:
|
||||
- /url: "#/scheduler"
|
||||
- img [ref=e20]
|
||||
- generic [ref=e22]: Scheduler
|
||||
- link "Customers" [ref=e23] [cursor=pointer]:
|
||||
- /url: "#/customers"
|
||||
- img [ref=e24]
|
||||
- generic [ref=e29]: Customers
|
||||
- link "Services" [ref=e30] [cursor=pointer]:
|
||||
- /url: "#/services"
|
||||
- img [ref=e31]
|
||||
- generic [ref=e34]: Services
|
||||
- link "Resources" [ref=e35] [cursor=pointer]:
|
||||
- /url: "#/resources"
|
||||
- img [ref=e36]
|
||||
- generic [ref=e39]: Resources
|
||||
- generic "Payments are disabled. Enable them in Business Settings to accept payments from customers." [ref=e40]:
|
||||
- img [ref=e41]
|
||||
- generic [ref=e43]: Payments
|
||||
- link "Messages" [ref=e44] [cursor=pointer]:
|
||||
- /url: "#/messages"
|
||||
- img [ref=e45]
|
||||
- generic [ref=e47]: Messages
|
||||
- link "Staff" [ref=e48] [cursor=pointer]:
|
||||
- /url: "#/staff"
|
||||
- img [ref=e49]
|
||||
- generic [ref=e54]: Staff
|
||||
- link "Business Settings" [ref=e56] [cursor=pointer]:
|
||||
- /url: "#/settings"
|
||||
- img [ref=e57]
|
||||
- generic [ref=e60]: Business Settings
|
||||
- generic [ref=e61]:
|
||||
- generic [ref=e62]:
|
||||
- img [ref=e63]
|
||||
- generic [ref=e69]:
|
||||
- generic [ref=e70]: Powered by
|
||||
- text: Smooth Schedule
|
||||
- button "Sign Out" [ref=e71]:
|
||||
- img [ref=e72]
|
||||
- generic [ref=e75]: Sign Out
|
||||
- generic [ref=e76]:
|
||||
- banner [ref=e77]:
|
||||
- generic [ref=e79]:
|
||||
- img [ref=e81]
|
||||
- textbox "Search" [ref=e84]
|
||||
- generic [ref=e85]:
|
||||
- button "🇺🇸 English" [ref=e87]:
|
||||
- img [ref=e88]
|
||||
- generic [ref=e91]: 🇺🇸
|
||||
- generic [ref=e92]: English
|
||||
- img [ref=e93]
|
||||
- button [ref=e95]:
|
||||
- img [ref=e96]
|
||||
- button [ref=e98]:
|
||||
- img [ref=e99]
|
||||
- button "Business Owner Owner BO" [ref=e104]:
|
||||
- generic [ref=e105]:
|
||||
- paragraph [ref=e106]: Business Owner
|
||||
- paragraph [ref=e107]: Owner
|
||||
- generic [ref=e108]: BO
|
||||
- img [ref=e109]
|
||||
- main [active] [ref=e111]:
|
||||
- generic [ref=e112]:
|
||||
- generic [ref=e113]:
|
||||
- heading "Dashboard" [level=2] [ref=e114]
|
||||
- paragraph [ref=e115]: Today's Overview
|
||||
- generic [ref=e116]:
|
||||
- generic [ref=e117]:
|
||||
- paragraph [ref=e118]: Total Appointments
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]: "50"
|
||||
- generic [ref=e121]:
|
||||
- img [ref=e122]
|
||||
- text: +12%
|
||||
- generic [ref=e125]:
|
||||
- paragraph [ref=e126]: Customers
|
||||
- generic [ref=e127]:
|
||||
- generic [ref=e128]: "1"
|
||||
- generic [ref=e129]:
|
||||
- img [ref=e130]
|
||||
- text: +8%
|
||||
- generic [ref=e133]:
|
||||
- paragraph [ref=e134]: Services
|
||||
- generic [ref=e135]:
|
||||
- generic [ref=e136]: "5"
|
||||
- generic [ref=e137]:
|
||||
- img [ref=e138]
|
||||
- text: 0%
|
||||
- generic [ref=e139]:
|
||||
- paragraph [ref=e140]: Resources
|
||||
- generic [ref=e141]:
|
||||
- generic [ref=e142]: "4"
|
||||
- generic [ref=e143]:
|
||||
- img [ref=e144]
|
||||
- text: +3%
|
||||
- generic [ref=e147]:
|
||||
- generic [ref=e149]:
|
||||
- generic [ref=e150]:
|
||||
- img [ref=e152]
|
||||
- heading "Quick Add Appointment" [level=3] [ref=e154]
|
||||
- generic [ref=e155]:
|
||||
- generic [ref=e156]:
|
||||
- generic [ref=e157]:
|
||||
- img [ref=e158]
|
||||
- text: Customer
|
||||
- combobox [ref=e161]:
|
||||
- option "Walk-in / No customer" [selected]
|
||||
- option "Customer User (customer@demo.com)"
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]:
|
||||
- img [ref=e164]
|
||||
- text: Service *
|
||||
- combobox [ref=e167]:
|
||||
- option "Select service..." [selected]
|
||||
- option "Beard Trim (15 min - $15)"
|
||||
- option "Consultation (30 min - $0)"
|
||||
- option "Full Styling (60 min - $75)"
|
||||
- option "Hair Coloring (90 min - $120)"
|
||||
- option "Haircut (30 min - $35)"
|
||||
- generic [ref=e168]:
|
||||
- generic [ref=e169]:
|
||||
- img [ref=e170]
|
||||
- text: Resource
|
||||
- combobox [ref=e173]:
|
||||
- option "Unassigned" [selected]
|
||||
- option "Conference Room A"
|
||||
- option "Dental Chair 1"
|
||||
- option "Meeting Room B"
|
||||
- option "Meeting Room B"
|
||||
- generic [ref=e174]:
|
||||
- generic [ref=e175]:
|
||||
- generic [ref=e176]: Date *
|
||||
- textbox [ref=e177]: 2025-11-27
|
||||
- generic [ref=e178]:
|
||||
- generic [ref=e179]:
|
||||
- img [ref=e180]
|
||||
- text: Time *
|
||||
- combobox [ref=e183]:
|
||||
- option "06:00"
|
||||
- option "06:15"
|
||||
- option "06:30"
|
||||
- option "06:45"
|
||||
- option "07:00"
|
||||
- option "07:15"
|
||||
- option "07:30"
|
||||
- option "07:45"
|
||||
- option "08:00"
|
||||
- option "08:15"
|
||||
- option "08:30"
|
||||
- option "08:45"
|
||||
- option "09:00" [selected]
|
||||
- option "09:15"
|
||||
- option "09:30"
|
||||
- option "09:45"
|
||||
- option "10:00"
|
||||
- option "10:15"
|
||||
- option "10:30"
|
||||
- option "10:45"
|
||||
- option "11:00"
|
||||
- option "11:15"
|
||||
- option "11:30"
|
||||
- option "11:45"
|
||||
- option "12:00"
|
||||
- option "12:15"
|
||||
- option "12:30"
|
||||
- option "12:45"
|
||||
- option "13:00"
|
||||
- option "13:15"
|
||||
- option "13:30"
|
||||
- option "13:45"
|
||||
- option "14:00"
|
||||
- option "14:15"
|
||||
- option "14:30"
|
||||
- option "14:45"
|
||||
- option "15:00"
|
||||
- option "15:15"
|
||||
- option "15:30"
|
||||
- option "15:45"
|
||||
- option "16:00"
|
||||
- option "16:15"
|
||||
- option "16:30"
|
||||
- option "16:45"
|
||||
- option "17:00"
|
||||
- option "17:15"
|
||||
- option "17:30"
|
||||
- option "17:45"
|
||||
- option "18:00"
|
||||
- option "18:15"
|
||||
- option "18:30"
|
||||
- option "18:45"
|
||||
- option "19:00"
|
||||
- option "19:15"
|
||||
- option "19:30"
|
||||
- option "19:45"
|
||||
- option "20:00"
|
||||
- option "20:15"
|
||||
- option "20:30"
|
||||
- option "20:45"
|
||||
- option "21:00"
|
||||
- option "21:15"
|
||||
- option "21:30"
|
||||
- option "21:45"
|
||||
- option "22:00"
|
||||
- option "22:15"
|
||||
- option "22:30"
|
||||
- option "22:45"
|
||||
- generic [ref=e184]:
|
||||
- generic [ref=e185]:
|
||||
- img [ref=e186]
|
||||
- text: Notes
|
||||
- textbox "Optional notes..." [ref=e189]
|
||||
- button "Add Appointment" [disabled] [ref=e190]:
|
||||
- img [ref=e191]
|
||||
- text: Add Appointment
|
||||
- generic [ref=e193]:
|
||||
- heading "Total Revenue" [level=3] [ref=e194]
|
||||
- application [ref=e198]:
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]:
|
||||
- generic [ref=e205]: Mon
|
||||
- generic [ref=e207]: Tue
|
||||
- generic [ref=e209]: Wed
|
||||
- generic [ref=e211]: Thu
|
||||
- generic [ref=e213]: Fri
|
||||
- generic [ref=e215]: Sat
|
||||
- generic [ref=e217]: Sun
|
||||
- generic [ref=e218]:
|
||||
- generic [ref=e220]: $0
|
||||
- generic [ref=e222]: $1
|
||||
- generic [ref=e224]: $2
|
||||
- generic [ref=e226]: $3
|
||||
- generic [ref=e228]: $4
|
||||
- generic [ref=e229]:
|
||||
- heading "Upcoming Appointments" [level=3] [ref=e230]
|
||||
- application [ref=e234]:
|
||||
- generic [ref=e250]:
|
||||
- generic [ref=e251]:
|
||||
- generic [ref=e253]: Mon
|
||||
- generic [ref=e255]: Tue
|
||||
- generic [ref=e257]: Wed
|
||||
- generic [ref=e259]: Thu
|
||||
- generic [ref=e261]: Fri
|
||||
- generic [ref=e263]: Sat
|
||||
- generic [ref=e265]: Sun
|
||||
- generic [ref=e266]:
|
||||
- generic [ref=e268]: "0"
|
||||
- generic [ref=e270]: "3"
|
||||
- generic [ref=e272]: "6"
|
||||
- generic [ref=e274]: "9"
|
||||
- generic [ref=e276]: "12"
|
||||
- generic [ref=e277]: "0"
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
File diff suppressed because one or more lines are too long
@@ -194,29 +194,29 @@ const AppContent: React.FC = () => {
|
||||
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
|
||||
};
|
||||
|
||||
// Not authenticated - show public routes
|
||||
if (!user) {
|
||||
// On root domain, show marketing site
|
||||
if (isRootDomain()) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<MarketingLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/features" element={<FeaturesPage />} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
// On root domain, ALWAYS show marketing site (even if logged in)
|
||||
// Logged-in users will see a "Go to Dashboard" link in the navbar
|
||||
if (isRootDomain()) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<MarketingLayout user={user} />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/features" element={<FeaturesPage />} />
|
||||
<Route path="/pricing" element={<PricingPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/signup" element={<SignupPage />} />
|
||||
</Route>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// On business subdomain, show login
|
||||
// Not authenticated on subdomain - show login
|
||||
if (!user) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
@@ -232,6 +232,43 @@ const AppContent: React.FC = () => {
|
||||
return <ErrorScreen error={userError as Error} />;
|
||||
}
|
||||
|
||||
// Subdomain validation for logged-in users
|
||||
const currentHostname = window.location.hostname;
|
||||
const isPlatformDomain = currentHostname === 'platform.lvh.me';
|
||||
const currentSubdomain = currentHostname.split('.')[0];
|
||||
const isBusinessSubdomain = !isRootDomain() && !isPlatformDomain && currentSubdomain !== 'api';
|
||||
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
|
||||
const isCustomer = user.role === 'customer';
|
||||
|
||||
// RULE: Platform users must be on platform subdomain (not business subdomains)
|
||||
if (isPlatformUser && isBusinessSubdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://platform.lvh.me${port}/`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// RULE: Business users must be on their own business subdomain
|
||||
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// RULE: Customers must be on their business subdomain
|
||||
if (isCustomer && isPlatformDomain && user.business_subdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (isCustomer && isBusinessSubdomain && user.business_subdomain && user.business_subdomain !== currentSubdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Handlers
|
||||
const toggleTheme = () => setDarkMode((prev) => !prev);
|
||||
const handleSignOut = () => {
|
||||
@@ -242,22 +279,20 @@ const AppContent: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleMasquerade = (targetUser: any) => {
|
||||
// Call the masquerade API with the target user's username
|
||||
// Fallback to email prefix if username is not available
|
||||
const username = targetUser.username || targetUser.email?.split('@')[0];
|
||||
if (!username) {
|
||||
console.error('Cannot masquerade: no username or email available', targetUser);
|
||||
// Call the masquerade API with the target user's id
|
||||
const userId = targetUser.id;
|
||||
if (!userId) {
|
||||
console.error('Cannot masquerade: no user id available', targetUser);
|
||||
return;
|
||||
}
|
||||
masqueradeMutation.mutate(username);
|
||||
// Ensure userId is a number
|
||||
const userPk = typeof userId === 'string' ? parseInt(userId, 10) : userId;
|
||||
masqueradeMutation.mutate(userPk);
|
||||
};
|
||||
|
||||
// Helper to check access based on roles
|
||||
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
||||
|
||||
// Platform users (superuser, platform_manager, platform_support)
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
|
||||
if (isPlatformUser) {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -329,54 +364,16 @@ const AppContent: React.FC = () => {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// Check if we're on root/platform domain without proper business context
|
||||
const currentHostname = window.location.hostname;
|
||||
const isRootOrPlatform = currentHostname === 'lvh.me' || currentHostname === 'localhost' || currentHostname === 'platform.lvh.me';
|
||||
|
||||
// Business error or no business found
|
||||
if (businessError || !business) {
|
||||
// If user is a business owner on root domain, redirect to their business
|
||||
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
|
||||
// If user has a business subdomain, redirect them there
|
||||
if (user.business_subdomain) {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
// If on root/platform and shouldn't be here, show appropriate message
|
||||
if (isRootOrPlatform) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
<h2 className="text-2xl font-bold text-amber-600 dark:text-amber-400 mb-4">Wrong Location</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
{user.business_subdomain
|
||||
? `Please access the app at your business subdomain: ${user.business_subdomain}.lvh.me`
|
||||
: 'Your account is not associated with a business. Please contact support.'}
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
{user.business_subdomain && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
Go to Business
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No business subdomain - show error
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||
<div className="text-center max-w-md p-6">
|
||||
|
||||
@@ -86,15 +86,15 @@ export const refreshToken = async (refresh: string): Promise<{ access: string }>
|
||||
};
|
||||
|
||||
/**
|
||||
* Masquerade as another user
|
||||
* Masquerade as another user (hijack)
|
||||
*/
|
||||
export const masquerade = async (
|
||||
username: string,
|
||||
masquerade_stack?: MasqueradeStackEntry[]
|
||||
user_pk: number,
|
||||
hijack_history?: MasqueradeStackEntry[]
|
||||
): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
`/api/users/${username}/masquerade/`,
|
||||
{ masquerade_stack }
|
||||
'/api/auth/hijack/acquire/',
|
||||
{ user_pk, hijack_history }
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
@@ -106,7 +106,7 @@ export const stopMasquerade = async (
|
||||
masquerade_stack: MasqueradeStackEntry[]
|
||||
): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post<LoginResponse>(
|
||||
'/api/users/stop_masquerade/',
|
||||
'/api/auth/hijack/release/',
|
||||
{ masquerade_stack }
|
||||
);
|
||||
return response.data;
|
||||
|
||||
@@ -5,14 +5,23 @@
|
||||
|
||||
import apiClient from './client';
|
||||
|
||||
export interface PlatformBusinessOwner {
|
||||
id: number;
|
||||
username: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface PlatformBusiness {
|
||||
id: number;
|
||||
name: string;
|
||||
subdomain: string;
|
||||
tier: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
created_on: string;
|
||||
user_count: number;
|
||||
owner: PlatformBusinessOwner | null;
|
||||
}
|
||||
|
||||
export interface PlatformUser {
|
||||
|
||||
@@ -34,7 +34,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
? location.pathname === path
|
||||
: location.pathname.startsWith(path);
|
||||
|
||||
const baseClasses = `flex items-center gap-3 py-3 text-sm font-medium rounded-lg transition-colors`;
|
||||
const baseClasses = `flex items-center gap-3 py-3 text-base font-medium rounded-lg transition-colors`;
|
||||
const collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
|
||||
const activeClasses = 'bg-white/10 text-white';
|
||||
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
|
||||
@@ -70,14 +70,40 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
className={`flex items-center gap-3 w-full text-left px-6 py-8 ${isCollapsed ? 'justify-center' : ''} hover:bg-white/5 transition-colors focus:outline-none`}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
|
||||
{business.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="overflow-hidden">
|
||||
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
|
||||
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
||||
{/* Logo-only mode: full width */}
|
||||
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<img
|
||||
src={business.logoUrl}
|
||||
alt={business.name}
|
||||
className="max-w-full max-h-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Logo/Icon display */}
|
||||
{business.logoUrl && business.logoDisplayMode !== 'text-only' ? (
|
||||
<div className="flex items-center justify-center w-10 h-10 shrink-0">
|
||||
<img
|
||||
src={business.logoUrl}
|
||||
alt={business.name}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : business.logoDisplayMode !== 'logo-only' && (
|
||||
<div className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0" style={{ color: business.primaryColor }}>
|
||||
{business.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text display */}
|
||||
{!isCollapsed && business.logoDisplayMode !== 'logo-only' && (
|
||||
<div className="overflow-hidden">
|
||||
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
|
||||
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -111,19 +137,22 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
|
||||
{canViewAdminPages && (
|
||||
<>
|
||||
{business.paymentsEnabled ? (
|
||||
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
|
||||
<CreditCard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={getNavClass('/payments', false, true)}
|
||||
title={t('nav.paymentsDisabledTooltip')}
|
||||
>
|
||||
<CreditCard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||
</div>
|
||||
{/* Payments link: always visible for owners, only visible for others if enabled */}
|
||||
{(role === 'owner' || business.paymentsEnabled) && (
|
||||
business.paymentsEnabled ? (
|
||||
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
|
||||
<CreditCard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||
</Link>
|
||||
) : (
|
||||
<div
|
||||
className={getNavClass('/payments', false, true)}
|
||||
title={t('nav.paymentsDisabledTooltip')}
|
||||
>
|
||||
<CreditCard size={20} className="shrink-0" />
|
||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
|
||||
<MessageSquare size={20} className="shrink-0" />
|
||||
@@ -149,7 +178,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<div className={`flex items-center gap-2 text-xs text-white/60 mb-4 ${isCollapsed ? 'justify-center' : ''}`}>
|
||||
<a
|
||||
href={`${window.location.protocol}//${window.location.host.split('.').slice(-2).join('.')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-2 text-xs text-white/60 mb-4 hover:text-white/80 transition-colors ${isCollapsed ? 'justify-center' : ''}`}
|
||||
>
|
||||
<SmoothScheduleLogo className="w-6 h-6 text-white" />
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
@@ -157,7 +191,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
||||
<span className="font-semibold text-white/80">Smooth Schedule</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
disabled={logoutMutation.isPending}
|
||||
|
||||
@@ -4,13 +4,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Menu, X, Sun, Moon } from 'lucide-react';
|
||||
import SmoothScheduleLogo from '../SmoothScheduleLogo';
|
||||
import LanguageSelector from '../LanguageSelector';
|
||||
import { User } from '../../api/auth';
|
||||
|
||||
interface NavbarProps {
|
||||
darkMode: boolean;
|
||||
toggleTheme: () => void;
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
|
||||
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -38,6 +40,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
// Get the dashboard URL based on user role
|
||||
const getDashboardUrl = (): string => {
|
||||
if (!user) return '/login';
|
||||
const port = window.location.port ? `:${window.location.port}` : '';
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
|
||||
return `${protocol}//platform.lvh.me${port}/`;
|
||||
}
|
||||
if (user.business_subdomain) {
|
||||
return `${protocol}//${user.business_subdomain}.lvh.me${port}/`;
|
||||
}
|
||||
return '/login';
|
||||
};
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
||||
@@ -90,12 +107,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
|
||||
</button>
|
||||
|
||||
{/* Login Button - Hidden on mobile */}
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.login')}
|
||||
</Link>
|
||||
{user ? (
|
||||
<a
|
||||
href={getDashboardUrl()}
|
||||
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.login')}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="hidden md:inline-flex px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Get Started CTA */}
|
||||
<Link
|
||||
@@ -139,12 +165,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
|
||||
</Link>
|
||||
))}
|
||||
<hr className="my-2 border-gray-200 dark:border-gray-800" />
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.login')}
|
||||
</Link>
|
||||
{user ? (
|
||||
<a
|
||||
href={getDashboardUrl()}
|
||||
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.login')}
|
||||
</a>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="px-4 py-3 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{t('marketing.nav.login')}
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/signup"
|
||||
className="px-4 py-3 rounded-lg text-sm font-medium text-center text-white bg-brand-600 hover:bg-brand-700 transition-colors"
|
||||
|
||||
@@ -101,13 +101,13 @@ export const useMasquerade = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (username: string) => {
|
||||
mutationFn: async (user_pk: number) => {
|
||||
// Get current masquerading stack from localStorage
|
||||
const stackJson = localStorage.getItem('masquerade_stack');
|
||||
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
|
||||
|
||||
// Call masquerade API with current stack
|
||||
return masquerade(username, currentStack);
|
||||
return masquerade(user_pk, currentStack);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
// Store the updated masquerading stack
|
||||
|
||||
@@ -33,6 +33,8 @@ export const useCurrentBusiness = () => {
|
||||
primaryColor: data.primary_color || '#3B82F6', // Blue-500 default
|
||||
secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default
|
||||
logoUrl: data.logo_url,
|
||||
emailLogoUrl: data.email_logo_url,
|
||||
logoDisplayMode: data.logo_display_mode || 'text-only',
|
||||
whitelabelEnabled: data.whitelabel_enabled,
|
||||
plan: data.tier, // Map tier to plan
|
||||
status: data.status,
|
||||
@@ -64,6 +66,8 @@ export const useUpdateBusiness = () => {
|
||||
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
|
||||
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
|
||||
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl;
|
||||
if (updates.emailLogoUrl !== undefined) backendData.email_logo_url = updates.emailLogoUrl;
|
||||
if (updates.logoDisplayMode !== undefined) backendData.logo_display_mode = updates.logoDisplayMode;
|
||||
if (updates.whitelabelEnabled !== undefined) {
|
||||
backendData.whitelabel_enabled = updates.whitelabelEnabled;
|
||||
}
|
||||
@@ -136,7 +140,7 @@ export const useBusinessUsers = () => {
|
||||
return useQuery({
|
||||
queryKey: ['businessUsers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/api/business/users/');
|
||||
const { data } = await apiClient.get('/api/staff/');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
|
||||
91
frontend/src/hooks/useResourceTypes.ts
Normal file
91
frontend/src/hooks/useResourceTypes.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Resource Types Management Hooks
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
import { ResourceTypeDefinition } from '../types';
|
||||
|
||||
/**
|
||||
* Hook to fetch resource types for the current business
|
||||
*/
|
||||
export const useResourceTypes = () => {
|
||||
return useQuery<ResourceTypeDefinition[]>({
|
||||
queryKey: ['resourceTypes'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get('/api/resource-types/');
|
||||
return data;
|
||||
},
|
||||
// Provide default types if API doesn't have them yet
|
||||
placeholderData: [
|
||||
{
|
||||
id: 'default-staff',
|
||||
name: 'Staff',
|
||||
category: 'STAFF',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'default-room',
|
||||
name: 'Room',
|
||||
category: 'OTHER',
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 'default-equipment',
|
||||
name: 'Equipment',
|
||||
category: 'OTHER',
|
||||
isDefault: true,
|
||||
},
|
||||
] as ResourceTypeDefinition[],
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create a new resource type
|
||||
*/
|
||||
export const useCreateResourceType = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (newType: Omit<ResourceTypeDefinition, 'id' | 'isDefault'>) => {
|
||||
const { data } = await apiClient.post('/api/resource-types/', newType);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to update a resource type
|
||||
*/
|
||||
export const useUpdateResourceType = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, updates }: { id: string; updates: Partial<ResourceTypeDefinition> }) => {
|
||||
const { data } = await apiClient.patch(`/api/resource-types/${id}/`, updates);
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to delete a resource type
|
||||
*/
|
||||
export const useDeleteResourceType = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await apiClient.delete(`/api/resource-types/${id}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['resourceTypes'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -28,6 +28,8 @@ export const useResources = (filters?: ResourceFilters) => {
|
||||
name: r.name,
|
||||
type: r.type as ResourceType,
|
||||
userId: r.user_id ? String(r.user_id) : undefined,
|
||||
maxConcurrentEvents: r.max_concurrent_events ?? 1,
|
||||
savedLaneCount: r.saved_lane_count,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -47,6 +49,8 @@ export const useResource = (id: string) => {
|
||||
name: data.name,
|
||||
type: data.type as ResourceType,
|
||||
userId: data.user_id ? String(data.user_id) : undefined,
|
||||
maxConcurrentEvents: data.max_concurrent_events ?? 1,
|
||||
savedLaneCount: data.saved_lane_count,
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
@@ -91,6 +95,12 @@ export const useUpdateResource = () => {
|
||||
if (updates.userId !== undefined) {
|
||||
backendData.user = updates.userId ? parseInt(updates.userId) : null;
|
||||
}
|
||||
if (updates.maxConcurrentEvents !== undefined) {
|
||||
backendData.max_concurrent_events = updates.maxConcurrentEvents;
|
||||
}
|
||||
if (updates.savedLaneCount !== undefined) {
|
||||
backendData.saved_lane_count = updates.savedLaneCount;
|
||||
}
|
||||
|
||||
const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData);
|
||||
return data;
|
||||
|
||||
@@ -22,6 +22,8 @@ export const useServices = () => {
|
||||
durationMinutes: s.duration || s.duration_minutes,
|
||||
price: parseFloat(s.price),
|
||||
description: s.description || '',
|
||||
displayOrder: s.display_order ?? 0,
|
||||
photos: s.photos || [],
|
||||
}));
|
||||
},
|
||||
retry: false, // Don't retry on 404 - endpoint may not exist yet
|
||||
@@ -43,6 +45,8 @@ export const useService = (id: string) => {
|
||||
durationMinutes: data.duration || data.duration_minutes,
|
||||
price: parseFloat(data.price),
|
||||
description: data.description || '',
|
||||
displayOrder: data.display_order ?? 0,
|
||||
photos: data.photos || [],
|
||||
};
|
||||
},
|
||||
enabled: !!id,
|
||||
@@ -63,6 +67,7 @@ export const useCreateService = () => {
|
||||
duration: serviceData.durationMinutes,
|
||||
price: serviceData.price.toString(),
|
||||
description: serviceData.description,
|
||||
photos: serviceData.photos || [],
|
||||
};
|
||||
|
||||
const { data } = await apiClient.post('/api/services/', backendData);
|
||||
@@ -87,6 +92,7 @@ export const useUpdateService = () => {
|
||||
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
|
||||
if (updates.price) backendData.price = updates.price.toString();
|
||||
if (updates.description !== undefined) backendData.description = updates.description;
|
||||
if (updates.photos !== undefined) backendData.photos = updates.photos;
|
||||
|
||||
const { data } = await apiClient.patch(`/api/services/${id}/`, backendData);
|
||||
return data;
|
||||
@@ -112,3 +118,22 @@ export const useDeleteService = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to reorder services (drag and drop)
|
||||
*/
|
||||
export const useReorderServices = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (orderedIds: string[]) => {
|
||||
// Convert string IDs to numbers for the backend
|
||||
const order = orderedIds.map(id => parseInt(id, 10));
|
||||
const { data } = await apiClient.post('/api/services/reorder/', { order });
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['services'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
42
frontend/src/hooks/useStaff.ts
Normal file
42
frontend/src/hooks/useStaff.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Staff Management Hooks
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
export interface StaffMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface StaffFilters {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch staff members with optional filters
|
||||
* Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF
|
||||
*/
|
||||
export const useStaff = (filters?: StaffFilters) => {
|
||||
return useQuery<StaffMember[]>({
|
||||
queryKey: ['staff', filters],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
|
||||
const { data } = await apiClient.get(`/api/staff/?${params}`);
|
||||
|
||||
// Transform backend format to frontend format
|
||||
return data.map((s: any) => ({
|
||||
id: String(s.id),
|
||||
name: s.name || `${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email,
|
||||
email: s.email || '',
|
||||
phone: s.phone || '',
|
||||
}));
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
};
|
||||
@@ -3,8 +3,13 @@ import { Outlet } from 'react-router-dom';
|
||||
import Navbar from '../components/marketing/Navbar';
|
||||
import Footer from '../components/marketing/Footer';
|
||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
||||
import { User } from '../api/auth';
|
||||
|
||||
const MarketingLayout: React.FC = () => {
|
||||
interface MarketingLayoutProps {
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
const MarketingLayout: React.FC<MarketingLayoutProps> = ({ user }) => {
|
||||
useScrollToTop();
|
||||
|
||||
const [darkMode, setDarkMode] = useState(() => {
|
||||
@@ -28,7 +33,7 @@ const MarketingLayout: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white dark:bg-gray-900 transition-colors duration-200">
|
||||
<Navbar darkMode={darkMode} toggleTheme={toggleTheme} />
|
||||
<Navbar darkMode={darkMode} toggleTheme={toggleTheme} user={user} />
|
||||
|
||||
{/* Main Content - with padding for fixed navbar */}
|
||||
<main className="flex-1 pt-16 lg:pt-20">
|
||||
|
||||
@@ -99,7 +99,8 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
return sorted;
|
||||
}, [customers, searchTerm, sortConfig]);
|
||||
|
||||
const canMasquerade = ['owner', 'manager', 'staff'].includes(effectiveUser.role);
|
||||
// Only owners can masquerade as customers (per backend permissions)
|
||||
const canMasquerade = effectiveUser.role === 'owner';
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -33,40 +33,68 @@ const LoginPage: React.FC = () => {
|
||||
const user = data.user;
|
||||
const currentHostname = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const portStr = currentPort ? `:${currentPort}` : '';
|
||||
|
||||
// Check if we're on the root domain (no subdomain)
|
||||
// Check domain type
|
||||
const isRootDomain = currentHostname === 'lvh.me' || currentHostname === 'localhost';
|
||||
const isPlatformDomain = currentHostname === 'platform.lvh.me';
|
||||
const currentSubdomain = currentHostname.split('.')[0];
|
||||
const isBusinessSubdomain = !isRootDomain && !isPlatformDomain && currentSubdomain !== 'api';
|
||||
|
||||
// Roles allowed to login at the root domain
|
||||
const rootAllowedRoles = ['superuser', 'platform_manager', 'platform_support', 'owner'];
|
||||
// Platform users (superuser, platform_manager, platform_support)
|
||||
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||
|
||||
// If on root domain, only allow specific roles
|
||||
if (isRootDomain && !rootAllowedRoles.includes(user.role)) {
|
||||
setError(t('auth.loginAtSubdomain'));
|
||||
// Business-associated users (owner, manager, staff, resource)
|
||||
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
|
||||
|
||||
// Customer users
|
||||
const isCustomer = user.role === 'customer';
|
||||
|
||||
// RULE 1: Platform users cannot login on business subdomains
|
||||
if (isPlatformUser && isBusinessSubdomain) {
|
||||
setError(t('auth.invalidCredentials'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the correct subdomain based on user role
|
||||
// RULE 2: Business users cannot login on other business subdomains
|
||||
if (isBusinessUser && isBusinessSubdomain && user.business_subdomain !== currentSubdomain) {
|
||||
setError(t('auth.invalidCredentials'));
|
||||
return;
|
||||
}
|
||||
|
||||
// RULE 3: Customers cannot login on root domain (they must use their business subdomain)
|
||||
if (isCustomer && isRootDomain) {
|
||||
setError(t('auth.invalidCredentials'));
|
||||
return;
|
||||
}
|
||||
|
||||
// RULE 4: Customers cannot login on platform domain
|
||||
if (isCustomer && isPlatformDomain) {
|
||||
setError(t('auth.invalidCredentials'));
|
||||
return;
|
||||
}
|
||||
|
||||
// RULE 5: Customers cannot login on a different business subdomain
|
||||
if (isCustomer && isBusinessSubdomain && user.business_subdomain !== currentSubdomain) {
|
||||
setError(t('auth.invalidCredentials'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine target subdomain for redirect
|
||||
let targetSubdomain: string | null = null;
|
||||
|
||||
// Platform users (superuser, platform_manager, platform_support)
|
||||
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
|
||||
if (isPlatformUser) {
|
||||
targetSubdomain = 'platform';
|
||||
}
|
||||
// Business users - redirect to their business subdomain
|
||||
else if (user.business_subdomain) {
|
||||
} else if (user.business_subdomain) {
|
||||
targetSubdomain = user.business_subdomain;
|
||||
}
|
||||
|
||||
// Check if we need to redirect to a different subdomain
|
||||
// Need to redirect if we have a target subdomain AND we're not already on it
|
||||
const isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
|
||||
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
|
||||
|
||||
if (needsRedirect) {
|
||||
// Pass tokens in URL to ensure they're available immediately on the new subdomain
|
||||
// This avoids race conditions where cookies might not be set before the page loads
|
||||
const portStr = currentPort ? `:${currentPort}` : '';
|
||||
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import React, { useMemo, useEffect, useState, useRef } from 'react';
|
||||
import React, { useMemo, useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ResourceType, User, Resource } from '../types';
|
||||
import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources';
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
Eye,
|
||||
Calendar,
|
||||
Settings,
|
||||
X
|
||||
X,
|
||||
Pencil
|
||||
} from 'lucide-react';
|
||||
|
||||
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
|
||||
@@ -55,17 +56,29 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
// Staff selection state
|
||||
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
|
||||
const [staffSearchQuery, setStaffSearchQuery] = useState('');
|
||||
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||
const [showStaffDropdown, setShowStaffDropdown] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
const staffInputRef = useRef<HTMLInputElement>(null);
|
||||
const staffDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch staff members for autocomplete
|
||||
const { data: staffMembers = [] } = useStaff({ search: staffSearchQuery });
|
||||
// Debounce search query for API calls
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearchQuery(staffSearchQuery);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [staffSearchQuery]);
|
||||
|
||||
// Fetch staff members for autocomplete (using debounced query)
|
||||
const { data: staffMembers = [] } = useStaff({ search: debouncedSearchQuery });
|
||||
|
||||
// Filter staff members based on search query (client-side filtering for immediate feedback)
|
||||
const filteredStaff = useMemo(() => {
|
||||
if (!staffSearchQuery) return staffMembers;
|
||||
const query = staffSearchQuery.toLowerCase();
|
||||
// Always show all staff when dropdown is open and no search query
|
||||
if (!staffSearchQuery.trim()) return staffMembers;
|
||||
|
||||
const query = staffSearchQuery.toLowerCase().trim();
|
||||
return staffMembers.filter(
|
||||
(s) => s.name.toLowerCase().includes(query) || s.email.toLowerCase().includes(query)
|
||||
);
|
||||
@@ -76,6 +89,59 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
return staffMembers.find((s) => s.id === selectedStaffId) || null;
|
||||
}, [staffMembers, selectedStaffId]);
|
||||
|
||||
// Get the list that's currently displayed in the dropdown
|
||||
const displayedStaff = useMemo(() => {
|
||||
return staffSearchQuery.trim() === '' ? staffMembers : filteredStaff;
|
||||
}, [staffSearchQuery, staffMembers, filteredStaff]);
|
||||
|
||||
// Reset highlighted index when filtered staff changes
|
||||
useEffect(() => {
|
||||
setHighlightedIndex(-1);
|
||||
}, [filteredStaff]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!showStaffDropdown || displayedStaff.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < displayedStaff.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < displayedStaff.length) {
|
||||
const staff = displayedStaff[highlightedIndex];
|
||||
setSelectedStaffId(staff.id);
|
||||
setStaffSearchQuery(staff.name);
|
||||
setShowStaffDropdown(false);
|
||||
setHighlightedIndex(-1);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setShowStaffDropdown(false);
|
||||
setHighlightedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll highlighted item into view
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && staffDropdownRef.current) {
|
||||
const highlightedElement = staffDropdownRef.current.children[highlightedIndex] as HTMLElement;
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex]);
|
||||
|
||||
const createResourceMutation = useCreateResource();
|
||||
const updateResourceMutation = useUpdateResource();
|
||||
|
||||
@@ -97,6 +163,8 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
}, [allAppointments]);
|
||||
|
||||
// Reset form when modal opens/closes or editing resource changes
|
||||
// NOTE: Only depend on editingResource and isModalOpen, NOT staffMembers
|
||||
// to avoid clearing the form when staff data updates during search
|
||||
useEffect(() => {
|
||||
if (editingResource) {
|
||||
setFormType(editingResource.type);
|
||||
@@ -108,28 +176,40 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
// Pre-fill staff if editing a STAFF resource
|
||||
if (editingResource.type === 'STAFF' && editingResource.userId) {
|
||||
setSelectedStaffId(editingResource.userId);
|
||||
// Find the staff member to set the initial search query (display name)
|
||||
const staff = staffMembers.find(s => s.id === editingResource.userId);
|
||||
setStaffSearchQuery(staff ? staff.name : '');
|
||||
// We'll set the staff name in a separate effect
|
||||
} else {
|
||||
setSelectedStaffId(null);
|
||||
setStaffSearchQuery('');
|
||||
}
|
||||
} else {
|
||||
} else if (isModalOpen) {
|
||||
// Only reset when creating new (modal opened without editing resource)
|
||||
setFormType('STAFF');
|
||||
setFormName('');
|
||||
setFormDescription('');
|
||||
setFormMaxConcurrent(1);
|
||||
setFormMultilaneEnabled(false);
|
||||
setFormSavedLaneCount(undefined);
|
||||
setSelectedStaffId(null); // Clear selected staff when creating new
|
||||
setSelectedStaffId(null);
|
||||
setStaffSearchQuery('');
|
||||
setDebouncedSearchQuery('');
|
||||
}
|
||||
}, [editingResource, staffMembers]);
|
||||
}, [editingResource, isModalOpen]);
|
||||
|
||||
// Separate effect to populate staff name when editing
|
||||
// This runs when staffMembers loads and we have a selected staff ID
|
||||
useEffect(() => {
|
||||
if (editingResource && editingResource.type === 'STAFF' && editingResource.userId && selectedStaffId === editingResource.userId) {
|
||||
const staff = staffMembers.find(s => s.id === editingResource.userId);
|
||||
if (staff && !staffSearchQuery) {
|
||||
// Only set if not already set to avoid overwriting user input
|
||||
setStaffSearchQuery(staff.name);
|
||||
}
|
||||
}
|
||||
}, [staffMembers, editingResource, selectedStaffId, staffSearchQuery]);
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingResource(null);
|
||||
setIsModalOpen(true);
|
||||
setEditingResource(null);
|
||||
};
|
||||
|
||||
const openEditModal = (resource: Resource) => {
|
||||
@@ -138,8 +218,8 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingResource(null);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleMultilaneToggle = (enabled: boolean) => {
|
||||
@@ -251,8 +331,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
return (
|
||||
<tr
|
||||
key={resource.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group cursor-pointer"
|
||||
onClick={() => openEditModal(resource)}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -297,7 +376,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setCalendarResource({ id: resource.id, name: resource.name })}
|
||||
className="text-brand-600 hover:text-brand-500 dark:text-brand-400 dark:hover:text-brand-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-brand-200 dark:border-brand-800 rounded-lg hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"
|
||||
@@ -305,6 +384,13 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
>
|
||||
<Eye size={14} /> {t('resources.viewCalendar')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditModal(resource)}
|
||||
className="p-1.5 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -324,7 +410,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
{isModalOpen && (
|
||||
<Portal>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
|
||||
<div key={editingResource?.id || 'new'} className="bg-white dark:bg-gray-800 rounded-xl shadow-xl w-full max-w-md overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{editingResource ? t('resources.editResource') : t('resources.addNewResource')}
|
||||
@@ -338,7 +424,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
{/* Resource Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('resources.resourceType')}
|
||||
{t('resources.resourceType')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formType}
|
||||
@@ -346,9 +432,12 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
setFormType(e.target.value as ResourceType);
|
||||
setSelectedStaffId(null); // Clear staff selection if type changes
|
||||
setStaffSearchQuery('');
|
||||
setDebouncedSearchQuery('');
|
||||
setShowStaffDropdown(false);
|
||||
setHighlightedIndex(-1);
|
||||
}}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600"
|
||||
disabled={!!editingResource}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
required
|
||||
>
|
||||
<option value="STAFF">{t('resources.staffMember')}</option>
|
||||
<option value="ROOM">{t('resources.room')}</option>
|
||||
@@ -370,47 +459,80 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
||||
onChange={(e) => {
|
||||
setStaffSearchQuery(e.target.value);
|
||||
setShowStaffDropdown(true);
|
||||
setHighlightedIndex(-1);
|
||||
// Clear selection when user types
|
||||
if (selectedStaffId) {
|
||||
setSelectedStaffId(null);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
setShowStaffDropdown(true);
|
||||
setHighlightedIndex(-1);
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Delay to allow click on dropdown
|
||||
setTimeout(() => {
|
||||
setShowStaffDropdown(false);
|
||||
setHighlightedIndex(-1);
|
||||
}, 200);
|
||||
}}
|
||||
onFocus={() => setShowStaffDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowStaffDropdown(false), 150)} // Delay to allow click on dropdown
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 focus:bg-white dark:focus:bg-gray-600"
|
||||
placeholder={t('resources.searchStaffPlaceholder')}
|
||||
required={formType === 'STAFF'}
|
||||
autoComplete="off"
|
||||
aria-autocomplete="list"
|
||||
aria-controls="staff-suggestions"
|
||||
aria-expanded={showStaffDropdown}
|
||||
aria-activedescendant={highlightedIndex >= 0 ? `staff-option-${highlightedIndex}` : undefined}
|
||||
/>
|
||||
{showStaffDropdown && filteredStaff.length > 0 && (
|
||||
{showStaffDropdown && displayedStaff.length > 0 && (
|
||||
<div
|
||||
ref={staffDropdownRef}
|
||||
id="staff-suggestions"
|
||||
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600 max-h-60 overflow-auto"
|
||||
role="listbox"
|
||||
>
|
||||
{filteredStaff.map((staff) => (
|
||||
{displayedStaff.map((staff, index) => (
|
||||
<div
|
||||
key={staff.id}
|
||||
className="p-2 text-sm text-gray-900 dark:text-white hover:bg-brand-50 dark:hover:bg-brand-900/30 cursor-pointer"
|
||||
id={`staff-option-${index}`}
|
||||
className={`p-2 text-sm text-gray-900 dark:text-white cursor-pointer transition-colors ${
|
||||
index === highlightedIndex
|
||||
? 'bg-brand-100 dark:bg-brand-900/50'
|
||||
: selectedStaffId === staff.id
|
||||
? 'bg-brand-50 dark:bg-brand-900/30'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedStaffId(staff.id);
|
||||
setStaffSearchQuery(staff.name);
|
||||
setShowStaffDropdown(false);
|
||||
setHighlightedIndex(-1);
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
onMouseDown={(e) => {
|
||||
// Prevent input blur on mousedown
|
||||
e.preventDefault();
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={selectedStaffId === staff.id}
|
||||
>
|
||||
{staff.name} ({staff.email})
|
||||
<div className="font-medium">{staff.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{staff.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{formType === 'STAFF' && !selectedStaffId && staffSearchQuery && filteredStaff.length === 0 && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{formType === 'STAFF' && !selectedStaffId && staffSearchQuery.trim() !== '' && filteredStaff.length === 0 && (
|
||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
{t('resources.noMatchingStaff')}
|
||||
</p>
|
||||
)}
|
||||
{formType === 'STAFF' && !selectedStaffId && !staffSearchQuery && (
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
{t('resources.staffRequired')}
|
||||
{selectedStaffId && selectedStaff && (
|
||||
<p className="mt-1 text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||
<span className="inline-block w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
Selected: {selectedStaff.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2 } from 'lucide-react';
|
||||
import { useServices, useCreateService, useUpdateService, useDeleteService } from '../hooks/useServices';
|
||||
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image } from 'lucide-react';
|
||||
import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices';
|
||||
import { Service } from '../types';
|
||||
|
||||
interface ServiceFormData {
|
||||
@@ -9,6 +9,7 @@ interface ServiceFormData {
|
||||
durationMinutes: number;
|
||||
price: number;
|
||||
description: string;
|
||||
photos: string[];
|
||||
}
|
||||
|
||||
const Services: React.FC = () => {
|
||||
@@ -17,6 +18,7 @@ const Services: React.FC = () => {
|
||||
const createService = useCreateService();
|
||||
const updateService = useUpdateService();
|
||||
const deleteService = useDeleteService();
|
||||
const reorderServices = useReorderServices();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingService, setEditingService] = useState<Service | null>(null);
|
||||
@@ -25,8 +27,165 @@ const Services: React.FC = () => {
|
||||
durationMinutes: 60,
|
||||
price: 0,
|
||||
description: '',
|
||||
photos: [],
|
||||
});
|
||||
|
||||
// Photo gallery state
|
||||
const [isDraggingPhoto, setIsDraggingPhoto] = useState(false);
|
||||
const [draggedPhotoIndex, setDraggedPhotoIndex] = useState<number | null>(null);
|
||||
const [dragOverPhotoIndex, setDragOverPhotoIndex] = useState<number | null>(null);
|
||||
|
||||
// Drag and drop state
|
||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||
const [localServices, setLocalServices] = useState<Service[] | null>(null);
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Use local state during drag, otherwise use fetched data
|
||||
const displayServices = localServices ?? services;
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, serviceId: string) => {
|
||||
setDraggedId(serviceId);
|
||||
dragNodeRef.current = e.currentTarget;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Add a slight delay to allow the drag image to be set
|
||||
setTimeout(() => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.style.opacity = '0.5';
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.style.opacity = '1';
|
||||
}
|
||||
setDraggedId(null);
|
||||
setDragOverId(null);
|
||||
dragNodeRef.current = null;
|
||||
|
||||
// If we have local changes, save them
|
||||
if (localServices) {
|
||||
const orderedIds = localServices.map(s => s.id);
|
||||
reorderServices.mutate(orderedIds, {
|
||||
onSettled: () => {
|
||||
setLocalServices(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, serviceId: string) => {
|
||||
e.preventDefault();
|
||||
if (draggedId === serviceId) return;
|
||||
|
||||
setDragOverId(serviceId);
|
||||
|
||||
// Reorder locally for visual feedback
|
||||
const currentServices = localServices ?? services ?? [];
|
||||
const draggedIndex = currentServices.findIndex(s => s.id === draggedId);
|
||||
const targetIndex = currentServices.findIndex(s => s.id === serviceId);
|
||||
|
||||
if (draggedIndex === -1 || targetIndex === -1 || draggedIndex === targetIndex) return;
|
||||
|
||||
const newServices = [...currentServices];
|
||||
const [removed] = newServices.splice(draggedIndex, 1);
|
||||
newServices.splice(targetIndex, 0, removed);
|
||||
|
||||
setLocalServices(newServices);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverId(null);
|
||||
};
|
||||
|
||||
// Photo upload handlers
|
||||
const handlePhotoDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingPhoto(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
Array.from(files).forEach((file) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
photos: [...prev.photos, reader.result as string],
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhotoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingPhoto(true);
|
||||
};
|
||||
|
||||
const handlePhotoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingPhoto(false);
|
||||
};
|
||||
|
||||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
Array.from(files).forEach((file) => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
photos: [...prev.photos, reader.result as string],
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Reset input
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removePhoto = (index: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
photos: prev.photos.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
// Photo reorder drag handlers
|
||||
const handlePhotoReorderStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
setDraggedPhotoIndex(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handlePhotoReorderOver = (e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedPhotoIndex === null || draggedPhotoIndex === index) return;
|
||||
setDragOverPhotoIndex(index);
|
||||
|
||||
// Reorder photos
|
||||
const newPhotos = [...formData.photos];
|
||||
const [removed] = newPhotos.splice(draggedPhotoIndex, 1);
|
||||
newPhotos.splice(index, 0, removed);
|
||||
setFormData((prev) => ({ ...prev, photos: newPhotos }));
|
||||
setDraggedPhotoIndex(index);
|
||||
};
|
||||
|
||||
const handlePhotoReorderEnd = () => {
|
||||
setDraggedPhotoIndex(null);
|
||||
setDragOverPhotoIndex(null);
|
||||
};
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingService(null);
|
||||
setFormData({
|
||||
@@ -34,6 +193,7 @@ const Services: React.FC = () => {
|
||||
durationMinutes: 60,
|
||||
price: 0,
|
||||
description: '',
|
||||
photos: [],
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@@ -45,6 +205,7 @@ const Services: React.FC = () => {
|
||||
durationMinutes: service.durationMinutes,
|
||||
price: service.price,
|
||||
description: service.description || '',
|
||||
photos: service.photos || [],
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
@@ -122,7 +283,7 @@ const Services: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{services && services.length === 0 ? (
|
||||
{displayServices && displayServices.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('services.noServices', 'No services yet. Add your first service to get started.')}
|
||||
@@ -136,60 +297,149 @@ const Services: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{services?.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{service.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(service)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(service.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Left Column - Editable Services List */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{t('services.dragToReorder', 'Drag services to reorder how they appear in menus')}
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{displayServices?.map((service) => (
|
||||
<div
|
||||
key={service.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, service.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, service.id)}
|
||||
onDragLeave={handleDragLeave}
|
||||
className={`p-4 bg-white dark:bg-gray-800 border rounded-xl shadow-sm cursor-move transition-all ${
|
||||
draggedId === service.id
|
||||
? 'opacity-50 border-brand-500'
|
||||
: dragOverId === service.id
|
||||
? 'border-brand-500 ring-2 ring-brand-500/50'
|
||||
: 'border-gray-100 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<GripVertical className="h-5 w-5 text-gray-400 cursor-grab active:cursor-grabbing shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">
|
||||
{service.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => openEditModal(service)}
|
||||
className="p-1.5 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(service.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{service.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1 line-clamp-1">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{service.durationMinutes} {t('common.minutes', 'min')}
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
|
||||
<DollarSign className="h-3.5 w-3.5" />
|
||||
${service.price.toFixed(2)}
|
||||
</span>
|
||||
{service.photos && service.photos.length > 0 && (
|
||||
<span className="text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Image className="h-3.5 w-3.5" />
|
||||
{service.photos.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{service.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
{/* Right Column - Customer Preview Mockup */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Eye className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('services.customerPreview', 'Customer Preview')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{service.durationMinutes} {t('common.minutes', 'min')}</span>
|
||||
{/* Mockup Container - styled like a booking widget */}
|
||||
<div className="sticky top-8">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Mockup Header */}
|
||||
<div className="bg-brand-600 px-6 py-4">
|
||||
<h4 className="text-white font-semibold text-lg">{t('services.selectService', 'Select a Service')}</h4>
|
||||
<p className="text-white/70 text-sm">{t('services.chooseFromMenu', 'Choose from our available services')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
||||
<DollarSign className="h-4 w-4" />
|
||||
<span>${service.price.toFixed(2)}</span>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700 max-h-[500px] overflow-y-auto">
|
||||
{displayServices?.map((service) => (
|
||||
<div
|
||||
key={`preview-${service.id}`}
|
||||
className="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{service.name}
|
||||
</h5>
|
||||
{service.description && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 line-clamp-1">
|
||||
{service.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-2 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-300 flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{service.durationMinutes} min
|
||||
</span>
|
||||
<span className="font-semibold text-brand-600 dark:text-brand-400">
|
||||
${service.price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors shrink-0 ml-4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mockup Footer */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 px-6 py-3 text-center border-t border-gray-100 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{t('services.mockupNote', 'Preview only - not clickable')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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-md w-full mx-4">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700">
|
||||
<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="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
|
||||
? t('services.editService', 'Edit Service')
|
||||
@@ -203,66 +453,157 @@ const Services: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('services.name', 'Name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
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={t('services.namePlaceholder', 'e.g., Haircut, Massage, Consultation')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('services.duration', 'Duration (min)')} *
|
||||
{t('services.name', 'Name')} *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.durationMinutes}
|
||||
onChange={(e) => setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })}
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
min={5}
|
||||
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={t('services.namePlaceholder', 'e.g., Haircut, Massage, Consultation')}
|
||||
/>
|
||||
</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.duration', 'Duration (min)')} *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.durationMinutes}
|
||||
onChange={(e) => setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })}
|
||||
required
|
||||
min={5}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('services.price', 'Price ($)')} *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
|
||||
required
|
||||
min={0}
|
||||
step={0.01}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('services.price', 'Price ($)')} *
|
||||
{t('services.descriptionLabel', 'Description')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
|
||||
required
|
||||
min={0}
|
||||
step={0.01}
|
||||
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"
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
placeholder={t('services.descriptionPlaceholder', 'Optional description of the service...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Photo Gallery */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('services.photos', 'Photos')}
|
||||
</label>
|
||||
|
||||
{/* Photo Grid */}
|
||||
{formData.photos.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-3 mb-3">
|
||||
{formData.photos.map((photo, index) => (
|
||||
<div
|
||||
key={index}
|
||||
draggable
|
||||
onDragStart={(e) => handlePhotoReorderStart(e, index)}
|
||||
onDragOver={(e) => handlePhotoReorderOver(e, index)}
|
||||
onDragEnd={handlePhotoReorderEnd}
|
||||
className={`relative group aspect-square rounded-lg overflow-hidden border-2 cursor-move transition-all ${
|
||||
draggedPhotoIndex === index
|
||||
? 'opacity-50 border-brand-500'
|
||||
: dragOverPhotoIndex === index
|
||||
? 'border-brand-500 ring-2 ring-brand-500/50'
|
||||
: 'border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={photo}
|
||||
alt={`Photo ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<div className="absolute top-1 left-1 text-white/70">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePhoto(index)}
|
||||
className="p-1.5 bg-red-500 hover:bg-red-600 text-white rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute bottom-1 right-1 bg-black/60 text-white text-[10px] px-1.5 py-0.5 rounded">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
onDrop={handlePhotoDrop}
|
||||
onDragOver={handlePhotoDragOver}
|
||||
onDragLeave={handlePhotoDragLeave}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isDraggingPhoto
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
}`}
|
||||
>
|
||||
<ImagePlus className={`mx-auto mb-2 h-8 w-8 ${isDraggingPhoto ? 'text-brand-500' : 'text-gray-400'}`} />
|
||||
<p className={`text-sm ${isDraggingPhoto ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{isDraggingPhoto ? t('services.dropImagesHere', 'Drop images here') : t('services.dragAndDropImages', 'Drag and drop images here, or')}
|
||||
</p>
|
||||
{!isDraggingPhoto && (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
id="service-photo-upload"
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handlePhotoUpload}
|
||||
/>
|
||||
<label
|
||||
htmlFor="service-photo-upload"
|
||||
className="inline-flex items-center gap-1 mt-2 px-3 py-1.5 text-sm font-medium text-brand-600 dark:text-brand-400 hover:text-brand-700 dark:hover:text-brand-300 cursor-pointer"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t('services.browseFiles', 'browse files')}
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{t('services.photosHint', 'Drag photos to reorder. First photo is the primary image.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('services.descriptionLabel', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
placeholder={t('services.descriptionPlaceholder', 'Optional description of the service...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
|
||||
@@ -2,11 +2,12 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { Business, User, CustomDomain } from '../types';
|
||||
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet } from 'lucide-react';
|
||||
import { Save, Globe, Palette, BookKey, Check, Sparkles, CheckCircle, Link2, AlertCircle, ExternalLink, Copy, Crown, ShieldCheck, Trash2, RefreshCw, Star, Eye, EyeOff, Key, ShoppingCart, Building2, Users, Lock, Wallet, X, Plus, Layers, Pencil } from 'lucide-react';
|
||||
import DomainPurchase from '../components/DomainPurchase';
|
||||
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
|
||||
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
|
||||
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
|
||||
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
|
||||
import OnboardingWizard from '../components/OnboardingWizard';
|
||||
|
||||
// Curated color palettes with complementary primary and secondary colors
|
||||
@@ -18,6 +19,55 @@ const colorPalettes = [
|
||||
secondary: '#0ea5e9',
|
||||
preview: 'bg-gradient-to-br from-blue-600 to-sky-500',
|
||||
},
|
||||
{
|
||||
name: 'Sky Blue',
|
||||
description: 'Light & airy',
|
||||
primary: '#0ea5e9',
|
||||
secondary: '#38bdf8',
|
||||
preview: 'bg-gradient-to-br from-sky-500 to-sky-400',
|
||||
},
|
||||
{
|
||||
name: 'Cyan Splash',
|
||||
description: 'Modern & vibrant',
|
||||
primary: '#06b6d4',
|
||||
secondary: '#22d3ee',
|
||||
preview: 'bg-gradient-to-br from-cyan-500 to-cyan-400',
|
||||
},
|
||||
{
|
||||
name: 'Aqua Fresh',
|
||||
description: 'Clean & refreshing',
|
||||
primary: '#14b8a6',
|
||||
secondary: '#2dd4bf',
|
||||
preview: 'bg-gradient-to-br from-teal-500 to-teal-400',
|
||||
},
|
||||
{
|
||||
name: 'Mint Green',
|
||||
description: 'Soft & welcoming',
|
||||
primary: '#10b981',
|
||||
secondary: '#34d399',
|
||||
preview: 'bg-gradient-to-br from-emerald-500 to-emerald-400',
|
||||
},
|
||||
{
|
||||
name: 'Coral Reef',
|
||||
description: 'Warm & inviting',
|
||||
primary: '#f97316',
|
||||
secondary: '#fb923c',
|
||||
preview: 'bg-gradient-to-br from-orange-500 to-orange-400',
|
||||
},
|
||||
{
|
||||
name: 'Lavender Dream',
|
||||
description: 'Gentle & elegant',
|
||||
primary: '#a78bfa',
|
||||
secondary: '#c4b5fd',
|
||||
preview: 'bg-gradient-to-br from-violet-400 to-violet-300',
|
||||
},
|
||||
{
|
||||
name: 'Rose Pink',
|
||||
description: 'Friendly & modern',
|
||||
primary: '#ec4899',
|
||||
secondary: '#f472b6',
|
||||
preview: 'bg-gradient-to-br from-pink-500 to-pink-400',
|
||||
},
|
||||
{
|
||||
name: 'Forest Green',
|
||||
description: 'Natural & calming',
|
||||
@@ -32,20 +82,6 @@ const colorPalettes = [
|
||||
secondary: '#a78bfa',
|
||||
preview: 'bg-gradient-to-br from-violet-600 to-purple-400',
|
||||
},
|
||||
{
|
||||
name: 'Sunset Orange',
|
||||
description: 'Energetic & warm',
|
||||
primary: '#ea580c',
|
||||
secondary: '#f97316',
|
||||
preview: 'bg-gradient-to-br from-orange-600 to-amber-500',
|
||||
},
|
||||
{
|
||||
name: 'Rose Pink',
|
||||
description: 'Friendly & modern',
|
||||
primary: '#db2777',
|
||||
secondary: '#f472b6',
|
||||
preview: 'bg-gradient-to-br from-pink-600 to-pink-400',
|
||||
},
|
||||
{
|
||||
name: 'Slate Gray',
|
||||
description: 'Minimal & sophisticated',
|
||||
@@ -53,13 +89,6 @@ const colorPalettes = [
|
||||
secondary: '#64748b',
|
||||
preview: 'bg-gradient-to-br from-slate-600 to-slate-400',
|
||||
},
|
||||
{
|
||||
name: 'Teal Wave',
|
||||
description: 'Fresh & balanced',
|
||||
primary: '#0d9488',
|
||||
secondary: '#14b8a6',
|
||||
preview: 'bg-gradient-to-br from-teal-600 to-teal-400',
|
||||
},
|
||||
{
|
||||
name: 'Crimson Red',
|
||||
description: 'Bold & dynamic',
|
||||
@@ -69,7 +98,256 @@ const colorPalettes = [
|
||||
},
|
||||
];
|
||||
|
||||
type SettingsTab = 'general' | 'domains' | 'authentication';
|
||||
type SettingsTab = 'general' | 'domains' | 'authentication' | 'resources';
|
||||
|
||||
// Resource Types Management Section Component
|
||||
const ResourceTypesSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: resourceTypes = [], isLoading } = useResourceTypes();
|
||||
const createResourceType = useCreateResourceType();
|
||||
const updateResourceType = useUpdateResourceType();
|
||||
const deleteResourceType = useDeleteResourceType();
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingType, setEditingType] = useState<any>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category: 'OTHER' as 'STAFF' | 'OTHER',
|
||||
iconName: '',
|
||||
});
|
||||
|
||||
const openCreateModal = () => {
|
||||
setEditingType(null);
|
||||
setFormData({ name: '', description: '', category: 'OTHER', iconName: '' });
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (type: any) => {
|
||||
setEditingType(type);
|
||||
setFormData({
|
||||
name: type.name,
|
||||
description: type.description || '',
|
||||
category: type.category,
|
||||
iconName: type.icon_name || type.iconName || '',
|
||||
});
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingType(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (editingType) {
|
||||
await updateResourceType.mutateAsync({
|
||||
id: editingType.id,
|
||||
updates: formData,
|
||||
});
|
||||
} else {
|
||||
await createResourceType.mutateAsync(formData);
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save resource type:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string, name: string) => {
|
||||
if (window.confirm(`Are you sure you want to delete the "${name}" resource type?`)) {
|
||||
try {
|
||||
await deleteResourceType.mutateAsync(id);
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.error || 'Failed to delete resource type');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-white dark:bg-gray-800 p-6 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Layers size={20} className="text-indigo-500" />
|
||||
{t('settings.resourceTypes', 'Resource Types')}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('settings.resourceTypesDescription', 'Define custom types for your resources (e.g., Stylist, Treatment Room, Equipment)')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
{t('settings.addResourceType', 'Add Type')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : resourceTypes.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<Layers size={40} className="mx-auto mb-2 opacity-30" />
|
||||
<p>{t('settings.noResourceTypes', 'No custom resource types yet.')}</p>
|
||||
<p className="text-sm mt-1">{t('settings.addFirstResourceType', 'Add your first resource type to categorize your resources.')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{resourceTypes.map((type: any) => {
|
||||
const isDefault = type.is_default || type.isDefault;
|
||||
return (
|
||||
<div
|
||||
key={type.id}
|
||||
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-start justify-between">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
|
||||
type.category === 'STAFF' ? 'bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{type.category === 'STAFF' ? <Users size={20} /> : <Layers size={20} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white flex items-center gap-2">
|
||||
{type.name}
|
||||
{isDefault && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{type.category === 'STAFF' ? 'Requires staff assignment' : 'General resource'}
|
||||
</p>
|
||||
{type.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mt-1 line-clamp-2">
|
||||
{type.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<button
|
||||
onClick={() => openEditModal(type)}
|
||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
{!isDefault && (
|
||||
<button
|
||||
onClick={() => handleDelete(type.id, type.name)}
|
||||
disabled={deleteResourceType.isPending}
|
||||
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal for Create/Edit */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full max-h-[90vh] overflow-hidden 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">
|
||||
{editingType
|
||||
? t('settings.editResourceType', 'Edit Resource Type')
|
||||
: t('settings.addResourceType', 'Add Resource Type')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.resourceTypeName', 'Name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
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={t('settings.resourceTypeNamePlaceholder', 'e.g., Stylist, Treatment Room, Camera')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.resourceTypeDescription', 'Description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
placeholder={t('settings.resourceTypeDescriptionPlaceholder', 'Describe this type of resource...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('settings.resourceTypeCategory', 'Category')} *
|
||||
</label>
|
||||
<select
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData({ ...formData, category: e.target.value as 'STAFF' | 'OTHER' })}
|
||||
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"
|
||||
>
|
||||
<option value="STAFF">{t('settings.categoryStaff', 'Staff (requires staff assignment)')}</option>
|
||||
<option value="OTHER">{t('settings.categoryOther', 'Other (general resource)')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{formData.category === 'STAFF'
|
||||
? t('settings.staffCategoryHint', 'Staff resources must be assigned to a team member')
|
||||
: t('settings.otherCategoryHint', 'General resources like rooms, equipment, or vehicles')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createResourceType.isPending || updateResourceType.isPending}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{editingType ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -115,6 +393,16 @@ const SettingsPage: React.FC = () => {
|
||||
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
|
||||
// Drag and drop state for logo uploads
|
||||
const [isDraggingLogo, setIsDraggingLogo] = useState(false);
|
||||
const [isDraggingEmailLogo, setIsDraggingEmailLogo] = useState(false);
|
||||
|
||||
// Lightbox state for viewing logos
|
||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; title: string } | null>(null);
|
||||
|
||||
// Email preview modal state
|
||||
const [showEmailPreview, setShowEmailPreview] = useState(false);
|
||||
|
||||
// Update OAuth settings when data loads
|
||||
useEffect(() => {
|
||||
if (oauthData?.businessSettings) {
|
||||
@@ -159,11 +447,77 @@ const SettingsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and drop handlers for logo upload
|
||||
const handleLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingLogo(true);
|
||||
};
|
||||
|
||||
const handleLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingLogo(false);
|
||||
};
|
||||
|
||||
const handleLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingLogo(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and drop handlers for email logo upload
|
||||
const handleEmailLogoDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingEmailLogo(true);
|
||||
};
|
||||
|
||||
const handleEmailLogoDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingEmailLogo(false);
|
||||
};
|
||||
|
||||
const handleEmailLogoDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDraggingEmailLogo(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateBusiness(formState);
|
||||
setShowToast(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setFormState(business);
|
||||
};
|
||||
|
||||
const handleOAuthSave = () => {
|
||||
updateOAuthMutation.mutate(oauthSettings, {
|
||||
onSuccess: () => {
|
||||
@@ -289,25 +643,17 @@ const SettingsPage: React.FC = () => {
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ id: 'general' as const, label: 'General', icon: Building2 },
|
||||
{ id: 'resources' as const, label: 'Resource Types', icon: Layers },
|
||||
{ id: 'domains' as const, label: 'Domains', icon: Globe },
|
||||
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="p-8 max-w-4xl mx-auto pb-24">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors shadow-sm font-medium"
|
||||
>
|
||||
<Save size={18} />
|
||||
{t('common.saveChanges')}
|
||||
</button>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">{t('settings.businessSettingsDescription')}</p>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
@@ -367,6 +713,270 @@ const SettingsPage: React.FC = () => {
|
||||
<Palette size={20} className="text-purple-500"/> {t('settings.branding')}
|
||||
</h3>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div className="mb-6 pb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Image size={16} className="text-blue-500" />
|
||||
Brand Logos
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Upload your logos for different purposes. PNG with transparent background recommended.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Website Logo Upload/Display */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-3">Website Logo</h5>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Used in sidebar and customer-facing pages
|
||||
</p>
|
||||
<div
|
||||
onDragOver={handleLogoDragOver}
|
||||
onDragLeave={handleLogoDragLeave}
|
||||
onDrop={handleLogoDrop}
|
||||
className={`transition-all ${isDraggingLogo ? 'scale-105' : ''}`}
|
||||
>
|
||||
{formState.logoUrl ? (
|
||||
<div className="space-y-3">
|
||||
<div className="relative inline-block group">
|
||||
<img
|
||||
src={formState.logoUrl}
|
||||
alt="Business logo"
|
||||
onClick={() => setLightboxImage({ url: formState.logoUrl!, title: 'Website Logo' })}
|
||||
className="w-32 h-32 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormState(prev => ({
|
||||
...prev,
|
||||
logoUrl: undefined,
|
||||
logoDisplayMode: 'logo-and-text' // Reset to show icon with text
|
||||
}));
|
||||
}}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
|
||||
title="Remove logo"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Click to view full size • Click × to remove • Drag and drop to replace
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`w-32 h-32 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
|
||||
isDraggingLogo
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-400'
|
||||
}`}>
|
||||
<div className="text-center">
|
||||
<Image size={32} className="mx-auto mb-2" />
|
||||
<p className="text-xs">Drop image here</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="file"
|
||||
id="logo-upload"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// TODO: Upload to backend
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormState(prev => ({ ...prev, logoUrl: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="logo-upload"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{formState.logoUrl ? 'Change Logo' : 'Upload Logo'}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
PNG, JPG, or SVG. Recommended: 500x500px
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Logo Display Mode */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Display Mode
|
||||
</label>
|
||||
<select
|
||||
name="logoDisplayMode"
|
||||
value={formState.logoDisplayMode || 'text-only'}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg text-sm"
|
||||
>
|
||||
<option value="text-only">Text Only</option>
|
||||
<option value="logo-only">Logo Only</option>
|
||||
<option value="logo-and-text">Logo and Text</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
How your branding appears in the sidebar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Logo Upload/Display */}
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<h5 className="font-medium text-gray-900 dark:text-white mb-3">Email Logo</h5>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Used in email notifications and receipts
|
||||
</p>
|
||||
<div
|
||||
onDragOver={handleEmailLogoDragOver}
|
||||
onDragLeave={handleEmailLogoDragLeave}
|
||||
onDrop={handleEmailLogoDrop}
|
||||
className={`transition-all ${isDraggingEmailLogo ? 'scale-105' : ''}`}
|
||||
>
|
||||
{formState.emailLogoUrl ? (
|
||||
<div className="space-y-3">
|
||||
<div className="relative inline-block group">
|
||||
<img
|
||||
src={formState.emailLogoUrl}
|
||||
alt="Email logo"
|
||||
onClick={() => setLightboxImage({ url: formState.emailLogoUrl!, title: 'Email Logo' })}
|
||||
className="w-48 h-16 object-contain border-2 border-gray-200 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 p-2 cursor-pointer hover:border-blue-400 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFormState(prev => ({ ...prev, emailLogoUrl: undefined }));
|
||||
}}
|
||||
className="absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5 shadow-lg transition-colors z-10"
|
||||
title="Remove email logo"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Click to view full size • Click × to remove • Drag and drop to replace
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`w-48 h-16 border-2 border-dashed rounded-lg flex items-center justify-center transition-colors ${
|
||||
isDraggingEmailLogo
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-500'
|
||||
: 'border-gray-300 dark:border-gray-600 text-gray-400'
|
||||
}`}>
|
||||
<div className="text-center">
|
||||
<Image size={24} className="mx-auto mb-1" />
|
||||
<p className="text-xs">Drop image here</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<input
|
||||
type="file"
|
||||
id="email-logo-upload"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// TODO: Upload to backend
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormState(prev => ({ ...prev, emailLogoUrl: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="email-logo-upload"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer transition-colors text-sm font-medium"
|
||||
>
|
||||
<Upload size={16} />
|
||||
{formState.emailLogoUrl ? 'Change Email Logo' : 'Upload Email Logo'}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
PNG with transparent background. Recommended: 600x200px
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailPreview(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium mt-3"
|
||||
>
|
||||
<Eye size={16} />
|
||||
Preview Email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Preview */}
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Sidebar Preview
|
||||
</label>
|
||||
<div
|
||||
className="w-full max-w-xs p-6 rounded-xl"
|
||||
style={{ backgroundColor: formState.primaryColor }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Logo-only mode: full width */}
|
||||
{formState.logoDisplayMode === 'logo-only' && formState.logoUrl ? (
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<img
|
||||
src={formState.logoUrl}
|
||||
alt={formState.name}
|
||||
className="max-w-full max-h-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Logo/Icon display - only show if NOT text-only mode */}
|
||||
{formState.logoDisplayMode !== 'text-only' && (
|
||||
formState.logoUrl ? (
|
||||
<div className="flex items-center justify-center w-10 h-10 shrink-0">
|
||||
<img
|
||||
src={formState.logoUrl}
|
||||
alt={formState.name}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center justify-center w-10 h-10 bg-white rounded-lg text-brand-600 font-bold text-xl shrink-0"
|
||||
style={{ color: formState.primaryColor }}
|
||||
>
|
||||
{formState.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Text display - only show if NOT logo-only mode */}
|
||||
{formState.logoDisplayMode !== 'logo-only' && (
|
||||
<div className="overflow-hidden">
|
||||
<h1 className="font-bold leading-tight truncate text-white">{formState.name}</h1>
|
||||
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
This is how your branding will appear in the navigation sidebar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Palette Selection */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -593,6 +1203,11 @@ const SettingsPage: React.FC = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RESOURCES TAB */}
|
||||
{activeTab === 'resources' && isOwner && (
|
||||
<ResourceTypesSection />
|
||||
)}
|
||||
|
||||
{/* DOMAINS TAB */}
|
||||
{activeTab === 'domains' && (
|
||||
<>
|
||||
@@ -1103,6 +1718,185 @@ const SettingsPage: React.FC = () => {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Lightbox Modal for Logo Preview */}
|
||||
{lightboxImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
onClick={() => setLightboxImage(null)}
|
||||
>
|
||||
<div className="relative max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4 text-white">
|
||||
<h3 className="text-lg font-semibold">{lightboxImage.title}</h3>
|
||||
<button
|
||||
onClick={() => setLightboxImage(null)}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 rounded-lg p-8 overflow-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={lightboxImage.url}
|
||||
alt={lightboxImage.title}
|
||||
className="max-w-full max-h-[70vh] object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-white text-sm mt-4 text-center">
|
||||
Click anywhere outside to close
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Preview Modal */}
|
||||
{showEmailPreview && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
onClick={() => setShowEmailPreview(false)}
|
||||
>
|
||||
<div
|
||||
className="relative max-w-2xl w-full bg-white dark:bg-gray-800 rounded-lg shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">Email Preview</h3>
|
||||
<button
|
||||
onClick={() => setShowEmailPreview(false)}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-auto max-h-[70vh]">
|
||||
{/* Email Template Preview */}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-8" style={{ fontFamily: 'Arial, sans-serif' }}>
|
||||
{/* Email Header with Logo */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-t-lg p-6 text-center border-b-4" style={{ borderBottomColor: formState.primaryColor }}>
|
||||
{formState.emailLogoUrl ? (
|
||||
<img
|
||||
src={formState.emailLogoUrl}
|
||||
alt={formState.name}
|
||||
className="mx-auto max-h-20 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center">
|
||||
<div
|
||||
className="inline-flex items-center justify-center w-16 h-16 rounded-full text-white font-bold text-2xl"
|
||||
style={{ backgroundColor: formState.primaryColor }}
|
||||
>
|
||||
{formState.name.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Body */}
|
||||
<div className="bg-white dark:bg-gray-800 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Appointment Confirmation
|
||||
</h2>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||
Hi John Doe,
|
||||
</p>
|
||||
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-6">
|
||||
Your appointment has been confirmed. Here are the details:
|
||||
</p>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-6 mb-6">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">Service</p>
|
||||
<p className="text-gray-900 dark:text-white">Haircut & Style</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">Date & Time</p>
|
||||
<p className="text-gray-900 dark:text-white">Dec 15, 2025 at 2:00 PM</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">Duration</p>
|
||||
<p className="text-gray-900 dark:text-white">60 minutes</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500 dark:text-gray-400 font-medium">Price</p>
|
||||
<p className="text-gray-900 dark:text-white">$45.00</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="w-full text-white font-semibold py-3 px-6 rounded-lg transition-colors"
|
||||
style={{ backgroundColor: formState.primaryColor }}
|
||||
>
|
||||
View Appointment Details
|
||||
</button>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6">
|
||||
Need to make changes? You can reschedule or cancel up to 24 hours before your appointment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email Footer */}
|
||||
<div className="bg-gray-100 dark:bg-gray-900 rounded-b-lg p-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-2">
|
||||
{formState.name}
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-500 text-xs">
|
||||
{business.subdomain}.smoothschedule.com
|
||||
</p>
|
||||
<p className="text-gray-400 dark:text-gray-600 text-xs mt-4">
|
||||
© 2025 {formState.name}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 rounded-b-lg">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
This is a preview of how your appointment confirmation emails will appear to customers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Action Buttons */}
|
||||
<div className="fixed bottom-0 left-64 right-0 p-4 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 shadow-lg z-40 md:left-64">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{showToast && (
|
||||
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<CheckCircle size={16} />
|
||||
Changes saved successfully
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
|
||||
>
|
||||
<X size={18} />
|
||||
Cancel Changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md font-medium"
|
||||
>
|
||||
<Save size={18} />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -97,8 +97,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
||||
{staffUsers.map((user: any) => {
|
||||
const linkedResource = getLinkedResource(user.id);
|
||||
|
||||
// Owners/Managers can log in as anyone.
|
||||
const canMasquerade = ['owner', 'manager'].includes(effectiveUser.role) && user.id !== effectiveUser.id;
|
||||
// Only owners can masquerade as staff (per backend permissions)
|
||||
const canMasquerade = effectiveUser.role === 'owner' && user.id !== effectiveUser.id;
|
||||
|
||||
return (
|
||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, Filter, MoreHorizontal, Eye, ShieldCheck, Ban } from 'lucide-react';
|
||||
import { User } from '../../types';
|
||||
import { useBusinesses } from '../../hooks/usePlatform';
|
||||
|
||||
interface PlatformBusinessesProps {
|
||||
onMasquerade: (targetUser: User) => void;
|
||||
onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
|
||||
}
|
||||
|
||||
const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade }) => {
|
||||
@@ -22,19 +21,14 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
|
||||
const handleLoginAs = (business: any) => {
|
||||
// Use the owner data from the API response
|
||||
if (business.owner) {
|
||||
const targetOwner: User = {
|
||||
id: business.owner.id.toString(),
|
||||
// Pass owner info to masquerade - we only need the id
|
||||
onMasquerade({
|
||||
id: business.owner.id,
|
||||
username: business.owner.username,
|
||||
name: business.owner.name,
|
||||
name: business.owner.full_name,
|
||||
email: business.owner.email,
|
||||
role: business.owner.role,
|
||||
business_id: business.id.toString(),
|
||||
business_subdomain: business.subdomain,
|
||||
is_active: true,
|
||||
is_staff: false,
|
||||
is_superuser: false,
|
||||
};
|
||||
onMasquerade(targetOwner);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -130,14 +124,14 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
|
||||
{new Date(biz.created_at).toLocaleDateString()}
|
||||
{new Date(biz.created_on).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<button
|
||||
onClick={() => handleLoginAs(biz)}
|
||||
className="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300 font-medium text-xs inline-flex items-center gap-1 px-3 py-1 border border-indigo-200 dark:border-indigo-800 rounded-lg hover:bg-indigo-50 dark:hover:bg-indigo-900/30 transition-colors mr-2"
|
||||
disabled={!biz.owner}
|
||||
title={!biz.owner ? 'No owner assigned' : `Masquerade as ${biz.owner.name}`}
|
||||
title={!biz.owner ? 'No owner assigned' : `Masquerade as ${biz.owner.full_name}`}
|
||||
>
|
||||
<Eye size={14} /> {t('platform.masquerade')}
|
||||
</button>
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, Filter, Eye, Shield, User as UserIcon } from 'lucide-react';
|
||||
import { User } from '../../types';
|
||||
import { usePlatformUsers } from '../../hooks/usePlatform';
|
||||
|
||||
interface PlatformUsersProps {
|
||||
onMasquerade: (targetUser: User) => void;
|
||||
onMasquerade: (targetUser: { id: number; username?: string; name?: string; email?: string; role?: string }) => void;
|
||||
}
|
||||
|
||||
const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
||||
@@ -36,20 +35,14 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
||||
};
|
||||
|
||||
const handleMasquerade = (platformUser: any) => {
|
||||
// Convert platform user to User type for masquerade
|
||||
const targetUser: User = {
|
||||
id: platformUser.id.toString(),
|
||||
// Pass user info to masquerade - we only need the id
|
||||
onMasquerade({
|
||||
id: platformUser.id,
|
||||
username: platformUser.username,
|
||||
name: platformUser.name || platformUser.username,
|
||||
name: platformUser.full_name || platformUser.username,
|
||||
email: platformUser.email,
|
||||
role: platformUser.role || 'customer',
|
||||
business_id: platformUser.business?.toString() || null,
|
||||
business_subdomain: platformUser.business_subdomain || null,
|
||||
is_active: platformUser.is_active,
|
||||
is_staff: platformUser.is_staff,
|
||||
is_superuser: platformUser.is_superuser,
|
||||
};
|
||||
onMasquerade(targetUser);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface Business {
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
logoUrl?: string;
|
||||
emailLogoUrl?: string;
|
||||
logoDisplayMode?: 'logo-only' | 'text-only' | 'logo-and-text'; // How to display branding
|
||||
whitelabelEnabled: boolean;
|
||||
plan?: 'Free' | 'Professional' | 'Business' | 'Enterprise';
|
||||
status?: 'Active' | 'Suspended' | 'Trial';
|
||||
@@ -58,6 +60,7 @@ export interface Business {
|
||||
isTrialActive?: boolean;
|
||||
isTrialExpired?: boolean;
|
||||
daysLeftInTrial?: number;
|
||||
resourceTypes?: ResourceTypeDefinition[]; // Custom resource types
|
||||
}
|
||||
|
||||
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer';
|
||||
@@ -87,11 +90,25 @@ export interface User {
|
||||
|
||||
export type ResourceType = 'STAFF' | 'ROOM' | 'EQUIPMENT';
|
||||
|
||||
export type ResourceTypeCategory = 'STAFF' | 'OTHER';
|
||||
|
||||
export interface ResourceTypeDefinition {
|
||||
id: string;
|
||||
name: string; // User-facing name like "Stylist", "Massage Therapist", "Treatment Room"
|
||||
description?: string; // Description of this resource type
|
||||
category: ResourceTypeCategory; // STAFF (requires staff assignment) or OTHER
|
||||
isDefault: boolean; // Cannot be deleted
|
||||
iconName?: string; // Optional icon identifier
|
||||
}
|
||||
|
||||
export interface Resource {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ResourceType;
|
||||
type: ResourceType; // Legacy field - will be deprecated
|
||||
typeId?: string; // New field - references ResourceTypeDefinition
|
||||
userId?: string;
|
||||
maxConcurrentEvents: number;
|
||||
savedLaneCount?: number; // Remembered lane count when multilane is disabled
|
||||
}
|
||||
|
||||
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
|
||||
@@ -147,6 +164,8 @@ export interface Service {
|
||||
durationMinutes: number;
|
||||
price: number;
|
||||
description: string;
|
||||
displayOrder: number;
|
||||
photos?: string[];
|
||||
}
|
||||
|
||||
export interface Metric {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"7662eeffef95b745c0c7-05f7d22eaed6ca80a04d"
|
||||
"4ccd3b6df344f024c4e8-470435a1aee1bc432b30"
|
||||
]
|
||||
}
|
||||
BIN
frontend/test-results/before-scheduler-click.png
Normal file
BIN
frontend/test-results/before-scheduler-click.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
@@ -0,0 +1,268 @@
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=e1]:
|
||||
- generic [ref=e3]:
|
||||
- generic [ref=e5]:
|
||||
- button "Collapse sidebar" [ref=e6]:
|
||||
- generic [ref=e7]: DE
|
||||
- generic [ref=e8]:
|
||||
- heading "Demo Company" [level=1] [ref=e9]
|
||||
- paragraph [ref=e10]: demo.smoothschedule.com
|
||||
- navigation [ref=e11]:
|
||||
- link "Dashboard" [ref=e12] [cursor=pointer]:
|
||||
- /url: "#/"
|
||||
- img [ref=e13]
|
||||
- generic [ref=e18]: Dashboard
|
||||
- link "Scheduler" [ref=e19] [cursor=pointer]:
|
||||
- /url: "#/scheduler"
|
||||
- img [ref=e20]
|
||||
- generic [ref=e22]: Scheduler
|
||||
- link "Customers" [ref=e23] [cursor=pointer]:
|
||||
- /url: "#/customers"
|
||||
- img [ref=e24]
|
||||
- generic [ref=e29]: Customers
|
||||
- link "Services" [ref=e30] [cursor=pointer]:
|
||||
- /url: "#/services"
|
||||
- img [ref=e31]
|
||||
- generic [ref=e34]: Services
|
||||
- link "Resources" [ref=e35] [cursor=pointer]:
|
||||
- /url: "#/resources"
|
||||
- img [ref=e36]
|
||||
- generic [ref=e39]: Resources
|
||||
- generic "Payments are disabled. Enable them in Business Settings to accept payments from customers." [ref=e40]:
|
||||
- img [ref=e41]
|
||||
- generic [ref=e43]: Payments
|
||||
- link "Messages" [ref=e44] [cursor=pointer]:
|
||||
- /url: "#/messages"
|
||||
- img [ref=e45]
|
||||
- generic [ref=e47]: Messages
|
||||
- link "Staff" [ref=e48] [cursor=pointer]:
|
||||
- /url: "#/staff"
|
||||
- img [ref=e49]
|
||||
- generic [ref=e54]: Staff
|
||||
- link "Business Settings" [ref=e56] [cursor=pointer]:
|
||||
- /url: "#/settings"
|
||||
- img [ref=e57]
|
||||
- generic [ref=e60]: Business Settings
|
||||
- generic [ref=e61]:
|
||||
- generic [ref=e62]:
|
||||
- img [ref=e63]
|
||||
- generic [ref=e69]:
|
||||
- generic [ref=e70]: Powered by
|
||||
- text: Smooth Schedule
|
||||
- button "Sign Out" [ref=e71]:
|
||||
- img [ref=e72]
|
||||
- generic [ref=e75]: Sign Out
|
||||
- generic [ref=e76]:
|
||||
- banner [ref=e77]:
|
||||
- generic [ref=e79]:
|
||||
- img [ref=e81]
|
||||
- textbox "Search" [ref=e84]
|
||||
- generic [ref=e85]:
|
||||
- button "🇺🇸 English" [ref=e87]:
|
||||
- img [ref=e88]
|
||||
- generic [ref=e91]: 🇺🇸
|
||||
- generic [ref=e92]: English
|
||||
- img [ref=e93]
|
||||
- button [ref=e95]:
|
||||
- img [ref=e96]
|
||||
- button [ref=e98]:
|
||||
- img [ref=e99]
|
||||
- button "Business Owner Owner BO" [ref=e104]:
|
||||
- generic [ref=e105]:
|
||||
- paragraph [ref=e106]: Business Owner
|
||||
- paragraph [ref=e107]: Owner
|
||||
- generic [ref=e108]: BO
|
||||
- img [ref=e109]
|
||||
- main [active] [ref=e111]:
|
||||
- generic [ref=e112]:
|
||||
- generic [ref=e113]:
|
||||
- heading "Dashboard" [level=2] [ref=e114]
|
||||
- paragraph [ref=e115]: Today's Overview
|
||||
- generic [ref=e116]:
|
||||
- generic [ref=e117]:
|
||||
- paragraph [ref=e118]: Total Appointments
|
||||
- generic [ref=e119]:
|
||||
- generic [ref=e120]: "50"
|
||||
- generic [ref=e121]:
|
||||
- img [ref=e122]
|
||||
- text: +12%
|
||||
- generic [ref=e125]:
|
||||
- paragraph [ref=e126]: Customers
|
||||
- generic [ref=e127]:
|
||||
- generic [ref=e128]: "1"
|
||||
- generic [ref=e129]:
|
||||
- img [ref=e130]
|
||||
- text: +8%
|
||||
- generic [ref=e133]:
|
||||
- paragraph [ref=e134]: Services
|
||||
- generic [ref=e135]:
|
||||
- generic [ref=e136]: "5"
|
||||
- generic [ref=e137]:
|
||||
- img [ref=e138]
|
||||
- text: 0%
|
||||
- generic [ref=e139]:
|
||||
- paragraph [ref=e140]: Resources
|
||||
- generic [ref=e141]:
|
||||
- generic [ref=e142]: "4"
|
||||
- generic [ref=e143]:
|
||||
- img [ref=e144]
|
||||
- text: +3%
|
||||
- generic [ref=e147]:
|
||||
- generic [ref=e149]:
|
||||
- generic [ref=e150]:
|
||||
- img [ref=e152]
|
||||
- heading "Quick Add Appointment" [level=3] [ref=e154]
|
||||
- generic [ref=e155]:
|
||||
- generic [ref=e156]:
|
||||
- generic [ref=e157]:
|
||||
- img [ref=e158]
|
||||
- text: Customer
|
||||
- combobox [ref=e161]:
|
||||
- option "Walk-in / No customer" [selected]
|
||||
- option "Customer User (customer@demo.com)"
|
||||
- generic [ref=e162]:
|
||||
- generic [ref=e163]:
|
||||
- img [ref=e164]
|
||||
- text: Service *
|
||||
- combobox [ref=e167]:
|
||||
- option "Select service..." [selected]
|
||||
- option "Beard Trim (15 min - $15)"
|
||||
- option "Consultation (30 min - $0)"
|
||||
- option "Full Styling (60 min - $75)"
|
||||
- option "Hair Coloring (90 min - $120)"
|
||||
- option "Haircut (30 min - $35)"
|
||||
- generic [ref=e168]:
|
||||
- generic [ref=e169]:
|
||||
- img [ref=e170]
|
||||
- text: Resource
|
||||
- combobox [ref=e173]:
|
||||
- option "Unassigned" [selected]
|
||||
- option "Conference Room A"
|
||||
- option "Dental Chair 1"
|
||||
- option "Meeting Room B"
|
||||
- option "Meeting Room B"
|
||||
- generic [ref=e174]:
|
||||
- generic [ref=e175]:
|
||||
- generic [ref=e176]: Date *
|
||||
- textbox [ref=e177]: 2025-11-27
|
||||
- generic [ref=e178]:
|
||||
- generic [ref=e179]:
|
||||
- img [ref=e180]
|
||||
- text: Time *
|
||||
- combobox [ref=e183]:
|
||||
- option "06:00"
|
||||
- option "06:15"
|
||||
- option "06:30"
|
||||
- option "06:45"
|
||||
- option "07:00"
|
||||
- option "07:15"
|
||||
- option "07:30"
|
||||
- option "07:45"
|
||||
- option "08:00"
|
||||
- option "08:15"
|
||||
- option "08:30"
|
||||
- option "08:45"
|
||||
- option "09:00" [selected]
|
||||
- option "09:15"
|
||||
- option "09:30"
|
||||
- option "09:45"
|
||||
- option "10:00"
|
||||
- option "10:15"
|
||||
- option "10:30"
|
||||
- option "10:45"
|
||||
- option "11:00"
|
||||
- option "11:15"
|
||||
- option "11:30"
|
||||
- option "11:45"
|
||||
- option "12:00"
|
||||
- option "12:15"
|
||||
- option "12:30"
|
||||
- option "12:45"
|
||||
- option "13:00"
|
||||
- option "13:15"
|
||||
- option "13:30"
|
||||
- option "13:45"
|
||||
- option "14:00"
|
||||
- option "14:15"
|
||||
- option "14:30"
|
||||
- option "14:45"
|
||||
- option "15:00"
|
||||
- option "15:15"
|
||||
- option "15:30"
|
||||
- option "15:45"
|
||||
- option "16:00"
|
||||
- option "16:15"
|
||||
- option "16:30"
|
||||
- option "16:45"
|
||||
- option "17:00"
|
||||
- option "17:15"
|
||||
- option "17:30"
|
||||
- option "17:45"
|
||||
- option "18:00"
|
||||
- option "18:15"
|
||||
- option "18:30"
|
||||
- option "18:45"
|
||||
- option "19:00"
|
||||
- option "19:15"
|
||||
- option "19:30"
|
||||
- option "19:45"
|
||||
- option "20:00"
|
||||
- option "20:15"
|
||||
- option "20:30"
|
||||
- option "20:45"
|
||||
- option "21:00"
|
||||
- option "21:15"
|
||||
- option "21:30"
|
||||
- option "21:45"
|
||||
- option "22:00"
|
||||
- option "22:15"
|
||||
- option "22:30"
|
||||
- option "22:45"
|
||||
- generic [ref=e184]:
|
||||
- generic [ref=e185]:
|
||||
- img [ref=e186]
|
||||
- text: Notes
|
||||
- textbox "Optional notes..." [ref=e189]
|
||||
- button "Add Appointment" [disabled] [ref=e190]:
|
||||
- img [ref=e191]
|
||||
- text: Add Appointment
|
||||
- generic [ref=e193]:
|
||||
- heading "Total Revenue" [level=3] [ref=e194]
|
||||
- application [ref=e198]:
|
||||
- generic [ref=e202]:
|
||||
- generic [ref=e203]:
|
||||
- generic [ref=e205]: Mon
|
||||
- generic [ref=e207]: Tue
|
||||
- generic [ref=e209]: Wed
|
||||
- generic [ref=e211]: Thu
|
||||
- generic [ref=e213]: Fri
|
||||
- generic [ref=e215]: Sat
|
||||
- generic [ref=e217]: Sun
|
||||
- generic [ref=e218]:
|
||||
- generic [ref=e220]: $0
|
||||
- generic [ref=e222]: $1
|
||||
- generic [ref=e224]: $2
|
||||
- generic [ref=e226]: $3
|
||||
- generic [ref=e228]: $4
|
||||
- generic [ref=e229]:
|
||||
- heading "Upcoming Appointments" [level=3] [ref=e230]
|
||||
- application [ref=e234]:
|
||||
- generic [ref=e250]:
|
||||
- generic [ref=e251]:
|
||||
- generic [ref=e253]: Mon
|
||||
- generic [ref=e255]: Tue
|
||||
- generic [ref=e257]: Wed
|
||||
- generic [ref=e259]: Thu
|
||||
- generic [ref=e261]: Fri
|
||||
- generic [ref=e263]: Sat
|
||||
- generic [ref=e265]: Sun
|
||||
- generic [ref=e266]:
|
||||
- generic [ref=e268]: "0"
|
||||
- generic [ref=e270]: "3"
|
||||
- generic [ref=e272]: "6"
|
||||
- generic [ref=e274]: "9"
|
||||
- generic [ref=e276]: "12"
|
||||
- generic [ref=e277]: "0"
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
175
smoothschedule/CLAUDE.md
Normal file
175
smoothschedule/CLAUDE.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# SmoothSchedule Backend Development Guide
|
||||
|
||||
## Docker-Based Development
|
||||
|
||||
**IMPORTANT:** This project runs in Docker containers. Do NOT try to run Django commands directly on the host machine - they will fail due to missing environment variables and database connection.
|
||||
|
||||
### Running Django Commands
|
||||
|
||||
Always use Docker Compose to execute commands:
|
||||
|
||||
```bash
|
||||
# Navigate to the smoothschedule directory first
|
||||
cd /home/poduck/Desktop/smoothschedule2/smoothschedule
|
||||
|
||||
# Run migrations
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||
|
||||
# Run migrations for a specific app
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate schedule
|
||||
|
||||
# Make migrations
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py makemigrations
|
||||
|
||||
# Create superuser
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py createsuperuser
|
||||
|
||||
# Run management commands
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py <command>
|
||||
|
||||
# Access 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
|
||||
```
|
||||
|
||||
### Multi-Tenant Migrations
|
||||
|
||||
This is a multi-tenant app using django-tenants. To run migrations on a specific tenant schema:
|
||||
|
||||
```bash
|
||||
# Run on a specific tenant (e.g., 'demo')
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py tenant_command migrate --schema=demo
|
||||
|
||||
# Run on public schema
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate_schemas --shared
|
||||
```
|
||||
|
||||
### Docker Services
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker compose -f docker-compose.local.yml up -d
|
||||
|
||||
# View logs
|
||||
docker compose -f docker-compose.local.yml logs -f django
|
||||
|
||||
# Restart Django after code changes (usually auto-reloads)
|
||||
docker compose -f docker-compose.local.yml restart django
|
||||
|
||||
# Rebuild after dependency changes
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Critical Configuration Files
|
||||
|
||||
```
|
||||
smoothschedule/
|
||||
├── docker-compose.local.yml # Local development Docker config
|
||||
├── docker-compose.production.yml # Production Docker config
|
||||
├── .envs/
|
||||
│ ├── .local/
|
||||
│ │ ├── .django # Django env vars (SECRET_KEY, DEBUG, etc.)
|
||||
│ │ └── .postgres # Database credentials
|
||||
│ └── .production/
|
||||
│ ├── .django
|
||||
│ └── .postgres
|
||||
├── config/
|
||||
│ ├── settings/
|
||||
│ │ ├── base.py # Base settings (shared)
|
||||
│ │ ├── local.py # Local dev settings (imports multitenancy.py)
|
||||
│ │ ├── production.py # Production settings
|
||||
│ │ ├── multitenancy.py # Multi-tenant configuration
|
||||
│ │ └── test.py # Test settings
|
||||
│ └── urls.py # Main URL configuration
|
||||
├── compose/
|
||||
│ ├── local/django/
|
||||
│ │ ├── Dockerfile # Local Django container
|
||||
│ │ └── start # Startup script
|
||||
│ └── production/
|
||||
│ ├── django/
|
||||
│ ├── postgres/
|
||||
│ └── traefik/
|
||||
```
|
||||
|
||||
### Django Apps
|
||||
|
||||
```
|
||||
smoothschedule/smoothschedule/
|
||||
├── users/ # User management, authentication
|
||||
│ ├── models.py # User model with roles
|
||||
│ ├── api_views.py # Auth endpoints, user API
|
||||
│ └── migrations/
|
||||
├── schedule/ # Core scheduling functionality
|
||||
│ ├── models.py # Resource, Event, Service, Participant
|
||||
│ ├── serializers.py # DRF serializers
|
||||
│ ├── views.py # ViewSets for API
|
||||
│ ├── services.py # AvailabilityService
|
||||
│ └── migrations/
|
||||
├── tenants/ # Multi-tenancy (Business/Tenant models)
|
||||
│ ├── models.py # Tenant, Domain models
|
||||
│ └── migrations/
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
Base URL: `http://lvh.me:8000/api/`
|
||||
|
||||
- `/api/resources/` - Resource CRUD
|
||||
- `/api/events/` - Event/Appointment CRUD
|
||||
- `/api/services/` - Service CRUD
|
||||
- `/api/customers/` - Customer listing
|
||||
- `/api/auth/login/` - Authentication
|
||||
- `/api/auth/logout/` - Logout
|
||||
- `/api/users/me/` - Current user info
|
||||
- `/api/business/` - Business settings
|
||||
|
||||
## Local Development URLs
|
||||
|
||||
- **Backend API:** `http://lvh.me:8000`
|
||||
- **Frontend:** `http://demo.lvh.me:5173` (business subdomain)
|
||||
- **Platform Frontend:** `http://platform.lvh.me:5173`
|
||||
|
||||
Note: `lvh.me` resolves to `127.0.0.1` and allows subdomain-based multi-tenancy with cookies.
|
||||
|
||||
## Database
|
||||
|
||||
- **Type:** PostgreSQL with django-tenants
|
||||
- **Public Schema:** Shared tables (tenants, domains, platform users)
|
||||
- **Tenant Schemas:** Per-business data (resources, events, customers)
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 500 Error with No CORS Headers
|
||||
When Django crashes (500 error), CORS headers aren't sent. Check Django logs:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml logs django --tail=100
|
||||
```
|
||||
|
||||
### Missing Column/Table Errors
|
||||
Run migrations:
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml exec django python manage.py migrate
|
||||
```
|
||||
|
||||
### ModuleNotFoundError / ImportError
|
||||
You're trying to run Python directly instead of through Docker. Use `docker compose exec`.
|
||||
|
||||
## Key Models
|
||||
|
||||
### Resource (schedule/models.py)
|
||||
- `name`, `type` (STAFF/ROOM/EQUIPMENT)
|
||||
- `max_concurrent_events` - concurrency limit (1=exclusive, >1=multilane, 0=unlimited)
|
||||
- `saved_lane_count` - remembers lane count when multilane disabled
|
||||
- `buffer_duration` - time between events
|
||||
|
||||
### Event (schedule/models.py)
|
||||
- `title`, `start_time`, `end_time`, `status`
|
||||
- Links to resources/customers via `Participant` model
|
||||
|
||||
### User (users/models.py)
|
||||
- Roles: `superuser`, `platform_manager`, `platform_support`, `owner`, `manager`, `staff`, `resource`, `customer`
|
||||
- `business_subdomain` - which tenant they belong to
|
||||
@@ -10,8 +10,11 @@ from drf_spectacular.views import SpectacularAPIView
|
||||
from drf_spectacular.views import SpectacularSwaggerView
|
||||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
|
||||
from smoothschedule.users.api_views import current_user_view, logout_view, send_verification_email, verify_email
|
||||
from schedule.api_views import current_business_view
|
||||
from smoothschedule.users.api_views import (
|
||||
current_user_view, logout_view, send_verification_email, verify_email,
|
||||
hijack_acquire_view, hijack_release_view
|
||||
)
|
||||
from schedule.api_views import current_business_view, update_business_view
|
||||
|
||||
urlpatterns = [
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
@@ -19,6 +22,8 @@ urlpatterns = [
|
||||
# User management
|
||||
path("users/", include("smoothschedule.users.urls", namespace="users")),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
# Django Hijack (masquerade) - for admin interface
|
||||
path("hijack/", include("hijack.urls")),
|
||||
# Your stuff: custom urls includes go here
|
||||
# ...
|
||||
# Media files
|
||||
@@ -39,8 +44,12 @@ urlpatterns += [
|
||||
path("api/auth/logout/", logout_view, name="logout"),
|
||||
path("api/auth/email/verify/send/", send_verification_email, name="send_verification_email"),
|
||||
path("api/auth/email/verify/", verify_email, name="verify_email"),
|
||||
# Hijack (masquerade) API
|
||||
path("api/auth/hijack/acquire/", hijack_acquire_view, name="hijack_acquire"),
|
||||
path("api/auth/hijack/release/", hijack_release_view, name="hijack_release"),
|
||||
# Business API
|
||||
path("api/business/current/", current_business_view, name="current_business"),
|
||||
path("api/business/current/update/", update_business_view, name="update_business"),
|
||||
# API Docs
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
|
||||
path(
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 03:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_tierlimit'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='logo',
|
||||
field=models.ImageField(blank=True, help_text='Business logo (recommended: 500x500px square or 500x200px wide)', null=True, upload_to='tenant_logos/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='logo_display_mode',
|
||||
field=models.CharField(choices=[('logo-only', 'Logo Only'), ('text-only', 'Text Only'), ('logo-and-text', 'Logo and Text')], default='text-only', help_text='How to display branding in sidebar and headers', max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='primary_color',
|
||||
field=models.CharField(default='#2563eb', help_text='Primary brand color (hex format, e.g., #2563eb)', max_length=7),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='secondary_color',
|
||||
field=models.CharField(default='#0ea5e9', help_text='Secondary brand color (hex format, e.g., #0ea5e9)', max_length=7),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 03:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_tenant_logo_tenant_logo_display_mode_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tenant',
|
||||
name='email_logo',
|
||||
field=models.ImageField(blank=True, help_text='Email logo (recommended: 600x200px wide, PNG with transparent background)', null=True, upload_to='tenant_email_logos/'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tenant',
|
||||
name='logo',
|
||||
field=models.ImageField(blank=True, help_text='Website logo (recommended: 500x500px square or 500x200px wide, PNG with transparent background)', null=True, upload_to='tenant_logos/'),
|
||||
),
|
||||
]
|
||||
@@ -15,7 +15,7 @@ class Tenant(TenantMixin):
|
||||
"""
|
||||
name = models.CharField(max_length=100)
|
||||
created_on = models.DateField(auto_now_add=True)
|
||||
|
||||
|
||||
# Subscription & billing
|
||||
is_active = models.BooleanField(default=True)
|
||||
subscription_tier = models.CharField(
|
||||
@@ -28,11 +28,45 @@ class Tenant(TenantMixin):
|
||||
],
|
||||
default='FREE'
|
||||
)
|
||||
|
||||
|
||||
# Feature flags
|
||||
max_users = models.IntegerField(default=5)
|
||||
max_resources = models.IntegerField(default=10)
|
||||
|
||||
|
||||
# Branding
|
||||
logo = models.ImageField(
|
||||
upload_to='tenant_logos/',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Website logo (recommended: 500x500px square or 500x200px wide, PNG with transparent background)"
|
||||
)
|
||||
email_logo = models.ImageField(
|
||||
upload_to='tenant_email_logos/',
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Email logo (recommended: 600x200px wide, PNG with transparent background)"
|
||||
)
|
||||
logo_display_mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('logo-only', 'Logo Only'),
|
||||
('text-only', 'Text Only'),
|
||||
('logo-and-text', 'Logo and Text'),
|
||||
],
|
||||
default='text-only',
|
||||
help_text="How to display branding in sidebar and headers"
|
||||
)
|
||||
primary_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#2563eb',
|
||||
help_text="Primary brand color (hex format, e.g., #2563eb)"
|
||||
)
|
||||
secondary_color = models.CharField(
|
||||
max_length=7,
|
||||
default='#0ea5e9',
|
||||
help_text="Secondary brand color (hex format, e.g., #0ea5e9)"
|
||||
)
|
||||
|
||||
# Metadata
|
||||
contact_email = models.EmailField(blank=True)
|
||||
phone = models.CharField(max_length=20, blank=True)
|
||||
|
||||
@@ -33,7 +33,7 @@ def can_hijack(hijacker, hijacked):
|
||||
- Always validate tenant boundaries for tenant-scoped roles
|
||||
- Log all hijack attempts (success and failure) for audit
|
||||
"""
|
||||
from users.models import User
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
# Safety check: can't hijack yourself
|
||||
if hijacker.id == hijacked.id:
|
||||
@@ -60,16 +60,17 @@ def can_hijack(hijacker, hijacked):
|
||||
if hijacker.role == User.Role.PLATFORM_SALES:
|
||||
return hijacked.is_temporary
|
||||
|
||||
# Rule 4: TENANT_OWNER can hijack staff within their own tenant
|
||||
# Rule 4: TENANT_OWNER can hijack managers, staff, and customers within their own tenant
|
||||
if hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Must be in same tenant
|
||||
if not hijacker.tenant or not hijacked.tenant:
|
||||
return False
|
||||
if hijacker.tenant.id != hijacked.tenant.id:
|
||||
return False
|
||||
|
||||
# Can only hijack staff and customers, not other owners/managers
|
||||
|
||||
# Can hijack managers, staff, and customers (not other owners)
|
||||
return hijacked.role in [
|
||||
User.Role.TENANT_MANAGER,
|
||||
User.Role.TENANT_STAFF,
|
||||
User.Role.CUSTOMER,
|
||||
]
|
||||
@@ -112,7 +113,7 @@ def get_hijackable_users(hijacker):
|
||||
Returns:
|
||||
QuerySet: Users that can be hijacked by this user
|
||||
"""
|
||||
from users.models import User
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
# Start with all users except self
|
||||
qs = User.objects.exclude(id=hijacker.id)
|
||||
@@ -136,13 +137,13 @@ def get_hijackable_users(hijacker):
|
||||
return qs.filter(is_temporary=True)
|
||||
|
||||
elif hijacker.role == User.Role.TENANT_OWNER:
|
||||
# Only staff in same tenant
|
||||
# Managers, staff, and customers in same tenant
|
||||
if not hijacker.tenant:
|
||||
return qs.none()
|
||||
|
||||
|
||||
return qs.filter(
|
||||
tenant=hijacker.tenant,
|
||||
role__in=[User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
role__in=[User.Role.TENANT_MANAGER, User.Role.TENANT_STAFF, User.Role.CUSTOMER]
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""
|
||||
API views for business/tenant management
|
||||
"""
|
||||
import base64
|
||||
import uuid
|
||||
from django.core.files.base import ContentFile
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -39,10 +42,119 @@ def current_business_view(request):
|
||||
'tier': tenant.subscription_tier,
|
||||
'status': 'active' if tenant.is_active else 'inactive',
|
||||
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
|
||||
# Optional fields with defaults
|
||||
'primary_color': '#3B82F6', # Blue-500 default
|
||||
'secondary_color': '#1E40AF', # Blue-800 default
|
||||
'logo_url': None,
|
||||
# Branding fields from Tenant model
|
||||
'primary_color': tenant.primary_color,
|
||||
'secondary_color': tenant.secondary_color,
|
||||
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
|
||||
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
|
||||
'logo_display_mode': tenant.logo_display_mode,
|
||||
# Other optional fields with defaults
|
||||
'whitelabel_enabled': False,
|
||||
'resources_can_reschedule': False,
|
||||
'require_payment_method_to_book': False,
|
||||
'cancellation_window_hours': 24,
|
||||
'late_cancellation_fee_percent': 0,
|
||||
'initial_setup_complete': False,
|
||||
'website_pages': {},
|
||||
'customer_dashboard_content': [],
|
||||
}
|
||||
|
||||
return Response(business_data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@api_view(['PATCH'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def update_business_view(request):
|
||||
"""
|
||||
Update business (tenant) settings for the authenticated user
|
||||
PATCH /api/business/current/update/
|
||||
|
||||
Only business owners can update settings
|
||||
"""
|
||||
user = request.user
|
||||
tenant = user.tenant
|
||||
|
||||
# Platform users don't have a tenant
|
||||
if not tenant:
|
||||
return Response({'error': 'No business found'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Only owners can update business settings
|
||||
if user.role.lower() != 'tenant_owner':
|
||||
return Response({'error': 'Only business owners can update settings'}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Update fields if provided in request
|
||||
if 'name' in request.data:
|
||||
tenant.name = request.data['name']
|
||||
|
||||
if 'primary_color' in request.data:
|
||||
tenant.primary_color = request.data['primary_color']
|
||||
|
||||
if 'secondary_color' in request.data:
|
||||
tenant.secondary_color = request.data['secondary_color']
|
||||
|
||||
if 'logo_display_mode' in request.data:
|
||||
tenant.logo_display_mode = request.data['logo_display_mode']
|
||||
|
||||
# Handle logo uploads (base64 data URLs)
|
||||
if 'logo_url' in request.data:
|
||||
logo_data = request.data['logo_url']
|
||||
if logo_data and logo_data.startswith('data:image'):
|
||||
# Extract base64 data and file extension
|
||||
format_str, imgstr = logo_data.split(';base64,')
|
||||
ext = format_str.split('/')[-1]
|
||||
# Decode base64 and create Django file
|
||||
data = ContentFile(base64.b64decode(imgstr), name=f'logo_{uuid.uuid4()}.{ext}')
|
||||
# Delete old logo if exists
|
||||
if tenant.logo:
|
||||
tenant.logo.delete(save=False)
|
||||
tenant.logo = data
|
||||
elif logo_data is None or logo_data == '':
|
||||
# Remove logo if set to None or empty string
|
||||
if tenant.logo:
|
||||
tenant.logo.delete(save=False)
|
||||
tenant.logo = None
|
||||
|
||||
if 'email_logo_url' in request.data:
|
||||
email_logo_data = request.data['email_logo_url']
|
||||
if email_logo_data and email_logo_data.startswith('data:image'):
|
||||
# Extract base64 data and file extension
|
||||
format_str, imgstr = email_logo_data.split(';base64,')
|
||||
ext = format_str.split('/')[-1]
|
||||
# Decode base64 and create Django file
|
||||
data = ContentFile(base64.b64decode(imgstr), name=f'email_logo_{uuid.uuid4()}.{ext}')
|
||||
# Delete old email logo if exists
|
||||
if tenant.email_logo:
|
||||
tenant.email_logo.delete(save=False)
|
||||
tenant.email_logo = data
|
||||
elif email_logo_data is None or email_logo_data == '':
|
||||
# Remove email logo if set to None or empty string
|
||||
if tenant.email_logo:
|
||||
tenant.email_logo.delete(save=False)
|
||||
tenant.email_logo = None
|
||||
|
||||
# Save the tenant
|
||||
tenant.save()
|
||||
|
||||
# Return updated business data
|
||||
subdomain = None
|
||||
primary_domain = tenant.domains.filter(is_primary=True).first()
|
||||
if primary_domain:
|
||||
domain_parts = primary_domain.domain.split('.')
|
||||
if len(domain_parts) > 0:
|
||||
subdomain = domain_parts[0]
|
||||
|
||||
business_data = {
|
||||
'id': tenant.id,
|
||||
'name': tenant.name,
|
||||
'subdomain': subdomain or tenant.schema_name,
|
||||
'tier': tenant.subscription_tier,
|
||||
'status': 'active' if tenant.is_active else 'inactive',
|
||||
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
|
||||
'primary_color': tenant.primary_color,
|
||||
'secondary_color': tenant.secondary_color,
|
||||
'logo_url': request.build_absolute_uri(tenant.logo.url) if tenant.logo else None,
|
||||
'email_logo_url': request.build_absolute_uri(tenant.email_logo.url) if tenant.email_logo else None,
|
||||
'logo_display_mode': tenant.logo_display_mode,
|
||||
'whitelabel_enabled': False,
|
||||
'resources_can_reschedule': False,
|
||||
'require_payment_method_to_book': False,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated manually
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0004_rename_schedule_se_is_acti_idx_schedule_se_is_acti_8c055e_idx'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='saved_lane_count',
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True,
|
||||
help_text='Remembered lane count when multilane is disabled',
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 02:59
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0005_resource_saved_lane_count'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='user',
|
||||
field=models.ForeignKey(blank=True, help_text='Link to User account for STAFF type resources', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff_resources', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 03:37
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0006_add_user_to_resource'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='resource',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('STAFF', 'Staff Member'), ('ROOM', 'Room'), ('EQUIPMENT', 'Equipment')], default='STAFF', help_text='DEPRECATED: Use resource_type instead', max_length=20),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ResourceType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text="User-facing name like 'Stylist' or 'Treatment Room'", max_length=100)),
|
||||
('category', models.CharField(choices=[('STAFF', 'Staff'), ('OTHER', 'Other')], default='OTHER', help_text='STAFF types require staff assignment, OTHER types do not', max_length=10)),
|
||||
('is_default', models.BooleanField(default=False, help_text="Default types cannot be deleted (e.g., 'Staff')")),
|
||||
('icon_name', models.CharField(blank=True, help_text='Optional icon identifier', max_length=50)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
'indexes': [models.Index(fields=['category', 'name'], name='schedule_re_categor_3040dd_idx')],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='resource_type',
|
||||
field=models.ForeignKey(blank=True, help_text='Custom resource type definition', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='resources', to='schedule.resourcetype'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 03:43
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_default_resource_types(apps, schema_editor):
|
||||
"""Create default resource types for all tenants"""
|
||||
ResourceType = apps.get_model('schedule', 'ResourceType')
|
||||
|
||||
# Create default types if they don't exist
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Staff',
|
||||
defaults={
|
||||
'category': 'STAFF',
|
||||
'is_default': True,
|
||||
'icon_name': 'user'
|
||||
}
|
||||
)
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Room',
|
||||
defaults={
|
||||
'category': 'OTHER',
|
||||
'is_default': True,
|
||||
'icon_name': 'home'
|
||||
}
|
||||
)
|
||||
ResourceType.objects.get_or_create(
|
||||
name='Equipment',
|
||||
defaults={
|
||||
'category': 'OTHER',
|
||||
'is_default': True,
|
||||
'icon_name': 'wrench'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def reverse_default_resource_types(apps, schema_editor):
|
||||
"""Remove default resource types (for rollback)"""
|
||||
ResourceType = apps.get_model('schedule', 'ResourceType')
|
||||
ResourceType.objects.filter(is_default=True).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0007_alter_resource_type_resourcetype_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(create_default_resource_types, reverse_default_resource_types),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 05:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0008_create_default_resource_types'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='service',
|
||||
options={'ordering': ['display_order', 'name']},
|
||||
),
|
||||
migrations.RemoveIndex(
|
||||
model_name='service',
|
||||
name='schedule_se_is_acti_8c055e_idx',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='display_order',
|
||||
field=models.PositiveIntegerField(default=0, help_text='Order in which services appear in menus (lower = first)'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='service',
|
||||
index=models.Index(fields=['is_active', 'display_order'], name='schedule_se_is_acti_d33934_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 05:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0009_add_service_display_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='resourcetype',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, help_text='Description of this resource type'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='resourcetype',
|
||||
name='photos',
|
||||
field=models.JSONField(blank=True, default=list, help_text='List of photo URLs in display order'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 06:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0010_add_resourcetype_description_photos'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='photos',
|
||||
field=models.JSONField(blank=True, default=list, help_text='List of photo URLs in display order'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 06:09
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('schedule', '0011_add_photos_to_service'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='resourcetype',
|
||||
name='photos',
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.utils import timezone
|
||||
from decimal import Decimal
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class Service(models.Model):
|
||||
@@ -21,18 +22,75 @@ class Service(models.Model):
|
||||
decimal_places=2,
|
||||
default=Decimal('0.00')
|
||||
)
|
||||
display_order = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Order in which services appear in menus (lower = first)"
|
||||
)
|
||||
photos = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
help_text="List of photo URLs in display order"
|
||||
)
|
||||
is_active = models.BooleanField(default=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
indexes = [models.Index(fields=['is_active', 'name'])]
|
||||
ordering = ['display_order', 'name']
|
||||
indexes = [models.Index(fields=['is_active', 'display_order'])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.duration} min - ${self.price})"
|
||||
|
||||
|
||||
class ResourceType(models.Model):
|
||||
"""
|
||||
Custom resource type definitions (e.g., "Hair Stylist", "Massage Room").
|
||||
Businesses can create their own types instead of using hardcoded STAFF/ROOM/EQUIPMENT.
|
||||
"""
|
||||
class Category(models.TextChoices):
|
||||
STAFF = 'STAFF', 'Staff' # Requires staff member assignment
|
||||
OTHER = 'OTHER', 'Other' # No staff assignment needed
|
||||
|
||||
name = models.CharField(max_length=100, help_text="User-facing name like 'Stylist' or 'Treatment Room'")
|
||||
description = models.TextField(blank=True, help_text="Description of this resource type")
|
||||
category = models.CharField(
|
||||
max_length=10,
|
||||
choices=Category.choices,
|
||||
default=Category.OTHER,
|
||||
help_text="STAFF types require staff assignment, OTHER types do not"
|
||||
)
|
||||
is_default = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Default types cannot be deleted (e.g., 'Staff')"
|
||||
)
|
||||
icon_name = models.CharField(max_length=50, blank=True, help_text="Optional icon identifier")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
indexes = [models.Index(fields=['category', 'name'])]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_category_display()})"
|
||||
|
||||
def clean(self):
|
||||
"""Prevent deletion of default types"""
|
||||
if self.is_default and self.pk:
|
||||
# Check if being deleted
|
||||
if not ResourceType.objects.filter(pk=self.pk).exists():
|
||||
raise ValidationError("Cannot delete default resource types.")
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""Prevent deletion of default types and types in use"""
|
||||
if self.is_default:
|
||||
raise ValidationError("Cannot delete default resource types.")
|
||||
if self.resources.exists():
|
||||
raise ValidationError(f"Cannot delete resource type '{self.name}' because it is in use by {self.resources.count()} resource(s).")
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class Resource(models.Model):
|
||||
"""
|
||||
A bookable resource with configurable concurrency.
|
||||
@@ -48,10 +106,32 @@ class Resource(models.Model):
|
||||
EQUIPMENT = 'EQUIPMENT', 'Equipment'
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
|
||||
# NEW: Custom resource type (preferred)
|
||||
resource_type = models.ForeignKey(
|
||||
ResourceType,
|
||||
on_delete=models.PROTECT, # Cannot delete type if resources use it
|
||||
related_name='resources',
|
||||
null=True, # For migration compatibility
|
||||
blank=True,
|
||||
help_text="Custom resource type definition"
|
||||
)
|
||||
|
||||
# LEGACY: Hardcoded type (deprecated, kept for backwards compatibility)
|
||||
type = models.CharField(
|
||||
max_length=20,
|
||||
choices=Type.choices,
|
||||
default=Type.STAFF
|
||||
default=Type.STAFF,
|
||||
help_text="DEPRECATED: Use resource_type instead"
|
||||
)
|
||||
|
||||
user = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='staff_resources',
|
||||
help_text="Link to User account for STAFF type resources"
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
max_concurrent_events = models.PositiveIntegerField(
|
||||
@@ -63,6 +143,11 @@ class Resource(models.Model):
|
||||
default=timezone.timedelta(0),
|
||||
help_text="Time buffer before/after events. Buffers consume capacity."
|
||||
)
|
||||
saved_lane_count = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Remembered lane count when multilane is disabled"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
@@ -3,11 +3,38 @@ DRF Serializers for Schedule App with Availability Validation
|
||||
"""
|
||||
from rest_framework import serializers
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from .models import Resource, Event, Participant, Service
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from .models import Resource, Event, Participant, Service, ResourceType
|
||||
from .services import AvailabilityService
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
|
||||
class ResourceTypeSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for custom resource types"""
|
||||
|
||||
class Meta:
|
||||
model = ResourceType
|
||||
fields = ['id', 'name', 'description', 'category', 'is_default', 'icon_name', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'is_default']
|
||||
|
||||
def validate(self, attrs):
|
||||
# If updating, check if trying to modify is_default
|
||||
if self.instance and self.instance.is_default:
|
||||
if 'name' in attrs and attrs['name'] != self.instance.name:
|
||||
# Allow renaming default types
|
||||
pass
|
||||
return attrs
|
||||
|
||||
def delete(self, instance):
|
||||
"""Validate before deletion"""
|
||||
if instance.is_default:
|
||||
raise serializers.ValidationError("Cannot delete default resource types.")
|
||||
if instance.resources.exists():
|
||||
raise serializers.ValidationError(
|
||||
f"Cannot delete resource type '{instance.name}' because it is in use by {instance.resources.count()} resource(s)."
|
||||
)
|
||||
|
||||
|
||||
class CustomerSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Customer (User with role=CUSTOMER)"""
|
||||
name = serializers.SerializerMethodField()
|
||||
@@ -20,13 +47,14 @@ class CustomerSerializer(serializers.ModelSerializer):
|
||||
city = serializers.SerializerMethodField()
|
||||
state = serializers.SerializerMethodField()
|
||||
zip = serializers.SerializerMethodField()
|
||||
user_data = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'name', 'email', 'phone', 'city', 'state', 'zip',
|
||||
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
||||
'user_id',
|
||||
'user_id', 'user_data',
|
||||
]
|
||||
read_only_fields = ['id', 'email']
|
||||
|
||||
@@ -59,6 +87,41 @@ class CustomerSerializer(serializers.ModelSerializer):
|
||||
def get_zip(self, obj):
|
||||
return ''
|
||||
|
||||
def get_user_data(self, obj):
|
||||
"""Return user data needed for masquerading"""
|
||||
return {
|
||||
'id': obj.id,
|
||||
'username': obj.username,
|
||||
'name': obj.full_name,
|
||||
'email': obj.email,
|
||||
'role': 'customer',
|
||||
}
|
||||
|
||||
|
||||
class StaffSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Staff members (Users with staff roles)"""
|
||||
name = serializers.SerializerMethodField()
|
||||
role = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id', 'username', 'name', 'email', 'phone', 'role',
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_name(self, obj):
|
||||
return obj.full_name
|
||||
|
||||
def get_role(self, obj):
|
||||
# Map database roles to frontend roles
|
||||
role_mapping = {
|
||||
'TENANT_OWNER': 'owner',
|
||||
'TENANT_MANAGER': 'manager',
|
||||
'TENANT_STAFF': 'staff',
|
||||
}
|
||||
return role_mapping.get(obj.role, obj.role.lower())
|
||||
|
||||
|
||||
class ServiceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Service model"""
|
||||
@@ -68,7 +131,7 @@ class ServiceSerializer(serializers.ModelSerializer):
|
||||
model = Service
|
||||
fields = [
|
||||
'id', 'name', 'description', 'duration', 'duration_minutes',
|
||||
'price', 'is_active', 'created_at', 'updated_at',
|
||||
'price', 'display_order', 'photos', 'is_active', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
@@ -76,16 +139,25 @@ class ServiceSerializer(serializers.ModelSerializer):
|
||||
class ResourceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Resource model"""
|
||||
capacity_description = serializers.SerializerMethodField()
|
||||
user_id = serializers.IntegerField(source='user.id', read_only=True, allow_null=True)
|
||||
user_name = serializers.CharField(source='user.full_name', read_only=True, allow_null=True)
|
||||
user = serializers.PrimaryKeyRelatedField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
write_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = [
|
||||
'id', 'name', 'type', 'description', 'max_concurrent_events',
|
||||
'id', 'name', 'type', 'user', 'user_id', 'user_name',
|
||||
'description', 'max_concurrent_events',
|
||||
'buffer_duration', 'is_active', 'capacity_description',
|
||||
'saved_lane_count', 'created_at', 'updated_at',
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
|
||||
def get_capacity_description(self, obj):
|
||||
if obj.max_concurrent_events == 0:
|
||||
return "Unlimited capacity"
|
||||
|
||||
@@ -3,16 +3,21 @@ Schedule App URLs
|
||||
"""
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ResourceViewSet, EventViewSet, ParticipantViewSet, CustomerViewSet, ServiceViewSet
|
||||
from .views import (
|
||||
ResourceViewSet, EventViewSet, ParticipantViewSet,
|
||||
CustomerViewSet, ServiceViewSet, StaffViewSet, ResourceTypeViewSet
|
||||
)
|
||||
|
||||
# Create router and register viewsets
|
||||
router = DefaultRouter()
|
||||
router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype')
|
||||
router.register(r'resources', ResourceViewSet, basename='resource')
|
||||
router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend
|
||||
router.register(r'events', EventViewSet, basename='event')
|
||||
router.register(r'participants', ParticipantViewSet, basename='participant')
|
||||
router.register(r'customers', CustomerViewSet, basename='customer')
|
||||
router.register(r'services', ServiceViewSet, basename='service')
|
||||
router.register(r'staff', StaffViewSet, basename='staff')
|
||||
|
||||
# URL patterns
|
||||
urlpatterns = [
|
||||
|
||||
@@ -6,13 +6,60 @@ API endpoints for Resources and Events with quota enforcement.
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from .models import Resource, Event, Participant
|
||||
from .serializers import ResourceSerializer, EventSerializer, ParticipantSerializer, CustomerSerializer, ServiceSerializer
|
||||
from rest_framework.decorators import action
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from .models import Resource, Event, Participant, ResourceType
|
||||
from .serializers import (
|
||||
ResourceSerializer, EventSerializer, ParticipantSerializer,
|
||||
CustomerSerializer, ServiceSerializer, ResourceTypeSerializer, StaffSerializer
|
||||
)
|
||||
from .models import Service
|
||||
from core.permissions import HasQuota
|
||||
from smoothschedule.users.models import User
|
||||
|
||||
|
||||
class ResourceTypeViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing custom Resource Types.
|
||||
|
||||
Permissions:
|
||||
- Must be authenticated
|
||||
- Only owners/managers can create/update/delete
|
||||
|
||||
Functionality:
|
||||
- List all resource types
|
||||
- Create new custom types
|
||||
- Update existing types (except is_default flag)
|
||||
- Delete types (only if not default and not in use)
|
||||
"""
|
||||
queryset = ResourceType.objects.all()
|
||||
serializer_class = ResourceTypeSerializer
|
||||
permission_classes = [AllowAny] # TODO: Change to IsAuthenticated for production
|
||||
ordering = ['name']
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Override destroy to add validation"""
|
||||
instance = self.get_object()
|
||||
|
||||
# Check if default
|
||||
if instance.is_default:
|
||||
return Response(
|
||||
{'error': 'Cannot delete default resource types.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if in use
|
||||
if instance.resources.exists():
|
||||
return Response(
|
||||
{
|
||||
'error': f"Cannot delete resource type '{instance.name}' because it is in use by {instance.resources.count()} resource(s)."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
class ResourceViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for managing Resources.
|
||||
@@ -198,8 +245,8 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
|
||||
filterset_fields = ['is_active']
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'price', 'duration', 'created_at']
|
||||
ordering = ['name']
|
||||
ordering_fields = ['name', 'price', 'duration', 'display_order', 'created_at']
|
||||
ordering = ['display_order', 'name']
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return services, optionally including inactive ones."""
|
||||
@@ -211,3 +258,71 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
||||
queryset = queryset.filter(is_active=True)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def reorder(self, request):
|
||||
"""
|
||||
Bulk update service display order.
|
||||
|
||||
Expects: { "order": [1, 3, 2, 5, 4] }
|
||||
Where the list contains service IDs in the desired display order.
|
||||
"""
|
||||
order = request.data.get('order', [])
|
||||
|
||||
if not isinstance(order, list):
|
||||
return Response(
|
||||
{'error': 'order must be a list of service IDs'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Update display_order for each service
|
||||
for index, service_id in enumerate(order):
|
||||
Service.objects.filter(id=service_id).update(display_order=index)
|
||||
|
||||
return Response({'status': 'ok', 'updated': len(order)})
|
||||
|
||||
|
||||
class StaffViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
API endpoint for listing staff members (Users who can be assigned to resources).
|
||||
|
||||
Staff members are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
||||
This endpoint is read-only for assigning staff to resources.
|
||||
"""
|
||||
serializer_class = StaffSerializer
|
||||
# TODO: Re-enable authentication for production
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
search_fields = ['email', 'first_name', 'last_name']
|
||||
ordering_fields = ['email', 'first_name', 'last_name']
|
||||
ordering = ['first_name', 'last_name']
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Return staff members for the current tenant.
|
||||
|
||||
Staff are Users with roles: TENANT_OWNER, TENANT_MANAGER, TENANT_STAFF.
|
||||
"""
|
||||
from django.db.models import Q
|
||||
|
||||
queryset = User.objects.filter(
|
||||
Q(role=User.Role.TENANT_OWNER) |
|
||||
Q(role=User.Role.TENANT_MANAGER) |
|
||||
Q(role=User.Role.TENANT_STAFF)
|
||||
).filter(is_active=True)
|
||||
|
||||
# Filter by tenant if user is authenticated and has a tenant
|
||||
# TODO: Re-enable this when authentication is enabled
|
||||
# if self.request.user.is_authenticated and self.request.user.tenant:
|
||||
# queryset = queryset.filter(tenant=self.request.user.tenant)
|
||||
|
||||
# Apply search filter if provided
|
||||
search = self.request.query_params.get('search')
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(email__icontains=search) |
|
||||
Q(first_name__icontains=search) |
|
||||
Q(last_name__icontains=search)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
@@ -5,12 +5,15 @@ import secrets
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from .models import User, EmailVerificationToken
|
||||
from core.permissions import can_hijack
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@@ -160,3 +163,169 @@ def verify_email(request):
|
||||
token.user.save(update_fields=['email_verified'])
|
||||
|
||||
return Response({"detail": "Email verified successfully."}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
def _get_user_data(user):
|
||||
"""Helper to get user data for API responses."""
|
||||
# Get business info if user has a tenant
|
||||
business_name = None
|
||||
business_subdomain = None
|
||||
if user.tenant:
|
||||
business_name = user.tenant.name
|
||||
primary_domain = user.tenant.domains.filter(is_primary=True).first()
|
||||
if primary_domain:
|
||||
business_subdomain = primary_domain.domain.split('.')[0]
|
||||
else:
|
||||
business_subdomain = user.tenant.schema_name
|
||||
|
||||
# Map database roles to frontend roles
|
||||
role_mapping = {
|
||||
'superuser': 'superuser',
|
||||
'platform_manager': 'platform_manager',
|
||||
'platform_sales': 'platform_sales',
|
||||
'platform_support': 'platform_support',
|
||||
'tenant_owner': 'owner',
|
||||
'tenant_manager': 'manager',
|
||||
'tenant_staff': 'staff',
|
||||
'customer': 'customer',
|
||||
}
|
||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'name': user.full_name,
|
||||
'role': frontend_role,
|
||||
'avatar_url': None,
|
||||
'email_verified': user.email_verified,
|
||||
'is_staff': user.is_staff,
|
||||
'is_superuser': user.is_superuser,
|
||||
'business': user.tenant_id,
|
||||
'business_name': business_name,
|
||||
'business_subdomain': business_subdomain,
|
||||
}
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def hijack_acquire_view(request):
|
||||
"""
|
||||
Masquerade as another user (hijack).
|
||||
POST /api/auth/hijack/acquire/
|
||||
|
||||
Body: { "user_pk": <user_id> }
|
||||
|
||||
Returns new auth token for the hijacked user along with the hijack history.
|
||||
"""
|
||||
# Debug logging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Hijack API called. User authenticated: {request.user.is_authenticated}, User: {request.user}")
|
||||
|
||||
user_pk = request.data.get('user_pk')
|
||||
if not user_pk:
|
||||
return Response({"error": "user_pk is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
hijacker = request.user
|
||||
hijacked = get_object_or_404(User, pk=user_pk)
|
||||
|
||||
logger.warning(f"Hijack attempt: hijacker={hijacker.email} (role={hijacker.role}), hijacked={hijacked.email} (role={hijacked.role})")
|
||||
|
||||
# Check permission
|
||||
can_hijack_result = can_hijack(hijacker, hijacked)
|
||||
logger.warning(f"can_hijack result: {can_hijack_result}")
|
||||
if not can_hijack_result:
|
||||
logger.warning(f"Hijack DENIED: {hijacker.email} -> {hijacked.email}")
|
||||
return Response(
|
||||
{"error": f"You do not have permission to masquerade as this user."},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# Get or build hijack history from request
|
||||
hijack_history = request.data.get('hijack_history', [])
|
||||
logger.warning(f"hijack_history length: {len(hijack_history)}")
|
||||
|
||||
# Don't allow hijacking while already hijacked (max depth 1)
|
||||
if len(hijack_history) > 0:
|
||||
logger.warning("Hijack denied - already masquerading")
|
||||
return Response(
|
||||
{"error": "Cannot start a new masquerade session while already masquerading. Please exit your current session first."},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
logger.warning("Passed all checks, proceeding with hijack...")
|
||||
|
||||
# Add current user (hijacker) to the history stack
|
||||
hijacker_business_subdomain = None
|
||||
if hijacker.tenant:
|
||||
primary_domain = hijacker.tenant.domains.filter(is_primary=True).first()
|
||||
if primary_domain:
|
||||
hijacker_business_subdomain = primary_domain.domain.split('.')[0]
|
||||
else:
|
||||
hijacker_business_subdomain = hijacker.tenant.schema_name
|
||||
|
||||
role_mapping = {
|
||||
'superuser': 'superuser',
|
||||
'platform_manager': 'platform_manager',
|
||||
'platform_sales': 'platform_sales',
|
||||
'platform_support': 'platform_support',
|
||||
'tenant_owner': 'owner',
|
||||
'tenant_manager': 'manager',
|
||||
'tenant_staff': 'staff',
|
||||
'customer': 'customer',
|
||||
}
|
||||
|
||||
new_history = [{
|
||||
'user_id': hijacker.id,
|
||||
'username': hijacker.username,
|
||||
'role': role_mapping.get(hijacker.role.lower(), hijacker.role.lower()),
|
||||
'business_id': hijacker.tenant_id,
|
||||
'business_subdomain': hijacker_business_subdomain,
|
||||
}]
|
||||
|
||||
# Create or get token for hijacked user
|
||||
Token.objects.filter(user=hijacked).delete() # Delete old token
|
||||
token = Token.objects.create(user=hijacked)
|
||||
|
||||
return Response({
|
||||
'access': token.key,
|
||||
'refresh': token.key, # For API compatibility (we don't use refresh tokens with DRF Token auth)
|
||||
'user': _get_user_data(hijacked),
|
||||
'masquerade_stack': new_history,
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def hijack_release_view(request):
|
||||
"""
|
||||
Stop masquerading and return to previous user.
|
||||
POST /api/auth/hijack/release/
|
||||
|
||||
Body: { "masquerade_stack": [...] }
|
||||
|
||||
Returns auth token for the original user.
|
||||
"""
|
||||
masquerade_stack = request.data.get('masquerade_stack', [])
|
||||
|
||||
if not masquerade_stack:
|
||||
return Response(
|
||||
{"error": "No masquerade session to stop. masquerade_stack is empty."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get the original user from the stack
|
||||
original_user_entry = masquerade_stack.pop()
|
||||
original_user = get_object_or_404(User, pk=original_user_entry['user_id'])
|
||||
|
||||
# Create or get token for original user
|
||||
Token.objects.filter(user=original_user).delete() # Delete old token
|
||||
token = Token.objects.create(user=original_user)
|
||||
|
||||
return Response({
|
||||
'access': token.key,
|
||||
'refresh': token.key,
|
||||
'user': _get_user_data(original_user),
|
||||
'masquerade_stack': masquerade_stack, # Return remaining stack (should be empty now)
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user