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 |
|
||||
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User 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
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
|
||||
|
||||
// Import pages
|
||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
|
||||
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
|
||||
const Scheduler = React.lazy(() => import('./pages/Scheduler'));
|
||||
const Customers = React.lazy(() => import('./pages/Customers'));
|
||||
@@ -667,7 +668,7 @@ const AppContent: React.FC = () => {
|
||||
{/* Regular Routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />}
|
||||
element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
|
||||
/>
|
||||
{/* Staff Schedule - vertical timeline view */}
|
||||
<Route
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface User {
|
||||
permissions?: Record<string, boolean>;
|
||||
can_invite_staff?: boolean;
|
||||
can_access_tickets?: boolean;
|
||||
can_edit_schedule?: boolean;
|
||||
linked_resource_id?: number;
|
||||
quota_overages?: QuotaOverage[];
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
|
||||
defaultValue: true,
|
||||
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)
|
||||
{
|
||||
key: 'can_access_tickets',
|
||||
|
||||
@@ -155,6 +155,10 @@ interface TimeBlockCreatorModalProps {
|
||||
holidays: Holiday[];
|
||||
resources: Resource[];
|
||||
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';
|
||||
@@ -168,6 +172,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
holidays,
|
||||
resources,
|
||||
isResourceLevel: initialIsResourceLevel = false,
|
||||
staffMode = false,
|
||||
staffResourceId = null,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
|
||||
@@ -177,7 +183,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
// Form state
|
||||
const [title, setTitle] = useState(editingBlock?.title || '');
|
||||
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 [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
|
||||
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
|
||||
@@ -270,7 +277,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setAllDay(true);
|
||||
setStartTime('09:00');
|
||||
setEndTime('17:00');
|
||||
setResourceId(null);
|
||||
// In staff mode, pre-select the staff's resource
|
||||
setResourceId(staffMode && staffResourceId ? String(staffResourceId) : null);
|
||||
setSelectedDates([]);
|
||||
setDaysOfWeek([]);
|
||||
setDaysOfMonth([]);
|
||||
@@ -279,10 +287,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setHolidayCodes([]);
|
||||
setRecurrenceStart('');
|
||||
setRecurrenceEnd('');
|
||||
setIsResourceLevel(initialIsResourceLevel);
|
||||
// In staff mode, always resource-level
|
||||
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel]);
|
||||
}, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
|
||||
|
||||
// Apply preset configuration
|
||||
const applyPreset = (presetId: string) => {
|
||||
@@ -293,7 +302,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
setTitle(preset.config.title);
|
||||
setRecurrenceType(preset.config.recurrence_type);
|
||||
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.end_time) setEndTime(preset.config.end_time);
|
||||
@@ -367,12 +377,15 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
// In staff mode, always use the staff's resource ID
|
||||
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
|
||||
|
||||
const baseData: any = {
|
||||
description: description || undefined,
|
||||
block_type: blockType,
|
||||
recurrence_type: recurrenceType,
|
||||
all_day: allDay,
|
||||
resource: isResourceLevel ? resourceId : null,
|
||||
resource: isResourceLevel ? effectiveResourceId : null,
|
||||
};
|
||||
|
||||
if (!allDay) {
|
||||
@@ -425,7 +438,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
return true;
|
||||
case 'details':
|
||||
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;
|
||||
case 'schedule':
|
||||
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
|
||||
@@ -556,7 +570,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
{/* Step 2: Details */}
|
||||
{step === 'details' && (
|
||||
<div className="space-y-6">
|
||||
{/* Block Level Selector */}
|
||||
{/* Block Level Selector - Hidden in staff mode */}
|
||||
{!staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Level
|
||||
@@ -613,6 +628,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
@@ -642,8 +658,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource (if resource-level) */}
|
||||
{isResourceLevel && (
|
||||
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
|
||||
{isResourceLevel && !staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Resource
|
||||
@@ -661,7 +677,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Block Type */}
|
||||
{/* Block Type - hidden in staff mode (always SOFT for time-off requests) */}
|
||||
{!staffMode && (
|
||||
<div>
|
||||
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
Block Type
|
||||
@@ -707,6 +724,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Day Toggle & Time */}
|
||||
<div>
|
||||
@@ -1188,11 +1206,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{isResourceLevel && resourceId && (
|
||||
{isResourceLevel && (resourceId || staffResourceId) && (
|
||||
<div className="flex justify-between py-2">
|
||||
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -158,8 +158,9 @@ export const useMyBlocks = () => {
|
||||
id: String(b.id),
|
||||
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,
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -491,7 +491,39 @@
|
||||
"reactivateAccount": "Reactivate Account",
|
||||
"deactivateHint": "Prevent this user from logging in while keeping their data",
|
||||
"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": {
|
||||
"title": "Support Tickets",
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
* My Availability Page
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
TimeBlockListItem,
|
||||
BlockType,
|
||||
RecurrenceType,
|
||||
RecurrencePattern,
|
||||
User,
|
||||
} from '../types';
|
||||
import {
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
useDeleteTimeBlock,
|
||||
useToggleTimeBlock,
|
||||
useHolidays,
|
||||
CreateTimeBlockData,
|
||||
} from '../hooks/useTimeBlocks';
|
||||
import Portal from '../components/Portal';
|
||||
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
|
||||
import TimeBlockCreatorModal from '../components/time-blocks/TimeBlockCreatorModal';
|
||||
import {
|
||||
Calendar,
|
||||
Building2,
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
CalendarDays,
|
||||
@@ -42,8 +41,13 @@ import {
|
||||
Power,
|
||||
PowerOff,
|
||||
Info,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
HourglassIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
type AvailabilityTab = 'blocks' | 'calendar';
|
||||
|
||||
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
|
||||
NONE: 'One-time',
|
||||
WEEKLY: 'Weekly',
|
||||
@@ -57,43 +61,6 @@ const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
||||
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 {
|
||||
user?: User;
|
||||
}
|
||||
@@ -103,9 +70,9 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
const contextUser = useOutletContext<{ user?: User }>()?.user;
|
||||
const user = props.user || contextUser;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<AvailabilityTab>('blocks');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
|
||||
const [formData, setFormData] = useState<TimeBlockFormData>(defaultFormData);
|
||||
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||
|
||||
// Fetch data
|
||||
@@ -118,105 +85,20 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
const deleteBlock = useDeleteTimeBlock();
|
||||
const toggleBlock = useToggleTimeBlock();
|
||||
|
||||
// Check if user can create hard blocks
|
||||
const canCreateHardBlocks = user?.permissions?.can_create_hard_blocks ?? false;
|
||||
|
||||
// Modal handlers
|
||||
const openCreateModal = () => {
|
||||
setEditingBlock(null);
|
||||
setFormData(defaultFormData);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditModal = (block: TimeBlockListItem) => {
|
||||
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);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
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) => {
|
||||
@@ -264,6 +146,35 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</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
|
||||
if (!isLoading && !myBlocksData?.resource_id) {
|
||||
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 (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Header */}
|
||||
@@ -299,77 +216,115 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
{t('myAvailability.title', 'My Availability')}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{myBlocksData?.resource_name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<UserIcon size={14} />
|
||||
{myBlocksData.resource_name}
|
||||
</span>
|
||||
)}
|
||||
{t('myAvailability.subtitle', 'Manage your time off and unavailability')}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={openCreateModal} className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors">
|
||||
<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} />
|
||||
{t('myAvailability.addBlock', 'Block Time')}
|
||||
</button>
|
||||
</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 ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Business Blocks (Read-only) */}
|
||||
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<Building2 size={20} />
|
||||
{t('myAvailability.businessBlocks', 'Business Closures')}
|
||||
</h2>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3">
|
||||
<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.')}
|
||||
<div className="space-y-4">
|
||||
{activeTab === 'blocks' && (
|
||||
<>
|
||||
{/* Resource Info Banner */}
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-300 flex items-center gap-2">
|
||||
<UserIcon size={16} />
|
||||
{t('myAvailability.resourceInfo', 'Managing blocks for:')}
|
||||
<span className="font-semibold">{myBlocksData?.resource_name}</span>
|
||||
</p>
|
||||
</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">
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{myBlocksData.business_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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* My Blocks (Editable) */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2">
|
||||
<UserIcon size={20} />
|
||||
{t('myAvailability.myBlocks', 'My Time Blocks')}
|
||||
</h2>
|
||||
|
||||
{/* My Blocks List */}
|
||||
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Calendar size={48} className="mx-auto text-gray-400 mb-4" />
|
||||
@@ -379,7 +334,10 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
<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">
|
||||
<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>
|
||||
@@ -398,6 +356,9 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
<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>
|
||||
@@ -405,11 +366,18 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</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">
|
||||
<tr key={block.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 ${!block.is_active ? 'opacity-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
<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)}
|
||||
@@ -424,6 +392,16 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
)}
|
||||
</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
|
||||
@@ -455,9 +433,10 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Yearly Calendar View */}
|
||||
{activeTab === 'calendar' && (
|
||||
<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}
|
||||
@@ -470,276 +449,38 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{isModalOpen && (
|
||||
<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 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{editingBlock
|
||||
? t('myAvailability.editBlock', 'Edit Time Block')
|
||||
: t('myAvailability.createBlock', 'Block Time Off')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('myAvailability.form.title', 'Title')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
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);
|
||||
{/* Create/Edit Modal - Using TimeBlockCreatorModal in staff mode */}
|
||||
<TimeBlockCreatorModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
onSubmit={async (data) => {
|
||||
try {
|
||||
if (editingBlock) {
|
||||
await updateBlock.mutateAsync({ id: editingBlock.id, updates: data });
|
||||
} else {
|
||||
// Handle array of blocks (multiple holidays)
|
||||
const blocks = Array.isArray(data) ? data : [data];
|
||||
for (const block of blocks) {
|
||||
await createBlock.mutateAsync(block);
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
} catch (error) {
|
||||
console.error('Failed to save time block:', error);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
isSubmitting={createBlock.isPending || updateBlock.isPending}
|
||||
editingBlock={editingBlock}
|
||||
holidays={holidays}
|
||||
resources={staffResource ? [staffResource as any] : []}
|
||||
isResourceLevel={true}
|
||||
staffMode={true}
|
||||
staffResourceId={myBlocksData?.resource_id}
|
||||
/>
|
||||
<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 */}
|
||||
{deleteConfirmId && (
|
||||
@@ -760,12 +501,15 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
<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')}
|
||||
</button>
|
||||
<button
|
||||
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}
|
||||
>
|
||||
{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,
|
||||
useToggleTimeBlock,
|
||||
useHolidays,
|
||||
usePendingReviews,
|
||||
useApproveTimeBlock,
|
||||
useDenyTimeBlock,
|
||||
} from '../hooks/useTimeBlocks';
|
||||
import { useResources } from '../hooks/useResources';
|
||||
import Portal from '../components/Portal';
|
||||
@@ -38,6 +41,12 @@ import {
|
||||
AlertCircle,
|
||||
Power,
|
||||
PowerOff,
|
||||
HourglassIcon,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
|
||||
type TimeBlockTab = 'business' | 'resource' | 'calendar';
|
||||
@@ -61,6 +70,9 @@ const TimeBlocks: React.FC = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | 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)
|
||||
const {
|
||||
@@ -75,12 +87,15 @@ const TimeBlocks: React.FC = () => {
|
||||
|
||||
const { data: holidays = [] } = useHolidays('US');
|
||||
const { data: resources = [] } = useResources();
|
||||
const { data: pendingReviews } = usePendingReviews();
|
||||
|
||||
// Mutations
|
||||
const createBlock = useCreateTimeBlock();
|
||||
const updateBlock = useUpdateTimeBlock();
|
||||
const deleteBlock = useDeleteTimeBlock();
|
||||
const toggleBlock = useToggleTimeBlock();
|
||||
const approveBlock = useApproveTimeBlock();
|
||||
const denyBlock = useDenyTimeBlock();
|
||||
|
||||
// Current blocks based on tab
|
||||
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
|
||||
const renderBlockTypeBadge = (type: BlockType) => (
|
||||
<span
|
||||
@@ -179,6 +214,97 @@ const TimeBlocks: React.FC = () => {
|
||||
</button>
|
||||
</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 */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="flex gap-1" aria-label="Time block tabs">
|
||||
@@ -548,6 +674,205 @@ const TimeBlocks: React.FC = () => {
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -599,6 +599,8 @@ export interface TimeBlock {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export type ApprovalStatus = 'APPROVED' | 'PENDING' | 'DENIED';
|
||||
|
||||
export interface TimeBlockListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -619,6 +621,12 @@ export interface TimeBlockListItem {
|
||||
pattern_display?: string;
|
||||
is_active: boolean;
|
||||
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 {
|
||||
@@ -648,6 +656,7 @@ export interface TimeBlockConflictCheck {
|
||||
export interface MyBlocksResponse {
|
||||
business_blocks: TimeBlockListItem[];
|
||||
my_blocks: TimeBlockListItem[];
|
||||
resource_id: string;
|
||||
resource_name: string;
|
||||
resource_id: string | null;
|
||||
resource_name: string | null;
|
||||
can_self_approve: boolean;
|
||||
}
|
||||
@@ -60,7 +60,6 @@ export interface StatusHistoryItem {
|
||||
export interface JobDetail {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
status: JobStatus;
|
||||
@@ -70,23 +69,29 @@ export interface JobDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
phone_masked?: string;
|
||||
phone_masked: string | null;
|
||||
} | null;
|
||||
address: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
service: {
|
||||
id: number;
|
||||
name: string;
|
||||
duration_minutes: number;
|
||||
duration: number;
|
||||
price: string | null;
|
||||
} | null;
|
||||
notes: string | null;
|
||||
available_transitions: JobStatus[];
|
||||
allowed_transitions: JobStatus[];
|
||||
is_tracking_location: boolean;
|
||||
can_track_location: boolean;
|
||||
has_active_call_session: boolean;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -125,10 +130,8 @@ export interface LocationPoint {
|
||||
|
||||
export interface RouteResponse {
|
||||
job_id: number;
|
||||
status: JobStatus;
|
||||
is_tracking: boolean;
|
||||
route: LocationPoint[];
|
||||
latest_location: LocationPoint | null;
|
||||
point_count: number;
|
||||
}
|
||||
|
||||
export interface CallResponse {
|
||||
@@ -151,11 +154,16 @@ export interface SMSResponse {
|
||||
export interface CallHistoryItem {
|
||||
id: number;
|
||||
call_type: 'OUTBOUND_CALL' | 'INBOUND_CALL' | 'OUTBOUND_SMS' | 'INBOUND_SMS';
|
||||
type_display: string;
|
||||
direction: 'outbound' | 'inbound';
|
||||
duration_seconds: number | null;
|
||||
direction_display: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
sms_body: string | null;
|
||||
status_display: string;
|
||||
duration_seconds: number | null;
|
||||
initiated_at: string;
|
||||
answered_at: string | null;
|
||||
ended_at: string | null;
|
||||
employee_name: string | null;
|
||||
}
|
||||
|
||||
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)
|
||||
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
|
||||
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'),
|
||||
path('refunds/', RefundPaymentView.as_view(), name='create-refund'),
|
||||
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'), # 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/payment-methods/', CustomerPaymentMethodsView.as_view(), name='customer-payment-methods'),
|
||||
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'),
|
||||
|
||||
# Variable pricing / final charge endpoints
|
||||
path('events/<int:event_id>/final-price/', SetFinalPriceView.as_view(), name='set-final-price'),
|
||||
path('events/<int:event_id>/pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'),
|
||||
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'), # 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 import status
|
||||
from core.permissions import HasFeaturePermission
|
||||
from core.mixins import TenantAPIView, TenantRequiredAPIView
|
||||
from decimal import Decimal
|
||||
from .services import get_stripe_service_for_tenant
|
||||
from .models import TransactionLink
|
||||
@@ -18,11 +19,24 @@ from schedule.models import Event
|
||||
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
|
||||
# ============================================================================
|
||||
|
||||
class PaymentConfigStatusView(APIView):
|
||||
class PaymentConfigStatusView(TenantRequiredAPIView, APIView):
|
||||
"""
|
||||
Get unified payment configuration status.
|
||||
|
||||
@@ -38,7 +52,7 @@ class PaymentConfigStatusView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
tenant = request.tenant
|
||||
tenant = self.tenant
|
||||
|
||||
# Build API keys info if configured
|
||||
api_keys = None
|
||||
@@ -46,8 +60,8 @@ class PaymentConfigStatusView(APIView):
|
||||
api_keys = {
|
||||
'id': tenant.id,
|
||||
'status': tenant.stripe_api_key_status,
|
||||
'secret_key_masked': self._mask_key(tenant.stripe_secret_key),
|
||||
'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key),
|
||||
'secret_key_masked': mask_key(tenant.stripe_secret_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,
|
||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||
@@ -98,14 +112,6 @@ class PaymentConfigStatusView(APIView):
|
||||
'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
|
||||
@@ -511,7 +517,7 @@ class ReactivateSubscriptionView(APIView):
|
||||
# API Keys Endpoints (Free Tier)
|
||||
# ============================================================================
|
||||
|
||||
class ApiKeysView(APIView):
|
||||
class ApiKeysView(TenantRequiredAPIView, APIView):
|
||||
"""
|
||||
Manage Stripe API keys for direct integration (free tier).
|
||||
|
||||
@@ -522,7 +528,7 @@ class ApiKeysView(APIView):
|
||||
|
||||
def get(self, request):
|
||||
"""Get current API key configuration."""
|
||||
tenant = request.tenant
|
||||
tenant = self.tenant
|
||||
|
||||
if not tenant.stripe_secret_key:
|
||||
return Response({
|
||||
@@ -534,8 +540,8 @@ class ApiKeysView(APIView):
|
||||
'configured': True,
|
||||
'id': tenant.id,
|
||||
'status': tenant.stripe_api_key_status,
|
||||
'secret_key_masked': self._mask_key(tenant.stripe_secret_key),
|
||||
'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key),
|
||||
'secret_key_masked': mask_key(tenant.stripe_secret_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,
|
||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||
@@ -548,22 +554,16 @@ class ApiKeysView(APIView):
|
||||
publishable_key = request.data.get('publishable_key', '').strip()
|
||||
|
||||
if not secret_key or not publishable_key:
|
||||
return Response(
|
||||
{'error': 'Both secret_key and publishable_key are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.error_response('Both secret_key and publishable_key are required')
|
||||
|
||||
# Validate keys against Stripe
|
||||
validation = self._validate_keys(secret_key, publishable_key)
|
||||
validation = validate_stripe_keys(secret_key, publishable_key)
|
||||
|
||||
if not validation['valid']:
|
||||
return Response(
|
||||
{'error': validation.get('error', 'Invalid API keys')},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
return self.error_response(validation.get('error', 'Invalid API keys'))
|
||||
|
||||
# Save keys to tenant
|
||||
tenant = request.tenant
|
||||
tenant = self.tenant
|
||||
tenant.stripe_secret_key = secret_key
|
||||
tenant.stripe_publishable_key = publishable_key
|
||||
tenant.stripe_api_key_status = 'active'
|
||||
@@ -577,16 +577,17 @@ class ApiKeysView(APIView):
|
||||
return Response({
|
||||
'id': tenant.id,
|
||||
'status': 'active',
|
||||
'secret_key_masked': self._mask_key(secret_key),
|
||||
'publishable_key_masked': self._mask_key(publishable_key),
|
||||
'secret_key_masked': mask_key(secret_key),
|
||||
'publishable_key_masked': mask_key(publishable_key),
|
||||
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat(),
|
||||
'stripe_account_id': tenant.stripe_api_key_account_id,
|
||||
'stripe_account_name': tenant.stripe_api_key_account_name,
|
||||
'validation_error': '',
|
||||
}, status=status.HTTP_201_CREATED)
|
||||
|
||||
def _validate_keys(self, secret_key, publishable_key):
|
||||
"""Validate Stripe API keys."""
|
||||
|
||||
def validate_stripe_keys(secret_key, publishable_key):
|
||||
"""Validate Stripe API keys. Returns dict with 'valid' key and validation info."""
|
||||
try:
|
||||
# Test the secret key by retrieving account info
|
||||
stripe.api_key = secret_key
|
||||
@@ -613,16 +614,8 @@ class ApiKeysView(APIView):
|
||||
# Reset to platform key
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
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:]
|
||||
|
||||
|
||||
class ApiKeysValidateView(APIView):
|
||||
class ApiKeysValidateView(TenantAPIView, APIView):
|
||||
"""
|
||||
Validate API keys without saving.
|
||||
|
||||
@@ -641,30 +634,8 @@ class ApiKeysValidateView(APIView):
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
stripe.api_key = secret_key
|
||||
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
|
||||
validation = validate_stripe_keys(secret_key, publishable_key)
|
||||
return Response(validation)
|
||||
|
||||
|
||||
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)'
|
||||
HOLIDAY = 'HOLIDAY', 'Holiday (floating dates)'
|
||||
|
||||
class ApprovalStatus(models.TextChoices):
|
||||
APPROVED = 'APPROVED', 'Approved'
|
||||
PENDING = 'PENDING', 'Pending Review'
|
||||
DENIED = 'DENIED', 'Denied'
|
||||
|
||||
# Core identification
|
||||
title = models.CharField(
|
||||
max_length=200,
|
||||
@@ -1755,6 +1760,32 @@ class TimeBlock(models.Model):
|
||||
# Status
|
||||
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
|
||||
created_by = models.ForeignKey(
|
||||
'users.User',
|
||||
@@ -1771,6 +1802,7 @@ class TimeBlock(models.Model):
|
||||
models.Index(fields=['resource', 'is_active']),
|
||||
models.Index(fields=['recurrence_type', 'is_active']),
|
||||
models.Index(fields=['start_date', 'end_date']),
|
||||
models.Index(fields=['approval_status']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
@@ -1782,6 +1814,16 @@ class TimeBlock(models.Model):
|
||||
"""Check if this is a business-level block (affects all resources)."""
|
||||
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):
|
||||
"""
|
||||
Check if this block applies to a given date.
|
||||
@@ -1794,7 +1836,8 @@ class TimeBlock(models.Model):
|
||||
"""
|
||||
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
|
||||
|
||||
# Check recurrence bounds
|
||||
|
||||
@@ -1164,6 +1164,7 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
||||
"""Full serializer for TimeBlock CRUD operations"""
|
||||
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()
|
||||
pattern_display = serializers.SerializerMethodField()
|
||||
holiday_name = serializers.SerializerMethodField()
|
||||
@@ -1178,16 +1179,23 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
||||
'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
|
||||
'recurrence_pattern', 'pattern_display', 'holiday_name',
|
||||
'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',
|
||||
]
|
||||
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):
|
||||
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):
|
||||
"""Return 'business' if no resource, otherwise '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
|
||||
if resource:
|
||||
if not hasattr(user, 'resource') or user.resource != resource:
|
||||
if not user.staff_resources.filter(pk=resource.pk).exists():
|
||||
raise serializers.ValidationError({
|
||||
'resource': 'Staff can only create blocks for their own resource'
|
||||
})
|
||||
@@ -1421,6 +1429,8 @@ class TimeBlockSerializer(serializers.ModelSerializer):
|
||||
class TimeBlockListSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for time block lists - includes fields needed for editing"""
|
||||
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()
|
||||
pattern_display = serializers.SerializerMethodField()
|
||||
|
||||
@@ -1431,9 +1441,21 @@ class TimeBlockListSerializer(serializers.ModelSerializer):
|
||||
'block_type', 'recurrence_type', 'start_date', 'end_date',
|
||||
'all_day', 'start_time', 'end_time', 'recurrence_pattern',
|
||||
'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):
|
||||
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
|
||||
4. Cancelling tasks when Events are deleted or cancelled
|
||||
5. Broadcasting real-time updates via WebSocket for calendar sync
|
||||
6. Customer notification hooks on status changes
|
||||
"""
|
||||
import logging
|
||||
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__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 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
|
||||
# ============================================================================
|
||||
@@ -402,3 +416,268 @@ def broadcast_event_delete(sender, instance, **kwargs):
|
||||
# Store the event data before deletion for broadcasting
|
||||
broadcast_event_change_sync(instance, 'event_deleted')
|
||||
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
|
||||
|
||||
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 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'appointments', EventViewSet, basename='appointment') # Alias for frontend
|
||||
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'services', ServiceViewSet, basename='service')
|
||||
router.register(r'staff', StaffViewSet, basename='staff')
|
||||
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask')
|
||||
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog')
|
||||
router.register(r'plugins', PluginViewSet, basename='plugin')
|
||||
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') # UNUSED_ENDPOINT: Frontend uses plugin-templates instead
|
||||
router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemplate')
|
||||
router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation')
|
||||
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:
|
||||
self._stop_location_tracking(event)
|
||||
|
||||
# Trigger notifications if needed
|
||||
if not skip_notifications:
|
||||
notification_type = self.NOTIFY_CUSTOMER_TRANSITIONS.get(
|
||||
(old_status, new_status)
|
||||
# Emit status change signal (triggers notifications and plugin hooks)
|
||||
from schedule.signals import emit_status_change
|
||||
emit_status_change(
|
||||
event=event,
|
||||
old_status=old_status,
|
||||
new_status=new_status,
|
||||
changed_by=self.user,
|
||||
tenant=self.tenant,
|
||||
skip_notifications=skip_notifications,
|
||||
)
|
||||
if notification_type:
|
||||
self._send_customer_notification(event, notification_type)
|
||||
|
||||
return event
|
||||
|
||||
@@ -255,29 +258,6 @@ class StatusMachine:
|
||||
# For now, the app will check the event status before sending updates
|
||||
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:
|
||||
"""
|
||||
Get the list of statuses this event can transition to.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
Field Mobile URL Configuration
|
||||
|
||||
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
|
||||
|
||||
@@ -9,8 +13,7 @@ from .views import (
|
||||
# Employee profile
|
||||
employee_profile_view,
|
||||
logout_view,
|
||||
# Job endpoints
|
||||
job_list_view,
|
||||
# Job endpoints (list uses /api/appointments/ with date range filtering)
|
||||
job_detail_view,
|
||||
# Status management
|
||||
set_status_view,
|
||||
@@ -37,8 +40,7 @@ urlpatterns = [
|
||||
path('me/', employee_profile_view, name='employee_profile'),
|
||||
path('logout/', logout_view, name='logout'),
|
||||
|
||||
# Job management
|
||||
path('jobs/', job_list_view, name='job_list'),
|
||||
# Job management (list uses /api/appointments/ with date range filtering)
|
||||
path('jobs/<int:job_id>/', job_detail_view, name='job_detail'),
|
||||
|
||||
# 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'])
|
||||
@permission_classes([IsAuthenticated])
|
||||
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())
|
||||
|
||||
# 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
|
||||
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:
|
||||
with schema_context(user.tenant.schema_name):
|
||||
linked_resource = Resource.objects.filter(user=user).first()
|
||||
if linked_resource:
|
||||
linked_resource_id = linked_resource.id
|
||||
can_edit_schedule = linked_resource.user_can_edit_schedule
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error getting linked resource for user {user.id}: {e}")
|
||||
|
||||
user_data = {
|
||||
'id': user.id,
|
||||
@@ -313,18 +315,20 @@ def _get_user_data(user):
|
||||
}
|
||||
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
|
||||
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:
|
||||
with schema_context(user.tenant.schema_name):
|
||||
linked_resource = Resource.objects.filter(user=user).first()
|
||||
if linked_resource:
|
||||
linked_resource_id = linked_resource.id
|
||||
can_edit_schedule = linked_resource.user_can_edit_schedule
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error getting linked resource for user {user.id} in _get_user_data: {e}")
|
||||
|
||||
return {
|
||||
'id': user.id,
|
||||
|
||||
@@ -260,6 +260,27 @@ class User(AbstractUser):
|
||||
# All others cannot whitelist
|
||||
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):
|
||||
"""
|
||||
Get list of tenants this user can access.
|
||||
|
||||
@@ -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 .views import user_detail_view
|
||||
@@ -6,7 +12,7 @@ from .views import user_update_view
|
||||
|
||||
app_name = "users"
|
||||
urlpatterns = [
|
||||
path("~redirect/", view=user_redirect_view, name="redirect"),
|
||||
path("~update/", view=user_update_view, name="update"),
|
||||
path("<str:username>/", view=user_detail_view, name="detail"),
|
||||
path("~redirect/", view=user_redirect_view, name="redirect"), # UNUSED_ENDPOINT: Legacy template view
|
||||
path("~update/", view=user_update_view, name="update"), # UNUSED_ENDPOINT: Legacy template view
|
||||
path("<str:username>/", view=user_detail_view, name="detail"), # UNUSED_ENDPOINT: Legacy template view
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user