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
|
# 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
|
## Local Development Domain Setup
|
||||||
|
|
||||||
### Why lvh.me instead of localhost?
|
### 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';
|
return hostname === 'lvh.me' || hostname === 'localhost' || hostname === '127.0.0.1';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Not authenticated - show public routes
|
// On root domain, ALWAYS show marketing site (even if logged in)
|
||||||
if (!user) {
|
// Logged-in users will see a "Go to Dashboard" link in the navbar
|
||||||
// On root domain, show marketing site
|
if (isRootDomain()) {
|
||||||
if (isRootDomain()) {
|
return (
|
||||||
return (
|
<Routes>
|
||||||
<Routes>
|
<Route element={<MarketingLayout user={user} />}>
|
||||||
<Route element={<MarketingLayout />}>
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/features" element={<FeaturesPage />} />
|
||||||
<Route path="/features" element={<FeaturesPage />} />
|
<Route path="/pricing" element={<PricingPage />} />
|
||||||
<Route path="/pricing" element={<PricingPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/contact" element={<ContactPage />} />
|
||||||
<Route path="/contact" element={<ContactPage />} />
|
<Route path="/signup" element={<SignupPage />} />
|
||||||
<Route path="/signup" element={<SignupPage />} />
|
</Route>
|
||||||
</Route>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
||||||
<Route path="/oauth/callback/:provider" element={<OAuthCallback />} />
|
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
</Routes>
|
||||||
</Routes>
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// On business subdomain, show login
|
// Not authenticated on subdomain - show login
|
||||||
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
@@ -232,6 +232,43 @@ const AppContent: React.FC = () => {
|
|||||||
return <ErrorScreen error={userError as Error} />;
|
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
|
// Handlers
|
||||||
const toggleTheme = () => setDarkMode((prev) => !prev);
|
const toggleTheme = () => setDarkMode((prev) => !prev);
|
||||||
const handleSignOut = () => {
|
const handleSignOut = () => {
|
||||||
@@ -242,22 +279,20 @@ const AppContent: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMasquerade = (targetUser: any) => {
|
const handleMasquerade = (targetUser: any) => {
|
||||||
// Call the masquerade API with the target user's username
|
// Call the masquerade API with the target user's id
|
||||||
// Fallback to email prefix if username is not available
|
const userId = targetUser.id;
|
||||||
const username = targetUser.username || targetUser.email?.split('@')[0];
|
if (!userId) {
|
||||||
if (!username) {
|
console.error('Cannot masquerade: no user id available', targetUser);
|
||||||
console.error('Cannot masquerade: no username or email available', targetUser);
|
|
||||||
return;
|
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
|
// Helper to check access based on roles
|
||||||
const hasAccess = (allowedRoles: string[]) => allowedRoles.includes(user.role);
|
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) {
|
if (isPlatformUser) {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
@@ -329,54 +364,16 @@ const AppContent: React.FC = () => {
|
|||||||
return <LoadingScreen />;
|
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
|
// Business error or no business found
|
||||||
if (businessError || !business) {
|
if (businessError || !business) {
|
||||||
// If user is a business owner on root domain, redirect to their business
|
// If user has a business subdomain, redirect them there
|
||||||
if (isRootOrPlatform && user.role === 'owner' && user.business_subdomain) {
|
if (user.business_subdomain) {
|
||||||
const port = window.location.port ? `:${window.location.port}` : '';
|
const port = window.location.port ? `:${window.location.port}` : '';
|
||||||
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
window.location.href = `http://${user.business_subdomain}.lvh.me${port}/`;
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If on root/platform and shouldn't be here, show appropriate message
|
// No business subdomain - show error
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
<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">
|
<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 (
|
export const masquerade = async (
|
||||||
username: string,
|
user_pk: number,
|
||||||
masquerade_stack?: MasqueradeStackEntry[]
|
hijack_history?: MasqueradeStackEntry[]
|
||||||
): Promise<LoginResponse> => {
|
): Promise<LoginResponse> => {
|
||||||
const response = await apiClient.post<LoginResponse>(
|
const response = await apiClient.post<LoginResponse>(
|
||||||
`/api/users/${username}/masquerade/`,
|
'/api/auth/hijack/acquire/',
|
||||||
{ masquerade_stack }
|
{ user_pk, hijack_history }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
@@ -106,7 +106,7 @@ export const stopMasquerade = async (
|
|||||||
masquerade_stack: MasqueradeStackEntry[]
|
masquerade_stack: MasqueradeStackEntry[]
|
||||||
): Promise<LoginResponse> => {
|
): Promise<LoginResponse> => {
|
||||||
const response = await apiClient.post<LoginResponse>(
|
const response = await apiClient.post<LoginResponse>(
|
||||||
'/api/users/stop_masquerade/',
|
'/api/auth/hijack/release/',
|
||||||
{ masquerade_stack }
|
{ masquerade_stack }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -5,14 +5,23 @@
|
|||||||
|
|
||||||
import apiClient from './client';
|
import apiClient from './client';
|
||||||
|
|
||||||
|
export interface PlatformBusinessOwner {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlatformBusiness {
|
export interface PlatformBusiness {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
tier: string;
|
tier: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_on: string;
|
||||||
user_count: number;
|
user_count: number;
|
||||||
|
owner: PlatformBusinessOwner | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformUser {
|
export interface PlatformUser {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
? location.pathname === path
|
? location.pathname === path
|
||||||
: location.pathname.startsWith(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 collapsedClasses = isCollapsed ? 'px-3 justify-center' : 'px-4';
|
||||||
const activeClasses = 'bg-white/10 text-white';
|
const activeClasses = 'bg-white/10 text-white';
|
||||||
const inactiveClasses = 'text-white/70 hover:text-white hover:bg-white/5';
|
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`}
|
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"}
|
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 }}>
|
{/* Logo-only mode: full width */}
|
||||||
{business.name.substring(0, 2).toUpperCase()}
|
{business.logoDisplayMode === 'logo-only' && business.logoUrl ? (
|
||||||
</div>
|
<div className="flex items-center justify-center w-full">
|
||||||
{!isCollapsed && (
|
<img
|
||||||
<div className="overflow-hidden">
|
src={business.logoUrl}
|
||||||
<h1 className="font-bold leading-tight truncate">{business.name}</h1>
|
alt={business.name}
|
||||||
<p className="text-xs text-white/60 truncate">{business.subdomain}.smoothschedule.com</p>
|
className="max-w-full max-h-16 object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
</button>
|
||||||
|
|
||||||
@@ -111,19 +137,22 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
|
|
||||||
{canViewAdminPages && (
|
{canViewAdminPages && (
|
||||||
<>
|
<>
|
||||||
{business.paymentsEnabled ? (
|
{/* Payments link: always visible for owners, only visible for others if enabled */}
|
||||||
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
|
{(role === 'owner' || business.paymentsEnabled) && (
|
||||||
<CreditCard size={20} className="shrink-0" />
|
business.paymentsEnabled ? (
|
||||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
<Link to="/payments" className={getNavClass('/payments')} title={t('nav.payments')}>
|
||||||
</Link>
|
<CreditCard size={20} className="shrink-0" />
|
||||||
) : (
|
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||||
<div
|
</Link>
|
||||||
className={getNavClass('/payments', false, true)}
|
) : (
|
||||||
title={t('nav.paymentsDisabledTooltip')}
|
<div
|
||||||
>
|
className={getNavClass('/payments', false, true)}
|
||||||
<CreditCard size={20} className="shrink-0" />
|
title={t('nav.paymentsDisabledTooltip')}
|
||||||
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
>
|
||||||
</div>
|
<CreditCard size={20} className="shrink-0" />
|
||||||
|
{!isCollapsed && <span>{t('nav.payments')}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
|
<Link to="/messages" className={getNavClass('/messages')} title={t('nav.messages')}>
|
||||||
<MessageSquare size={20} className="shrink-0" />
|
<MessageSquare size={20} className="shrink-0" />
|
||||||
@@ -149,7 +178,12 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t border-white/10">
|
<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" />
|
<SmoothScheduleLogo className="w-6 h-6 text-white" />
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div>
|
<div>
|
||||||
@@ -157,7 +191,7 @@ const Sidebar: React.FC<SidebarProps> = ({ business, user, isCollapsed, toggleCo
|
|||||||
<span className="font-semibold text-white/80">Smooth Schedule</span>
|
<span className="font-semibold text-white/80">Smooth Schedule</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</a>
|
||||||
<button
|
<button
|
||||||
onClick={handleSignOut}
|
onClick={handleSignOut}
|
||||||
disabled={logoutMutation.isPending}
|
disabled={logoutMutation.isPending}
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Menu, X, Sun, Moon } from 'lucide-react';
|
import { Menu, X, Sun, Moon } from 'lucide-react';
|
||||||
import SmoothScheduleLogo from '../SmoothScheduleLogo';
|
import SmoothScheduleLogo from '../SmoothScheduleLogo';
|
||||||
import LanguageSelector from '../LanguageSelector';
|
import LanguageSelector from '../LanguageSelector';
|
||||||
|
import { User } from '../../api/auth';
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
|
user?: User | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
|
const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme, user }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
@@ -38,6 +40,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
|
|||||||
|
|
||||||
const isActive = (path: string) => location.pathname === path;
|
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 (
|
return (
|
||||||
<nav
|
<nav
|
||||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
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>
|
</button>
|
||||||
|
|
||||||
{/* Login Button - Hidden on mobile */}
|
{/* Login Button - Hidden on mobile */}
|
||||||
<Link
|
{user ? (
|
||||||
to="/login"
|
<a
|
||||||
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"
|
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')}
|
>
|
||||||
</Link>
|
{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 */}
|
{/* Get Started CTA */}
|
||||||
<Link
|
<Link
|
||||||
@@ -139,12 +165,21 @@ const Navbar: React.FC<NavbarProps> = ({ darkMode, toggleTheme }) => {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<hr className="my-2 border-gray-200 dark:border-gray-800" />
|
<hr className="my-2 border-gray-200 dark:border-gray-800" />
|
||||||
<Link
|
{user ? (
|
||||||
to="/login"
|
<a
|
||||||
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"
|
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')}
|
>
|
||||||
</Link>
|
{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
|
<Link
|
||||||
to="/signup"
|
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"
|
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();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (username: string) => {
|
mutationFn: async (user_pk: number) => {
|
||||||
// Get current masquerading stack from localStorage
|
// Get current masquerading stack from localStorage
|
||||||
const stackJson = localStorage.getItem('masquerade_stack');
|
const stackJson = localStorage.getItem('masquerade_stack');
|
||||||
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
|
const currentStack: MasqueradeStackEntry[] = stackJson ? JSON.parse(stackJson) : [];
|
||||||
|
|
||||||
// Call masquerade API with current stack
|
// Call masquerade API with current stack
|
||||||
return masquerade(username, currentStack);
|
return masquerade(user_pk, currentStack);
|
||||||
},
|
},
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
// Store the updated masquerading stack
|
// Store the updated masquerading stack
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export const useCurrentBusiness = () => {
|
|||||||
primaryColor: data.primary_color || '#3B82F6', // Blue-500 default
|
primaryColor: data.primary_color || '#3B82F6', // Blue-500 default
|
||||||
secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default
|
secondaryColor: data.secondary_color || '#1E40AF', // Blue-800 default
|
||||||
logoUrl: data.logo_url,
|
logoUrl: data.logo_url,
|
||||||
|
emailLogoUrl: data.email_logo_url,
|
||||||
|
logoDisplayMode: data.logo_display_mode || 'text-only',
|
||||||
whitelabelEnabled: data.whitelabel_enabled,
|
whitelabelEnabled: data.whitelabel_enabled,
|
||||||
plan: data.tier, // Map tier to plan
|
plan: data.tier, // Map tier to plan
|
||||||
status: data.status,
|
status: data.status,
|
||||||
@@ -64,6 +66,8 @@ export const useUpdateBusiness = () => {
|
|||||||
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
|
if (updates.primaryColor) backendData.primary_color = updates.primaryColor;
|
||||||
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
|
if (updates.secondaryColor) backendData.secondary_color = updates.secondaryColor;
|
||||||
if (updates.logoUrl !== undefined) backendData.logo_url = updates.logoUrl;
|
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) {
|
if (updates.whitelabelEnabled !== undefined) {
|
||||||
backendData.whitelabel_enabled = updates.whitelabelEnabled;
|
backendData.whitelabel_enabled = updates.whitelabelEnabled;
|
||||||
}
|
}
|
||||||
@@ -136,7 +140,7 @@ export const useBusinessUsers = () => {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['businessUsers'],
|
queryKey: ['businessUsers'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await apiClient.get('/api/business/users/');
|
const { data } = await apiClient.get('/api/staff/');
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
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,
|
name: r.name,
|
||||||
type: r.type as ResourceType,
|
type: r.type as ResourceType,
|
||||||
userId: r.user_id ? String(r.user_id) : undefined,
|
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,
|
name: data.name,
|
||||||
type: data.type as ResourceType,
|
type: data.type as ResourceType,
|
||||||
userId: data.user_id ? String(data.user_id) : undefined,
|
userId: data.user_id ? String(data.user_id) : undefined,
|
||||||
|
maxConcurrentEvents: data.max_concurrent_events ?? 1,
|
||||||
|
savedLaneCount: data.saved_lane_count,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
@@ -91,6 +95,12 @@ export const useUpdateResource = () => {
|
|||||||
if (updates.userId !== undefined) {
|
if (updates.userId !== undefined) {
|
||||||
backendData.user = updates.userId ? parseInt(updates.userId) : null;
|
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);
|
const { data } = await apiClient.patch(`/api/resources/${id}/`, backendData);
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export const useServices = () => {
|
|||||||
durationMinutes: s.duration || s.duration_minutes,
|
durationMinutes: s.duration || s.duration_minutes,
|
||||||
price: parseFloat(s.price),
|
price: parseFloat(s.price),
|
||||||
description: s.description || '',
|
description: s.description || '',
|
||||||
|
displayOrder: s.display_order ?? 0,
|
||||||
|
photos: s.photos || [],
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
retry: false, // Don't retry on 404 - endpoint may not exist yet
|
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,
|
durationMinutes: data.duration || data.duration_minutes,
|
||||||
price: parseFloat(data.price),
|
price: parseFloat(data.price),
|
||||||
description: data.description || '',
|
description: data.description || '',
|
||||||
|
displayOrder: data.display_order ?? 0,
|
||||||
|
photos: data.photos || [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
@@ -63,6 +67,7 @@ export const useCreateService = () => {
|
|||||||
duration: serviceData.durationMinutes,
|
duration: serviceData.durationMinutes,
|
||||||
price: serviceData.price.toString(),
|
price: serviceData.price.toString(),
|
||||||
description: serviceData.description,
|
description: serviceData.description,
|
||||||
|
photos: serviceData.photos || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data } = await apiClient.post('/api/services/', backendData);
|
const { data } = await apiClient.post('/api/services/', backendData);
|
||||||
@@ -87,6 +92,7 @@ export const useUpdateService = () => {
|
|||||||
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
|
if (updates.durationMinutes) backendData.duration = updates.durationMinutes;
|
||||||
if (updates.price) backendData.price = updates.price.toString();
|
if (updates.price) backendData.price = updates.price.toString();
|
||||||
if (updates.description !== undefined) backendData.description = updates.description;
|
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);
|
const { data } = await apiClient.patch(`/api/services/${id}/`, backendData);
|
||||||
return data;
|
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 Navbar from '../components/marketing/Navbar';
|
||||||
import Footer from '../components/marketing/Footer';
|
import Footer from '../components/marketing/Footer';
|
||||||
import { useScrollToTop } from '../hooks/useScrollToTop';
|
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();
|
useScrollToTop();
|
||||||
|
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
@@ -28,7 +33,7 @@ const MarketingLayout: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col bg-white dark:bg-gray-900 transition-colors duration-200">
|
<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 Content - with padding for fixed navbar */}
|
||||||
<main className="flex-1 pt-16 lg:pt-20">
|
<main className="flex-1 pt-16 lg:pt-20">
|
||||||
|
|||||||
@@ -99,7 +99,8 @@ const Customers: React.FC<CustomersProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
return sorted;
|
return sorted;
|
||||||
}, [customers, searchTerm, sortConfig]);
|
}, [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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -33,40 +33,68 @@ const LoginPage: React.FC = () => {
|
|||||||
const user = data.user;
|
const user = data.user;
|
||||||
const currentHostname = window.location.hostname;
|
const currentHostname = window.location.hostname;
|
||||||
const currentPort = window.location.port;
|
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 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
|
// Platform users (superuser, platform_manager, platform_support)
|
||||||
const rootAllowedRoles = ['superuser', 'platform_manager', 'platform_support', 'owner'];
|
const isPlatformUser = ['superuser', 'platform_manager', 'platform_support'].includes(user.role);
|
||||||
|
|
||||||
// If on root domain, only allow specific roles
|
// Business-associated users (owner, manager, staff, resource)
|
||||||
if (isRootDomain && !rootAllowedRoles.includes(user.role)) {
|
const isBusinessUser = ['owner', 'manager', 'staff', 'resource'].includes(user.role);
|
||||||
setError(t('auth.loginAtSubdomain'));
|
|
||||||
|
// Customer users
|
||||||
|
const isCustomer = user.role === 'customer';
|
||||||
|
|
||||||
|
// RULE 1: Platform users cannot login on business subdomains
|
||||||
|
if (isPlatformUser && isBusinessSubdomain) {
|
||||||
|
setError(t('auth.invalidCredentials'));
|
||||||
return;
|
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;
|
let targetSubdomain: string | null = null;
|
||||||
|
|
||||||
// Platform users (superuser, platform_manager, platform_support)
|
if (isPlatformUser) {
|
||||||
if (['superuser', 'platform_manager', 'platform_support'].includes(user.role)) {
|
|
||||||
targetSubdomain = 'platform';
|
targetSubdomain = 'platform';
|
||||||
}
|
} else if (user.business_subdomain) {
|
||||||
// Business users - redirect to their business subdomain
|
|
||||||
else if (user.business_subdomain) {
|
|
||||||
targetSubdomain = user.business_subdomain;
|
targetSubdomain = user.business_subdomain;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we need to redirect to a different 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 isOnTargetSubdomain = currentHostname === `${targetSubdomain}.lvh.me`;
|
||||||
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
|
const needsRedirect = targetSubdomain && !isOnTargetSubdomain;
|
||||||
|
|
||||||
if (needsRedirect) {
|
if (needsRedirect) {
|
||||||
// Pass tokens in URL to ensure they're available immediately on the new subdomain
|
// 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}`;
|
window.location.href = `http://${targetSubdomain}.lvh.me${portStr}/?access_token=${data.access}&refresh_token=${data.refresh}`;
|
||||||
return;
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { ResourceType, User, Resource } from '../types';
|
import { ResourceType, User, Resource } from '../types';
|
||||||
import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources';
|
import { useResources, useCreateResource, useUpdateResource } from '../hooks/useResources';
|
||||||
@@ -15,7 +15,8 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
Calendar,
|
Calendar,
|
||||||
Settings,
|
Settings,
|
||||||
X
|
X,
|
||||||
|
Pencil
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
|
const ResourceIcon: React.FC<{ type: ResourceType }> = ({ type }) => {
|
||||||
@@ -55,17 +56,29 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
// Staff selection state
|
// Staff selection state
|
||||||
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
|
const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);
|
||||||
const [staffSearchQuery, setStaffSearchQuery] = useState('');
|
const [staffSearchQuery, setStaffSearchQuery] = useState('');
|
||||||
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
|
||||||
const [showStaffDropdown, setShowStaffDropdown] = useState(false);
|
const [showStaffDropdown, setShowStaffDropdown] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||||
const staffInputRef = useRef<HTMLInputElement>(null);
|
const staffInputRef = useRef<HTMLInputElement>(null);
|
||||||
const staffDropdownRef = useRef<HTMLDivElement>(null);
|
const staffDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Fetch staff members for autocomplete
|
// Debounce search query for API calls
|
||||||
const { data: staffMembers = [] } = useStaff({ search: staffSearchQuery });
|
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)
|
// Filter staff members based on search query (client-side filtering for immediate feedback)
|
||||||
const filteredStaff = useMemo(() => {
|
const filteredStaff = useMemo(() => {
|
||||||
if (!staffSearchQuery) return staffMembers;
|
// Always show all staff when dropdown is open and no search query
|
||||||
const query = staffSearchQuery.toLowerCase();
|
if (!staffSearchQuery.trim()) return staffMembers;
|
||||||
|
|
||||||
|
const query = staffSearchQuery.toLowerCase().trim();
|
||||||
return staffMembers.filter(
|
return staffMembers.filter(
|
||||||
(s) => s.name.toLowerCase().includes(query) || s.email.toLowerCase().includes(query)
|
(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;
|
return staffMembers.find((s) => s.id === selectedStaffId) || null;
|
||||||
}, [staffMembers, selectedStaffId]);
|
}, [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 createResourceMutation = useCreateResource();
|
||||||
const updateResourceMutation = useUpdateResource();
|
const updateResourceMutation = useUpdateResource();
|
||||||
|
|
||||||
@@ -97,6 +163,8 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
}, [allAppointments]);
|
}, [allAppointments]);
|
||||||
|
|
||||||
// Reset form when modal opens/closes or editing resource changes
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (editingResource) {
|
if (editingResource) {
|
||||||
setFormType(editingResource.type);
|
setFormType(editingResource.type);
|
||||||
@@ -108,28 +176,40 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
// Pre-fill staff if editing a STAFF resource
|
// Pre-fill staff if editing a STAFF resource
|
||||||
if (editingResource.type === 'STAFF' && editingResource.userId) {
|
if (editingResource.type === 'STAFF' && editingResource.userId) {
|
||||||
setSelectedStaffId(editingResource.userId);
|
setSelectedStaffId(editingResource.userId);
|
||||||
// Find the staff member to set the initial search query (display name)
|
// We'll set the staff name in a separate effect
|
||||||
const staff = staffMembers.find(s => s.id === editingResource.userId);
|
|
||||||
setStaffSearchQuery(staff ? staff.name : '');
|
|
||||||
} else {
|
} else {
|
||||||
setSelectedStaffId(null);
|
setSelectedStaffId(null);
|
||||||
setStaffSearchQuery('');
|
setStaffSearchQuery('');
|
||||||
}
|
}
|
||||||
} else {
|
} else if (isModalOpen) {
|
||||||
|
// Only reset when creating new (modal opened without editing resource)
|
||||||
setFormType('STAFF');
|
setFormType('STAFF');
|
||||||
setFormName('');
|
setFormName('');
|
||||||
setFormDescription('');
|
setFormDescription('');
|
||||||
setFormMaxConcurrent(1);
|
setFormMaxConcurrent(1);
|
||||||
setFormMultilaneEnabled(false);
|
setFormMultilaneEnabled(false);
|
||||||
setFormSavedLaneCount(undefined);
|
setFormSavedLaneCount(undefined);
|
||||||
setSelectedStaffId(null); // Clear selected staff when creating new
|
setSelectedStaffId(null);
|
||||||
setStaffSearchQuery('');
|
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 = () => {
|
const openCreateModal = () => {
|
||||||
setEditingResource(null);
|
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
|
setEditingResource(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditModal = (resource: Resource) => {
|
const openEditModal = (resource: Resource) => {
|
||||||
@@ -138,8 +218,8 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setIsModalOpen(false);
|
|
||||||
setEditingResource(null);
|
setEditingResource(null);
|
||||||
|
setIsModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMultilaneToggle = (enabled: boolean) => {
|
const handleMultilaneToggle = (enabled: boolean) => {
|
||||||
@@ -251,8 +331,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={resource.id}
|
key={resource.id}
|
||||||
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group cursor-pointer"
|
className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group"
|
||||||
onClick={() => openEditModal(resource)}
|
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -297,7 +376,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-right">
|
<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
|
<button
|
||||||
onClick={() => setCalendarResource({ id: resource.id, name: resource.name })}
|
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"
|
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')}
|
<Eye size={14} /> {t('resources.viewCalendar')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -324,7 +410,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
<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">
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{editingResource ? t('resources.editResource') : t('resources.addNewResource')}
|
{editingResource ? t('resources.editResource') : t('resources.addNewResource')}
|
||||||
@@ -338,7 +424,7 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
{/* Resource Type */}
|
{/* Resource Type */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{t('resources.resourceType')}
|
{t('resources.resourceType')} <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formType}
|
value={formType}
|
||||||
@@ -346,9 +432,12 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
setFormType(e.target.value as ResourceType);
|
setFormType(e.target.value as ResourceType);
|
||||||
setSelectedStaffId(null); // Clear staff selection if type changes
|
setSelectedStaffId(null); // Clear staff selection if type changes
|
||||||
setStaffSearchQuery('');
|
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"
|
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"
|
||||||
disabled={!!editingResource}
|
required
|
||||||
>
|
>
|
||||||
<option value="STAFF">{t('resources.staffMember')}</option>
|
<option value="STAFF">{t('resources.staffMember')}</option>
|
||||||
<option value="ROOM">{t('resources.room')}</option>
|
<option value="ROOM">{t('resources.room')}</option>
|
||||||
@@ -370,47 +459,80 @@ const Resources: React.FC<ResourcesProps> = ({ onMasquerade, effectiveUser }) =>
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setStaffSearchQuery(e.target.value);
|
setStaffSearchQuery(e.target.value);
|
||||||
setShowStaffDropdown(true);
|
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"
|
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')}
|
placeholder={t('resources.searchStaffPlaceholder')}
|
||||||
required={formType === 'STAFF'}
|
required={formType === 'STAFF'}
|
||||||
|
autoComplete="off"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
aria-controls="staff-suggestions"
|
aria-controls="staff-suggestions"
|
||||||
|
aria-expanded={showStaffDropdown}
|
||||||
|
aria-activedescendant={highlightedIndex >= 0 ? `staff-option-${highlightedIndex}` : undefined}
|
||||||
/>
|
/>
|
||||||
{showStaffDropdown && filteredStaff.length > 0 && (
|
{showStaffDropdown && displayedStaff.length > 0 && (
|
||||||
<div
|
<div
|
||||||
ref={staffDropdownRef}
|
ref={staffDropdownRef}
|
||||||
id="staff-suggestions"
|
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"
|
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"
|
role="listbox"
|
||||||
>
|
>
|
||||||
{filteredStaff.map((staff) => (
|
{displayedStaff.map((staff, index) => (
|
||||||
<div
|
<div
|
||||||
key={staff.id}
|
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={() => {
|
onClick={() => {
|
||||||
setSelectedStaffId(staff.id);
|
setSelectedStaffId(staff.id);
|
||||||
setStaffSearchQuery(staff.name);
|
setStaffSearchQuery(staff.name);
|
||||||
setShowStaffDropdown(false);
|
setShowStaffDropdown(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(index)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Prevent input blur on mousedown
|
||||||
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={selectedStaffId === staff.id}
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{formType === 'STAFF' && !selectedStaffId && staffSearchQuery && filteredStaff.length === 0 && (
|
{formType === 'STAFF' && !selectedStaffId && staffSearchQuery.trim() !== '' && filteredStaff.length === 0 && (
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||||
{t('resources.noMatchingStaff')}
|
{t('resources.noMatchingStaff')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{formType === 'STAFF' && !selectedStaffId && !staffSearchQuery && (
|
{selectedStaffId && selectedStaff && (
|
||||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
<p className="mt-1 text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
{t('resources.staffRequired')}
|
<span className="inline-block w-2 h-2 bg-green-500 rounded-full"></span>
|
||||||
|
Selected: {selectedStaff.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2 } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image } from 'lucide-react';
|
||||||
import { useServices, useCreateService, useUpdateService, useDeleteService } from '../hooks/useServices';
|
import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices';
|
||||||
import { Service } from '../types';
|
import { Service } from '../types';
|
||||||
|
|
||||||
interface ServiceFormData {
|
interface ServiceFormData {
|
||||||
@@ -9,6 +9,7 @@ interface ServiceFormData {
|
|||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
price: number;
|
price: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
photos: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Services: React.FC = () => {
|
const Services: React.FC = () => {
|
||||||
@@ -17,6 +18,7 @@ const Services: React.FC = () => {
|
|||||||
const createService = useCreateService();
|
const createService = useCreateService();
|
||||||
const updateService = useUpdateService();
|
const updateService = useUpdateService();
|
||||||
const deleteService = useDeleteService();
|
const deleteService = useDeleteService();
|
||||||
|
const reorderServices = useReorderServices();
|
||||||
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingService, setEditingService] = useState<Service | null>(null);
|
const [editingService, setEditingService] = useState<Service | null>(null);
|
||||||
@@ -25,8 +27,165 @@ const Services: React.FC = () => {
|
|||||||
durationMinutes: 60,
|
durationMinutes: 60,
|
||||||
price: 0,
|
price: 0,
|
||||||
description: '',
|
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 = () => {
|
const openCreateModal = () => {
|
||||||
setEditingService(null);
|
setEditingService(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -34,6 +193,7 @@ const Services: React.FC = () => {
|
|||||||
durationMinutes: 60,
|
durationMinutes: 60,
|
||||||
price: 0,
|
price: 0,
|
||||||
description: '',
|
description: '',
|
||||||
|
photos: [],
|
||||||
});
|
});
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -45,6 +205,7 @@ const Services: React.FC = () => {
|
|||||||
durationMinutes: service.durationMinutes,
|
durationMinutes: service.durationMinutes,
|
||||||
price: service.price,
|
price: service.price,
|
||||||
description: service.description || '',
|
description: service.description || '',
|
||||||
|
photos: service.photos || [],
|
||||||
});
|
});
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
@@ -122,7 +283,7 @@ const Services: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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-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">
|
<div className="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
{t('services.noServices', 'No services yet. Add your first service to get started.')}
|
{t('services.noServices', 'No services yet. Add your first service to get started.')}
|
||||||
@@ -136,60 +297,149 @@ const Services: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
{services?.map((service) => (
|
{/* Left Column - Editable Services List */}
|
||||||
<div
|
<div>
|
||||||
key={service.id}
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm"
|
{t('services.dragToReorder', 'Drag services to reorder how they appear in menus')}
|
||||||
>
|
</p>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="space-y-3">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
{displayServices?.map((service) => (
|
||||||
{service.name}
|
<div
|
||||||
</h3>
|
key={service.id}
|
||||||
<div className="flex items-center gap-2">
|
draggable
|
||||||
<button
|
onDragStart={(e) => handleDragStart(e, service.id)}
|
||||||
onClick={() => openEditModal(service)}
|
onDragEnd={handleDragEnd}
|
||||||
className="p-2 text-gray-400 hover:text-brand-600 dark:hover:text-brand-400 transition-colors"
|
onDragOver={(e) => handleDragOver(e, service.id)}
|
||||||
title={t('common.edit', 'Edit')}
|
onDragLeave={handleDragLeave}
|
||||||
>
|
className={`p-4 bg-white dark:bg-gray-800 border rounded-xl shadow-sm cursor-move transition-all ${
|
||||||
<Pencil className="h-4 w-4" />
|
draggedId === service.id
|
||||||
</button>
|
? 'opacity-50 border-brand-500'
|
||||||
<button
|
: dragOverId === service.id
|
||||||
onClick={() => handleDelete(service.id)}
|
? 'border-brand-500 ring-2 ring-brand-500/50'
|
||||||
className="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
: 'border-gray-100 dark:border-gray-700'
|
||||||
title={t('common.delete', 'Delete')}
|
}`}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<div className="flex items-center gap-3">
|
||||||
</button>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
{service.description && (
|
{/* Right Column - Customer Preview Mockup */}
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
<div>
|
||||||
{service.description}
|
<div className="flex items-center gap-2 mb-4">
|
||||||
</p>
|
<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">
|
{/* Mockup Container - styled like a booking widget */}
|
||||||
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
<div className="sticky top-8">
|
||||||
<Clock className="h-4 w-4" />
|
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<span>{service.durationMinutes} {t('common.minutes', 'min')}</span>
|
{/* 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>
|
||||||
<div className="flex items-center gap-1 text-gray-600 dark:text-gray-300">
|
|
||||||
<DollarSign className="h-4 w-4" />
|
{/* Services List */}
|
||||||
<span>${service.price.toFixed(2)}</span>
|
<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>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-md w-full mx-4">
|
<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">
|
<div className="flex items-center justify-between p-6 border-b border-gray-100 dark:border-gray-700 shrink-0">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{editingService
|
{editingService
|
||||||
? t('services.editService', 'Edit Service')
|
? t('services.editService', 'Edit Service')
|
||||||
@@ -203,66 +453,157 @@ const Services: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
||||||
<div>
|
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||||
<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">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{t('services.duration', 'Duration (min)')} *
|
{t('services.name', 'Name')} *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="text"
|
||||||
value={formData.durationMinutes}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, durationMinutes: parseInt(e.target.value) || 0 })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
required
|
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"
|
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>
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
{t('services.price', 'Price ($)')} *
|
{t('services.descriptionLabel', 'Description')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<textarea
|
||||||
type="number"
|
value={formData.description}
|
||||||
value={formData.price}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
|
rows={3}
|
||||||
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 resize-none"
|
||||||
min={0}
|
placeholder={t('services.descriptionPlaceholder', 'Optional description of the service...')}
|
||||||
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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
<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">
|
||||||
<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">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={closeModal}
|
onClick={closeModal}
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { Business, User, CustomDomain } from '../types';
|
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 DomainPurchase from '../components/DomainPurchase';
|
||||||
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
|
import { useBusinessOAuthSettings, useUpdateBusinessOAuthSettings } from '../hooks/useBusinessOAuth';
|
||||||
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
|
import { useCustomDomains, useAddCustomDomain, useDeleteCustomDomain, useVerifyCustomDomain, useSetPrimaryDomain } from '../hooks/useCustomDomains';
|
||||||
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
|
import { useBusinessOAuthCredentials, useUpdateBusinessOAuthCredentials } from '../hooks/useBusinessOAuthCredentials';
|
||||||
|
import { useResourceTypes, useCreateResourceType, useUpdateResourceType, useDeleteResourceType } from '../hooks/useResourceTypes';
|
||||||
import OnboardingWizard from '../components/OnboardingWizard';
|
import OnboardingWizard from '../components/OnboardingWizard';
|
||||||
|
|
||||||
// Curated color palettes with complementary primary and secondary colors
|
// Curated color palettes with complementary primary and secondary colors
|
||||||
@@ -18,6 +19,55 @@ const colorPalettes = [
|
|||||||
secondary: '#0ea5e9',
|
secondary: '#0ea5e9',
|
||||||
preview: 'bg-gradient-to-br from-blue-600 to-sky-500',
|
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',
|
name: 'Forest Green',
|
||||||
description: 'Natural & calming',
|
description: 'Natural & calming',
|
||||||
@@ -32,20 +82,6 @@ const colorPalettes = [
|
|||||||
secondary: '#a78bfa',
|
secondary: '#a78bfa',
|
||||||
preview: 'bg-gradient-to-br from-violet-600 to-purple-400',
|
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',
|
name: 'Slate Gray',
|
||||||
description: 'Minimal & sophisticated',
|
description: 'Minimal & sophisticated',
|
||||||
@@ -53,13 +89,6 @@ const colorPalettes = [
|
|||||||
secondary: '#64748b',
|
secondary: '#64748b',
|
||||||
preview: 'bg-gradient-to-br from-slate-600 to-slate-400',
|
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',
|
name: 'Crimson Red',
|
||||||
description: 'Bold & dynamic',
|
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 SettingsPage: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -115,6 +393,16 @@ const SettingsPage: React.FC = () => {
|
|||||||
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
|
const [showSecrets, setShowSecrets] = useState<{ [key: string]: boolean }>({});
|
||||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
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
|
// Update OAuth settings when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (oauthData?.businessSettings) {
|
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 = () => {
|
const handleSave = () => {
|
||||||
updateBusiness(formState);
|
updateBusiness(formState);
|
||||||
setShowToast(true);
|
setShowToast(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setFormState(business);
|
||||||
|
};
|
||||||
|
|
||||||
const handleOAuthSave = () => {
|
const handleOAuthSave = () => {
|
||||||
updateOAuthMutation.mutate(oauthSettings, {
|
updateOAuthMutation.mutate(oauthSettings, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -289,25 +643,17 @@ const SettingsPage: React.FC = () => {
|
|||||||
// Tab configuration
|
// Tab configuration
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'general' as const, label: 'General', icon: Building2 },
|
{ 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: 'domains' as const, label: 'Domains', icon: Globe },
|
||||||
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
{ id: 'authentication' as const, label: 'Authentication', icon: Lock },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl mx-auto">
|
<div className="p-8 max-w-4xl mx-auto pb-24">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6">
|
||||||
<div>
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{t('settings.businessSettings')}</h2>
|
||||||
<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>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
@@ -367,6 +713,270 @@ const SettingsPage: React.FC = () => {
|
|||||||
<Palette size={20} className="text-purple-500"/> {t('settings.branding')}
|
<Palette size={20} className="text-purple-500"/> {t('settings.branding')}
|
||||||
</h3>
|
</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 */}
|
{/* Color Palette Selection */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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 */}
|
{/* DOMAINS TAB */}
|
||||||
{activeTab === 'domains' && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ const Staff: React.FC<StaffProps> = ({ onMasquerade, effectiveUser }) => {
|
|||||||
{staffUsers.map((user: any) => {
|
{staffUsers.map((user: any) => {
|
||||||
const linkedResource = getLinkedResource(user.id);
|
const linkedResource = getLinkedResource(user.id);
|
||||||
|
|
||||||
// Owners/Managers can log in as anyone.
|
// Only owners can masquerade as staff (per backend permissions)
|
||||||
const canMasquerade = ['owner', 'manager'].includes(effectiveUser.role) && user.id !== effectiveUser.id;
|
const canMasquerade = effectiveUser.role === 'owner' && user.id !== effectiveUser.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr key={user.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors group">
|
<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 React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Search, Filter, MoreHorizontal, Eye, ShieldCheck, Ban } from 'lucide-react';
|
import { Search, Filter, MoreHorizontal, Eye, ShieldCheck, Ban } from 'lucide-react';
|
||||||
import { User } from '../../types';
|
|
||||||
import { useBusinesses } from '../../hooks/usePlatform';
|
import { useBusinesses } from '../../hooks/usePlatform';
|
||||||
|
|
||||||
interface PlatformBusinessesProps {
|
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 }) => {
|
const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade }) => {
|
||||||
@@ -22,19 +21,14 @@ const PlatformBusinesses: React.FC<PlatformBusinessesProps> = ({ onMasquerade })
|
|||||||
const handleLoginAs = (business: any) => {
|
const handleLoginAs = (business: any) => {
|
||||||
// Use the owner data from the API response
|
// Use the owner data from the API response
|
||||||
if (business.owner) {
|
if (business.owner) {
|
||||||
const targetOwner: User = {
|
// Pass owner info to masquerade - we only need the id
|
||||||
id: business.owner.id.toString(),
|
onMasquerade({
|
||||||
|
id: business.owner.id,
|
||||||
username: business.owner.username,
|
username: business.owner.username,
|
||||||
name: business.owner.name,
|
name: business.owner.full_name,
|
||||||
email: business.owner.email,
|
email: business.owner.email,
|
||||||
role: business.owner.role,
|
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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-gray-500 dark:text-gray-400">
|
<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>
|
||||||
<td className="px-6 py-4 text-right">
|
<td className="px-6 py-4 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLoginAs(biz)}
|
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"
|
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}
|
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')}
|
<Eye size={14} /> {t('platform.masquerade')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Search, Filter, Eye, Shield, User as UserIcon } from 'lucide-react';
|
import { Search, Filter, Eye, Shield, User as UserIcon } from 'lucide-react';
|
||||||
import { User } from '../../types';
|
|
||||||
import { usePlatformUsers } from '../../hooks/usePlatform';
|
import { usePlatformUsers } from '../../hooks/usePlatform';
|
||||||
|
|
||||||
interface PlatformUsersProps {
|
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 }) => {
|
const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
||||||
@@ -36,20 +35,14 @@ const PlatformUsers: React.FC<PlatformUsersProps> = ({ onMasquerade }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMasquerade = (platformUser: any) => {
|
const handleMasquerade = (platformUser: any) => {
|
||||||
// Convert platform user to User type for masquerade
|
// Pass user info to masquerade - we only need the id
|
||||||
const targetUser: User = {
|
onMasquerade({
|
||||||
id: platformUser.id.toString(),
|
id: platformUser.id,
|
||||||
username: platformUser.username,
|
username: platformUser.username,
|
||||||
name: platformUser.name || platformUser.username,
|
name: platformUser.full_name || platformUser.username,
|
||||||
email: platformUser.email,
|
email: platformUser.email,
|
||||||
role: platformUser.role || 'customer',
|
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) {
|
if (isLoading) {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface Business {
|
|||||||
primaryColor: string;
|
primaryColor: string;
|
||||||
secondaryColor: string;
|
secondaryColor: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
emailLogoUrl?: string;
|
||||||
|
logoDisplayMode?: 'logo-only' | 'text-only' | 'logo-and-text'; // How to display branding
|
||||||
whitelabelEnabled: boolean;
|
whitelabelEnabled: boolean;
|
||||||
plan?: 'Free' | 'Professional' | 'Business' | 'Enterprise';
|
plan?: 'Free' | 'Professional' | 'Business' | 'Enterprise';
|
||||||
status?: 'Active' | 'Suspended' | 'Trial';
|
status?: 'Active' | 'Suspended' | 'Trial';
|
||||||
@@ -58,6 +60,7 @@ export interface Business {
|
|||||||
isTrialActive?: boolean;
|
isTrialActive?: boolean;
|
||||||
isTrialExpired?: boolean;
|
isTrialExpired?: boolean;
|
||||||
daysLeftInTrial?: number;
|
daysLeftInTrial?: number;
|
||||||
|
resourceTypes?: ResourceTypeDefinition[]; // Custom resource types
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRole = 'superuser' | 'platform_manager' | 'platform_support' | 'owner' | 'manager' | 'staff' | 'resource' | 'customer';
|
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 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 {
|
export interface Resource {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: ResourceType;
|
type: ResourceType; // Legacy field - will be deprecated
|
||||||
|
typeId?: string; // New field - references ResourceTypeDefinition
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
maxConcurrentEvents: number;
|
||||||
|
savedLaneCount?: number; // Remembered lane count when multilane is disabled
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
|
export type AppointmentStatus = 'PENDING' | 'CONFIRMED' | 'COMPLETED' | 'CANCELLED' | 'NO_SHOW';
|
||||||
@@ -147,6 +164,8 @@ export interface Service {
|
|||||||
durationMinutes: number;
|
durationMinutes: number;
|
||||||
price: number;
|
price: number;
|
||||||
description: string;
|
description: string;
|
||||||
|
displayOrder: number;
|
||||||
|
photos?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Metric {
|
export interface Metric {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"status": "failed",
|
"status": "failed",
|
||||||
"failedTests": [
|
"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 drf_spectacular.views import SpectacularSwaggerView
|
||||||
from rest_framework.authtoken.views import obtain_auth_token
|
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 smoothschedule.users.api_views import (
|
||||||
from schedule.api_views import current_business_view
|
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 = [
|
urlpatterns = [
|
||||||
# Django Admin, use {% url 'admin:index' %}
|
# Django Admin, use {% url 'admin:index' %}
|
||||||
@@ -19,6 +22,8 @@ urlpatterns = [
|
|||||||
# User management
|
# User management
|
||||||
path("users/", include("smoothschedule.users.urls", namespace="users")),
|
path("users/", include("smoothschedule.users.urls", namespace="users")),
|
||||||
path("accounts/", include("allauth.urls")),
|
path("accounts/", include("allauth.urls")),
|
||||||
|
# Django Hijack (masquerade) - for admin interface
|
||||||
|
path("hijack/", include("hijack.urls")),
|
||||||
# Your stuff: custom urls includes go here
|
# Your stuff: custom urls includes go here
|
||||||
# ...
|
# ...
|
||||||
# Media files
|
# Media files
|
||||||
@@ -39,8 +44,12 @@ urlpatterns += [
|
|||||||
path("api/auth/logout/", logout_view, name="logout"),
|
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/send/", send_verification_email, name="send_verification_email"),
|
||||||
path("api/auth/email/verify/", verify_email, name="verify_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
|
# Business API
|
||||||
path("api/business/current/", current_business_view, name="current_business"),
|
path("api/business/current/", current_business_view, name="current_business"),
|
||||||
|
path("api/business/current/update/", update_business_view, name="update_business"),
|
||||||
# API Docs
|
# API Docs
|
||||||
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
|
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
|
||||||
path(
|
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)
|
name = models.CharField(max_length=100)
|
||||||
created_on = models.DateField(auto_now_add=True)
|
created_on = models.DateField(auto_now_add=True)
|
||||||
|
|
||||||
# Subscription & billing
|
# Subscription & billing
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
subscription_tier = models.CharField(
|
subscription_tier = models.CharField(
|
||||||
@@ -28,11 +28,45 @@ class Tenant(TenantMixin):
|
|||||||
],
|
],
|
||||||
default='FREE'
|
default='FREE'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Feature flags
|
# Feature flags
|
||||||
max_users = models.IntegerField(default=5)
|
max_users = models.IntegerField(default=5)
|
||||||
max_resources = models.IntegerField(default=10)
|
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
|
# Metadata
|
||||||
contact_email = models.EmailField(blank=True)
|
contact_email = models.EmailField(blank=True)
|
||||||
phone = models.CharField(max_length=20, 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
|
- Always validate tenant boundaries for tenant-scoped roles
|
||||||
- Log all hijack attempts (success and failure) for audit
|
- 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
|
# Safety check: can't hijack yourself
|
||||||
if hijacker.id == hijacked.id:
|
if hijacker.id == hijacked.id:
|
||||||
@@ -60,16 +60,17 @@ def can_hijack(hijacker, hijacked):
|
|||||||
if hijacker.role == User.Role.PLATFORM_SALES:
|
if hijacker.role == User.Role.PLATFORM_SALES:
|
||||||
return hijacked.is_temporary
|
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:
|
if hijacker.role == User.Role.TENANT_OWNER:
|
||||||
# Must be in same tenant
|
# Must be in same tenant
|
||||||
if not hijacker.tenant or not hijacked.tenant:
|
if not hijacker.tenant or not hijacked.tenant:
|
||||||
return False
|
return False
|
||||||
if hijacker.tenant.id != hijacked.tenant.id:
|
if hijacker.tenant.id != hijacked.tenant.id:
|
||||||
return False
|
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 [
|
return hijacked.role in [
|
||||||
|
User.Role.TENANT_MANAGER,
|
||||||
User.Role.TENANT_STAFF,
|
User.Role.TENANT_STAFF,
|
||||||
User.Role.CUSTOMER,
|
User.Role.CUSTOMER,
|
||||||
]
|
]
|
||||||
@@ -112,7 +113,7 @@ def get_hijackable_users(hijacker):
|
|||||||
Returns:
|
Returns:
|
||||||
QuerySet: Users that can be hijacked by this user
|
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
|
# Start with all users except self
|
||||||
qs = User.objects.exclude(id=hijacker.id)
|
qs = User.objects.exclude(id=hijacker.id)
|
||||||
@@ -136,13 +137,13 @@ def get_hijackable_users(hijacker):
|
|||||||
return qs.filter(is_temporary=True)
|
return qs.filter(is_temporary=True)
|
||||||
|
|
||||||
elif hijacker.role == User.Role.TENANT_OWNER:
|
elif hijacker.role == User.Role.TENANT_OWNER:
|
||||||
# Only staff in same tenant
|
# Managers, staff, and customers in same tenant
|
||||||
if not hijacker.tenant:
|
if not hijacker.tenant:
|
||||||
return qs.none()
|
return qs.none()
|
||||||
|
|
||||||
return qs.filter(
|
return qs.filter(
|
||||||
tenant=hijacker.tenant,
|
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:
|
else:
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
API views for business/tenant management
|
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 import status
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
@@ -39,10 +42,119 @@ def current_business_view(request):
|
|||||||
'tier': tenant.subscription_tier,
|
'tier': tenant.subscription_tier,
|
||||||
'status': 'active' if tenant.is_active else 'inactive',
|
'status': 'active' if tenant.is_active else 'inactive',
|
||||||
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
|
'created_at': tenant.created_on.isoformat() if tenant.created_on else None,
|
||||||
# Optional fields with defaults
|
# Branding fields from Tenant model
|
||||||
'primary_color': '#3B82F6', # Blue-500 default
|
'primary_color': tenant.primary_color,
|
||||||
'secondary_color': '#1E40AF', # Blue-800 default
|
'secondary_color': tenant.secondary_color,
|
||||||
'logo_url': None,
|
'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,
|
'whitelabel_enabled': False,
|
||||||
'resources_can_reschedule': False,
|
'resources_can_reschedule': False,
|
||||||
'require_payment_method_to_book': 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.core.validators import MinValueValidator
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
|
||||||
class Service(models.Model):
|
class Service(models.Model):
|
||||||
@@ -21,18 +22,75 @@ class Service(models.Model):
|
|||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
default=Decimal('0.00')
|
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)
|
is_active = models.BooleanField(default=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['name']
|
ordering = ['display_order', 'name']
|
||||||
indexes = [models.Index(fields=['is_active', 'name'])]
|
indexes = [models.Index(fields=['is_active', 'display_order'])]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.duration} min - ${self.price})"
|
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):
|
class Resource(models.Model):
|
||||||
"""
|
"""
|
||||||
A bookable resource with configurable concurrency.
|
A bookable resource with configurable concurrency.
|
||||||
@@ -48,10 +106,32 @@ class Resource(models.Model):
|
|||||||
EQUIPMENT = 'EQUIPMENT', 'Equipment'
|
EQUIPMENT = 'EQUIPMENT', 'Equipment'
|
||||||
|
|
||||||
name = models.CharField(max_length=200)
|
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(
|
type = models.CharField(
|
||||||
max_length=20,
|
max_length=20,
|
||||||
choices=Type.choices,
|
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)
|
description = models.TextField(blank=True)
|
||||||
max_concurrent_events = models.PositiveIntegerField(
|
max_concurrent_events = models.PositiveIntegerField(
|
||||||
@@ -63,6 +143,11 @@ class Resource(models.Model):
|
|||||||
default=timezone.timedelta(0),
|
default=timezone.timedelta(0),
|
||||||
help_text="Time buffer before/after events. Buffers consume capacity."
|
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)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
is_active = models.BooleanField(default=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 rest_framework import serializers
|
||||||
from django.contrib.contenttypes.models import ContentType
|
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 .services import AvailabilityService
|
||||||
from smoothschedule.users.models import User
|
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):
|
class CustomerSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for Customer (User with role=CUSTOMER)"""
|
"""Serializer for Customer (User with role=CUSTOMER)"""
|
||||||
name = serializers.SerializerMethodField()
|
name = serializers.SerializerMethodField()
|
||||||
@@ -20,13 +47,14 @@ class CustomerSerializer(serializers.ModelSerializer):
|
|||||||
city = serializers.SerializerMethodField()
|
city = serializers.SerializerMethodField()
|
||||||
state = serializers.SerializerMethodField()
|
state = serializers.SerializerMethodField()
|
||||||
zip = serializers.SerializerMethodField()
|
zip = serializers.SerializerMethodField()
|
||||||
|
user_data = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'email', 'phone', 'city', 'state', 'zip',
|
'id', 'name', 'email', 'phone', 'city', 'state', 'zip',
|
||||||
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
'total_spend', 'last_visit', 'status', 'avatar_url', 'tags',
|
||||||
'user_id',
|
'user_id', 'user_data',
|
||||||
]
|
]
|
||||||
read_only_fields = ['id', 'email']
|
read_only_fields = ['id', 'email']
|
||||||
|
|
||||||
@@ -59,6 +87,41 @@ class CustomerSerializer(serializers.ModelSerializer):
|
|||||||
def get_zip(self, obj):
|
def get_zip(self, obj):
|
||||||
return ''
|
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):
|
class ServiceSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for Service model"""
|
"""Serializer for Service model"""
|
||||||
@@ -68,7 +131,7 @@ class ServiceSerializer(serializers.ModelSerializer):
|
|||||||
model = Service
|
model = Service
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'description', 'duration', 'duration_minutes',
|
'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']
|
read_only_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
@@ -76,16 +139,25 @@ class ServiceSerializer(serializers.ModelSerializer):
|
|||||||
class ResourceSerializer(serializers.ModelSerializer):
|
class ResourceSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for Resource model"""
|
"""Serializer for Resource model"""
|
||||||
capacity_description = serializers.SerializerMethodField()
|
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:
|
class Meta:
|
||||||
model = Resource
|
model = Resource
|
||||||
fields = [
|
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',
|
'buffer_duration', 'is_active', 'capacity_description',
|
||||||
'saved_lane_count', 'created_at', 'updated_at',
|
'saved_lane_count', 'created_at', 'updated_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['created_at', 'updated_at']
|
read_only_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
def get_capacity_description(self, obj):
|
def get_capacity_description(self, obj):
|
||||||
if obj.max_concurrent_events == 0:
|
if obj.max_concurrent_events == 0:
|
||||||
return "Unlimited capacity"
|
return "Unlimited capacity"
|
||||||
|
|||||||
@@ -3,16 +3,21 @@ Schedule App URLs
|
|||||||
"""
|
"""
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
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
|
# Create router and register viewsets
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
|
router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype')
|
||||||
router.register(r'resources', ResourceViewSet, basename='resource')
|
router.register(r'resources', ResourceViewSet, basename='resource')
|
||||||
router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend
|
router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend
|
||||||
router.register(r'events', EventViewSet, basename='event')
|
router.register(r'events', EventViewSet, basename='event')
|
||||||
router.register(r'participants', ParticipantViewSet, basename='participant')
|
router.register(r'participants', ParticipantViewSet, basename='participant')
|
||||||
router.register(r'customers', CustomerViewSet, basename='customer')
|
router.register(r'customers', CustomerViewSet, basename='customer')
|
||||||
router.register(r'services', ServiceViewSet, basename='service')
|
router.register(r'services', ServiceViewSet, basename='service')
|
||||||
|
router.register(r'staff', StaffViewSet, basename='staff')
|
||||||
|
|
||||||
# URL patterns
|
# URL patterns
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
@@ -6,13 +6,60 @@ API endpoints for Resources and Events with quota enforcement.
|
|||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from .models import Resource, Event, Participant
|
from rest_framework.decorators import action
|
||||||
from .serializers import ResourceSerializer, EventSerializer, ParticipantSerializer, CustomerSerializer, ServiceSerializer
|
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 .models import Service
|
||||||
from core.permissions import HasQuota
|
from core.permissions import HasQuota
|
||||||
from smoothschedule.users.models import User
|
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):
|
class ResourceViewSet(viewsets.ModelViewSet):
|
||||||
"""
|
"""
|
||||||
API endpoint for managing Resources.
|
API endpoint for managing Resources.
|
||||||
@@ -198,8 +245,8 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
filterset_fields = ['is_active']
|
filterset_fields = ['is_active']
|
||||||
search_fields = ['name', 'description']
|
search_fields = ['name', 'description']
|
||||||
ordering_fields = ['name', 'price', 'duration', 'created_at']
|
ordering_fields = ['name', 'price', 'duration', 'display_order', 'created_at']
|
||||||
ordering = ['name']
|
ordering = ['display_order', 'name']
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Return services, optionally including inactive ones."""
|
"""Return services, optionally including inactive ones."""
|
||||||
@@ -211,3 +258,71 @@ class ServiceViewSet(viewsets.ModelViewSet):
|
|||||||
queryset = queryset.filter(is_active=True)
|
queryset = queryset.filter(is_active=True)
|
||||||
|
|
||||||
return queryset
|
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.core.mail import send_mail
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from .models import User, EmailVerificationToken
|
from .models import User, EmailVerificationToken
|
||||||
|
from core.permissions import can_hijack
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@@ -160,3 +163,169 @@ def verify_email(request):
|
|||||||
token.user.save(update_fields=['email_verified'])
|
token.user.save(update_fields=['email_verified'])
|
||||||
|
|
||||||
return Response({"detail": "Email verified successfully."}, status=status.HTTP_200_OK)
|
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