feat: Add time block approval workflow and staff permission system
- Add TimeBlock approval status with manager approval workflow - Create core mixins for staff permission restrictions (DenyStaffWritePermission, etc.) - Add StaffDashboard page for staff-specific views - Refactor MyAvailability page for time block management - Update field mobile status machine and views - Add per-user permission overrides via JSONField - Document core mixins and permission system in CLAUDE.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
137
CLAUDE.md
137
CLAUDE.md
@@ -69,6 +69,143 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
|
|||||||
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
|
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
|
||||||
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
|
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
|
||||||
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
|
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
|
||||||
|
| `core` | `smoothschedule/core/` | Shared mixins, permissions, middleware |
|
||||||
|
| `payments` | `smoothschedule/payments/` | Stripe integration, subscriptions |
|
||||||
|
| `platform_admin` | `smoothschedule/platform_admin/` | Platform administration |
|
||||||
|
|
||||||
|
## Core Mixins & Base Classes
|
||||||
|
|
||||||
|
Located in `smoothschedule/core/mixins.py`. Use these to avoid code duplication.
|
||||||
|
|
||||||
|
### Permission Classes
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
|
||||||
|
|
||||||
|
class MyViewSet(ModelViewSet):
|
||||||
|
# Block write operations for staff (GET allowed)
|
||||||
|
permission_classes = [IsAuthenticated, DenyStaffWritePermission]
|
||||||
|
|
||||||
|
# Block ALL operations for staff
|
||||||
|
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
|
||||||
|
|
||||||
|
# Block list/create/update/delete but allow retrieve
|
||||||
|
permission_classes = [IsAuthenticated, DenyStaffListPermission]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Per-User Permission Overrides
|
||||||
|
|
||||||
|
Staff permissions can be overridden on a per-user basis using the `user.permissions` JSONField.
|
||||||
|
Permission keys are auto-derived from the view's basename or model name:
|
||||||
|
|
||||||
|
| Permission Class | Auto-derived Key | Example |
|
||||||
|
|-----------------|------------------|---------|
|
||||||
|
| `DenyStaffWritePermission` | `can_write_{basename}` | `can_write_resources` |
|
||||||
|
| `DenyStaffAllAccessPermission` | `can_access_{basename}` | `can_access_services` |
|
||||||
|
| `DenyStaffListPermission` | `can_list_{basename}` or `can_access_{basename}` | `can_list_customers` |
|
||||||
|
|
||||||
|
**Current ViewSet permission keys:**
|
||||||
|
|
||||||
|
| ViewSet | Permission Class | Override Key |
|
||||||
|
|---------|-----------------|--------------|
|
||||||
|
| `ResourceViewSet` | `DenyStaffAllAccessPermission` | `can_access_resources` |
|
||||||
|
| `ServiceViewSet` | `DenyStaffAllAccessPermission` | `can_access_services` |
|
||||||
|
| `CustomerViewSet` | `DenyStaffListPermission` | `can_list_customers` or `can_access_customers` |
|
||||||
|
| `ScheduledTaskViewSet` | `DenyStaffAllAccessPermission` | `can_access_scheduled-tasks` |
|
||||||
|
|
||||||
|
**Granting a specific staff member access:**
|
||||||
|
```bash
|
||||||
|
# Open Django shell
|
||||||
|
docker compose -f docker-compose.local.yml exec django python manage.py shell
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
# Find the staff member
|
||||||
|
staff = User.objects.get(email='john@example.com')
|
||||||
|
|
||||||
|
# Grant read access to resources
|
||||||
|
staff.permissions['can_access_resources'] = True
|
||||||
|
staff.save()
|
||||||
|
|
||||||
|
# Or grant list access to customers (but not full CRUD)
|
||||||
|
staff.permissions['can_list_customers'] = True
|
||||||
|
staff.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom permission keys (optional):**
|
||||||
|
```python
|
||||||
|
class ResourceViewSet(ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
|
||||||
|
# Override the auto-derived key
|
||||||
|
staff_access_permission_key = 'can_manage_equipment'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then grant via: `staff.permissions['can_manage_equipment'] = True`
|
||||||
|
|
||||||
|
### QuerySet Mixins
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
|
||||||
|
|
||||||
|
# For tenant-scoped models (automatic django-tenants filtering)
|
||||||
|
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
|
||||||
|
queryset = Resource.objects.all()
|
||||||
|
deny_staff_queryset = True # Optional: also filter staff at queryset level
|
||||||
|
|
||||||
|
def filter_queryset_for_tenant(self, queryset):
|
||||||
|
# Override for custom filtering
|
||||||
|
return queryset.filter(is_active=True)
|
||||||
|
|
||||||
|
# For User model (shared schema, needs explicit tenant filter)
|
||||||
|
class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
|
||||||
|
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Feature Permission Mixins
|
||||||
|
|
||||||
|
```python
|
||||||
|
from core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
|
||||||
|
|
||||||
|
# Checks can_use_plugins feature on list/retrieve/create
|
||||||
|
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Checks both can_use_plugins AND can_use_tasks
|
||||||
|
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, ModelViewSet):
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Base API Views (for non-ViewSet views)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||||
|
|
||||||
|
# Optional tenant - use self.get_tenant()
|
||||||
|
class MyView(TenantAPIView, APIView):
|
||||||
|
def get(self, request):
|
||||||
|
tenant = self.get_tenant() # May be None
|
||||||
|
return self.success_response({'data': 'value'})
|
||||||
|
# or: return self.error_response('Something went wrong', status_code=400)
|
||||||
|
|
||||||
|
# Required tenant - self.tenant always available
|
||||||
|
class MyTenantView(TenantRequiredAPIView, APIView):
|
||||||
|
def get(self, request):
|
||||||
|
# self.tenant is guaranteed to exist (returns 400 if missing)
|
||||||
|
return Response({'name': self.tenant.name})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helper Methods Available
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `self.get_tenant()` | Get tenant from request (may be None) |
|
||||||
|
| `self.get_tenant_or_error()` | Returns (tenant, error_response) tuple |
|
||||||
|
| `self.error_response(msg, status_code)` | Standard error response |
|
||||||
|
| `self.success_response(data, status_code)` | Standard success response |
|
||||||
|
| `self.check_feature(key, name)` | Check feature permission, returns error or None |
|
||||||
|
|
||||||
## Common Tasks
|
## Common Tasks
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
|
|||||||
|
|
||||||
// Import pages
|
// Import pages
|
||||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||||
|
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
|
||||||
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
||||||
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
||||||
const Customers = React.lazy(() => import('./pages/Customers'));
|
const Customers = React.lazy(() => import('./pages/Customers'));
|
||||||
@@ -667,7 +668,7 @@ const AppContent: React.FC = () => {
|
|||||||
{/* Regular Routes */}
|
{/* Regular Routes */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
||||||
/>
|
/>
|
||||||
{/* Staff Schedule - vertical timeline view */}
|
{/* Staff Schedule - vertical timeline view */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface User {
|
|||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
can_invite_staff?: boolean;
|
can_invite_staff?: boolean;
|
||||||
can_access_tickets?: boolean;
|
can_access_tickets?: boolean;
|
||||||
|
can_edit_schedule?: boolean;
|
||||||
|
linked_resource_id?: number;
|
||||||
quota_overages?: QuotaOverage[];
|
quota_overages?: QuotaOverage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
|||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
roles: ['staff'],
|
roles: ['staff'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'can_self_approve_time_off',
|
||||||
|
labelKey: 'staff.canSelfApproveTimeOff',
|
||||||
|
labelDefault: 'Can self-approve time off',
|
||||||
|
hintKey: 'staff.canSelfApproveTimeOffHint',
|
||||||
|
hintDefault: 'Add time off without requiring manager/owner approval',
|
||||||
|
defaultValue: false,
|
||||||
|
roles: ['staff'],
|
||||||
|
},
|
||||||
// Shared permissions (both manager and staff)
|
// Shared permissions (both manager and staff)
|
||||||
{
|
{
|
||||||
key: 'can_access_tickets',
|
key: 'can_access_tickets',
|
||||||
|
|||||||
@@ -155,6 +155,10 @@ interface TimeBlockCreatorModalProps {
|
|||||||
holidays: Holiday[];
|
holidays: Holiday[];
|
||||||
resources: Resource[];
|
resources: Resource[];
|
||||||
isResourceLevel?: boolean;
|
isResourceLevel?: boolean;
|
||||||
|
/** Staff mode: hides level selector, locks to resource, pre-selects resource */
|
||||||
|
staffMode?: boolean;
|
||||||
|
/** Pre-selected resource ID for staff mode */
|
||||||
|
staffResourceId?: string | number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = 'preset' | 'details' | 'schedule' | 'review';
|
type Step = 'preset' | 'details' | 'schedule' | 'review';
|
||||||
@@ -168,6 +172,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
holidays,
|
holidays,
|
||||||
resources,
|
resources,
|
||||||
isResourceLevel: initialIsResourceLevel = false,
|
isResourceLevel: initialIsResourceLevel = false,
|
||||||
|
staffMode = false,
|
||||||
|
staffResourceId = null,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
|
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
|
||||||
@@ -177,7 +183,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
// Form state
|
// Form state
|
||||||
const [title, setTitle] = useState(editingBlock?.title || '');
|
const [title, setTitle] = useState(editingBlock?.title || '');
|
||||||
const [description, setDescription] = useState(editingBlock?.description || '');
|
const [description, setDescription] = useState(editingBlock?.description || '');
|
||||||
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || 'HARD');
|
// In staff mode, default to SOFT blocks (time-off requests)
|
||||||
|
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || (staffMode ? 'SOFT' : 'HARD'));
|
||||||
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE');
|
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE');
|
||||||
const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
|
const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
|
||||||
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
|
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
|
||||||
@@ -270,7 +277,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
setAllDay(true);
|
setAllDay(true);
|
||||||
setStartTime('09:00');
|
setStartTime('09:00');
|
||||||
setEndTime('17:00');
|
setEndTime('17:00');
|
||||||
setResourceId(null);
|
// In staff mode, pre-select the staff's resource
|
||||||
|
setResourceId(staffMode && staffResourceId ? String(staffResourceId) : null);
|
||||||
setSelectedDates([]);
|
setSelectedDates([]);
|
||||||
setDaysOfWeek([]);
|
setDaysOfWeek([]);
|
||||||
setDaysOfMonth([]);
|
setDaysOfMonth([]);
|
||||||
@@ -279,10 +287,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
setHolidayCodes([]);
|
setHolidayCodes([]);
|
||||||
setRecurrenceStart('');
|
setRecurrenceStart('');
|
||||||
setRecurrenceEnd('');
|
setRecurrenceEnd('');
|
||||||
setIsResourceLevel(initialIsResourceLevel);
|
// In staff mode, always resource-level
|
||||||
|
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen, editingBlock, initialIsResourceLevel]);
|
}, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
|
||||||
|
|
||||||
// Apply preset configuration
|
// Apply preset configuration
|
||||||
const applyPreset = (presetId: string) => {
|
const applyPreset = (presetId: string) => {
|
||||||
@@ -293,7 +302,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
setTitle(preset.config.title);
|
setTitle(preset.config.title);
|
||||||
setRecurrenceType(preset.config.recurrence_type);
|
setRecurrenceType(preset.config.recurrence_type);
|
||||||
setAllDay(preset.config.all_day);
|
setAllDay(preset.config.all_day);
|
||||||
setBlockType(preset.config.block_type);
|
// In staff mode, always use SOFT blocks regardless of preset
|
||||||
|
setBlockType(staffMode ? 'SOFT' : preset.config.block_type);
|
||||||
|
|
||||||
if (preset.config.start_time) setStartTime(preset.config.start_time);
|
if (preset.config.start_time) setStartTime(preset.config.start_time);
|
||||||
if (preset.config.end_time) setEndTime(preset.config.end_time);
|
if (preset.config.end_time) setEndTime(preset.config.end_time);
|
||||||
@@ -367,12 +377,15 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
// In staff mode, always use the staff's resource ID
|
||||||
|
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
|
||||||
|
|
||||||
const baseData: any = {
|
const baseData: any = {
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
block_type: blockType,
|
block_type: blockType,
|
||||||
recurrence_type: recurrenceType,
|
recurrence_type: recurrenceType,
|
||||||
all_day: allDay,
|
all_day: allDay,
|
||||||
resource: isResourceLevel ? resourceId : null,
|
resource: isResourceLevel ? effectiveResourceId : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!allDay) {
|
if (!allDay) {
|
||||||
@@ -425,7 +438,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
return true;
|
return true;
|
||||||
case 'details':
|
case 'details':
|
||||||
if (!title.trim()) return false;
|
if (!title.trim()) return false;
|
||||||
if (isResourceLevel && !resourceId) return false;
|
// In staff mode, resource is auto-selected; otherwise check if selected
|
||||||
|
if (isResourceLevel && !staffMode && !resourceId) return false;
|
||||||
return true;
|
return true;
|
||||||
case 'schedule':
|
case 'schedule':
|
||||||
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
|
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
|
||||||
@@ -556,63 +570,65 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
{/* Step 2: Details */}
|
{/* Step 2: Details */}
|
||||||
{step === 'details' && (
|
{step === 'details' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Block Level Selector */}
|
{/* Block Level Selector - Hidden in staff mode */}
|
||||||
<div>
|
{!staffMode && (
|
||||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
<div>
|
||||||
Block Level
|
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
</label>
|
Block Level
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</label>
|
||||||
<button
|
<div className="grid grid-cols-2 gap-4">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setIsResourceLevel(false);
|
onClick={() => {
|
||||||
setResourceId(null);
|
setIsResourceLevel(false);
|
||||||
}}
|
setResourceId(null);
|
||||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
}}
|
||||||
!isResourceLevel
|
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
!isResourceLevel
|
||||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||||
}`}
|
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||||
>
|
}`}
|
||||||
<div className="flex items-center gap-3">
|
>
|
||||||
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
<div className="flex items-center gap-3">
|
||||||
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||||
|
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||||
|
Business-wide
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Affects all resources
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</button>
|
||||||
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
<button
|
||||||
Business-wide
|
type="button"
|
||||||
</p>
|
onClick={() => setIsResourceLevel(true)}
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
||||||
Affects all resources
|
isResourceLevel
|
||||||
</p>
|
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
||||||
|
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
||||||
|
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
||||||
|
Specific Resource
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Affects one resource
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setIsResourceLevel(true)}
|
|
||||||
className={`p-4 rounded-xl border-2 transition-all text-left ${
|
|
||||||
isResourceLevel
|
|
||||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
|
|
||||||
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
|
|
||||||
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
|
|
||||||
Specific Resource
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
Affects one resource
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
@@ -642,8 +658,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Resource (if resource-level) */}
|
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
|
||||||
{isResourceLevel && (
|
{isResourceLevel && !staffMode && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
|
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||||
Resource
|
Resource
|
||||||
@@ -661,52 +677,54 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Block Type */}
|
{/* Block Type - hidden in staff mode (always SOFT for time-off requests) */}
|
||||||
<div>
|
{!staffMode && (
|
||||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
<div>
|
||||||
Block Type
|
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
</label>
|
Block Type
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</label>
|
||||||
<button
|
<div className="grid grid-cols-2 gap-4">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setBlockType('HARD')}
|
type="button"
|
||||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
onClick={() => setBlockType('HARD')}
|
||||||
blockType === 'HARD'
|
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
|
blockType === 'HARD'
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
|
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
|
||||||
}`}
|
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
|
||||||
>
|
}`}
|
||||||
<div className="flex items-center gap-3 mb-2">
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
|
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||||
|
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Completely prevents bookings. Cannot be overridden.
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
</p>
|
||||||
Completely prevents bookings. Cannot be overridden.
|
</button>
|
||||||
</p>
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
onClick={() => setBlockType('SOFT')}
|
||||||
type="button"
|
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
onClick={() => setBlockType('SOFT')}
|
blockType === 'SOFT'
|
||||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
|
||||||
blockType === 'SOFT'
|
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
|
||||||
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
|
}`}
|
||||||
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
|
>
|
||||||
}`}
|
<div className="flex items-center gap-3 mb-2">
|
||||||
>
|
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||||
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
|
</div>
|
||||||
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
</div>
|
Shows a warning but allows bookings with override.
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
</p>
|
||||||
Shows a warning but allows bookings with override.
|
</button>
|
||||||
</p>
|
</div>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* All Day Toggle & Time */}
|
{/* All Day Toggle & Time */}
|
||||||
<div>
|
<div>
|
||||||
@@ -1188,11 +1206,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
{isResourceLevel && resourceId && (
|
{isResourceLevel && (resourceId || staffResourceId) && (
|
||||||
<div className="flex justify-between py-2">
|
<div className="flex justify-between py-2">
|
||||||
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
|
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
|
||||||
<dd className="font-medium text-gray-900 dark:text-white">
|
<dd className="font-medium text-gray-900 dark:text-white">
|
||||||
{resources.find(r => r.id === resourceId)?.name || resourceId}
|
{resources.find(r => String(r.id) === String(staffMode ? staffResourceId : resourceId))?.name || (staffMode ? staffResourceId : resourceId)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -158,8 +158,9 @@ export const useMyBlocks = () => {
|
|||||||
id: String(b.id),
|
id: String(b.id),
|
||||||
resource: b.resource ? String(b.resource) : null,
|
resource: b.resource ? String(b.resource) : null,
|
||||||
})),
|
})),
|
||||||
resource_id: String(data.resource_id),
|
resource_id: data.resource_id ? String(data.resource_id) : null,
|
||||||
resource_name: data.resource_name,
|
resource_name: data.resource_name,
|
||||||
|
can_self_approve: data.can_self_approve,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -248,6 +249,75 @@ export const useToggleTimeBlock = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Time Block Approval Hooks
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface PendingReviewsResponse {
|
||||||
|
count: number;
|
||||||
|
pending_blocks: TimeBlockListItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch pending time block reviews (for managers/owners)
|
||||||
|
*/
|
||||||
|
export const usePendingReviews = () => {
|
||||||
|
return useQuery<PendingReviewsResponse>({
|
||||||
|
queryKey: ['time-block-pending-reviews'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.get('/time-blocks/pending_reviews/');
|
||||||
|
return {
|
||||||
|
count: data.count,
|
||||||
|
pending_blocks: data.pending_blocks.map((b: any) => ({
|
||||||
|
...b,
|
||||||
|
id: String(b.id),
|
||||||
|
resource: b.resource ? String(b.resource) : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to approve a time block
|
||||||
|
*/
|
||||||
|
export const useApproveTimeBlock = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, notes }: { id: string; notes?: string }) => {
|
||||||
|
const { data } = await apiClient.post(`/time-blocks/${id}/approve/`, { notes });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['time-block-pending-reviews'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to deny a time block
|
||||||
|
*/
|
||||||
|
export const useDenyTimeBlock = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, notes }: { id: string; notes?: string }) => {
|
||||||
|
const { data } = await apiClient.post(`/time-blocks/${id}/deny/`, { notes });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['time-block-pending-reviews'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to check for conflicts before creating a time block
|
* Hook to check for conflicts before creating a time block
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -491,7 +491,39 @@
|
|||||||
"reactivateAccount": "Reactivate Account",
|
"reactivateAccount": "Reactivate Account",
|
||||||
"deactivateHint": "Prevent this user from logging in while keeping their data",
|
"deactivateHint": "Prevent this user from logging in while keeping their data",
|
||||||
"reactivateHint": "Allow this user to log in again",
|
"reactivateHint": "Allow this user to log in again",
|
||||||
"deactivate": "Deactivate"
|
"deactivate": "Deactivate",
|
||||||
|
"canSelfApproveTimeOff": "Can self-approve time off",
|
||||||
|
"canSelfApproveTimeOffHint": "Add time off without requiring manager/owner approval"
|
||||||
|
},
|
||||||
|
"staffDashboard": {
|
||||||
|
"welcomeTitle": "Welcome, {{name}}!",
|
||||||
|
"weekOverview": "Here's your week at a glance",
|
||||||
|
"noResourceLinked": "Your account is not linked to a resource yet. Please contact your manager to set up your schedule.",
|
||||||
|
"currentAppointment": "Current Appointment",
|
||||||
|
"nextAppointment": "Next Appointment",
|
||||||
|
"viewSchedule": "View Schedule",
|
||||||
|
"todayAppointments": "Today",
|
||||||
|
"thisWeek": "This Week",
|
||||||
|
"completed": "Completed",
|
||||||
|
"hoursWorked": "Hours Worked",
|
||||||
|
"appointmentsLabel": "appointments",
|
||||||
|
"totalAppointments": "total appointments",
|
||||||
|
"completionRate": "completion rate",
|
||||||
|
"thisWeekLabel": "this week",
|
||||||
|
"upcomingAppointments": "Upcoming",
|
||||||
|
"noUpcoming": "No upcoming appointments",
|
||||||
|
"weeklyOverview": "This Week",
|
||||||
|
"appointments": "Appointments",
|
||||||
|
"today": "Today",
|
||||||
|
"tomorrow": "Tomorrow",
|
||||||
|
"scheduled": "Scheduled",
|
||||||
|
"inProgress": "In Progress",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"noShows": "No-Shows",
|
||||||
|
"viewMySchedule": "View My Schedule",
|
||||||
|
"viewScheduleDesc": "See your daily appointments and manage your time",
|
||||||
|
"manageAvailability": "Manage Availability",
|
||||||
|
"availabilityDesc": "Set your working hours and time off"
|
||||||
},
|
},
|
||||||
"tickets": {
|
"tickets": {
|
||||||
"title": "Support Tickets",
|
"title": "Support Tickets",
|
||||||
|
|||||||
@@ -2,17 +2,17 @@
|
|||||||
* My Availability Page
|
* My Availability Page
|
||||||
*
|
*
|
||||||
* Staff-facing page to view and manage their own time blocks.
|
* Staff-facing page to view and manage their own time blocks.
|
||||||
|
* Uses the same UI as TimeBlocks but locked to the staff's own resource.
|
||||||
* Shows business-level blocks (read-only) and personal blocks (editable).
|
* Shows business-level blocks (read-only) and personal blocks (editable).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState } 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 {
|
import {
|
||||||
TimeBlockListItem,
|
TimeBlockListItem,
|
||||||
BlockType,
|
BlockType,
|
||||||
RecurrenceType,
|
RecurrenceType,
|
||||||
RecurrencePattern,
|
|
||||||
User,
|
User,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import {
|
import {
|
||||||
@@ -22,10 +22,10 @@ import {
|
|||||||
useDeleteTimeBlock,
|
useDeleteTimeBlock,
|
||||||
useToggleTimeBlock,
|
useToggleTimeBlock,
|
||||||
useHolidays,
|
useHolidays,
|
||||||
CreateTimeBlockData,
|
|
||||||
} from '../hooks/useTimeBlocks';
|
} from '../hooks/useTimeBlocks';
|
||||||
import Portal from '../components/Portal';
|
import Portal from '../components/Portal';
|
||||||
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
|
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
|
||||||
|
import TimeBlockCreatorModal from '../components/time-blocks/TimeBlockCreatorModal';
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Building2,
|
Building2,
|
||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Pencil,
|
Pencil,
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
@@ -42,8 +41,13 @@ import {
|
|||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
Info,
|
Info,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
HourglassIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
type AvailabilityTab = 'blocks' | 'calendar';
|
||||||
|
|
||||||
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
|
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
|
||||||
NONE: 'One-time',
|
NONE: 'One-time',
|
||||||
WEEKLY: 'Weekly',
|
WEEKLY: 'Weekly',
|
||||||
@@ -57,43 +61,6 @@ const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
|||||||
SOFT: 'Soft Block',
|
SOFT: 'Soft Block',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DAY_ABBREVS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
|
||||||
'January', 'February', 'March', 'April', 'May', 'June',
|
|
||||||
'July', 'August', 'September', 'October', 'November', 'December'
|
|
||||||
];
|
|
||||||
|
|
||||||
interface TimeBlockFormData {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
block_type: BlockType;
|
|
||||||
recurrence_type: RecurrenceType;
|
|
||||||
start_date: string;
|
|
||||||
end_date: string;
|
|
||||||
all_day: boolean;
|
|
||||||
start_time: string;
|
|
||||||
end_time: string;
|
|
||||||
recurrence_pattern: RecurrencePattern;
|
|
||||||
recurrence_start: string;
|
|
||||||
recurrence_end: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultFormData: TimeBlockFormData = {
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
block_type: 'SOFT',
|
|
||||||
recurrence_type: 'NONE',
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
all_day: true,
|
|
||||||
start_time: '09:00',
|
|
||||||
end_time: '17:00',
|
|
||||||
recurrence_pattern: {},
|
|
||||||
recurrence_start: '',
|
|
||||||
recurrence_end: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface MyAvailabilityProps {
|
interface MyAvailabilityProps {
|
||||||
user?: User;
|
user?: User;
|
||||||
}
|
}
|
||||||
@@ -103,9 +70,9 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
|||||||
const contextUser = useOutletContext<{ user?: User }>()?.user;
|
const contextUser = useOutletContext<{ user?: User }>()?.user;
|
||||||
const user = props.user || contextUser;
|
const user = props.user || contextUser;
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<AvailabilityTab>('blocks');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||||
const [formData, setFormData] = useState<TimeBlockFormData>(defaultFormData);
|
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch data
|
// Fetch data
|
||||||
@@ -118,105 +85,20 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
|||||||
const deleteBlock = useDeleteTimeBlock();
|
const deleteBlock = useDeleteTimeBlock();
|
||||||
const toggleBlock = useToggleTimeBlock();
|
const toggleBlock = useToggleTimeBlock();
|
||||||
|
|
||||||
// Check if user can create hard blocks
|
|
||||||
const canCreateHardBlocks = user?.permissions?.can_create_hard_blocks ?? false;
|
|
||||||
|
|
||||||
// Modal handlers
|
// Modal handlers
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
setEditingBlock(null);
|
setEditingBlock(null);
|
||||||
setFormData(defaultFormData);
|
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditModal = (block: TimeBlockListItem) => {
|
const openEditModal = (block: TimeBlockListItem) => {
|
||||||
setEditingBlock(block);
|
setEditingBlock(block);
|
||||||
setFormData({
|
|
||||||
title: block.title,
|
|
||||||
description: '',
|
|
||||||
block_type: block.block_type,
|
|
||||||
recurrence_type: block.recurrence_type,
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
all_day: true,
|
|
||||||
start_time: '09:00',
|
|
||||||
end_time: '17:00',
|
|
||||||
recurrence_pattern: {},
|
|
||||||
recurrence_start: '',
|
|
||||||
recurrence_end: '',
|
|
||||||
});
|
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingBlock(null);
|
setEditingBlock(null);
|
||||||
setFormData(defaultFormData);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Form handlers
|
|
||||||
const handleFormChange = (field: keyof TimeBlockFormData, value: any) => {
|
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePatternChange = (field: keyof RecurrencePattern, value: any) => {
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
recurrence_pattern: { ...prev.recurrence_pattern, [field]: value },
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDayOfWeekToggle = (day: number) => {
|
|
||||||
const current = formData.recurrence_pattern.days_of_week || [];
|
|
||||||
const newDays = current.includes(day)
|
|
||||||
? current.filter((d) => d !== day)
|
|
||||||
: [...current, day].sort();
|
|
||||||
handlePatternChange('days_of_week', newDays);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (!myBlocksData?.resource_id) {
|
|
||||||
console.error('No resource linked to user');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: CreateTimeBlockData = {
|
|
||||||
title: formData.title,
|
|
||||||
description: formData.description || undefined,
|
|
||||||
resource: myBlocksData.resource_id,
|
|
||||||
block_type: formData.block_type,
|
|
||||||
recurrence_type: formData.recurrence_type,
|
|
||||||
all_day: formData.all_day,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add type-specific fields
|
|
||||||
if (formData.recurrence_type === 'NONE') {
|
|
||||||
payload.start_date = formData.start_date;
|
|
||||||
payload.end_date = formData.end_date || formData.start_date;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.all_day) {
|
|
||||||
payload.start_time = formData.start_time;
|
|
||||||
payload.end_time = formData.end_time;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.recurrence_type !== 'NONE') {
|
|
||||||
payload.recurrence_pattern = formData.recurrence_pattern;
|
|
||||||
if (formData.recurrence_start) payload.recurrence_start = formData.recurrence_start;
|
|
||||||
if (formData.recurrence_end) payload.recurrence_end = formData.recurrence_end;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (editingBlock) {
|
|
||||||
await updateBlock.mutateAsync({ id: editingBlock.id, updates: payload });
|
|
||||||
} else {
|
|
||||||
await createBlock.mutateAsync(payload);
|
|
||||||
}
|
|
||||||
closeModal();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save time block:', error);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
@@ -264,6 +146,35 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Render approval status badge
|
||||||
|
const renderApprovalBadge = (status: string | undefined) => {
|
||||||
|
if (!status || status === 'APPROVED') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
<CheckCircle size={12} className="mr-1" />
|
||||||
|
{t('myAvailability.approved', 'Approved')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'PENDING') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
<HourglassIcon size={12} className="mr-1" />
|
||||||
|
{t('myAvailability.pending', 'Pending Review')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === 'DENIED') {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
<XCircle size={12} className="mr-1" />
|
||||||
|
{t('myAvailability.denied', 'Denied')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Handle no linked resource
|
// Handle no linked resource
|
||||||
if (!isLoading && !myBlocksData?.resource_id) {
|
if (!isLoading && !myBlocksData?.resource_id) {
|
||||||
return (
|
return (
|
||||||
@@ -290,6 +201,12 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create a mock resource for the modal
|
||||||
|
const staffResource = myBlocksData?.resource_id ? {
|
||||||
|
id: myBlocksData.resource_id,
|
||||||
|
name: myBlocksData.resource_name || 'My Resource',
|
||||||
|
} : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -299,447 +216,271 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
|||||||
{t('myAvailability.title', 'My Availability')}
|
{t('myAvailability.title', 'My Availability')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
{myBlocksData?.resource_name && (
|
{t('myAvailability.subtitle', 'Manage your time off and unavailability')}
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<UserIcon size={14} />
|
|
||||||
{myBlocksData.resource_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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"
|
||||||
|
>
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
{t('myAvailability.addBlock', 'Block Time')}
|
{t('myAvailability.addBlock', 'Block Time')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Approval Required Banner */}
|
||||||
|
{myBlocksData?.can_self_approve === false && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<HourglassIcon size={20} className="text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-amber-900 dark:text-amber-100">
|
||||||
|
{t('myAvailability.approvalRequired', 'Approval Required')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
|
{t('myAvailability.approvalRequiredInfo', 'Your time off requests require manager or owner approval. New blocks will show as "Pending Review" until approved.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Business Blocks Info Banner */}
|
||||||
|
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Building2 size={20} className="text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-blue-900 dark:text-blue-100">
|
||||||
|
{t('myAvailability.businessBlocks', 'Business Closures')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||||
|
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone:')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{myBlocksData.business_blocks.map((block) => (
|
||||||
|
<span
|
||||||
|
key={block.id}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 dark:bg-blue-800/50 text-blue-800 dark:text-blue-200 rounded text-sm"
|
||||||
|
>
|
||||||
|
{block.title}
|
||||||
|
{renderRecurrenceBadge(block.recurrence_type)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav className="flex gap-1" aria-label="Availability tabs">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('blocks')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'blocks'
|
||||||
|
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserIcon size={18} />
|
||||||
|
{t('myAvailability.myBlocksTab', 'My Time Blocks')}
|
||||||
|
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length > 0 && (
|
||||||
|
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{myBlocksData.my_blocks.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('calendar')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
activeTab === 'calendar'
|
||||||
|
? 'border-brand-500 text-brand-600 dark:text-brand-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarDays size={18} />
|
||||||
|
{t('myAvailability.calendarTab', 'Yearly View')}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-12">
|
<div className="flex justify-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{/* Business Blocks (Read-only) */}
|
{activeTab === 'blocks' && (
|
||||||
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
|
<>
|
||||||
<div>
|
{/* Resource Info Banner */}
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||||
<Building2 size={20} />
|
<p className="text-sm text-purple-800 dark:text-purple-300 flex items-center gap-2">
|
||||||
{t('myAvailability.businessBlocks', 'Business Closures')}
|
<UserIcon size={16} />
|
||||||
</h2>
|
{t('myAvailability.resourceInfo', 'Managing blocks for:')}
|
||||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
|
<span className="font-semibold">{myBlocksData?.resource_name}</span>
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-300 flex items-center gap-2">
|
|
||||||
<Info size={16} />
|
|
||||||
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone.')}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
{/* My Blocks List */}
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length === 0 ? (
|
||||||
{myBlocksData.business_blocks.map((block) => (
|
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
<Calendar size={48} className="mx-auto text-gray-400 mb-4" />
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
{t('myAvailability.noBlocks', 'No Time Blocks')}
|
||||||
{block.title}
|
</h3>
|
||||||
</span>
|
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
</td>
|
{t('myAvailability.noBlocksDesc', 'Add time blocks for vacations, lunch breaks, or any time you need off.')}
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
</p>
|
||||||
{renderBlockTypeBadge(block.block_type)}
|
<button
|
||||||
</td>
|
onClick={openCreateModal}
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
{renderRecurrenceBadge(block.recurrence_type)}
|
<Plus size={18} />
|
||||||
{block.pattern_display && (
|
{t('myAvailability.addFirstBlock', 'Add First Block')}
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
</button>
|
||||||
{block.pattern_display}
|
</div>
|
||||||
</span>
|
) : (
|
||||||
)}
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
</div>
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
</td>
|
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('myAvailability.titleCol', 'Title')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('myAvailability.typeCol', 'Type')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('myAvailability.patternCol', 'Pattern')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('myAvailability.statusCol', 'Status')}
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||||
|
{t('myAvailability.actionsCol', 'Actions')}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
</table>
|
{myBlocksData?.my_blocks.map((block) => (
|
||||||
</div>
|
<tr key={block.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${!block.is_active ? 'opacity-50' : ''}`}>
|
||||||
</div>
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`font-medium ${block.is_active ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400 line-through'}`}>
|
||||||
|
{block.title}
|
||||||
|
</span>
|
||||||
|
{!block.is_active && (
|
||||||
|
<span className="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400 rounded">
|
||||||
|
Inactive
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{renderBlockTypeBadge(block.block_type)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{renderRecurrenceBadge(block.recurrence_type)}
|
||||||
|
{block.pattern_display && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{block.pattern_display}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{renderApprovalBadge((block as any).approval_status)}
|
||||||
|
{(block as any).review_notes && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 italic">
|
||||||
|
"{(block as any).review_notes}"
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggle(block.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
title={block.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
>
|
||||||
|
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditModal(block)}
|
||||||
|
className="p-2 text-gray-400 hover:text-blue-600"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirmId(block.id)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-600"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* My Blocks (Editable) */}
|
{activeTab === 'calendar' && (
|
||||||
<div>
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
<YearlyBlockCalendar
|
||||||
<UserIcon size={20} />
|
resourceId={myBlocksData?.resource_id}
|
||||||
{t('myAvailability.myBlocks', 'My Time Blocks')}
|
onBlockClick={(blockId) => {
|
||||||
</h2>
|
// Find the block and open edit modal if it's my block
|
||||||
|
const block = myBlocksData?.my_blocks.find(b => b.id === blockId);
|
||||||
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length === 0 ? (
|
if (block) {
|
||||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
openEditModal(block);
|
||||||
<Calendar size={48} className="mx-auto text-gray-400 mb-4" />
|
}
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
}}
|
||||||
{t('myAvailability.noBlocks', 'No Time Blocks')}
|
/>
|
||||||
</h3>
|
</div>
|
||||||
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
)}
|
||||||
{t('myAvailability.noBlocksDesc', 'Add time blocks for vacations, lunch breaks, or any time you need off.')}
|
|
||||||
</p>
|
|
||||||
<button onClick={openCreateModal} className="inline-flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
|
||||||
<Plus size={18} />
|
|
||||||
{t('myAvailability.addFirstBlock', 'Add First Block')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
||||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900/50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
||||||
{t('myAvailability.titleCol', 'Title')}
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
||||||
{t('myAvailability.typeCol', 'Type')}
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
||||||
{t('myAvailability.patternCol', 'Pattern')}
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
||||||
{t('myAvailability.actionsCol', 'Actions')}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{myBlocksData?.my_blocks.map((block) => (
|
|
||||||
<tr key={block.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className="font-medium text-gray-900 dark:text-white">
|
|
||||||
{block.title}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
{renderBlockTypeBadge(block.block_type)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{renderRecurrenceBadge(block.recurrence_type)}
|
|
||||||
{block.pattern_display && (
|
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{block.pattern_display}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggle(block.id)}
|
|
||||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
|
||||||
title={block.is_active ? 'Deactivate' : 'Activate'}
|
|
||||||
>
|
|
||||||
{block.is_active ? <Power size={16} /> : <PowerOff size={16} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(block)}
|
|
||||||
className="p-2 text-gray-400 hover:text-blue-600"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<Pencil size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirmId(block.id)}
|
|
||||||
className="p-2 text-gray-400 hover:text-red-600"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Yearly Calendar View */}
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
||||||
<YearlyBlockCalendar
|
|
||||||
resourceId={myBlocksData?.resource_id}
|
|
||||||
onBlockClick={(blockId) => {
|
|
||||||
// Find the block and open edit modal if it's my block
|
|
||||||
const block = myBlocksData?.my_blocks.find(b => b.id === blockId);
|
|
||||||
if (block) {
|
|
||||||
openEditModal(block);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal - Using TimeBlockCreatorModal in staff mode */}
|
||||||
{isModalOpen && (
|
<TimeBlockCreatorModal
|
||||||
<Portal>
|
isOpen={isModalOpen}
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
onClose={closeModal}
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
|
onSubmit={async (data) => {
|
||||||
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
try {
|
||||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
if (editingBlock) {
|
||||||
{editingBlock
|
await updateBlock.mutateAsync({ id: editingBlock.id, updates: data });
|
||||||
? t('myAvailability.editBlock', 'Edit Time Block')
|
} else {
|
||||||
: t('myAvailability.createBlock', 'Block Time Off')}
|
// Handle array of blocks (multiple holidays)
|
||||||
</h2>
|
const blocks = Array.isArray(data) ? data : [data];
|
||||||
<button
|
for (const block of blocks) {
|
||||||
onClick={closeModal}
|
await createBlock.mutateAsync(block);
|
||||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
}
|
||||||
>
|
}
|
||||||
<X size={24} />
|
closeModal();
|
||||||
</button>
|
} catch (error) {
|
||||||
</div>
|
console.error('Failed to save time block:', error);
|
||||||
|
}
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
}}
|
||||||
{/* Title */}
|
isSubmitting={createBlock.isPending || updateBlock.isPending}
|
||||||
<div>
|
editingBlock={editingBlock}
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
holidays={holidays}
|
||||||
{t('myAvailability.form.title', 'Title')} *
|
resources={staffResource ? [staffResource as any] : []}
|
||||||
</label>
|
isResourceLevel={true}
|
||||||
<input
|
staffMode={true}
|
||||||
type="text"
|
staffResourceId={myBlocksData?.resource_id}
|
||||||
value={formData.title}
|
/>
|
||||||
onChange={(e) => handleFormChange('title', e.target.value)}
|
|
||||||
className="input-primary w-full"
|
|
||||||
placeholder="e.g., Vacation, Lunch Break, Doctor Appointment"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
{t('myAvailability.form.description', 'Description')}
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => handleFormChange('description', e.target.value)}
|
|
||||||
className="input-primary w-full"
|
|
||||||
rows={2}
|
|
||||||
placeholder="Optional reason"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Block Type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
{t('myAvailability.form.blockType', 'Block Type')}
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="block_type"
|
|
||||||
value="SOFT"
|
|
||||||
checked={formData.block_type === 'SOFT'}
|
|
||||||
onChange={() => handleFormChange('block_type', 'SOFT')}
|
|
||||||
className="text-brand-500"
|
|
||||||
/>
|
|
||||||
<AlertCircle size={16} className="text-yellow-500" />
|
|
||||||
<span className="text-sm">Soft Block</span>
|
|
||||||
<span className="text-xs text-gray-500">(shows warning, can be overridden)</span>
|
|
||||||
</label>
|
|
||||||
<label className={`flex items-center gap-2 ${canCreateHardBlocks ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed'}`}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="block_type"
|
|
||||||
value="HARD"
|
|
||||||
checked={formData.block_type === 'HARD'}
|
|
||||||
onChange={() => canCreateHardBlocks && handleFormChange('block_type', 'HARD')}
|
|
||||||
className="text-brand-500"
|
|
||||||
disabled={!canCreateHardBlocks}
|
|
||||||
/>
|
|
||||||
<Ban size={16} className="text-red-500" />
|
|
||||||
<span className="text-sm">Hard Block</span>
|
|
||||||
<span className="text-xs text-gray-500">(prevents booking)</span>
|
|
||||||
{!canCreateHardBlocks && (
|
|
||||||
<span className="text-xs text-red-500">(requires permission)</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recurrence Type */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
{t('myAvailability.form.recurrenceType', 'Recurrence')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.recurrence_type}
|
|
||||||
onChange={(e) => handleFormChange('recurrence_type', e.target.value as RecurrenceType)}
|
|
||||||
className="input-primary w-full"
|
|
||||||
>
|
|
||||||
<option value="NONE">One-time (specific date/range)</option>
|
|
||||||
<option value="WEEKLY">Weekly (e.g., every Monday)</option>
|
|
||||||
<option value="MONTHLY">Monthly (e.g., 1st of month)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recurrence Pattern - NONE */}
|
|
||||||
{formData.recurrence_type === 'NONE' && (
|
|
||||||
<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">
|
|
||||||
Start Date *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.start_date}
|
|
||||||
onChange={(e) => handleFormChange('start_date', e.target.value)}
|
|
||||||
className="input-primary w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
End Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.end_date}
|
|
||||||
onChange={(e) => handleFormChange('end_date', e.target.value)}
|
|
||||||
className="input-primary w-full"
|
|
||||||
min={formData.start_date}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recurrence Pattern - WEEKLY */}
|
|
||||||
{formData.recurrence_type === 'WEEKLY' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Days of Week *
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{DAY_ABBREVS.map((day, index) => {
|
|
||||||
const isSelected = (formData.recurrence_pattern.days_of_week || []).includes(index);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDayOfWeekToggle(index)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-brand-500 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recurrence Pattern - MONTHLY */}
|
|
||||||
{formData.recurrence_type === 'MONTHLY' && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
||||||
Days of Month *
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{Array.from({ length: 31 }, (_, i) => i + 1).map((day) => {
|
|
||||||
const isSelected = (formData.recurrence_pattern.days_of_month || []).includes(day);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={day}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = formData.recurrence_pattern.days_of_month || [];
|
|
||||||
const newDays = current.includes(day)
|
|
||||||
? current.filter((d) => d !== day)
|
|
||||||
: [...current, day].sort((a, b) => a - b);
|
|
||||||
handlePatternChange('days_of_month', newDays);
|
|
||||||
}}
|
|
||||||
className={`w-8 h-8 rounded text-sm font-medium transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-brand-500 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{day}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* All Day Toggle */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="all_day"
|
|
||||||
checked={formData.all_day}
|
|
||||||
onChange={(e) => handleFormChange('all_day', e.target.checked)}
|
|
||||||
className="rounded text-brand-500"
|
|
||||||
/>
|
|
||||||
<label htmlFor="all_day" className="text-sm text-gray-700 dark:text-gray-300">
|
|
||||||
{t('myAvailability.form.allDay', 'All day')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time Range (if not all day) */}
|
|
||||||
{!formData.all_day && (
|
|
||||||
<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">
|
|
||||||
Start Time *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={formData.start_time}
|
|
||||||
onChange={(e) => handleFormChange('start_time', e.target.value)}
|
|
||||||
className="input-primary w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
||||||
End Time *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={formData.end_time}
|
|
||||||
onChange={(e) => handleFormChange('end_time', e.target.value)}
|
|
||||||
className="input-primary w-full"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button type="button" onClick={closeModal} className="btn-secondary">
|
|
||||||
{t('common.cancel', 'Cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary"
|
|
||||||
disabled={createBlock.isPending || updateBlock.isPending}
|
|
||||||
>
|
|
||||||
{(createBlock.isPending || updateBlock.isPending) ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
|
||||||
{t('common.saving', 'Saving...')}
|
|
||||||
</span>
|
|
||||||
) : editingBlock ? (
|
|
||||||
t('common.save', 'Save Changes')
|
|
||||||
) : (
|
|
||||||
t('myAvailability.create', 'Block Time')
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Portal>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Modal */}
|
||||||
{deleteConfirmId && (
|
{deleteConfirmId && (
|
||||||
@@ -760,12 +501,15 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button onClick={() => setDeleteConfirmId(null)} className="btn-secondary">
|
<button
|
||||||
|
onClick={() => setDeleteConfirmId(null)}
|
||||||
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(deleteConfirmId)}
|
onClick={() => handleDelete(deleteConfirmId)}
|
||||||
className="btn-danger"
|
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
|
||||||
disabled={deleteBlock.isPending}
|
disabled={deleteBlock.isPending}
|
||||||
>
|
>
|
||||||
{deleteBlock.isPending ? (
|
{deleteBlock.isPending ? (
|
||||||
|
|||||||
627
frontend/src/pages/StaffDashboard.tsx
Normal file
627
frontend/src/pages/StaffDashboard.tsx
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
format,
|
||||||
|
startOfDay,
|
||||||
|
endOfDay,
|
||||||
|
startOfWeek,
|
||||||
|
endOfWeek,
|
||||||
|
addDays,
|
||||||
|
isToday,
|
||||||
|
isTomorrow,
|
||||||
|
isWithinInterval,
|
||||||
|
parseISO,
|
||||||
|
differenceInMinutes,
|
||||||
|
isBefore,
|
||||||
|
isAfter,
|
||||||
|
} from 'date-fns';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
TrendingUp,
|
||||||
|
CalendarDays,
|
||||||
|
CalendarOff,
|
||||||
|
ArrowRight,
|
||||||
|
PlayCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import apiClient from '../api/client';
|
||||||
|
import { User as UserType } from '../types';
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
interface StaffDashboardProps {
|
||||||
|
user: UserType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Appointment {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
customer_name?: string;
|
||||||
|
service_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StaffDashboard: React.FC<StaffDashboardProps> = ({ user }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const userResourceId = user.linked_resource_id ?? null;
|
||||||
|
|
||||||
|
// Fetch this week's appointments for statistics
|
||||||
|
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 });
|
||||||
|
const weekEnd = endOfWeek(new Date(), { weekStartsOn: 1 });
|
||||||
|
|
||||||
|
const { data: weekAppointments = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['staff-week-appointments', userResourceId, format(weekStart, 'yyyy-MM-dd')],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!userResourceId) return [];
|
||||||
|
|
||||||
|
const response = await apiClient.get('/appointments/', {
|
||||||
|
params: {
|
||||||
|
resource: userResourceId,
|
||||||
|
start_date: weekStart.toISOString(),
|
||||||
|
end_date: weekEnd.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data.map((apt: any) => ({
|
||||||
|
id: apt.id,
|
||||||
|
title: apt.title || apt.service_name || 'Appointment',
|
||||||
|
start_time: apt.start_time,
|
||||||
|
end_time: apt.end_time,
|
||||||
|
status: apt.status,
|
||||||
|
notes: apt.notes,
|
||||||
|
customer_name: apt.customer_name,
|
||||||
|
service_name: apt.service_name,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
enabled: !!userResourceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const todayStart = startOfDay(now);
|
||||||
|
const todayEnd = endOfDay(now);
|
||||||
|
|
||||||
|
const todayAppointments = weekAppointments.filter((apt) =>
|
||||||
|
isWithinInterval(parseISO(apt.start_time), { start: todayStart, end: todayEnd })
|
||||||
|
);
|
||||||
|
|
||||||
|
const completed = weekAppointments.filter(
|
||||||
|
(apt) => apt.status === 'COMPLETED' || apt.status === 'PAID'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const cancelled = weekAppointments.filter(
|
||||||
|
(apt) => apt.status === 'CANCELLED' || apt.status === 'CANCELED'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const noShows = weekAppointments.filter(
|
||||||
|
(apt) => apt.status === 'NOSHOW' || apt.status === 'NO_SHOW'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const scheduled = weekAppointments.filter(
|
||||||
|
(apt) =>
|
||||||
|
apt.status === 'SCHEDULED' ||
|
||||||
|
apt.status === 'CONFIRMED' ||
|
||||||
|
apt.status === 'PENDING'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const inProgress = weekAppointments.filter(
|
||||||
|
(apt) => apt.status === 'IN_PROGRESS'
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Calculate total hours worked this week
|
||||||
|
const totalMinutes = weekAppointments
|
||||||
|
.filter((apt) => apt.status === 'COMPLETED' || apt.status === 'PAID')
|
||||||
|
.reduce((acc, apt) => {
|
||||||
|
const start = parseISO(apt.start_time);
|
||||||
|
const end = parseISO(apt.end_time);
|
||||||
|
return acc + differenceInMinutes(end, start);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const hoursWorked = Math.round(totalMinutes / 60 * 10) / 10;
|
||||||
|
|
||||||
|
return {
|
||||||
|
todayCount: todayAppointments.length,
|
||||||
|
weekTotal: weekAppointments.length,
|
||||||
|
completed,
|
||||||
|
cancelled,
|
||||||
|
noShows,
|
||||||
|
scheduled,
|
||||||
|
inProgress,
|
||||||
|
hoursWorked,
|
||||||
|
completionRate: weekAppointments.length > 0
|
||||||
|
? Math.round((completed / weekAppointments.length) * 100)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
}, [weekAppointments]);
|
||||||
|
|
||||||
|
// Get current or next appointment
|
||||||
|
const currentOrNextAppointment = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
// First check for in-progress
|
||||||
|
const inProgress = weekAppointments.find((apt) => apt.status === 'IN_PROGRESS');
|
||||||
|
if (inProgress) {
|
||||||
|
return { type: 'current', appointment: inProgress };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find next upcoming appointment
|
||||||
|
const upcoming = weekAppointments
|
||||||
|
.filter(
|
||||||
|
(apt) =>
|
||||||
|
(apt.status === 'SCHEDULED' || apt.status === 'CONFIRMED' || apt.status === 'PENDING') &&
|
||||||
|
isAfter(parseISO(apt.start_time), now)
|
||||||
|
)
|
||||||
|
.sort((a, b) => parseISO(a.start_time).getTime() - parseISO(b.start_time).getTime());
|
||||||
|
|
||||||
|
if (upcoming.length > 0) {
|
||||||
|
return { type: 'next', appointment: upcoming[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [weekAppointments]);
|
||||||
|
|
||||||
|
// Get upcoming appointments (next 3 days)
|
||||||
|
const upcomingAppointments = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const threeDaysLater = addDays(now, 3);
|
||||||
|
|
||||||
|
return weekAppointments
|
||||||
|
.filter(
|
||||||
|
(apt) =>
|
||||||
|
(apt.status === 'SCHEDULED' || apt.status === 'CONFIRMED' || apt.status === 'PENDING') &&
|
||||||
|
isAfter(parseISO(apt.start_time), now) &&
|
||||||
|
isBefore(parseISO(apt.start_time), threeDaysLater)
|
||||||
|
)
|
||||||
|
.sort((a, b) => parseISO(a.start_time).getTime() - parseISO(b.start_time).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
}, [weekAppointments]);
|
||||||
|
|
||||||
|
// Weekly chart data
|
||||||
|
const weeklyChartData = useMemo(() => {
|
||||||
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||||
|
const dayMap: Record<string, number> = {};
|
||||||
|
|
||||||
|
days.forEach((day) => {
|
||||||
|
dayMap[day] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
weekAppointments.forEach((apt) => {
|
||||||
|
const date = parseISO(apt.start_time);
|
||||||
|
const dayIndex = (date.getDay() + 6) % 7; // Convert to Mon=0, Sun=6
|
||||||
|
const dayName = days[dayIndex];
|
||||||
|
dayMap[dayName]++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return days.map((day) => ({ name: day, appointments: dayMap[day] }));
|
||||||
|
}, [weekAppointments]);
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status.toUpperCase()) {
|
||||||
|
case 'SCHEDULED':
|
||||||
|
case 'CONFIRMED':
|
||||||
|
case 'PENDING':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300';
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300';
|
||||||
|
case 'COMPLETED':
|
||||||
|
case 'PAID':
|
||||||
|
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||||
|
case 'CANCELLED':
|
||||||
|
case 'CANCELED':
|
||||||
|
case 'NOSHOW':
|
||||||
|
case 'NO_SHOW':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatAppointmentDate = (dateStr: string) => {
|
||||||
|
const date = parseISO(dateStr);
|
||||||
|
if (isToday(date)) return t('staffDashboard.today', 'Today');
|
||||||
|
if (isTomorrow(date)) return t('staffDashboard.tomorrow', 'Tomorrow');
|
||||||
|
return format(date, 'EEE, MMM d');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show message if no resource is linked
|
||||||
|
if (!userResourceId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full bg-gray-50 dark:bg-gray-900 p-8">
|
||||||
|
<div className="max-w-2xl mx-auto text-center">
|
||||||
|
<Calendar size={64} className="mx-auto text-gray-300 dark:text-gray-600 mb-6" />
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-3">
|
||||||
|
{t('staffDashboard.welcomeTitle', 'Welcome, {{name}}!', { name: user.name })}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-6">
|
||||||
|
{t(
|
||||||
|
'staffDashboard.noResourceLinked',
|
||||||
|
'Your account is not linked to a resource yet. Please contact your manager to set up your schedule.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-64 mb-2"></div>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-48"></div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 animate-pulse"
|
||||||
|
>
|
||||||
|
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-24 mb-2"></div>
|
||||||
|
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 space-y-6 bg-gray-50 dark:bg-gray-900 min-h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{t('staffDashboard.welcomeTitle', 'Welcome, {{name}}!', { name: user.name })}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.weekOverview', "Here's your week at a glance")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current/Next Appointment Banner */}
|
||||||
|
{currentOrNextAppointment && (
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-xl border-l-4 ${
|
||||||
|
currentOrNextAppointment.type === 'current'
|
||||||
|
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-500'
|
||||||
|
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-lg ${
|
||||||
|
currentOrNextAppointment.type === 'current'
|
||||||
|
? 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-600'
|
||||||
|
: 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentOrNextAppointment.type === 'current' ? (
|
||||||
|
<PlayCircle size={24} />
|
||||||
|
) : (
|
||||||
|
<Clock size={24} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{currentOrNextAppointment.type === 'current'
|
||||||
|
? t('staffDashboard.currentAppointment', 'Current Appointment')
|
||||||
|
: t('staffDashboard.nextAppointment', 'Next Appointment')}
|
||||||
|
</p>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{currentOrNextAppointment.appointment.service_name ||
|
||||||
|
currentOrNextAppointment.appointment.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-300 mt-1">
|
||||||
|
{currentOrNextAppointment.appointment.customer_name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User size={14} />
|
||||||
|
{currentOrNextAppointment.appointment.customer_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={14} />
|
||||||
|
{format(parseISO(currentOrNextAppointment.appointment.start_time), 'h:mm a')} -{' '}
|
||||||
|
{format(parseISO(currentOrNextAppointment.appointment.end_time), 'h:mm a')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/my-schedule"
|
||||||
|
className="px-4 py-2 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{t('staffDashboard.viewSchedule', 'View Schedule')}
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Today's Appointments */}
|
||||||
|
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||||
|
<Calendar size={18} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.todayAppointments', 'Today')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.todayCount}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('staffDashboard.appointmentsLabel', 'appointments')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* This Week Total */}
|
||||||
|
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-purple-100 dark:bg-purple-900/40 rounded-lg">
|
||||||
|
<CalendarDays size={18} className="text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.thisWeek', 'This Week')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.weekTotal}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('staffDashboard.totalAppointments', 'total appointments')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completed */}
|
||||||
|
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-green-100 dark:bg-green-900/40 rounded-lg">
|
||||||
|
<CheckCircle size={18} className="text-green-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.completed', 'Completed')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.completed}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{stats.completionRate}% {t('staffDashboard.completionRate', 'completion rate')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hours Worked */}
|
||||||
|
<div className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="p-2 bg-orange-100 dark:bg-orange-900/40 rounded-lg">
|
||||||
|
<Clock size={18} className="text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.hoursWorked', 'Hours Worked')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{stats.hoursWorked}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{t('staffDashboard.thisWeekLabel', 'this week')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Upcoming Appointments */}
|
||||||
|
<div className="lg:col-span-1 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('staffDashboard.upcomingAppointments', 'Upcoming')}
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to="/my-schedule"
|
||||||
|
className="text-sm text-brand-600 hover:text-brand-700 dark:text-brand-400"
|
||||||
|
>
|
||||||
|
{t('common.viewAll', 'View All')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{upcomingAppointments.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Calendar size={40} className="mx-auto text-gray-300 dark:text-gray-600 mb-3" />
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.noUpcoming', 'No upcoming appointments')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{upcomingAppointments.map((apt) => (
|
||||||
|
<div
|
||||||
|
key={apt.id}
|
||||||
|
className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg border border-gray-100 dark:border-gray-600"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{apt.service_name || apt.title}
|
||||||
|
</h4>
|
||||||
|
{apt.customer_name && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-0.5">
|
||||||
|
<User size={10} />
|
||||||
|
{apt.customer_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{formatAppointmentDate(apt.start_time)} at{' '}
|
||||||
|
{format(parseISO(apt.start_time), 'h:mm a')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded-full font-medium ${getStatusColor(
|
||||||
|
apt.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{apt.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekly Chart */}
|
||||||
|
<div className="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{t('staffDashboard.weeklyOverview', 'This Week')}
|
||||||
|
</h2>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={weeklyChartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#374151" strokeOpacity={0.2} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tick={{ fill: '#9CA3AF', fontSize: 12 }}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: 'rgba(107, 114, 128, 0.1)' }}
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||||
|
backgroundColor: '#1F2937',
|
||||||
|
color: '#F3F4F6',
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [value, t('staffDashboard.appointments', 'Appointments')]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="appointments" fill="#3b82f6" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Breakdown */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||||
|
<Calendar size={18} className="text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.scheduled}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.scheduled', 'Scheduled')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-yellow-100 dark:bg-yellow-900/40 rounded-lg">
|
||||||
|
<TrendingUp size={18} className="text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.inProgress}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.inProgress', 'In Progress')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
|
||||||
|
<XCircle size={18} className="text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.cancelled}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.cancelled', 'Cancelled')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||||
|
<User size={18} className="text-gray-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xl font-bold text-gray-900 dark:text-white">{stats.noShows}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.noShows', 'No-Shows')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Link
|
||||||
|
to="/my-schedule"
|
||||||
|
className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-400 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-brand-100 dark:bg-brand-900/40 rounded-lg group-hover:bg-brand-200 dark:group-hover:bg-brand-800/40 transition-colors">
|
||||||
|
<CalendarDays size={24} className="text-brand-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('staffDashboard.viewMySchedule', 'View My Schedule')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.viewScheduleDesc', 'See your daily appointments and manage your time')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={20} className="text-gray-400 group-hover:text-brand-500 ml-auto transition-colors" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/my-availability"
|
||||||
|
className="p-5 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 hover:border-brand-500 dark:hover:border-brand-400 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="p-3 bg-green-100 dark:bg-green-900/40 rounded-lg group-hover:bg-green-200 dark:group-hover:bg-green-800/40 transition-colors">
|
||||||
|
<CalendarOff size={24} className="text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('staffDashboard.manageAvailability', 'Manage Availability')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('staffDashboard.availabilityDesc', 'Set your working hours and time off')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={20} className="text-gray-400 group-hover:text-green-500 ml-auto transition-colors" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StaffDashboard;
|
||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
useDeleteTimeBlock,
|
useDeleteTimeBlock,
|
||||||
useToggleTimeBlock,
|
useToggleTimeBlock,
|
||||||
useHolidays,
|
useHolidays,
|
||||||
|
usePendingReviews,
|
||||||
|
useApproveTimeBlock,
|
||||||
|
useDenyTimeBlock,
|
||||||
} from '../hooks/useTimeBlocks';
|
} from '../hooks/useTimeBlocks';
|
||||||
import { useResources } from '../hooks/useResources';
|
import { useResources } from '../hooks/useResources';
|
||||||
import Portal from '../components/Portal';
|
import Portal from '../components/Portal';
|
||||||
@@ -38,6 +41,12 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
|
HourglassIcon,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
MessageSquare,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
type TimeBlockTab = 'business' | 'resource' | 'calendar';
|
type TimeBlockTab = 'business' | 'resource' | 'calendar';
|
||||||
@@ -61,6 +70,9 @@ const TimeBlocks: React.FC = () => {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [isPendingReviewsExpanded, setIsPendingReviewsExpanded] = useState(true);
|
||||||
|
const [reviewingBlock, setReviewingBlock] = useState<TimeBlockListItem | null>(null);
|
||||||
|
const [reviewNotes, setReviewNotes] = useState('');
|
||||||
|
|
||||||
// Fetch data (include inactive blocks so users can re-enable them)
|
// Fetch data (include inactive blocks so users can re-enable them)
|
||||||
const {
|
const {
|
||||||
@@ -75,12 +87,15 @@ const TimeBlocks: React.FC = () => {
|
|||||||
|
|
||||||
const { data: holidays = [] } = useHolidays('US');
|
const { data: holidays = [] } = useHolidays('US');
|
||||||
const { data: resources = [] } = useResources();
|
const { data: resources = [] } = useResources();
|
||||||
|
const { data: pendingReviews } = usePendingReviews();
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const createBlock = useCreateTimeBlock();
|
const createBlock = useCreateTimeBlock();
|
||||||
const updateBlock = useUpdateTimeBlock();
|
const updateBlock = useUpdateTimeBlock();
|
||||||
const deleteBlock = useDeleteTimeBlock();
|
const deleteBlock = useDeleteTimeBlock();
|
||||||
const toggleBlock = useToggleTimeBlock();
|
const toggleBlock = useToggleTimeBlock();
|
||||||
|
const approveBlock = useApproveTimeBlock();
|
||||||
|
const denyBlock = useDenyTimeBlock();
|
||||||
|
|
||||||
// Current blocks based on tab
|
// Current blocks based on tab
|
||||||
const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks;
|
const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks;
|
||||||
@@ -130,6 +145,26 @@ const TimeBlocks: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleApprove = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await approveBlock.mutateAsync({ id, notes: reviewNotes });
|
||||||
|
setReviewingBlock(null);
|
||||||
|
setReviewNotes('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to approve time block:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeny = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await denyBlock.mutateAsync({ id, notes: reviewNotes });
|
||||||
|
setReviewingBlock(null);
|
||||||
|
setReviewNotes('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to deny time block:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Render block type badge
|
// Render block type badge
|
||||||
const renderBlockTypeBadge = (type: BlockType) => (
|
const renderBlockTypeBadge = (type: BlockType) => (
|
||||||
<span
|
<span
|
||||||
@@ -179,6 +214,97 @@ const TimeBlocks: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Reviews Section */}
|
||||||
|
{pendingReviews && pendingReviews.count > 0 && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsPendingReviewsExpanded(!isPendingReviewsExpanded)}
|
||||||
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-amber-100 dark:bg-amber-800/50 rounded-lg">
|
||||||
|
<HourglassIcon size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
|
||||||
|
{t('timeBlocks.pendingReviews', 'Pending Time Off Requests')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
{t('timeBlocks.pendingReviewsCount', '{{count}} request(s) need your review', { count: pendingReviews.count })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isPendingReviewsExpanded ? (
|
||||||
|
<ChevronUp size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={20} className="text-amber-600 dark:text-amber-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isPendingReviewsExpanded && (
|
||||||
|
<div className="border-t border-amber-200 dark:border-amber-800">
|
||||||
|
<div className="divide-y divide-amber-200 dark:divide-amber-800">
|
||||||
|
{pendingReviews.pending_blocks.map((block) => (
|
||||||
|
<div
|
||||||
|
key={block.id}
|
||||||
|
className="p-4 hover:bg-amber-100/50 dark:hover:bg-amber-900/30 cursor-pointer transition-colors"
|
||||||
|
onClick={() => setReviewingBlock(block)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{block.title}
|
||||||
|
</span>
|
||||||
|
{renderBlockTypeBadge(block.block_type)}
|
||||||
|
{renderRecurrenceBadge(block.recurrence_type)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{block.resource_name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User size={14} />
|
||||||
|
{block.resource_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{block.created_by_name && (
|
||||||
|
<span>Requested by {block.created_by_name}</span>
|
||||||
|
)}
|
||||||
|
{block.pattern_display && (
|
||||||
|
<span>{block.pattern_display}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleApprove(block.id);
|
||||||
|
}}
|
||||||
|
className="p-2 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-lg transition-colors"
|
||||||
|
title={t('timeBlocks.approve', 'Approve')}
|
||||||
|
>
|
||||||
|
<CheckCircle size={20} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setReviewingBlock(block);
|
||||||
|
}}
|
||||||
|
className="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||||
|
title={t('timeBlocks.deny', 'Deny')}
|
||||||
|
>
|
||||||
|
<XCircle size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||||
<nav className="flex gap-1" aria-label="Time block tabs">
|
<nav className="flex gap-1" aria-label="Time block tabs">
|
||||||
@@ -548,6 +674,205 @@ const TimeBlocks: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Review Modal */}
|
||||||
|
{reviewingBlock && (
|
||||||
|
<Portal>
|
||||||
|
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-full">
|
||||||
|
<HourglassIcon size={24} className="text-amber-600 dark:text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('timeBlocks.reviewRequest', 'Review Time Off Request')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('timeBlocks.reviewRequestDesc', 'Approve or deny this time off request')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Block Details */}
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.titleCol', 'Title')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.title}</span>
|
||||||
|
</div>
|
||||||
|
{reviewingBlock.resource_name && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.resource', 'Resource')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.resource_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.typeCol', 'Type')}</span>
|
||||||
|
{renderBlockTypeBadge(reviewingBlock.block_type)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule Details Section */}
|
||||||
|
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-2">
|
||||||
|
{t('timeBlocks.scheduleDetails', 'Schedule Details')}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Recurrence Type */}
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.patternCol', 'Pattern')}</span>
|
||||||
|
{renderRecurrenceBadge(reviewingBlock.recurrence_type)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* One-time block dates */}
|
||||||
|
{reviewingBlock.recurrence_type === 'NONE' && reviewingBlock.start_date && (
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.dates', 'Date(s)')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{reviewingBlock.start_date === reviewingBlock.end_date ? (
|
||||||
|
new Date(reviewingBlock.start_date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{new Date(reviewingBlock.start_date + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||||
|
{' - '}
|
||||||
|
{new Date((reviewingBlock.end_date || reviewingBlock.start_date) + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Weekly: Days of week */}
|
||||||
|
{reviewingBlock.recurrence_type === 'WEEKLY' && reviewingBlock.recurrence_pattern?.days_of_week && (
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.daysOfWeek', 'Days')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{reviewingBlock.recurrence_pattern.days_of_week
|
||||||
|
.map(d => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d])
|
||||||
|
.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monthly: Days of month */}
|
||||||
|
{reviewingBlock.recurrence_type === 'MONTHLY' && reviewingBlock.recurrence_pattern?.days_of_month && (
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.daysOfMonth', 'Days of Month')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{reviewingBlock.recurrence_pattern.days_of_month.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Yearly: Month and day */}
|
||||||
|
{reviewingBlock.recurrence_type === 'YEARLY' && reviewingBlock.recurrence_pattern?.month && (
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.yearlyDate', 'Annual Date')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][reviewingBlock.recurrence_pattern.month - 1]} {reviewingBlock.recurrence_pattern.day}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recurrence period (start/end) for recurring blocks */}
|
||||||
|
{reviewingBlock.recurrence_type !== 'NONE' && (reviewingBlock.recurrence_start || reviewingBlock.recurrence_end) && (
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.effectivePeriod', 'Effective Period')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{reviewingBlock.recurrence_start ? (
|
||||||
|
new Date(reviewingBlock.recurrence_start + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
) : 'No start date'}
|
||||||
|
{' - '}
|
||||||
|
{reviewingBlock.recurrence_end ? (
|
||||||
|
new Date(reviewingBlock.recurrence_end + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
) : 'Ongoing'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time range if not all-day */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.timeRange', 'Time')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{reviewingBlock.all_day !== false ? (
|
||||||
|
t('timeBlocks.allDay', 'All Day')
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{reviewingBlock.start_time?.slice(0, 5)} - {reviewingBlock.end_time?.slice(0, 5)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reviewingBlock.description && (
|
||||||
|
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400 block mb-1">{t('timeBlocks.description', 'Description')}</span>
|
||||||
|
<p className="text-sm text-gray-700 dark:text-gray-300">{reviewingBlock.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{reviewingBlock.created_by_name && (
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.requestedBy', 'Requested by')}</span>
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.created_by_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
<MessageSquare size={14} className="inline mr-1" />
|
||||||
|
{t('timeBlocks.reviewNotes', 'Notes (optional)')}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={reviewNotes}
|
||||||
|
onChange={(e) => setReviewNotes(e.target.value)}
|
||||||
|
placeholder={t('timeBlocks.reviewNotesPlaceholder', 'Add a note for the requester...')}
|
||||||
|
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 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setReviewingBlock(null);
|
||||||
|
setReviewNotes('');
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.cancel', 'Cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeny(reviewingBlock.id)}
|
||||||
|
disabled={denyBlock.isPending}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{denyBlock.isPending ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||||
|
) : (
|
||||||
|
<XCircle size={18} />
|
||||||
|
)}
|
||||||
|
{t('timeBlocks.deny', 'Deny')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleApprove(reviewingBlock.id)}
|
||||||
|
disabled={approveBlock.isPending}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{approveBlock.isPending ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle size={18} />
|
||||||
|
)}
|
||||||
|
{t('timeBlocks.approve', 'Approve')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Portal>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -599,6 +599,8 @@ export interface TimeBlock {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApprovalStatus = 'APPROVED' | 'PENDING' | 'DENIED';
|
||||||
|
|
||||||
export interface TimeBlockListItem {
|
export interface TimeBlockListItem {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -619,6 +621,12 @@ export interface TimeBlockListItem {
|
|||||||
pattern_display?: string;
|
pattern_display?: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
approval_status?: ApprovalStatus;
|
||||||
|
reviewed_by?: number;
|
||||||
|
reviewed_by_name?: string;
|
||||||
|
reviewed_at?: string;
|
||||||
|
review_notes?: string;
|
||||||
|
created_by_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockedDate {
|
export interface BlockedDate {
|
||||||
@@ -648,6 +656,7 @@ export interface TimeBlockConflictCheck {
|
|||||||
export interface MyBlocksResponse {
|
export interface MyBlocksResponse {
|
||||||
business_blocks: TimeBlockListItem[];
|
business_blocks: TimeBlockListItem[];
|
||||||
my_blocks: TimeBlockListItem[];
|
my_blocks: TimeBlockListItem[];
|
||||||
resource_id: string;
|
resource_id: string | null;
|
||||||
resource_name: string;
|
resource_name: string | null;
|
||||||
|
can_self_approve: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,6 @@ export interface StatusHistoryItem {
|
|||||||
export interface JobDetail {
|
export interface JobDetail {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time: string;
|
end_time: string;
|
||||||
status: JobStatus;
|
status: JobStatus;
|
||||||
@@ -70,23 +69,29 @@ export interface JobDetail {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone_masked: string | null;
|
||||||
phone_masked?: string;
|
|
||||||
} | null;
|
} | null;
|
||||||
address: string | null;
|
|
||||||
latitude: number | null;
|
|
||||||
longitude: number | null;
|
|
||||||
service: {
|
service: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
duration_minutes: number;
|
duration: number;
|
||||||
price: string | null;
|
price: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
available_transitions: JobStatus[];
|
|
||||||
allowed_transitions: JobStatus[];
|
allowed_transitions: JobStatus[];
|
||||||
is_tracking_location: boolean;
|
can_track_location: boolean;
|
||||||
|
has_active_call_session: boolean;
|
||||||
status_history?: StatusHistoryItem[];
|
status_history?: StatusHistoryItem[];
|
||||||
|
latest_location?: {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
timestamp: string;
|
||||||
|
accuracy: number | null;
|
||||||
|
} | null;
|
||||||
|
deposit_amount?: string | null;
|
||||||
|
final_price?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
can_edit_schedule?: boolean;
|
can_edit_schedule?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,10 +130,8 @@ export interface LocationPoint {
|
|||||||
|
|
||||||
export interface RouteResponse {
|
export interface RouteResponse {
|
||||||
job_id: number;
|
job_id: number;
|
||||||
status: JobStatus;
|
|
||||||
is_tracking: boolean;
|
|
||||||
route: LocationPoint[];
|
route: LocationPoint[];
|
||||||
latest_location: LocationPoint | null;
|
point_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CallResponse {
|
export interface CallResponse {
|
||||||
@@ -151,11 +154,16 @@ export interface SMSResponse {
|
|||||||
export interface CallHistoryItem {
|
export interface CallHistoryItem {
|
||||||
id: number;
|
id: number;
|
||||||
call_type: 'OUTBOUND_CALL' | 'INBOUND_CALL' | 'OUTBOUND_SMS' | 'INBOUND_SMS';
|
call_type: 'OUTBOUND_CALL' | 'INBOUND_CALL' | 'OUTBOUND_SMS' | 'INBOUND_SMS';
|
||||||
|
type_display: string;
|
||||||
direction: 'outbound' | 'inbound';
|
direction: 'outbound' | 'inbound';
|
||||||
duration_seconds: number | null;
|
direction_display: string;
|
||||||
status: string;
|
status: string;
|
||||||
created_at: string;
|
status_display: string;
|
||||||
sms_body: string | null;
|
duration_seconds: number | null;
|
||||||
|
initiated_at: string;
|
||||||
|
answered_at: string | null;
|
||||||
|
ended_at: string | null;
|
||||||
|
employee_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiError {
|
export interface ApiError {
|
||||||
|
|||||||
546
smoothschedule/core/mixins.py
Normal file
546
smoothschedule/core/mixins.py
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
"""
|
||||||
|
Core Mixins for DRF ViewSets
|
||||||
|
|
||||||
|
Reusable mixins to reduce code duplication across ViewSets.
|
||||||
|
"""
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
from rest_framework.exceptions import PermissionDenied
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Permission Classes
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
def _staff_has_permission_override(user, permission_key):
|
||||||
|
"""
|
||||||
|
Check if a staff member has a per-user permission override.
|
||||||
|
|
||||||
|
Staff members can be granted specific permissions via user.permissions JSONField.
|
||||||
|
This allows owners/managers to grant individual staff access to normally restricted areas.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to check
|
||||||
|
permission_key: The permission key to check (e.g., 'can_access_resources')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if user has the permission override
|
||||||
|
"""
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
permissions = getattr(user, 'permissions', {}) or {}
|
||||||
|
return permissions.get(permission_key, False)
|
||||||
|
|
||||||
|
|
||||||
|
class DenyStaffWritePermission(BasePermission):
|
||||||
|
"""
|
||||||
|
Permission class that denies write operations for staff members.
|
||||||
|
|
||||||
|
Use this instead of manually checking user.role in each view method.
|
||||||
|
Staff can still perform read operations (GET, HEAD, OPTIONS).
|
||||||
|
|
||||||
|
Per-user override: Set user.permissions['can_write_<resource>'] = True
|
||||||
|
where <resource> is derived from the view's basename or model name.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class ResourceViewSet(ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated, DenyStaffWritePermission]
|
||||||
|
|
||||||
|
# Optional: specify custom permission key
|
||||||
|
staff_write_permission_key = 'can_edit_resources'
|
||||||
|
"""
|
||||||
|
message = "Staff members do not have access to this resource."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
# Allow read operations
|
||||||
|
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if user is staff
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
if request.user.is_authenticated and request.user.role == User.Role.TENANT_STAFF:
|
||||||
|
# Check for per-user permission override
|
||||||
|
permission_key = self._get_permission_key(view, 'write')
|
||||||
|
if _staff_has_permission_override(request.user, permission_key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_permission_key(self, view, action_type):
|
||||||
|
"""Get the permission key to check for overrides."""
|
||||||
|
# First check if view specifies a custom key
|
||||||
|
custom_key = getattr(view, f'staff_{action_type}_permission_key', None)
|
||||||
|
if custom_key:
|
||||||
|
return custom_key
|
||||||
|
|
||||||
|
# Otherwise derive from view basename or model
|
||||||
|
basename = getattr(view, 'basename', None)
|
||||||
|
if basename:
|
||||||
|
return f'can_{action_type}_{basename}'
|
||||||
|
|
||||||
|
# Fallback to model name
|
||||||
|
queryset = getattr(view, 'queryset', None)
|
||||||
|
if queryset is not None:
|
||||||
|
model_name = queryset.model._meta.model_name
|
||||||
|
return f'can_{action_type}_{model_name}s'
|
||||||
|
|
||||||
|
return f'can_{action_type}_resource'
|
||||||
|
|
||||||
|
|
||||||
|
class DenyStaffAllAccessPermission(BasePermission):
|
||||||
|
"""
|
||||||
|
Permission class that denies ALL operations for staff members.
|
||||||
|
|
||||||
|
Use this for endpoints where staff should not have any access,
|
||||||
|
not even read access (e.g., services, resources).
|
||||||
|
|
||||||
|
Per-user override: Set user.permissions['can_access_<resource>'] = True
|
||||||
|
where <resource> is derived from the view's basename or model name.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class ServiceViewSet(ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
|
||||||
|
|
||||||
|
# Optional: specify custom permission key
|
||||||
|
staff_access_permission_key = 'can_access_services'
|
||||||
|
"""
|
||||||
|
message = "Staff members do not have access to this resource."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
if request.user.is_authenticated and request.user.role == User.Role.TENANT_STAFF:
|
||||||
|
# Check for per-user permission override
|
||||||
|
permission_key = self._get_permission_key(view)
|
||||||
|
if _staff_has_permission_override(request.user, permission_key):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_permission_key(self, view):
|
||||||
|
"""Get the permission key to check for overrides."""
|
||||||
|
# First check if view specifies a custom key
|
||||||
|
custom_key = getattr(view, 'staff_access_permission_key', None)
|
||||||
|
if custom_key:
|
||||||
|
return custom_key
|
||||||
|
|
||||||
|
# Otherwise derive from view basename or model
|
||||||
|
basename = getattr(view, 'basename', None)
|
||||||
|
if basename:
|
||||||
|
return f'can_access_{basename}'
|
||||||
|
|
||||||
|
# Fallback to model name
|
||||||
|
queryset = getattr(view, 'queryset', None)
|
||||||
|
if queryset is not None:
|
||||||
|
model_name = queryset.model._meta.model_name
|
||||||
|
return f'can_access_{model_name}s'
|
||||||
|
|
||||||
|
return 'can_access_resource'
|
||||||
|
|
||||||
|
|
||||||
|
class DenyStaffListPermission(BasePermission):
|
||||||
|
"""
|
||||||
|
Permission class that denies list/create/update/delete for staff members.
|
||||||
|
|
||||||
|
Staff can still retrieve individual objects (useful for customer details
|
||||||
|
on appointments where staff need name/address but not full list).
|
||||||
|
|
||||||
|
Per-user overrides:
|
||||||
|
- user.permissions['can_list_<resource>'] = True (allows list action)
|
||||||
|
- user.permissions['can_access_<resource>'] = True (allows all actions)
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class CustomerViewSet(ModelViewSet):
|
||||||
|
permission_classes = [IsAuthenticated, DenyStaffListPermission]
|
||||||
|
|
||||||
|
# Optional: specify custom permission keys
|
||||||
|
staff_list_permission_key = 'can_list_customers'
|
||||||
|
staff_access_permission_key = 'can_access_customers'
|
||||||
|
"""
|
||||||
|
message = "Staff members do not have access to this resource."
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
# Allow retrieve (detail view) for staff
|
||||||
|
if view.action == 'retrieve':
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Deny all other actions for staff (unless they have override)
|
||||||
|
if request.user.is_authenticated and request.user.role == User.Role.TENANT_STAFF:
|
||||||
|
if view.action in ['list', 'create', 'update', 'partial_update', 'destroy']:
|
||||||
|
# Check for full access override
|
||||||
|
access_key = self._get_permission_key(view, 'access')
|
||||||
|
if _staff_has_permission_override(request.user, access_key):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check for list-only override (for list action)
|
||||||
|
if view.action == 'list':
|
||||||
|
list_key = self._get_permission_key(view, 'list')
|
||||||
|
if _staff_has_permission_override(request.user, list_key):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_permission_key(self, view, action_type):
|
||||||
|
"""Get the permission key to check for overrides."""
|
||||||
|
# First check if view specifies a custom key
|
||||||
|
custom_key = getattr(view, f'staff_{action_type}_permission_key', None)
|
||||||
|
if custom_key:
|
||||||
|
return custom_key
|
||||||
|
|
||||||
|
# Otherwise derive from view basename or model
|
||||||
|
basename = getattr(view, 'basename', None)
|
||||||
|
if basename:
|
||||||
|
return f'can_{action_type}_{basename}'
|
||||||
|
|
||||||
|
# Fallback to model name
|
||||||
|
queryset = getattr(view, 'queryset', None)
|
||||||
|
if queryset is not None:
|
||||||
|
model_name = queryset.model._meta.model_name
|
||||||
|
return f'can_{action_type}_{model_name}s'
|
||||||
|
|
||||||
|
return f'can_{action_type}_resource'
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# QuerySet Mixins
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
class TenantFilteredQuerySetMixin:
|
||||||
|
"""
|
||||||
|
Mixin that filters querysets by tenant and validates user access.
|
||||||
|
|
||||||
|
Provides standardized tenant validation that was previously duplicated
|
||||||
|
across 10+ ViewSets. Use as the first mixin in your class definition.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Validates user is authenticated
|
||||||
|
- Validates user belongs to request tenant
|
||||||
|
- Returns empty queryset for invalid access
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
|
||||||
|
queryset = Resource.objects.all()
|
||||||
|
# ... rest of viewset
|
||||||
|
|
||||||
|
Override `filter_queryset_for_tenant` for custom filtering logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Set to True to deny staff access entirely (returns empty queryset)
|
||||||
|
deny_staff_queryset = False
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Filter queryset by tenant with security validation.
|
||||||
|
|
||||||
|
CRITICAL: This validates that the user belongs to the current tenant
|
||||||
|
and prevents cross-tenant data access.
|
||||||
|
"""
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
# Unauthenticated users get empty queryset
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Optionally deny staff access at queryset level
|
||||||
|
if self.deny_staff_queryset:
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
if user.role == User.Role.TENANT_STAFF:
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Validate user belongs to the current tenant
|
||||||
|
request_tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if user.tenant and request_tenant:
|
||||||
|
if user.tenant.schema_name != request_tenant.schema_name:
|
||||||
|
# User is accessing a tenant they don't belong to
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
# Apply any custom filtering
|
||||||
|
return self.filter_queryset_for_tenant(queryset)
|
||||||
|
|
||||||
|
def filter_queryset_for_tenant(self, queryset):
|
||||||
|
"""
|
||||||
|
Override this method for custom tenant filtering logic.
|
||||||
|
|
||||||
|
By default, returns the queryset unchanged (django-tenants handles
|
||||||
|
the actual tenant scoping for most models).
|
||||||
|
"""
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class SandboxFilteredQuerySetMixin(TenantFilteredQuerySetMixin):
|
||||||
|
"""
|
||||||
|
Mixin that adds sandbox mode filtering on top of tenant filtering.
|
||||||
|
|
||||||
|
For models with `is_sandbox` field, this filters based on the
|
||||||
|
request's sandbox_mode (set by middleware).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class CustomerViewSet(SandboxFilteredQuerySetMixin, ModelViewSet):
|
||||||
|
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter_queryset_for_tenant(self, queryset):
|
||||||
|
"""Filter by sandbox mode if the model supports it."""
|
||||||
|
queryset = super().filter_queryset_for_tenant(queryset)
|
||||||
|
|
||||||
|
# Check if model has is_sandbox field
|
||||||
|
model = queryset.model
|
||||||
|
if hasattr(model, 'is_sandbox'):
|
||||||
|
is_sandbox = getattr(self.request, 'sandbox_mode', False)
|
||||||
|
queryset = queryset.filter(is_sandbox=is_sandbox)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class UserTenantFilteredMixin(SandboxFilteredQuerySetMixin):
|
||||||
|
"""
|
||||||
|
Mixin for ViewSets that query User model (which is in shared schema).
|
||||||
|
|
||||||
|
Since User model uses django-tenants shared schema, it needs explicit
|
||||||
|
tenant filtering via the `tenant` foreign key.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
|
||||||
|
queryset = User.objects.filter(role=User.Role.CUSTOMER)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter_queryset_for_tenant(self, queryset):
|
||||||
|
"""Filter users by tenant foreign key."""
|
||||||
|
queryset = super().filter_queryset_for_tenant(queryset)
|
||||||
|
|
||||||
|
user = self.request.user
|
||||||
|
if user.tenant:
|
||||||
|
queryset = queryset.filter(tenant=user.tenant)
|
||||||
|
else:
|
||||||
|
# User has no tenant - return empty for safety
|
||||||
|
return queryset.none()
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Feature Permission Mixins
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
class PluginFeatureRequiredMixin:
|
||||||
|
"""
|
||||||
|
Mixin that checks plugin permission before allowing access.
|
||||||
|
|
||||||
|
Raises PermissionDenied if tenant doesn't have 'can_use_plugins' feature.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class PluginTemplateViewSet(PluginFeatureRequiredMixin, ModelViewSet):
|
||||||
|
# ...
|
||||||
|
"""
|
||||||
|
plugin_feature_key = 'can_use_plugins'
|
||||||
|
plugin_feature_error = (
|
||||||
|
"Your current plan does not include Plugin access. "
|
||||||
|
"Please upgrade your subscription to use plugins."
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_plugin_permission(self):
|
||||||
|
"""Check if tenant has plugin permission."""
|
||||||
|
tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if tenant and not tenant.has_feature(self.plugin_feature_key):
|
||||||
|
raise PermissionDenied(self.plugin_feature_error)
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
self.check_plugin_permission()
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def retrieve(self, request, *args, **kwargs):
|
||||||
|
self.check_plugin_permission()
|
||||||
|
return super().retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
self.check_plugin_permission()
|
||||||
|
return super().create(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskFeatureRequiredMixin(PluginFeatureRequiredMixin):
|
||||||
|
"""
|
||||||
|
Mixin that checks both plugin and task permissions.
|
||||||
|
|
||||||
|
Requires both 'can_use_plugins' AND 'can_use_tasks' features.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def check_plugin_permission(self):
|
||||||
|
"""Check both plugin and task permissions."""
|
||||||
|
super().check_plugin_permission()
|
||||||
|
|
||||||
|
tenant = getattr(self.request, 'tenant', None)
|
||||||
|
if tenant and not tenant.has_feature('can_use_tasks'):
|
||||||
|
raise PermissionDenied(
|
||||||
|
"Your current plan does not include Scheduled Tasks. "
|
||||||
|
"Please upgrade your subscription to use scheduled tasks."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Response Helper Mixin
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
class StandardResponseMixin:
|
||||||
|
"""
|
||||||
|
Mixin that provides standardized response helpers.
|
||||||
|
|
||||||
|
Reduces boilerplate for common response patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def success_response(self, message, data=None, status_code=200):
|
||||||
|
"""Return a success response with optional data."""
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status as drf_status
|
||||||
|
|
||||||
|
response_data = {'message': message}
|
||||||
|
if data:
|
||||||
|
response_data.update(data)
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
200: drf_status.HTTP_200_OK,
|
||||||
|
201: drf_status.HTTP_201_CREATED,
|
||||||
|
204: drf_status.HTTP_204_NO_CONTENT,
|
||||||
|
}
|
||||||
|
return Response(response_data, status=status_map.get(status_code, status_code))
|
||||||
|
|
||||||
|
def error_response(self, error, status_code=400):
|
||||||
|
"""Return an error response."""
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status as drf_status
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
400: drf_status.HTTP_400_BAD_REQUEST,
|
||||||
|
403: drf_status.HTTP_403_FORBIDDEN,
|
||||||
|
404: drf_status.HTTP_404_NOT_FOUND,
|
||||||
|
500: drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
return Response({'error': error}, status=status_map.get(status_code, status_code))
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# Base API Views
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
class TenantAPIView:
|
||||||
|
"""
|
||||||
|
Base class for tenant-aware APIViews.
|
||||||
|
|
||||||
|
Provides common functionality for views that require tenant context:
|
||||||
|
- Automatic tenant retrieval from request
|
||||||
|
- Standard error responses
|
||||||
|
- Helper methods for common patterns
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from core.mixins import TenantAPIView
|
||||||
|
|
||||||
|
class MyView(TenantAPIView, APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
tenant = self.get_tenant()
|
||||||
|
if not tenant:
|
||||||
|
return self.tenant_required_response()
|
||||||
|
# ... rest of implementation
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_tenant(self):
|
||||||
|
"""Get tenant from request. Returns None if not available."""
|
||||||
|
return getattr(self.request, 'tenant', None)
|
||||||
|
|
||||||
|
def get_tenant_or_error(self):
|
||||||
|
"""
|
||||||
|
Get tenant from request, returning error response if not available.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (tenant, error_response) - error_response is None if tenant exists
|
||||||
|
"""
|
||||||
|
tenant = self.get_tenant()
|
||||||
|
if not tenant:
|
||||||
|
return None, self.tenant_required_response()
|
||||||
|
return tenant, None
|
||||||
|
|
||||||
|
def tenant_required_response(self):
|
||||||
|
"""Return standard error response when tenant is required but missing."""
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status
|
||||||
|
return Response(
|
||||||
|
{'error': 'Tenant context required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
def error_response(self, error, status_code=400):
|
||||||
|
"""Return a standardized error response."""
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status as drf_status
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
400: drf_status.HTTP_400_BAD_REQUEST,
|
||||||
|
403: drf_status.HTTP_403_FORBIDDEN,
|
||||||
|
404: drf_status.HTTP_404_NOT_FOUND,
|
||||||
|
500: drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
}
|
||||||
|
return Response({'error': error}, status=status_map.get(status_code, status_code))
|
||||||
|
|
||||||
|
def success_response(self, data, status_code=200):
|
||||||
|
"""Return a standardized success response."""
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import status as drf_status
|
||||||
|
|
||||||
|
status_map = {
|
||||||
|
200: drf_status.HTTP_200_OK,
|
||||||
|
201: drf_status.HTTP_201_CREATED,
|
||||||
|
}
|
||||||
|
return Response(data, status=status_map.get(status_code, status_code))
|
||||||
|
|
||||||
|
def check_feature(self, feature_key, feature_name=None):
|
||||||
|
"""
|
||||||
|
Check if tenant has a feature, return error response if not.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feature_key: The feature permission key (e.g., 'can_accept_payments')
|
||||||
|
feature_name: Human-readable name for error message (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response or None: Error response if feature not available, None if OK
|
||||||
|
"""
|
||||||
|
tenant = self.get_tenant()
|
||||||
|
if not tenant:
|
||||||
|
return None # Let other checks handle missing tenant
|
||||||
|
|
||||||
|
if not tenant.has_feature(feature_key):
|
||||||
|
name = feature_name or feature_key.replace('can_', '').replace('_', ' ').title()
|
||||||
|
return self.error_response(
|
||||||
|
f"Your current plan does not include {name}. "
|
||||||
|
"Please upgrade your subscription to access this feature.",
|
||||||
|
status_code=403
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TenantRequiredAPIView(TenantAPIView):
|
||||||
|
"""
|
||||||
|
Base class for views that require tenant context.
|
||||||
|
|
||||||
|
Automatically checks for tenant in dispatch and returns error if missing.
|
||||||
|
Subclasses can assume self.tenant is always available.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
class MyView(TenantRequiredAPIView, APIView):
|
||||||
|
def get(self, request):
|
||||||
|
# self.tenant is guaranteed to exist here
|
||||||
|
return Response({'name': self.tenant.name})
|
||||||
|
"""
|
||||||
|
|
||||||
|
tenant = None # Will be set in dispatch
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
"""Check tenant before dispatching to handler."""
|
||||||
|
self.tenant = getattr(request, 'tenant', None)
|
||||||
|
if not self.tenant:
|
||||||
|
return self.tenant_required_response()
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
@@ -78,10 +78,10 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Payment operations (existing)
|
# Payment operations (existing)
|
||||||
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
|
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
|
||||||
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'),
|
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'), # UNUSED_ENDPOINT: For Stripe Terminal POS hardware
|
||||||
path('refunds/', RefundPaymentView.as_view(), name='create-refund'),
|
path('refunds/', RefundPaymentView.as_view(), name='create-refund'), # UNUSED_ENDPOINT: Refunds done via transactions/{id}/refund
|
||||||
|
|
||||||
# Customer billing endpoints
|
# Customer billing endpoints (used by customer portal)
|
||||||
path('customer/billing/', CustomerBillingView.as_view(), name='customer-billing'),
|
path('customer/billing/', CustomerBillingView.as_view(), name='customer-billing'),
|
||||||
path('customer/payment-methods/', CustomerPaymentMethodsView.as_view(), name='customer-payment-methods'),
|
path('customer/payment-methods/', CustomerPaymentMethodsView.as_view(), name='customer-payment-methods'),
|
||||||
path('customer/setup-intent/', CustomerSetupIntentView.as_view(), name='customer-setup-intent'),
|
path('customer/setup-intent/', CustomerSetupIntentView.as_view(), name='customer-setup-intent'),
|
||||||
@@ -89,6 +89,6 @@ urlpatterns = [
|
|||||||
path('customer/payment-methods/<str:payment_method_id>/default/', CustomerPaymentMethodDefaultView.as_view(), name='customer-payment-method-default'),
|
path('customer/payment-methods/<str:payment_method_id>/default/', CustomerPaymentMethodDefaultView.as_view(), name='customer-payment-method-default'),
|
||||||
|
|
||||||
# Variable pricing / final charge endpoints
|
# Variable pricing / final charge endpoints
|
||||||
path('events/<int:event_id>/final-price/', SetFinalPriceView.as_view(), name='set-final-price'),
|
path('events/<int:event_id>/final-price/', SetFinalPriceView.as_view(), name='set-final-price'), # UNUSED_ENDPOINT: For setting final price on variable-priced services
|
||||||
path('events/<int:event_id>/pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'),
|
path('events/<int:event_id>/pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'), # UNUSED_ENDPOINT: Get pricing info for variable-priced events
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.permissions import IsAuthenticated, AllowAny
|
from rest_framework.permissions import IsAuthenticated, AllowAny
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from core.permissions import HasFeaturePermission
|
from core.permissions import HasFeaturePermission
|
||||||
|
from core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from .services import get_stripe_service_for_tenant
|
from .services import get_stripe_service_for_tenant
|
||||||
from .models import TransactionLink
|
from .models import TransactionLink
|
||||||
@@ -18,11 +19,24 @@ from schedule.models import Event
|
|||||||
from platform_admin.models import SubscriptionPlan
|
from platform_admin.models import SubscriptionPlan
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def mask_key(key):
|
||||||
|
"""Mask a key showing only first 7 and last 4 characters."""
|
||||||
|
if not key:
|
||||||
|
return ''
|
||||||
|
if len(key) <= 12:
|
||||||
|
return '*' * len(key)
|
||||||
|
return key[:7] + '*' * (len(key) - 11) + key[-4:]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Payment Configuration Status
|
# Payment Configuration Status
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class PaymentConfigStatusView(APIView):
|
class PaymentConfigStatusView(TenantRequiredAPIView, APIView):
|
||||||
"""
|
"""
|
||||||
Get unified payment configuration status.
|
Get unified payment configuration status.
|
||||||
|
|
||||||
@@ -38,7 +52,7 @@ class PaymentConfigStatusView(APIView):
|
|||||||
permission_classes = [IsAuthenticated]
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
tenant = request.tenant
|
tenant = self.tenant
|
||||||
|
|
||||||
# Build API keys info if configured
|
# Build API keys info if configured
|
||||||
api_keys = None
|
api_keys = None
|
||||||
@@ -46,8 +60,8 @@ class PaymentConfigStatusView(APIView):
|
|||||||
api_keys = {
|
api_keys = {
|
||||||
'id': tenant.id,
|
'id': tenant.id,
|
||||||
'status': tenant.stripe_api_key_status,
|
'status': tenant.stripe_api_key_status,
|
||||||
'secret_key_masked': self._mask_key(tenant.stripe_secret_key),
|
'secret_key_masked': mask_key(tenant.stripe_secret_key),
|
||||||
'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key),
|
'publishable_key_masked': mask_key(tenant.stripe_publishable_key),
|
||||||
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
|
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
|
||||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||||
@@ -98,14 +112,6 @@ class PaymentConfigStatusView(APIView):
|
|||||||
'connect_account': connect_account,
|
'connect_account': connect_account,
|
||||||
})
|
})
|
||||||
|
|
||||||
def _mask_key(self, key):
|
|
||||||
"""Mask a key showing only first 7 and last 4 characters."""
|
|
||||||
if not key:
|
|
||||||
return ''
|
|
||||||
if len(key) <= 12:
|
|
||||||
return '*' * len(key)
|
|
||||||
return key[:7] + '*' * (len(key) - 11) + key[-4:]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Subscription Plans & Add-ons
|
# Subscription Plans & Add-ons
|
||||||
@@ -511,7 +517,7 @@ class ReactivateSubscriptionView(APIView):
|
|||||||
# API Keys Endpoints (Free Tier)
|
# API Keys Endpoints (Free Tier)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class ApiKeysView(APIView):
|
class ApiKeysView(TenantRequiredAPIView, APIView):
|
||||||
"""
|
"""
|
||||||
Manage Stripe API keys for direct integration (free tier).
|
Manage Stripe API keys for direct integration (free tier).
|
||||||
|
|
||||||
@@ -522,7 +528,7 @@ class ApiKeysView(APIView):
|
|||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
"""Get current API key configuration."""
|
"""Get current API key configuration."""
|
||||||
tenant = request.tenant
|
tenant = self.tenant
|
||||||
|
|
||||||
if not tenant.stripe_secret_key:
|
if not tenant.stripe_secret_key:
|
||||||
return Response({
|
return Response({
|
||||||
@@ -534,8 +540,8 @@ class ApiKeysView(APIView):
|
|||||||
'configured': True,
|
'configured': True,
|
||||||
'id': tenant.id,
|
'id': tenant.id,
|
||||||
'status': tenant.stripe_api_key_status,
|
'status': tenant.stripe_api_key_status,
|
||||||
'secret_key_masked': self._mask_key(tenant.stripe_secret_key),
|
'secret_key_masked': mask_key(tenant.stripe_secret_key),
|
||||||
'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key),
|
'publishable_key_masked': mask_key(tenant.stripe_publishable_key),
|
||||||
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
|
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
|
||||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||||
@@ -548,22 +554,16 @@ class ApiKeysView(APIView):
|
|||||||
publishable_key = request.data.get('publishable_key', '').strip()
|
publishable_key = request.data.get('publishable_key', '').strip()
|
||||||
|
|
||||||
if not secret_key or not publishable_key:
|
if not secret_key or not publishable_key:
|
||||||
return Response(
|
return self.error_response('Both secret_key and publishable_key are required')
|
||||||
{'error': 'Both secret_key and publishable_key are required'},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate keys against Stripe
|
# Validate keys against Stripe
|
||||||
validation = self._validate_keys(secret_key, publishable_key)
|
validation = validate_stripe_keys(secret_key, publishable_key)
|
||||||
|
|
||||||
if not validation['valid']:
|
if not validation['valid']:
|
||||||
return Response(
|
return self.error_response(validation.get('error', 'Invalid API keys'))
|
||||||
{'error': validation.get('error', 'Invalid API keys')},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
# Save keys to tenant
|
# Save keys to tenant
|
||||||
tenant = request.tenant
|
tenant = self.tenant
|
||||||
tenant.stripe_secret_key = secret_key
|
tenant.stripe_secret_key = secret_key
|
||||||
tenant.stripe_publishable_key = publishable_key
|
tenant.stripe_publishable_key = publishable_key
|
||||||
tenant.stripe_api_key_status = 'active'
|
tenant.stripe_api_key_status = 'active'
|
||||||
@@ -577,52 +577,45 @@ class ApiKeysView(APIView):
|
|||||||
return Response({
|
return Response({
|
||||||
'id': tenant.id,
|
'id': tenant.id,
|
||||||
'status': 'active',
|
'status': 'active',
|
||||||
'secret_key_masked': self._mask_key(secret_key),
|
'secret_key_masked': mask_key(secret_key),
|
||||||
'publishable_key_masked': self._mask_key(publishable_key),
|
'publishable_key_masked': mask_key(publishable_key),
|
||||||
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat(),
|
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat(),
|
||||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||||
'validation_error': '',
|
'validation_error': '',
|
||||||
}, status=status.HTTP_201_CREATED)
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
def _validate_keys(self, secret_key, publishable_key):
|
|
||||||
"""Validate Stripe API keys."""
|
|
||||||
try:
|
|
||||||
# Test the secret key by retrieving account info
|
|
||||||
stripe.api_key = secret_key
|
|
||||||
account = stripe.Account.retrieve()
|
|
||||||
|
|
||||||
# Verify publishable key format
|
def validate_stripe_keys(secret_key, publishable_key):
|
||||||
if not publishable_key.startswith('pk_'):
|
"""Validate Stripe API keys. Returns dict with 'valid' key and validation info."""
|
||||||
return {'valid': False, 'error': 'Invalid publishable key format'}
|
try:
|
||||||
|
# Test the secret key by retrieving account info
|
||||||
|
stripe.api_key = secret_key
|
||||||
|
account = stripe.Account.retrieve()
|
||||||
|
|
||||||
# Determine environment
|
# Verify publishable key format
|
||||||
is_test = secret_key.startswith('sk_test_')
|
if not publishable_key.startswith('pk_'):
|
||||||
|
return {'valid': False, 'error': 'Invalid publishable key format'}
|
||||||
|
|
||||||
return {
|
# Determine environment
|
||||||
'valid': True,
|
is_test = secret_key.startswith('sk_test_')
|
||||||
'account_id': account.id,
|
|
||||||
'account_name': account.get('business_profile', {}).get('name', '') or account.get('email', ''),
|
|
||||||
'environment': 'test' if is_test else 'live',
|
|
||||||
}
|
|
||||||
except stripe.error.AuthenticationError:
|
|
||||||
return {'valid': False, 'error': 'Invalid secret key'}
|
|
||||||
except stripe.error.StripeError as e:
|
|
||||||
return {'valid': False, 'error': str(e)}
|
|
||||||
finally:
|
|
||||||
# Reset to platform key
|
|
||||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
||||||
|
|
||||||
def _mask_key(self, key):
|
return {
|
||||||
"""Mask a key showing only first 7 and last 4 characters."""
|
'valid': True,
|
||||||
if not key:
|
'account_id': account.id,
|
||||||
return ''
|
'account_name': account.get('business_profile', {}).get('name', '') or account.get('email', ''),
|
||||||
if len(key) <= 12:
|
'environment': 'test' if is_test else 'live',
|
||||||
return '*' * len(key)
|
}
|
||||||
return key[:7] + '*' * (len(key) - 11) + key[-4:]
|
except stripe.error.AuthenticationError:
|
||||||
|
return {'valid': False, 'error': 'Invalid secret key'}
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
return {'valid': False, 'error': str(e)}
|
||||||
|
finally:
|
||||||
|
# Reset to platform key
|
||||||
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||||
|
|
||||||
|
|
||||||
class ApiKeysValidateView(APIView):
|
class ApiKeysValidateView(TenantAPIView, APIView):
|
||||||
"""
|
"""
|
||||||
Validate API keys without saving.
|
Validate API keys without saving.
|
||||||
|
|
||||||
@@ -641,30 +634,8 @@ class ApiKeysValidateView(APIView):
|
|||||||
status=status.HTTP_400_BAD_REQUEST
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
validation = validate_stripe_keys(secret_key, publishable_key)
|
||||||
stripe.api_key = secret_key
|
return Response(validation)
|
||||||
account = stripe.Account.retrieve()
|
|
||||||
|
|
||||||
if not publishable_key.startswith('pk_'):
|
|
||||||
return Response({
|
|
||||||
'valid': False,
|
|
||||||
'error': 'Invalid publishable key format'
|
|
||||||
})
|
|
||||||
|
|
||||||
is_test = secret_key.startswith('sk_test_')
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'valid': True,
|
|
||||||
'account_id': account.id,
|
|
||||||
'account_name': account.get('business_profile', {}).get('name', '') or account.get('email', ''),
|
|
||||||
'environment': 'test' if is_test else 'live',
|
|
||||||
})
|
|
||||||
except stripe.error.AuthenticationError:
|
|
||||||
return Response({'valid': False, 'error': 'Invalid secret key'})
|
|
||||||
except stripe.error.StripeError as e:
|
|
||||||
return Response({'valid': False, 'error': str(e)})
|
|
||||||
finally:
|
|
||||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
||||||
|
|
||||||
|
|
||||||
class ApiKeysRevalidateView(APIView):
|
class ApiKeysRevalidateView(APIView):
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-07 21:41
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('schedule', '0029_add_user_can_edit_schedule'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='timeblock',
|
||||||
|
name='approval_status',
|
||||||
|
field=models.CharField(choices=[('APPROVED', 'Approved'), ('PENDING', 'Pending Review'), ('DENIED', 'Denied')], db_index=True, default='APPROVED', help_text='Approval status for time-off requests', max_length=20),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='timeblock',
|
||||||
|
name='review_notes',
|
||||||
|
field=models.TextField(blank=True, help_text='Optional notes from reviewer'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='timeblock',
|
||||||
|
name='reviewed_at',
|
||||||
|
field=models.DateTimeField(blank=True, help_text='When the request was reviewed', null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='timeblock',
|
||||||
|
name='reviewed_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Manager/owner who reviewed the request', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_time_blocks', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='timeblock',
|
||||||
|
index=models.Index(fields=['approval_status'], name='schedule_ti_approva_127dbb_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1663,6 +1663,11 @@ class TimeBlock(models.Model):
|
|||||||
YEARLY = 'YEARLY', 'Yearly (specific days of year)'
|
YEARLY = 'YEARLY', 'Yearly (specific days of year)'
|
||||||
HOLIDAY = 'HOLIDAY', 'Holiday (floating dates)'
|
HOLIDAY = 'HOLIDAY', 'Holiday (floating dates)'
|
||||||
|
|
||||||
|
class ApprovalStatus(models.TextChoices):
|
||||||
|
APPROVED = 'APPROVED', 'Approved'
|
||||||
|
PENDING = 'PENDING', 'Pending Review'
|
||||||
|
DENIED = 'DENIED', 'Denied'
|
||||||
|
|
||||||
# Core identification
|
# Core identification
|
||||||
title = models.CharField(
|
title = models.CharField(
|
||||||
max_length=200,
|
max_length=200,
|
||||||
@@ -1755,6 +1760,32 @@ class TimeBlock(models.Model):
|
|||||||
# Status
|
# Status
|
||||||
is_active = models.BooleanField(default=True, db_index=True)
|
is_active = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
|
# Approval workflow (for staff time-off requests)
|
||||||
|
approval_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=ApprovalStatus.choices,
|
||||||
|
default=ApprovalStatus.APPROVED,
|
||||||
|
db_index=True,
|
||||||
|
help_text="Approval status for time-off requests"
|
||||||
|
)
|
||||||
|
reviewed_by = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='reviewed_time_blocks',
|
||||||
|
help_text="Manager/owner who reviewed the request"
|
||||||
|
)
|
||||||
|
reviewed_at = models.DateTimeField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="When the request was reviewed"
|
||||||
|
)
|
||||||
|
review_notes = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Optional notes from reviewer"
|
||||||
|
)
|
||||||
|
|
||||||
# Audit
|
# Audit
|
||||||
created_by = models.ForeignKey(
|
created_by = models.ForeignKey(
|
||||||
'users.User',
|
'users.User',
|
||||||
@@ -1771,6 +1802,7 @@ class TimeBlock(models.Model):
|
|||||||
models.Index(fields=['resource', 'is_active']),
|
models.Index(fields=['resource', 'is_active']),
|
||||||
models.Index(fields=['recurrence_type', 'is_active']),
|
models.Index(fields=['recurrence_type', 'is_active']),
|
||||||
models.Index(fields=['start_date', 'end_date']),
|
models.Index(fields=['start_date', 'end_date']),
|
||||||
|
models.Index(fields=['approval_status']),
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -1782,6 +1814,16 @@ class TimeBlock(models.Model):
|
|||||||
"""Check if this is a business-level block (affects all resources)."""
|
"""Check if this is a business-level block (affects all resources)."""
|
||||||
return self.resource is None
|
return self.resource is None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_effective(self):
|
||||||
|
"""Check if this block is effective (active and approved)."""
|
||||||
|
return self.is_active and self.approval_status == self.ApprovalStatus.APPROVED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pending_approval(self):
|
||||||
|
"""Check if this block is pending approval."""
|
||||||
|
return self.approval_status == self.ApprovalStatus.PENDING
|
||||||
|
|
||||||
def blocks_date(self, check_date):
|
def blocks_date(self, check_date):
|
||||||
"""
|
"""
|
||||||
Check if this block applies to a given date.
|
Check if this block applies to a given date.
|
||||||
@@ -1794,7 +1836,8 @@ class TimeBlock(models.Model):
|
|||||||
"""
|
"""
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
if not self.is_active:
|
# Block must be both active and approved to be effective
|
||||||
|
if not self.is_effective:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check recurrence bounds
|
# Check recurrence bounds
|
||||||
|
|||||||
@@ -1164,6 +1164,7 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
|||||||
"""Full serializer for TimeBlock CRUD operations"""
|
"""Full serializer for TimeBlock CRUD operations"""
|
||||||
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
|
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
|
||||||
created_by_name = serializers.SerializerMethodField()
|
created_by_name = serializers.SerializerMethodField()
|
||||||
|
reviewed_by_name = serializers.SerializerMethodField()
|
||||||
level = serializers.SerializerMethodField()
|
level = serializers.SerializerMethodField()
|
||||||
pattern_display = serializers.SerializerMethodField()
|
pattern_display = serializers.SerializerMethodField()
|
||||||
holiday_name = serializers.SerializerMethodField()
|
holiday_name = serializers.SerializerMethodField()
|
||||||
@@ -1178,16 +1179,23 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
|||||||
'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
|
'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
|
||||||
'recurrence_pattern', 'pattern_display', 'holiday_name',
|
'recurrence_pattern', 'pattern_display', 'holiday_name',
|
||||||
'recurrence_start', 'recurrence_end',
|
'recurrence_start', 'recurrence_end',
|
||||||
'is_active', 'created_by', 'created_by_name',
|
'is_active', 'approval_status', 'reviewed_by', 'reviewed_by_name',
|
||||||
|
'reviewed_at', 'review_notes',
|
||||||
|
'created_by', 'created_by_name',
|
||||||
'conflict_count', 'created_at', 'updated_at',
|
'conflict_count', 'created_at', 'updated_at',
|
||||||
]
|
]
|
||||||
read_only_fields = ['created_by', 'created_at', 'updated_at']
|
read_only_fields = ['created_by', 'created_at', 'updated_at', 'reviewed_by', 'reviewed_at']
|
||||||
|
|
||||||
def get_created_by_name(self, obj):
|
def get_created_by_name(self, obj):
|
||||||
if obj.created_by:
|
if obj.created_by:
|
||||||
return obj.created_by.get_full_name() or obj.created_by.email
|
return obj.created_by.get_full_name() or obj.created_by.email
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_reviewed_by_name(self, obj):
|
||||||
|
if obj.reviewed_by:
|
||||||
|
return obj.reviewed_by.get_full_name() or obj.reviewed_by.email
|
||||||
|
return None
|
||||||
|
|
||||||
def get_level(self, obj):
|
def get_level(self, obj):
|
||||||
"""Return 'business' if no resource, otherwise 'resource'"""
|
"""Return 'business' if no resource, otherwise 'resource'"""
|
||||||
return 'business' if obj.resource is None else 'resource'
|
return 'business' if obj.resource is None else 'resource'
|
||||||
@@ -1396,7 +1404,7 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
# Staff can only create blocks for their own resource
|
# Staff can only create blocks for their own resource
|
||||||
if resource:
|
if resource:
|
||||||
if not hasattr(user, 'resource') or user.resource != resource:
|
if not user.staff_resources.filter(pk=resource.pk).exists():
|
||||||
raise serializers.ValidationError({
|
raise serializers.ValidationError({
|
||||||
'resource': 'Staff can only create blocks for their own resource'
|
'resource': 'Staff can only create blocks for their own resource'
|
||||||
})
|
})
|
||||||
@@ -1421,6 +1429,8 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
|||||||
class TimeBlockListSerializer(serializers.ModelSerializer):
|
class TimeBlockListSerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for time block lists - includes fields needed for editing"""
|
"""Serializer for time block lists - includes fields needed for editing"""
|
||||||
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
|
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
|
||||||
|
created_by_name = serializers.SerializerMethodField()
|
||||||
|
reviewed_by_name = serializers.SerializerMethodField()
|
||||||
level = serializers.SerializerMethodField()
|
level = serializers.SerializerMethodField()
|
||||||
pattern_display = serializers.SerializerMethodField()
|
pattern_display = serializers.SerializerMethodField()
|
||||||
|
|
||||||
@@ -1431,9 +1441,21 @@ class TimeBlockListSerializer(serializers.ModelSerializer):
|
|||||||
'block_type', 'recurrence_type', 'start_date', 'end_date',
|
'block_type', 'recurrence_type', 'start_date', 'end_date',
|
||||||
'all_day', 'start_time', 'end_time', 'recurrence_pattern',
|
'all_day', 'start_time', 'end_time', 'recurrence_pattern',
|
||||||
'recurrence_start', 'recurrence_end', 'pattern_display',
|
'recurrence_start', 'recurrence_end', 'pattern_display',
|
||||||
'is_active', 'created_at',
|
'is_active', 'approval_status', 'reviewed_by', 'reviewed_by_name',
|
||||||
|
'reviewed_at', 'review_notes',
|
||||||
|
'created_by', 'created_by_name', 'created_at',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_created_by_name(self, obj):
|
||||||
|
if obj.created_by:
|
||||||
|
return obj.created_by.get_full_name() or obj.created_by.email
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_reviewed_by_name(self, obj):
|
||||||
|
if obj.reviewed_by:
|
||||||
|
return obj.reviewed_by.get_full_name() or obj.reviewed_by.email
|
||||||
|
return None
|
||||||
|
|
||||||
def get_level(self, obj):
|
def get_level(self, obj):
|
||||||
return 'business' if obj.resource is None else 'resource'
|
return 'business' if obj.resource is None else 'resource'
|
||||||
|
|
||||||
|
|||||||
@@ -7,14 +7,28 @@ Handles:
|
|||||||
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
|
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
|
||||||
4. Cancelling tasks when Events are deleted or cancelled
|
4. Cancelling tasks when Events are deleted or cancelled
|
||||||
5. Broadcasting real-time updates via WebSocket for calendar sync
|
5. Broadcasting real-time updates via WebSocket for calendar sync
|
||||||
|
6. Customer notification hooks on status changes
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
from django.db.models.signals import post_save, pre_save, post_delete, pre_delete
|
from django.db.models.signals import post_save, pre_save, post_delete, pre_delete
|
||||||
from django.dispatch import receiver
|
from django.dispatch import Signal, receiver
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Custom Signals for Status Changes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Fired when an event status changes (hook point for custom behavior)
|
||||||
|
# Provides: event, old_status, new_status, changed_by, tenant, skip_notifications
|
||||||
|
event_status_changed = Signal()
|
||||||
|
|
||||||
|
# Fired when a customer should be notified about an event
|
||||||
|
# Provides: event, notification_type, tenant
|
||||||
|
customer_notification_requested = Signal()
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# WebSocket Broadcasting Helpers
|
# WebSocket Broadcasting Helpers
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -402,3 +416,268 @@ def broadcast_event_delete(sender, instance, **kwargs):
|
|||||||
# Store the event data before deletion for broadcasting
|
# Store the event data before deletion for broadcasting
|
||||||
broadcast_event_change_sync(instance, 'event_deleted')
|
broadcast_event_change_sync(instance, 'event_deleted')
|
||||||
logger.info(f"Broadcast event_deleted for event {instance.id}")
|
logger.info(f"Broadcast event_deleted for event {instance.id}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Customer Notification Signal Handlers
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@receiver(event_status_changed)
|
||||||
|
def handle_event_status_change_notifications(sender, event, old_status, new_status, changed_by, tenant, **kwargs):
|
||||||
|
"""
|
||||||
|
Handle customer notifications when event status changes.
|
||||||
|
|
||||||
|
This is a hook point for custom notification behavior.
|
||||||
|
Default behavior: queue customer notifications for certain transitions.
|
||||||
|
"""
|
||||||
|
from .models import Event
|
||||||
|
|
||||||
|
skip_notifications = kwargs.get('skip_notifications', False)
|
||||||
|
if skip_notifications:
|
||||||
|
logger.debug(f"Notifications skipped for event {event.id} status change")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Define which transitions should notify customers
|
||||||
|
NOTIFY_TRANSITIONS = {
|
||||||
|
(Event.Status.SCHEDULED, Event.Status.EN_ROUTE): 'en_route_notification',
|
||||||
|
(Event.Status.EN_ROUTE, Event.Status.IN_PROGRESS): 'arrived_notification',
|
||||||
|
(Event.Status.IN_PROGRESS, Event.Status.COMPLETED): 'completed_notification',
|
||||||
|
}
|
||||||
|
|
||||||
|
notification_type = NOTIFY_TRANSITIONS.get((old_status, new_status))
|
||||||
|
|
||||||
|
if notification_type:
|
||||||
|
# Fire the notification signal
|
||||||
|
customer_notification_requested.send(
|
||||||
|
sender=sender,
|
||||||
|
event=event,
|
||||||
|
notification_type=notification_type,
|
||||||
|
tenant=tenant,
|
||||||
|
)
|
||||||
|
logger.info(f"Requested {notification_type} for event {event.id}")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(event_status_changed)
|
||||||
|
def handle_event_status_change_plugins(sender, event, old_status, new_status, changed_by, tenant, **kwargs):
|
||||||
|
"""
|
||||||
|
Execute plugins attached to status change events.
|
||||||
|
"""
|
||||||
|
from .models import Event
|
||||||
|
|
||||||
|
try:
|
||||||
|
if new_status == Event.Status.COMPLETED:
|
||||||
|
event.execute_plugins(trigger='on_complete')
|
||||||
|
elif new_status == Event.Status.CANCELED:
|
||||||
|
event.execute_plugins(trigger='on_cancel')
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error executing event plugins on status change: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(customer_notification_requested)
|
||||||
|
def send_customer_notification_task(sender, event, notification_type, tenant, **kwargs):
|
||||||
|
"""
|
||||||
|
Queue customer notification via Celery task.
|
||||||
|
|
||||||
|
This is the default handler that queues notifications.
|
||||||
|
Can be replaced or supplemented by custom handlers.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from smoothschedule.field_mobile.tasks import send_customer_status_notification
|
||||||
|
|
||||||
|
send_customer_status_notification.delay(
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
event_id=event.id,
|
||||||
|
notification_type=notification_type,
|
||||||
|
)
|
||||||
|
logger.debug(f"Queued {notification_type} for event {event.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Don't fail the status change if notification fails
|
||||||
|
logger.warning(f"Failed to queue customer notification: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helper function for status changes
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def emit_status_change(event, old_status, new_status, changed_by, tenant, skip_notifications=False):
|
||||||
|
"""
|
||||||
|
Emit the event_status_changed signal.
|
||||||
|
|
||||||
|
This should be called after a successful status transition.
|
||||||
|
The StatusMachine uses this to trigger hooks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The Event instance
|
||||||
|
old_status: Previous status value
|
||||||
|
new_status: New status value
|
||||||
|
changed_by: User who made the change
|
||||||
|
tenant: Business tenant
|
||||||
|
skip_notifications: If True, signal receivers should skip notifications
|
||||||
|
"""
|
||||||
|
event_status_changed.send(
|
||||||
|
sender=event.__class__,
|
||||||
|
event=event,
|
||||||
|
old_status=old_status,
|
||||||
|
new_status=new_status,
|
||||||
|
changed_by=changed_by,
|
||||||
|
tenant=tenant,
|
||||||
|
skip_notifications=skip_notifications,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# TimeBlock (Time-Off Request) Signals
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Custom signal for time-off request notifications
|
||||||
|
time_off_request_submitted = Signal()
|
||||||
|
|
||||||
|
|
||||||
|
def is_notifications_available():
|
||||||
|
"""Check if the notifications app is installed and migrated."""
|
||||||
|
try:
|
||||||
|
from notifications.models import Notification
|
||||||
|
Notification.objects.exists()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create_notification_safe(recipient, actor, verb, action_object=None, target=None, data=None):
|
||||||
|
"""Safely create a notification, handling import and creation errors."""
|
||||||
|
if not is_notifications_available():
|
||||||
|
logger.debug("notifications app not available, skipping notification creation")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from notifications.models import Notification
|
||||||
|
return Notification.objects.create(
|
||||||
|
recipient=recipient,
|
||||||
|
actor=actor,
|
||||||
|
verb=verb,
|
||||||
|
action_object=action_object,
|
||||||
|
target=target,
|
||||||
|
data=data or {}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create notification for {recipient}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender='schedule.TimeBlock')
|
||||||
|
def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
|
||||||
|
"""
|
||||||
|
When a TimeBlock is created with PENDING approval status,
|
||||||
|
notify all managers and owners in the business.
|
||||||
|
"""
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
|
||||||
|
from .models import TimeBlock
|
||||||
|
|
||||||
|
# Only notify for pending requests (staff time-off that needs approval)
|
||||||
|
if instance.approval_status != TimeBlock.ApprovalStatus.PENDING:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only for resource-level blocks (staff time-off)
|
||||||
|
if not instance.resource:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the requester
|
||||||
|
requester = instance.created_by
|
||||||
|
if not requester:
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Time-off request submitted by {requester.get_full_name() or requester.email} "
|
||||||
|
f"for resource '{instance.resource.name}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find all managers and owners to notify
|
||||||
|
from smoothschedule.users.models import User
|
||||||
|
|
||||||
|
reviewers = User.objects.filter(
|
||||||
|
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create in-app notifications for each reviewer
|
||||||
|
for reviewer in reviewers:
|
||||||
|
notification = create_notification_safe(
|
||||||
|
recipient=reviewer,
|
||||||
|
actor=requester,
|
||||||
|
verb='requested time off',
|
||||||
|
action_object=instance,
|
||||||
|
target=instance.resource,
|
||||||
|
data={
|
||||||
|
'time_block_id': instance.id,
|
||||||
|
'title': instance.title,
|
||||||
|
'resource_name': instance.resource.name if instance.resource else None,
|
||||||
|
'requester_name': requester.get_full_name() or requester.email,
|
||||||
|
'type': 'time_off_request',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if notification:
|
||||||
|
logger.debug(f"Created time-off notification for {reviewer.email}")
|
||||||
|
|
||||||
|
# Fire custom signal for additional handlers (e.g., email notifications)
|
||||||
|
time_off_request_submitted.send(
|
||||||
|
sender=sender,
|
||||||
|
time_block=instance,
|
||||||
|
requester=requester,
|
||||||
|
reviewers=list(reviewers),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(time_off_request_submitted)
|
||||||
|
def send_time_off_email_notifications(sender, time_block, requester, reviewers, **kwargs):
|
||||||
|
"""
|
||||||
|
Send email notifications to reviewers when a time-off request is submitted.
|
||||||
|
"""
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Get the resource name for the email
|
||||||
|
resource_name = time_block.resource.name if time_block.resource else 'Unknown'
|
||||||
|
requester_name = requester.get_full_name() or requester.email
|
||||||
|
|
||||||
|
# Build date description
|
||||||
|
if time_block.recurrence_type == 'NONE':
|
||||||
|
if time_block.start_date == time_block.end_date:
|
||||||
|
date_desc = time_block.start_date.strftime('%B %d, %Y') if time_block.start_date else 'Date not specified'
|
||||||
|
else:
|
||||||
|
start = time_block.start_date.strftime('%B %d, %Y') if time_block.start_date else ''
|
||||||
|
end = time_block.end_date.strftime('%B %d, %Y') if time_block.end_date else ''
|
||||||
|
date_desc = f"{start} - {end}"
|
||||||
|
else:
|
||||||
|
date_desc = f"Recurring ({time_block.get_recurrence_type_display()})"
|
||||||
|
|
||||||
|
subject = f"Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}"
|
||||||
|
|
||||||
|
message = f"""
|
||||||
|
A new time-off request has been submitted and needs your review.
|
||||||
|
|
||||||
|
Requester: {requester_name}
|
||||||
|
Resource: {resource_name}
|
||||||
|
Title: {time_block.title or 'Time Off'}
|
||||||
|
Date(s): {date_desc}
|
||||||
|
Description: {time_block.description or 'No description provided'}
|
||||||
|
|
||||||
|
Please log in to review this request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Send email to each reviewer
|
||||||
|
for reviewer in reviewers:
|
||||||
|
if reviewer.email:
|
||||||
|
try:
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[reviewer.email],
|
||||||
|
fail_silently=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Sent time-off request email to {reviewer.email}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to send time-off email to {reviewer.email}: {e}")
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Schedule App URLs
|
Schedule App URLs
|
||||||
|
|
||||||
|
UNUSED_ENDPOINT tags mark endpoints not currently used by the frontend.
|
||||||
|
Search for "UNUSED_ENDPOINT" to find all unused endpoints in the codebase.
|
||||||
"""
|
"""
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
@@ -19,13 +22,13 @@ 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') # UNUSED_ENDPOINT: Participants managed via Event serializer
|
||||||
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')
|
router.register(r'staff', StaffViewSet, basename='staff')
|
||||||
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask')
|
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask')
|
||||||
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog')
|
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog') # UNUSED_ENDPOINT: Logs accessed via scheduled-tasks/{id}/logs action
|
||||||
router.register(r'plugins', PluginViewSet, basename='plugin')
|
router.register(r'plugins', PluginViewSet, basename='plugin') # UNUSED_ENDPOINT: Frontend uses plugin-templates instead
|
||||||
router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemplate')
|
router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemplate')
|
||||||
router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation')
|
router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation')
|
||||||
router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')
|
router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -234,13 +234,16 @@ class StatusMachine:
|
|||||||
if new_status not in self.TRACKING_STATUSES:
|
if new_status not in self.TRACKING_STATUSES:
|
||||||
self._stop_location_tracking(event)
|
self._stop_location_tracking(event)
|
||||||
|
|
||||||
# Trigger notifications if needed
|
# Emit status change signal (triggers notifications and plugin hooks)
|
||||||
if not skip_notifications:
|
from schedule.signals import emit_status_change
|
||||||
notification_type = self.NOTIFY_CUSTOMER_TRANSITIONS.get(
|
emit_status_change(
|
||||||
(old_status, new_status)
|
event=event,
|
||||||
)
|
old_status=old_status,
|
||||||
if notification_type:
|
new_status=new_status,
|
||||||
self._send_customer_notification(event, notification_type)
|
changed_by=self.user,
|
||||||
|
tenant=self.tenant,
|
||||||
|
skip_notifications=skip_notifications,
|
||||||
|
)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
@@ -255,29 +258,6 @@ class StatusMachine:
|
|||||||
# For now, the app will check the event status before sending updates
|
# For now, the app will check the event status before sending updates
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _send_customer_notification(self, event: Event, notification_type: str):
|
|
||||||
"""
|
|
||||||
Send a notification to the customer about the status change.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: The event that changed
|
|
||||||
notification_type: Type of notification to send
|
|
||||||
"""
|
|
||||||
# Import here to avoid circular imports
|
|
||||||
from smoothschedule.field_mobile.tasks import send_customer_status_notification
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Queue the notification task
|
|
||||||
send_customer_status_notification.delay(
|
|
||||||
tenant_id=self.tenant.id,
|
|
||||||
event_id=event.id,
|
|
||||||
notification_type=notification_type,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# Don't fail the status change if notification fails
|
|
||||||
# The notification system should handle retries
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_allowed_transitions(self, event: Event) -> list:
|
def get_allowed_transitions(self, event: Event) -> list:
|
||||||
"""
|
"""
|
||||||
Get the list of statuses this event can transition to.
|
Get the list of statuses this event can transition to.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
Field Mobile URL Configuration
|
Field Mobile URL Configuration
|
||||||
|
|
||||||
All endpoints are mounted under /api/mobile/
|
All endpoints are mounted under /api/mobile/
|
||||||
|
|
||||||
|
UNUSED_ENDPOINT: All endpoints in this file are for a separate mobile app (React Native/Flutter)
|
||||||
|
and are not used by the main React web frontend. These are kept for the field technician
|
||||||
|
mobile application.
|
||||||
"""
|
"""
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
@@ -9,8 +13,7 @@ from .views import (
|
|||||||
# Employee profile
|
# Employee profile
|
||||||
employee_profile_view,
|
employee_profile_view,
|
||||||
logout_view,
|
logout_view,
|
||||||
# Job endpoints
|
# Job endpoints (list uses /api/appointments/ with date range filtering)
|
||||||
job_list_view,
|
|
||||||
job_detail_view,
|
job_detail_view,
|
||||||
# Status management
|
# Status management
|
||||||
set_status_view,
|
set_status_view,
|
||||||
@@ -37,8 +40,7 @@ urlpatterns = [
|
|||||||
path('me/', employee_profile_view, name='employee_profile'),
|
path('me/', employee_profile_view, name='employee_profile'),
|
||||||
path('logout/', logout_view, name='logout'),
|
path('logout/', logout_view, name='logout'),
|
||||||
|
|
||||||
# Job management
|
# Job management (list uses /api/appointments/ with date range filtering)
|
||||||
path('jobs/', job_list_view, name='job_list'),
|
|
||||||
path('jobs/<int:job_id>/', job_detail_view, name='job_detail'),
|
path('jobs/<int:job_id>/', job_detail_view, name='job_detail'),
|
||||||
|
|
||||||
# Status management
|
# Status management
|
||||||
|
|||||||
@@ -153,92 +153,10 @@ def logout_view(request):
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Job List and Detail Endpoints
|
# Job Detail Endpoint
|
||||||
|
# Note: Job list uses /api/appointments/ with date range filtering (start_date, end_date)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
@api_view(['GET'])
|
|
||||||
@permission_classes([IsAuthenticated])
|
|
||||||
def job_list_view(request):
|
|
||||||
"""
|
|
||||||
List jobs assigned to the current employee.
|
|
||||||
|
|
||||||
GET /api/mobile/jobs/
|
|
||||||
|
|
||||||
Query params:
|
|
||||||
- date: Filter by date (YYYY-MM-DD). Defaults to today.
|
|
||||||
- status: Filter by status (comma-separated)
|
|
||||||
- upcoming: If true, include future jobs
|
|
||||||
|
|
||||||
Returns jobs sorted by start time.
|
|
||||||
"""
|
|
||||||
user = request.user
|
|
||||||
tenant = get_tenant_from_user(user)
|
|
||||||
|
|
||||||
if not tenant:
|
|
||||||
return Response(
|
|
||||||
{'error': 'No business associated with your account'},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
|
|
||||||
if not is_field_employee(user):
|
|
||||||
return Response(
|
|
||||||
{'error': 'This app is for field employees only'},
|
|
||||||
status=status.HTTP_403_FORBIDDEN
|
|
||||||
)
|
|
||||||
|
|
||||||
with schema_context(tenant.schema_name):
|
|
||||||
queryset = get_employee_jobs_queryset(user, tenant)
|
|
||||||
|
|
||||||
# Date filtering
|
|
||||||
date_str = request.query_params.get('date')
|
|
||||||
include_upcoming = request.query_params.get('upcoming', 'false').lower() == 'true'
|
|
||||||
|
|
||||||
if date_str:
|
|
||||||
try:
|
|
||||||
from datetime import datetime
|
|
||||||
filter_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
|
||||||
queryset = queryset.filter(
|
|
||||||
start_time__date=filter_date
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
return Response(
|
|
||||||
{'error': 'Invalid date format. Use YYYY-MM-DD'},
|
|
||||||
status=status.HTTP_400_BAD_REQUEST
|
|
||||||
)
|
|
||||||
elif include_upcoming:
|
|
||||||
# Show today and future jobs (using business timezone)
|
|
||||||
import pytz
|
|
||||||
business_tz = pytz.timezone(tenant.timezone) if tenant.timezone else pytz.UTC
|
|
||||||
now_business = timezone.now().astimezone(business_tz)
|
|
||||||
today_start = now_business.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
queryset = queryset.filter(start_time__gte=today_start)
|
|
||||||
else:
|
|
||||||
# Default to today only (using business timezone)
|
|
||||||
import pytz
|
|
||||||
business_tz = pytz.timezone(tenant.timezone) if tenant.timezone else pytz.UTC
|
|
||||||
now_business = timezone.now().astimezone(business_tz)
|
|
||||||
today = now_business.date()
|
|
||||||
queryset = queryset.filter(start_time__date=today)
|
|
||||||
|
|
||||||
# Status filtering
|
|
||||||
status_filter = request.query_params.get('status')
|
|
||||||
if status_filter:
|
|
||||||
statuses = [s.strip().upper() for s in status_filter.split(',')]
|
|
||||||
queryset = queryset.filter(status__in=statuses)
|
|
||||||
|
|
||||||
# Order by start time
|
|
||||||
queryset = queryset.order_by('start_time')
|
|
||||||
|
|
||||||
# Prefetch for efficiency
|
|
||||||
queryset = queryset.select_related('service').prefetch_related('participants')
|
|
||||||
|
|
||||||
serializer = JobListSerializer(queryset, many=True)
|
|
||||||
return Response({
|
|
||||||
'jobs': serializer.data,
|
|
||||||
'count': queryset.count(),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
def job_detail_view(request, job_id):
|
def job_detail_view(request, job_id):
|
||||||
|
|||||||
@@ -153,18 +153,20 @@ def current_user_view(request):
|
|||||||
}
|
}
|
||||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||||
|
|
||||||
# Get linked resource info for staff users
|
# Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
|
||||||
linked_resource_id = None
|
linked_resource_id = None
|
||||||
can_edit_schedule = False
|
can_edit_schedule = False
|
||||||
if user.tenant and user.role == User.Role.TENANT_STAFF:
|
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
|
||||||
try:
|
try:
|
||||||
with schema_context(user.tenant.schema_name):
|
with schema_context(user.tenant.schema_name):
|
||||||
linked_resource = Resource.objects.filter(user=user).first()
|
linked_resource = Resource.objects.filter(user=user).first()
|
||||||
if linked_resource:
|
if linked_resource:
|
||||||
linked_resource_id = linked_resource.id
|
linked_resource_id = linked_resource.id
|
||||||
can_edit_schedule = linked_resource.user_can_edit_schedule
|
can_edit_schedule = linked_resource.user_can_edit_schedule
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error getting linked resource for user {user.id}: {e}")
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
@@ -313,18 +315,20 @@ def _get_user_data(user):
|
|||||||
}
|
}
|
||||||
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
|
||||||
|
|
||||||
# Get linked resource info for staff users
|
# Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
|
||||||
linked_resource_id = None
|
linked_resource_id = None
|
||||||
can_edit_schedule = False
|
can_edit_schedule = False
|
||||||
if user.tenant and user.role == User.Role.TENANT_STAFF:
|
if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
|
||||||
try:
|
try:
|
||||||
with schema_context(user.tenant.schema_name):
|
with schema_context(user.tenant.schema_name):
|
||||||
linked_resource = Resource.objects.filter(user=user).first()
|
linked_resource = Resource.objects.filter(user=user).first()
|
||||||
if linked_resource:
|
if linked_resource:
|
||||||
linked_resource_id = linked_resource.id
|
linked_resource_id = linked_resource.id
|
||||||
can_edit_schedule = linked_resource.user_can_edit_schedule
|
can_edit_schedule = linked_resource.user_can_edit_schedule
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Error getting linked resource for user {user.id} in _get_user_data: {e}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'id': user.id,
|
'id': user.id,
|
||||||
|
|||||||
@@ -259,6 +259,27 @@ class User(AbstractUser):
|
|||||||
return self.permissions.get('can_whitelist_urls', False)
|
return self.permissions.get('can_whitelist_urls', False)
|
||||||
# All others cannot whitelist
|
# All others cannot whitelist
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def can_self_approve_time_off(self):
|
||||||
|
"""
|
||||||
|
Check if user can self-approve time off requests.
|
||||||
|
Owners and managers can always self-approve.
|
||||||
|
Staff need explicit permission.
|
||||||
|
"""
|
||||||
|
# Owners and managers can always self-approve
|
||||||
|
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
|
||||||
|
return True
|
||||||
|
# Staff can self-approve if granted permission
|
||||||
|
if self.role == self.Role.TENANT_STAFF:
|
||||||
|
return self.permissions.get('can_self_approve_time_off', False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def can_review_time_off_requests(self):
|
||||||
|
"""
|
||||||
|
Check if user can review (approve/deny) time off requests from others.
|
||||||
|
Only owners and managers can review.
|
||||||
|
"""
|
||||||
|
return self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]
|
||||||
|
|
||||||
def get_accessible_tenants(self):
|
def get_accessible_tenants(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
Legacy user URLs from cookiecutter-django template.
|
||||||
|
|
||||||
|
UNUSED_ENDPOINT: All endpoints in this file are legacy views from the cookiecutter template
|
||||||
|
and are not used by the React frontend. The API uses /auth/ and /api/ endpoints instead.
|
||||||
|
"""
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import user_detail_view
|
from .views import user_detail_view
|
||||||
@@ -6,7 +12,7 @@ from .views import user_update_view
|
|||||||
|
|
||||||
app_name = "users"
|
app_name = "users"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("~redirect/", view=user_redirect_view, name="redirect"),
|
path("~redirect/", view=user_redirect_view, name="redirect"), # UNUSED_ENDPOINT: Legacy template view
|
||||||
path("~update/", view=user_update_view, name="update"),
|
path("~update/", view=user_update_view, name="update"), # UNUSED_ENDPOINT: Legacy template view
|
||||||
path("<str:username>/", view=user_detail_view, name="detail"),
|
path("<str:username>/", view=user_detail_view, name="detail"), # UNUSED_ENDPOINT: Legacy template view
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user