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:
poduck
2025-12-07 17:49:37 -05:00
parent 01020861c7
commit 410b46a896
27 changed files with 3192 additions and 1237 deletions

137
CLAUDE.md
View File

@@ -69,6 +69,143 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services | | `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model | | `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) | | `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
| `core` | `smoothschedule/core/` | Shared mixins, permissions, middleware |
| `payments` | `smoothschedule/payments/` | Stripe integration, subscriptions |
| `platform_admin` | `smoothschedule/platform_admin/` | Platform administration |
## Core Mixins & Base Classes
Located in `smoothschedule/core/mixins.py`. Use these to avoid code duplication.
### Permission Classes
```python
from core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
class MyViewSet(ModelViewSet):
# Block write operations for staff (GET allowed)
permission_classes = [IsAuthenticated, DenyStaffWritePermission]
# Block ALL operations for staff
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
# Block list/create/update/delete but allow retrieve
permission_classes = [IsAuthenticated, DenyStaffListPermission]
```
#### Per-User Permission Overrides
Staff permissions can be overridden on a per-user basis using the `user.permissions` JSONField.
Permission keys are auto-derived from the view's basename or model name:
| Permission Class | Auto-derived Key | Example |
|-----------------|------------------|---------|
| `DenyStaffWritePermission` | `can_write_{basename}` | `can_write_resources` |
| `DenyStaffAllAccessPermission` | `can_access_{basename}` | `can_access_services` |
| `DenyStaffListPermission` | `can_list_{basename}` or `can_access_{basename}` | `can_list_customers` |
**Current ViewSet permission keys:**
| ViewSet | Permission Class | Override Key |
|---------|-----------------|--------------|
| `ResourceViewSet` | `DenyStaffAllAccessPermission` | `can_access_resources` |
| `ServiceViewSet` | `DenyStaffAllAccessPermission` | `can_access_services` |
| `CustomerViewSet` | `DenyStaffListPermission` | `can_list_customers` or `can_access_customers` |
| `ScheduledTaskViewSet` | `DenyStaffAllAccessPermission` | `can_access_scheduled-tasks` |
**Granting a specific staff member access:**
```bash
# Open Django shell
docker compose -f docker-compose.local.yml exec django python manage.py shell
```
```python
from smoothschedule.users.models import User
# Find the staff member
staff = User.objects.get(email='john@example.com')
# Grant read access to resources
staff.permissions['can_access_resources'] = True
staff.save()
# Or grant list access to customers (but not full CRUD)
staff.permissions['can_list_customers'] = True
staff.save()
```
**Custom permission keys (optional):**
```python
class ResourceViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
# Override the auto-derived key
staff_access_permission_key = 'can_manage_equipment'
```
Then grant via: `staff.permissions['can_manage_equipment'] = True`
### QuerySet Mixins
```python
from core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
# For tenant-scoped models (automatic django-tenants filtering)
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
queryset = Resource.objects.all()
deny_staff_queryset = True # Optional: also filter staff at queryset level
def filter_queryset_for_tenant(self, queryset):
# Override for custom filtering
return queryset.filter(is_active=True)
# For User model (shared schema, needs explicit tenant filter)
class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
queryset = User.objects.filter(role=User.Role.CUSTOMER)
```
### Feature Permission Mixins
```python
from core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
# Checks can_use_plugins feature on list/retrieve/create
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
pass
# Checks both can_use_plugins AND can_use_tasks
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, ModelViewSet):
pass
```
### Base API Views (for non-ViewSet views)
```python
from rest_framework.views import APIView
from core.mixins import TenantAPIView, TenantRequiredAPIView
# Optional tenant - use self.get_tenant()
class MyView(TenantAPIView, APIView):
def get(self, request):
tenant = self.get_tenant() # May be None
return self.success_response({'data': 'value'})
# or: return self.error_response('Something went wrong', status_code=400)
# Required tenant - self.tenant always available
class MyTenantView(TenantRequiredAPIView, APIView):
def get(self, request):
# self.tenant is guaranteed to exist (returns 400 if missing)
return Response({'name': self.tenant.name})
```
### Helper Methods Available
| Method | Description |
|--------|-------------|
| `self.get_tenant()` | Get tenant from request (may be None) |
| `self.get_tenant_or_error()` | Returns (tenant, error_response) tuple |
| `self.error_response(msg, status_code)` | Standard error response |
| `self.success_response(data, status_code)` | Standard success response |
| `self.check_feature(key, name)` | Check feature permission, returns error or None |
## Common Tasks ## Common Tasks

View File

@@ -35,6 +35,7 @@ const TermsOfServicePage = React.lazy(() => import('./pages/marketing/TermsOfSer
// Import pages // Import pages
const Dashboard = React.lazy(() => import('./pages/Dashboard')); const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const StaffDashboard = React.lazy(() => import('./pages/StaffDashboard'));
const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule')); const StaffSchedule = React.lazy(() => import('./pages/StaffSchedule'));
const Scheduler = React.lazy(() => import('./pages/Scheduler')); const Scheduler = React.lazy(() => import('./pages/Scheduler'));
const Customers = React.lazy(() => import('./pages/Customers')); const Customers = React.lazy(() => import('./pages/Customers'));
@@ -667,7 +668,7 @@ const AppContent: React.FC = () => {
{/* Regular Routes */} {/* Regular Routes */}
<Route <Route
path="/" path="/"
element={user.role === 'resource' ? <ResourceDashboard /> : <Dashboard />} element={user.role === 'resource' ? <ResourceDashboard /> : user.role === 'staff' ? <StaffDashboard user={user} /> : <Dashboard />}
/> />
{/* Staff Schedule - vertical timeline view */} {/* Staff Schedule - vertical timeline view */}
<Route <Route

View File

@@ -72,6 +72,8 @@ export interface User {
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
can_invite_staff?: boolean; can_invite_staff?: boolean;
can_access_tickets?: boolean; can_access_tickets?: boolean;
can_edit_schedule?: boolean;
linked_resource_id?: number;
quota_overages?: QuotaOverage[]; quota_overages?: QuotaOverage[];
} }

View File

@@ -87,6 +87,15 @@ export const PERMISSION_CONFIGS: PermissionConfig[] = [
defaultValue: true, defaultValue: true,
roles: ['staff'], roles: ['staff'],
}, },
{
key: 'can_self_approve_time_off',
labelKey: 'staff.canSelfApproveTimeOff',
labelDefault: 'Can self-approve time off',
hintKey: 'staff.canSelfApproveTimeOffHint',
hintDefault: 'Add time off without requiring manager/owner approval',
defaultValue: false,
roles: ['staff'],
},
// Shared permissions (both manager and staff) // Shared permissions (both manager and staff)
{ {
key: 'can_access_tickets', key: 'can_access_tickets',

View File

@@ -155,6 +155,10 @@ interface TimeBlockCreatorModalProps {
holidays: Holiday[]; holidays: Holiday[];
resources: Resource[]; resources: Resource[];
isResourceLevel?: boolean; isResourceLevel?: boolean;
/** Staff mode: hides level selector, locks to resource, pre-selects resource */
staffMode?: boolean;
/** Pre-selected resource ID for staff mode */
staffResourceId?: string | number | null;
} }
type Step = 'preset' | 'details' | 'schedule' | 'review'; type Step = 'preset' | 'details' | 'schedule' | 'review';
@@ -168,6 +172,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
holidays, holidays,
resources, resources,
isResourceLevel: initialIsResourceLevel = false, isResourceLevel: initialIsResourceLevel = false,
staffMode = false,
staffResourceId = null,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset'); const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
@@ -177,7 +183,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
// Form state // Form state
const [title, setTitle] = useState(editingBlock?.title || ''); const [title, setTitle] = useState(editingBlock?.title || '');
const [description, setDescription] = useState(editingBlock?.description || ''); const [description, setDescription] = useState(editingBlock?.description || '');
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || 'HARD'); // In staff mode, default to SOFT blocks (time-off requests)
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || (staffMode ? 'SOFT' : 'HARD'));
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE'); const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE');
const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true); const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00'); const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
@@ -270,7 +277,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
setAllDay(true); setAllDay(true);
setStartTime('09:00'); setStartTime('09:00');
setEndTime('17:00'); setEndTime('17:00');
setResourceId(null); // In staff mode, pre-select the staff's resource
setResourceId(staffMode && staffResourceId ? String(staffResourceId) : null);
setSelectedDates([]); setSelectedDates([]);
setDaysOfWeek([]); setDaysOfWeek([]);
setDaysOfMonth([]); setDaysOfMonth([]);
@@ -279,10 +287,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
setHolidayCodes([]); setHolidayCodes([]);
setRecurrenceStart(''); setRecurrenceStart('');
setRecurrenceEnd(''); setRecurrenceEnd('');
setIsResourceLevel(initialIsResourceLevel); // In staff mode, always resource-level
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
} }
} }
}, [isOpen, editingBlock, initialIsResourceLevel]); }, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
// Apply preset configuration // Apply preset configuration
const applyPreset = (presetId: string) => { const applyPreset = (presetId: string) => {
@@ -293,7 +302,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
setTitle(preset.config.title); setTitle(preset.config.title);
setRecurrenceType(preset.config.recurrence_type); setRecurrenceType(preset.config.recurrence_type);
setAllDay(preset.config.all_day); setAllDay(preset.config.all_day);
setBlockType(preset.config.block_type); // In staff mode, always use SOFT blocks regardless of preset
setBlockType(staffMode ? 'SOFT' : preset.config.block_type);
if (preset.config.start_time) setStartTime(preset.config.start_time); if (preset.config.start_time) setStartTime(preset.config.start_time);
if (preset.config.end_time) setEndTime(preset.config.end_time); if (preset.config.end_time) setEndTime(preset.config.end_time);
@@ -367,12 +377,15 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
}; };
const handleSubmit = () => { const handleSubmit = () => {
// In staff mode, always use the staff's resource ID
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
const baseData: any = { const baseData: any = {
description: description || undefined, description: description || undefined,
block_type: blockType, block_type: blockType,
recurrence_type: recurrenceType, recurrence_type: recurrenceType,
all_day: allDay, all_day: allDay,
resource: isResourceLevel ? resourceId : null, resource: isResourceLevel ? effectiveResourceId : null,
}; };
if (!allDay) { if (!allDay) {
@@ -425,7 +438,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
return true; return true;
case 'details': case 'details':
if (!title.trim()) return false; if (!title.trim()) return false;
if (isResourceLevel && !resourceId) return false; // In staff mode, resource is auto-selected; otherwise check if selected
if (isResourceLevel && !staffMode && !resourceId) return false;
return true; return true;
case 'schedule': case 'schedule':
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false; if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
@@ -556,7 +570,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
{/* Step 2: Details */} {/* Step 2: Details */}
{step === 'details' && ( {step === 'details' && (
<div className="space-y-6"> <div className="space-y-6">
{/* Block Level Selector */} {/* Block Level Selector - Hidden in staff mode */}
{!staffMode && (
<div> <div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3"> <label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Level Block Level
@@ -613,6 +628,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
</button> </button>
</div> </div>
</div> </div>
)}
{/* Title */} {/* Title */}
<div> <div>
@@ -642,8 +658,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
/> />
</div> </div>
{/* Resource (if resource-level) */} {/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
{isResourceLevel && ( {isResourceLevel && !staffMode && (
<div> <div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2"> <label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
Resource Resource
@@ -661,7 +677,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
</div> </div>
)} )}
{/* Block Type */} {/* Block Type - hidden in staff mode (always SOFT for time-off requests) */}
{!staffMode && (
<div> <div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3"> <label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Type Block Type
@@ -707,6 +724,7 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
</button> </button>
</div> </div>
</div> </div>
)}
{/* All Day Toggle & Time */} {/* All Day Toggle & Time */}
<div> <div>
@@ -1188,11 +1206,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
)} )}
</dd> </dd>
</div> </div>
{isResourceLevel && resourceId && ( {isResourceLevel && (resourceId || staffResourceId) && (
<div className="flex justify-between py-2"> <div className="flex justify-between py-2">
<dt className="text-gray-500 dark:text-gray-400">Resource</dt> <dt className="text-gray-500 dark:text-gray-400">Resource</dt>
<dd className="font-medium text-gray-900 dark:text-white"> <dd className="font-medium text-gray-900 dark:text-white">
{resources.find(r => r.id === resourceId)?.name || resourceId} {resources.find(r => String(r.id) === String(staffMode ? staffResourceId : resourceId))?.name || (staffMode ? staffResourceId : resourceId)}
</dd> </dd>
</div> </div>
)} )}

View File

@@ -158,8 +158,9 @@ export const useMyBlocks = () => {
id: String(b.id), id: String(b.id),
resource: b.resource ? String(b.resource) : null, resource: b.resource ? String(b.resource) : null,
})), })),
resource_id: String(data.resource_id), resource_id: data.resource_id ? String(data.resource_id) : null,
resource_name: data.resource_name, resource_name: data.resource_name,
can_self_approve: data.can_self_approve,
}; };
}, },
}); });
@@ -248,6 +249,75 @@ export const useToggleTimeBlock = () => {
}); });
}; };
// =============================================================================
// Time Block Approval Hooks
// =============================================================================
export interface PendingReviewsResponse {
count: number;
pending_blocks: TimeBlockListItem[];
}
/**
* Hook to fetch pending time block reviews (for managers/owners)
*/
export const usePendingReviews = () => {
return useQuery<PendingReviewsResponse>({
queryKey: ['time-block-pending-reviews'],
queryFn: async () => {
const { data } = await apiClient.get('/time-blocks/pending_reviews/');
return {
count: data.count,
pending_blocks: data.pending_blocks.map((b: any) => ({
...b,
id: String(b.id),
resource: b.resource ? String(b.resource) : null,
})),
};
},
});
};
/**
* Hook to approve a time block
*/
export const useApproveTimeBlock = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, notes }: { id: string; notes?: string }) => {
const { data } = await apiClient.post(`/time-blocks/${id}/approve/`, { notes });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
queryClient.invalidateQueries({ queryKey: ['time-block-pending-reviews'] });
},
});
};
/**
* Hook to deny a time block
*/
export const useDenyTimeBlock = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, notes }: { id: string; notes?: string }) => {
const { data } = await apiClient.post(`/time-blocks/${id}/deny/`, { notes });
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['time-blocks'] });
queryClient.invalidateQueries({ queryKey: ['blocked-dates'] });
queryClient.invalidateQueries({ queryKey: ['my-blocks'] });
queryClient.invalidateQueries({ queryKey: ['time-block-pending-reviews'] });
},
});
};
/** /**
* Hook to check for conflicts before creating a time block * Hook to check for conflicts before creating a time block
*/ */

View File

@@ -491,7 +491,39 @@
"reactivateAccount": "Reactivate Account", "reactivateAccount": "Reactivate Account",
"deactivateHint": "Prevent this user from logging in while keeping their data", "deactivateHint": "Prevent this user from logging in while keeping their data",
"reactivateHint": "Allow this user to log in again", "reactivateHint": "Allow this user to log in again",
"deactivate": "Deactivate" "deactivate": "Deactivate",
"canSelfApproveTimeOff": "Can self-approve time off",
"canSelfApproveTimeOffHint": "Add time off without requiring manager/owner approval"
},
"staffDashboard": {
"welcomeTitle": "Welcome, {{name}}!",
"weekOverview": "Here's your week at a glance",
"noResourceLinked": "Your account is not linked to a resource yet. Please contact your manager to set up your schedule.",
"currentAppointment": "Current Appointment",
"nextAppointment": "Next Appointment",
"viewSchedule": "View Schedule",
"todayAppointments": "Today",
"thisWeek": "This Week",
"completed": "Completed",
"hoursWorked": "Hours Worked",
"appointmentsLabel": "appointments",
"totalAppointments": "total appointments",
"completionRate": "completion rate",
"thisWeekLabel": "this week",
"upcomingAppointments": "Upcoming",
"noUpcoming": "No upcoming appointments",
"weeklyOverview": "This Week",
"appointments": "Appointments",
"today": "Today",
"tomorrow": "Tomorrow",
"scheduled": "Scheduled",
"inProgress": "In Progress",
"cancelled": "Cancelled",
"noShows": "No-Shows",
"viewMySchedule": "View My Schedule",
"viewScheduleDesc": "See your daily appointments and manage your time",
"manageAvailability": "Manage Availability",
"availabilityDesc": "Set your working hours and time off"
}, },
"tickets": { "tickets": {
"title": "Support Tickets", "title": "Support Tickets",

View File

@@ -2,17 +2,17 @@
* My Availability Page * My Availability Page
* *
* Staff-facing page to view and manage their own time blocks. * Staff-facing page to view and manage their own time blocks.
* Uses the same UI as TimeBlocks but locked to the staff's own resource.
* Shows business-level blocks (read-only) and personal blocks (editable). * Shows business-level blocks (read-only) and personal blocks (editable).
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { import {
TimeBlockListItem, TimeBlockListItem,
BlockType, BlockType,
RecurrenceType, RecurrenceType,
RecurrencePattern,
User, User,
} from '../types'; } from '../types';
import { import {
@@ -22,10 +22,10 @@ import {
useDeleteTimeBlock, useDeleteTimeBlock,
useToggleTimeBlock, useToggleTimeBlock,
useHolidays, useHolidays,
CreateTimeBlockData,
} from '../hooks/useTimeBlocks'; } from '../hooks/useTimeBlocks';
import Portal from '../components/Portal'; import Portal from '../components/Portal';
import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar'; import YearlyBlockCalendar from '../components/time-blocks/YearlyBlockCalendar';
import TimeBlockCreatorModal from '../components/time-blocks/TimeBlockCreatorModal';
import { import {
Calendar, Calendar,
Building2, Building2,
@@ -33,7 +33,6 @@ import {
Plus, Plus,
Pencil, Pencil,
Trash2, Trash2,
X,
AlertTriangle, AlertTriangle,
Clock, Clock,
CalendarDays, CalendarDays,
@@ -42,8 +41,13 @@ import {
Power, Power,
PowerOff, PowerOff,
Info, Info,
CheckCircle,
XCircle,
HourglassIcon,
} from 'lucide-react'; } from 'lucide-react';
type AvailabilityTab = 'blocks' | 'calendar';
const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = { const RECURRENCE_TYPE_LABELS: Record<RecurrenceType, string> = {
NONE: 'One-time', NONE: 'One-time',
WEEKLY: 'Weekly', WEEKLY: 'Weekly',
@@ -57,43 +61,6 @@ const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
SOFT: 'Soft Block', SOFT: 'Soft Block',
}; };
const DAY_ABBREVS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const MONTH_NAMES = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
interface TimeBlockFormData {
title: string;
description: string;
block_type: BlockType;
recurrence_type: RecurrenceType;
start_date: string;
end_date: string;
all_day: boolean;
start_time: string;
end_time: string;
recurrence_pattern: RecurrencePattern;
recurrence_start: string;
recurrence_end: string;
}
const defaultFormData: TimeBlockFormData = {
title: '',
description: '',
block_type: 'SOFT',
recurrence_type: 'NONE',
start_date: '',
end_date: '',
all_day: true,
start_time: '09:00',
end_time: '17:00',
recurrence_pattern: {},
recurrence_start: '',
recurrence_end: '',
};
interface MyAvailabilityProps { interface MyAvailabilityProps {
user?: User; user?: User;
} }
@@ -103,9 +70,9 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
const contextUser = useOutletContext<{ user?: User }>()?.user; const contextUser = useOutletContext<{ user?: User }>()?.user;
const user = props.user || contextUser; const user = props.user || contextUser;
const [activeTab, setActiveTab] = useState<AvailabilityTab>('blocks');
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null); const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
const [formData, setFormData] = useState<TimeBlockFormData>(defaultFormData);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
// Fetch data // Fetch data
@@ -118,105 +85,20 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
const deleteBlock = useDeleteTimeBlock(); const deleteBlock = useDeleteTimeBlock();
const toggleBlock = useToggleTimeBlock(); const toggleBlock = useToggleTimeBlock();
// Check if user can create hard blocks
const canCreateHardBlocks = user?.permissions?.can_create_hard_blocks ?? false;
// Modal handlers // Modal handlers
const openCreateModal = () => { const openCreateModal = () => {
setEditingBlock(null); setEditingBlock(null);
setFormData(defaultFormData);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const openEditModal = (block: TimeBlockListItem) => { const openEditModal = (block: TimeBlockListItem) => {
setEditingBlock(block); setEditingBlock(block);
setFormData({
title: block.title,
description: '',
block_type: block.block_type,
recurrence_type: block.recurrence_type,
start_date: '',
end_date: '',
all_day: true,
start_time: '09:00',
end_time: '17:00',
recurrence_pattern: {},
recurrence_start: '',
recurrence_end: '',
});
setIsModalOpen(true); setIsModalOpen(true);
}; };
const closeModal = () => { const closeModal = () => {
setIsModalOpen(false); setIsModalOpen(false);
setEditingBlock(null); setEditingBlock(null);
setFormData(defaultFormData);
};
// Form handlers
const handleFormChange = (field: keyof TimeBlockFormData, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handlePatternChange = (field: keyof RecurrencePattern, value: any) => {
setFormData((prev) => ({
...prev,
recurrence_pattern: { ...prev.recurrence_pattern, [field]: value },
}));
};
const handleDayOfWeekToggle = (day: number) => {
const current = formData.recurrence_pattern.days_of_week || [];
const newDays = current.includes(day)
? current.filter((d) => d !== day)
: [...current, day].sort();
handlePatternChange('days_of_week', newDays);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!myBlocksData?.resource_id) {
console.error('No resource linked to user');
return;
}
const payload: CreateTimeBlockData = {
title: formData.title,
description: formData.description || undefined,
resource: myBlocksData.resource_id,
block_type: formData.block_type,
recurrence_type: formData.recurrence_type,
all_day: formData.all_day,
};
// Add type-specific fields
if (formData.recurrence_type === 'NONE') {
payload.start_date = formData.start_date;
payload.end_date = formData.end_date || formData.start_date;
}
if (!formData.all_day) {
payload.start_time = formData.start_time;
payload.end_time = formData.end_time;
}
if (formData.recurrence_type !== 'NONE') {
payload.recurrence_pattern = formData.recurrence_pattern;
if (formData.recurrence_start) payload.recurrence_start = formData.recurrence_start;
if (formData.recurrence_end) payload.recurrence_end = formData.recurrence_end;
}
try {
if (editingBlock) {
await updateBlock.mutateAsync({ id: editingBlock.id, updates: payload });
} else {
await createBlock.mutateAsync(payload);
}
closeModal();
} catch (error) {
console.error('Failed to save time block:', error);
}
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
@@ -264,6 +146,35 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
</span> </span>
); );
// Render approval status badge
const renderApprovalBadge = (status: string | undefined) => {
if (!status || status === 'APPROVED') {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<CheckCircle size={12} className="mr-1" />
{t('myAvailability.approved', 'Approved')}
</span>
);
}
if (status === 'PENDING') {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<HourglassIcon size={12} className="mr-1" />
{t('myAvailability.pending', 'Pending Review')}
</span>
);
}
if (status === 'DENIED') {
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
<XCircle size={12} className="mr-1" />
{t('myAvailability.denied', 'Denied')}
</span>
);
}
return null;
};
// Handle no linked resource // Handle no linked resource
if (!isLoading && !myBlocksData?.resource_id) { if (!isLoading && !myBlocksData?.resource_id) {
return ( return (
@@ -290,6 +201,12 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
); );
} }
// Create a mock resource for the modal
const staffResource = myBlocksData?.resource_id ? {
id: myBlocksData.resource_id,
name: myBlocksData.resource_name || 'My Resource',
} : null;
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* Header */} {/* Header */}
@@ -299,77 +216,115 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
{t('myAvailability.title', 'My Availability')} {t('myAvailability.title', 'My Availability')}
</h1> </h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{myBlocksData?.resource_name && ( {t('myAvailability.subtitle', 'Manage your time off and unavailability')}
<span className="flex items-center gap-1">
<UserIcon size={14} />
{myBlocksData.resource_name}
</span>
)}
</p> </p>
</div> </div>
<button onClick={openCreateModal} className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"> <button
onClick={openCreateModal}
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors"
>
<Plus size={18} /> <Plus size={18} />
{t('myAvailability.addBlock', 'Block Time')} {t('myAvailability.addBlock', 'Block Time')}
</button> </button>
</div> </div>
{/* Approval Required Banner */}
{myBlocksData?.can_self_approve === false && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<HourglassIcon size={20} className="text-amber-600 dark:text-amber-400 mt-0.5" />
<div>
<h3 className="font-medium text-amber-900 dark:text-amber-100">
{t('myAvailability.approvalRequired', 'Approval Required')}
</h3>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
{t('myAvailability.approvalRequiredInfo', 'Your time off requests require manager or owner approval. New blocks will show as "Pending Review" until approved.')}
</p>
</div>
</div>
</div>
)}
{/* Business Blocks Info Banner */}
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<Building2 size={20} className="text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<h3 className="font-medium text-blue-900 dark:text-blue-100">
{t('myAvailability.businessBlocks', 'Business Closures')}
</h3>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone:')}
</p>
<div className="mt-2 flex flex-wrap gap-2">
{myBlocksData.business_blocks.map((block) => (
<span
key={block.id}
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 dark:bg-blue-800/50 text-blue-800 dark:text-blue-200 rounded text-sm"
>
{block.title}
{renderRecurrenceBadge(block.recurrence_type)}
</span>
))}
</div>
</div>
</div>
</div>
)}
{/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-1" aria-label="Availability tabs">
<button
onClick={() => setActiveTab('blocks')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'blocks'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<UserIcon size={18} />
{t('myAvailability.myBlocksTab', 'My Time Blocks')}
{myBlocksData?.my_blocks && myBlocksData.my_blocks.length > 0 && (
<span className="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full text-xs">
{myBlocksData.my_blocks.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('calendar')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'calendar'
? 'border-brand-500 text-brand-600 dark:text-brand-400'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
}`}
>
<CalendarDays size={18} />
{t('myAvailability.calendarTab', 'Yearly View')}
</button>
</nav>
</div>
{/* Tab Content */}
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-4">
{/* Business Blocks (Read-only) */} {activeTab === 'blocks' && (
{myBlocksData?.business_blocks && myBlocksData.business_blocks.length > 0 && ( <>
<div> {/* Resource Info Banner */}
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-3 flex items-center gap-2"> <div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<Building2 size={20} /> <p className="text-sm text-purple-800 dark:text-purple-300 flex items-center gap-2">
{t('myAvailability.businessBlocks', 'Business Closures')} <UserIcon size={16} />
</h2> {t('myAvailability.resourceInfo', 'Managing blocks for:')}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-3"> <span className="font-semibold">{myBlocksData?.resource_name}</span>
<p className="text-sm text-blue-800 dark:text-blue-300 flex items-center gap-2">
<Info size={16} />
{t('myAvailability.businessBlocksInfo', 'These blocks are set by your business and apply to everyone.')}
</p> </p>
</div> </div>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<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 ? ( {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"> <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" /> <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"> <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.')} {t('myAvailability.noBlocksDesc', 'Add time blocks for vacations, lunch breaks, or any time you need off.')}
</p> </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} /> <Plus size={18} />
{t('myAvailability.addFirstBlock', 'Add First Block')} {t('myAvailability.addFirstBlock', 'Add First Block')}
</button> </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"> <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')} {t('myAvailability.patternCol', 'Pattern')}
</th> </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"> <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')} {t('myAvailability.actionsCol', 'Actions')}
</th> </th>
@@ -405,11 +366,18 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
</thead> </thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{myBlocksData?.my_blocks.map((block) => ( {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"> <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} {block.title}
</span> </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>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
{renderBlockTypeBadge(block.block_type)} {renderBlockTypeBadge(block.block_type)}
@@ -424,6 +392,16 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
)} )}
</div> </div>
</td> </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"> <td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
@@ -455,9 +433,10 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
</table> </table>
</div> </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"> <div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<YearlyBlockCalendar <YearlyBlockCalendar
resourceId={myBlocksData?.resource_id} resourceId={myBlocksData?.resource_id}
@@ -470,276 +449,38 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
}} }}
/> />
</div> </div>
)}
</div> </div>
)} )}
{/* Create/Edit Modal */} {/* Create/Edit Modal - Using TimeBlockCreatorModal in staff mode */}
{isModalOpen && ( <TimeBlockCreatorModal
<Portal> isOpen={isModalOpen}
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> onClose={closeModal}
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full max-h-[90vh] overflow-y-auto"> onSubmit={async (data) => {
<div className="flex justify-between items-center p-6 border-b border-gray-200 dark:border-gray-700"> try {
<h2 className="text-xl font-semibold text-gray-900 dark:text-white"> if (editingBlock) {
{editingBlock await updateBlock.mutateAsync({ id: editingBlock.id, updates: data });
? t('myAvailability.editBlock', 'Edit Time Block') } else {
: t('myAvailability.createBlock', 'Block Time Off')} // Handle array of blocks (multiple holidays)
</h2> const blocks = Array.isArray(data) ? data : [data];
<button for (const block of blocks) {
onClick={closeModal} await createBlock.mutateAsync(block);
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" }
> }
<X size={24} /> closeModal();
</button> } catch (error) {
</div> console.error('Failed to save time block:', error);
}
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{/* Title */}
<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);
}} }}
className={`w-8 h-8 rounded text-sm font-medium transition-colors ${ isSubmitting={createBlock.isPending || updateBlock.isPending}
isSelected editingBlock={editingBlock}
? 'bg-brand-500 text-white' holidays={holidays}
: 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300' resources={staffResource ? [staffResource as any] : []}
}`} isResourceLevel={true}
> staffMode={true}
{day} staffResourceId={myBlocksData?.resource_id}
</button>
);
})}
</div>
</div>
)}
{/* All Day Toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="all_day"
checked={formData.all_day}
onChange={(e) => handleFormChange('all_day', e.target.checked)}
className="rounded text-brand-500"
/> />
<label htmlFor="all_day" className="text-sm text-gray-700 dark:text-gray-300">
{t('myAvailability.form.allDay', 'All day')}
</label>
</div>
{/* Time Range (if not all day) */}
{!formData.all_day && (
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Time *
</label>
<input
type="time"
value={formData.start_time}
onChange={(e) => handleFormChange('start_time', e.target.value)}
className="input-primary w-full"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Time *
</label>
<input
type="time"
value={formData.end_time}
onChange={(e) => handleFormChange('end_time', e.target.value)}
className="input-primary w-full"
required
/>
</div>
</div>
)}
{/* Submit Button */}
<div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-700">
<button type="button" onClick={closeModal} className="btn-secondary">
{t('common.cancel', 'Cancel')}
</button>
<button
type="submit"
className="btn-primary"
disabled={createBlock.isPending || updateBlock.isPending}
>
{(createBlock.isPending || updateBlock.isPending) ? (
<span className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
{t('common.saving', 'Saving...')}
</span>
) : editingBlock ? (
t('common.save', 'Save Changes')
) : (
t('myAvailability.create', 'Block Time')
)}
</button>
</div>
</form>
</div>
</div>
</Portal>
)}
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
{deleteConfirmId && ( {deleteConfirmId && (
@@ -760,12 +501,15 @@ const MyAvailability: React.FC<MyAvailabilityProps> = (props) => {
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<button onClick={() => setDeleteConfirmId(null)} className="btn-secondary"> <button
onClick={() => setDeleteConfirmId(null)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')} {t('common.cancel', 'Cancel')}
</button> </button>
<button <button
onClick={() => handleDelete(deleteConfirmId)} onClick={() => handleDelete(deleteConfirmId)}
className="btn-danger" className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50"
disabled={deleteBlock.isPending} disabled={deleteBlock.isPending}
> >
{deleteBlock.isPending ? ( {deleteBlock.isPending ? (

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

View File

@@ -19,6 +19,9 @@ import {
useDeleteTimeBlock, useDeleteTimeBlock,
useToggleTimeBlock, useToggleTimeBlock,
useHolidays, useHolidays,
usePendingReviews,
useApproveTimeBlock,
useDenyTimeBlock,
} from '../hooks/useTimeBlocks'; } from '../hooks/useTimeBlocks';
import { useResources } from '../hooks/useResources'; import { useResources } from '../hooks/useResources';
import Portal from '../components/Portal'; import Portal from '../components/Portal';
@@ -38,6 +41,12 @@ import {
AlertCircle, AlertCircle,
Power, Power,
PowerOff, PowerOff,
HourglassIcon,
CheckCircle,
XCircle,
MessageSquare,
ChevronDown,
ChevronUp,
} from 'lucide-react'; } from 'lucide-react';
type TimeBlockTab = 'business' | 'resource' | 'calendar'; type TimeBlockTab = 'business' | 'resource' | 'calendar';
@@ -61,6 +70,9 @@ const TimeBlocks: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null); const [editingBlock, setEditingBlock] = useState<TimeBlockListItem | null>(null);
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null); const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const [isPendingReviewsExpanded, setIsPendingReviewsExpanded] = useState(true);
const [reviewingBlock, setReviewingBlock] = useState<TimeBlockListItem | null>(null);
const [reviewNotes, setReviewNotes] = useState('');
// Fetch data (include inactive blocks so users can re-enable them) // Fetch data (include inactive blocks so users can re-enable them)
const { const {
@@ -75,12 +87,15 @@ const TimeBlocks: React.FC = () => {
const { data: holidays = [] } = useHolidays('US'); const { data: holidays = [] } = useHolidays('US');
const { data: resources = [] } = useResources(); const { data: resources = [] } = useResources();
const { data: pendingReviews } = usePendingReviews();
// Mutations // Mutations
const createBlock = useCreateTimeBlock(); const createBlock = useCreateTimeBlock();
const updateBlock = useUpdateTimeBlock(); const updateBlock = useUpdateTimeBlock();
const deleteBlock = useDeleteTimeBlock(); const deleteBlock = useDeleteTimeBlock();
const toggleBlock = useToggleTimeBlock(); const toggleBlock = useToggleTimeBlock();
const approveBlock = useApproveTimeBlock();
const denyBlock = useDenyTimeBlock();
// Current blocks based on tab // Current blocks based on tab
const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks; const currentBlocks = activeTab === 'business' ? businessBlocks : resourceBlocks;
@@ -130,6 +145,26 @@ const TimeBlocks: React.FC = () => {
} }
}; };
const handleApprove = async (id: string) => {
try {
await approveBlock.mutateAsync({ id, notes: reviewNotes });
setReviewingBlock(null);
setReviewNotes('');
} catch (error) {
console.error('Failed to approve time block:', error);
}
};
const handleDeny = async (id: string) => {
try {
await denyBlock.mutateAsync({ id, notes: reviewNotes });
setReviewingBlock(null);
setReviewNotes('');
} catch (error) {
console.error('Failed to deny time block:', error);
}
};
// Render block type badge // Render block type badge
const renderBlockTypeBadge = (type: BlockType) => ( const renderBlockTypeBadge = (type: BlockType) => (
<span <span
@@ -179,6 +214,97 @@ const TimeBlocks: React.FC = () => {
</button> </button>
</div> </div>
{/* Pending Reviews Section */}
{pendingReviews && pendingReviews.count > 0 && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg overflow-hidden">
<button
onClick={() => setIsPendingReviewsExpanded(!isPendingReviewsExpanded)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-amber-100 dark:hover:bg-amber-900/30 transition-colors"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 dark:bg-amber-800/50 rounded-lg">
<HourglassIcon size={20} className="text-amber-600 dark:text-amber-400" />
</div>
<div className="text-left">
<h3 className="font-semibold text-amber-900 dark:text-amber-100">
{t('timeBlocks.pendingReviews', 'Pending Time Off Requests')}
</h3>
<p className="text-sm text-amber-700 dark:text-amber-300">
{t('timeBlocks.pendingReviewsCount', '{{count}} request(s) need your review', { count: pendingReviews.count })}
</p>
</div>
</div>
{isPendingReviewsExpanded ? (
<ChevronUp size={20} className="text-amber-600 dark:text-amber-400" />
) : (
<ChevronDown size={20} className="text-amber-600 dark:text-amber-400" />
)}
</button>
{isPendingReviewsExpanded && (
<div className="border-t border-amber-200 dark:border-amber-800">
<div className="divide-y divide-amber-200 dark:divide-amber-800">
{pendingReviews.pending_blocks.map((block) => (
<div
key={block.id}
className="p-4 hover:bg-amber-100/50 dark:hover:bg-amber-900/30 cursor-pointer transition-colors"
onClick={() => setReviewingBlock(block)}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900 dark:text-white">
{block.title}
</span>
{renderBlockTypeBadge(block.block_type)}
{renderRecurrenceBadge(block.recurrence_type)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
{block.resource_name && (
<span className="flex items-center gap-1">
<User size={14} />
{block.resource_name}
</span>
)}
{block.created_by_name && (
<span>Requested by {block.created_by_name}</span>
)}
{block.pattern_display && (
<span>{block.pattern_display}</span>
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={(e) => {
e.stopPropagation();
handleApprove(block.id);
}}
className="p-2 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-lg transition-colors"
title={t('timeBlocks.approve', 'Approve')}
>
<CheckCircle size={20} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setReviewingBlock(block);
}}
className="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title={t('timeBlocks.deny', 'Deny')}
>
<XCircle size={20} />
</button>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Tabs */} {/* Tabs */}
<div className="border-b border-gray-200 dark:border-gray-700"> <div className="border-b border-gray-200 dark:border-gray-700">
<nav className="flex gap-1" aria-label="Time block tabs"> <nav className="flex gap-1" aria-label="Time block tabs">
@@ -548,6 +674,205 @@ const TimeBlocks: React.FC = () => {
</div> </div>
</Portal> </Portal>
)} )}
{/* Review Modal */}
{reviewingBlock && (
<Portal>
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-lg w-full p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-full">
<HourglassIcon size={24} className="text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('timeBlocks.reviewRequest', 'Review Time Off Request')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('timeBlocks.reviewRequestDesc', 'Approve or deny this time off request')}
</p>
</div>
</div>
{/* Block Details */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4 mb-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.titleCol', 'Title')}</span>
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.title}</span>
</div>
{reviewingBlock.resource_name && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.resource', 'Resource')}</span>
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.resource_name}</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.typeCol', 'Type')}</span>
{renderBlockTypeBadge(reviewingBlock.block_type)}
</div>
{/* Schedule Details Section */}
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-2">
{t('timeBlocks.scheduleDetails', 'Schedule Details')}
</span>
{/* Recurrence Type */}
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.patternCol', 'Pattern')}</span>
{renderRecurrenceBadge(reviewingBlock.recurrence_type)}
</div>
{/* One-time block dates */}
{reviewingBlock.recurrence_type === 'NONE' && reviewingBlock.start_date && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.dates', 'Date(s)')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{reviewingBlock.start_date === reviewingBlock.end_date ? (
new Date(reviewingBlock.start_date + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' })
) : (
<>
{new Date(reviewingBlock.start_date + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
{' - '}
{new Date((reviewingBlock.end_date || reviewingBlock.start_date) + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</>
)}
</span>
</div>
)}
{/* Weekly: Days of week */}
{reviewingBlock.recurrence_type === 'WEEKLY' && reviewingBlock.recurrence_pattern?.days_of_week && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.daysOfWeek', 'Days')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{reviewingBlock.recurrence_pattern.days_of_week
.map(d => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d])
.join(', ')}
</span>
</div>
)}
{/* Monthly: Days of month */}
{reviewingBlock.recurrence_type === 'MONTHLY' && reviewingBlock.recurrence_pattern?.days_of_month && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.daysOfMonth', 'Days of Month')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{reviewingBlock.recurrence_pattern.days_of_month.join(', ')}
</span>
</div>
)}
{/* Yearly: Month and day */}
{reviewingBlock.recurrence_type === 'YEARLY' && reviewingBlock.recurrence_pattern?.month && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.yearlyDate', 'Annual Date')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][reviewingBlock.recurrence_pattern.month - 1]} {reviewingBlock.recurrence_pattern.day}
</span>
</div>
)}
{/* Recurrence period (start/end) for recurring blocks */}
{reviewingBlock.recurrence_type !== 'NONE' && (reviewingBlock.recurrence_start || reviewingBlock.recurrence_end) && (
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.effectivePeriod', 'Effective Period')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{reviewingBlock.recurrence_start ? (
new Date(reviewingBlock.recurrence_start + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
) : 'No start date'}
{' - '}
{reviewingBlock.recurrence_end ? (
new Date(reviewingBlock.recurrence_end + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
) : 'Ongoing'}
</span>
</div>
)}
{/* Time range if not all-day */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.timeRange', 'Time')}</span>
<span className="font-medium text-gray-900 dark:text-white">
{reviewingBlock.all_day !== false ? (
t('timeBlocks.allDay', 'All Day')
) : (
<>
{reviewingBlock.start_time?.slice(0, 5)} - {reviewingBlock.end_time?.slice(0, 5)}
</>
)}
</span>
</div>
</div>
{reviewingBlock.description && (
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="text-sm text-gray-500 dark:text-gray-400 block mb-1">{t('timeBlocks.description', 'Description')}</span>
<p className="text-sm text-gray-700 dark:text-gray-300">{reviewingBlock.description}</p>
</div>
)}
{reviewingBlock.created_by_name && (
<div className="flex items-center justify-between pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="text-sm text-gray-500 dark:text-gray-400">{t('timeBlocks.requestedBy', 'Requested by')}</span>
<span className="font-medium text-gray-900 dark:text-white">{reviewingBlock.created_by_name}</span>
</div>
)}
</div>
{/* Notes */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<MessageSquare size={14} className="inline mr-1" />
{t('timeBlocks.reviewNotes', 'Notes (optional)')}
</label>
<textarea
value={reviewNotes}
onChange={(e) => setReviewNotes(e.target.value)}
placeholder={t('timeBlocks.reviewNotesPlaceholder', 'Add a note for the requester...')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-brand-500 focus:border-transparent"
rows={3}
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<button
onClick={() => {
setReviewingBlock(null);
setReviewNotes('');
}}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={() => handleDeny(reviewingBlock.id)}
disabled={denyBlock.isPending}
className="px-4 py-2 bg-red-600 text-white hover:bg-red-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
>
{denyBlock.isPending ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<XCircle size={18} />
)}
{t('timeBlocks.deny', 'Deny')}
</button>
<button
onClick={() => handleApprove(reviewingBlock.id)}
disabled={approveBlock.isPending}
className="px-4 py-2 bg-green-600 text-white hover:bg-green-700 rounded-lg transition-colors disabled:opacity-50 flex items-center gap-2"
>
{approveBlock.isPending ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
) : (
<CheckCircle size={18} />
)}
{t('timeBlocks.approve', 'Approve')}
</button>
</div>
</div>
</div>
</Portal>
)}
</div> </div>
); );
}; };

View File

@@ -599,6 +599,8 @@ export interface TimeBlock {
updated_at?: string; updated_at?: string;
} }
export type ApprovalStatus = 'APPROVED' | 'PENDING' | 'DENIED';
export interface TimeBlockListItem { export interface TimeBlockListItem {
id: string; id: string;
title: string; title: string;
@@ -619,6 +621,12 @@ export interface TimeBlockListItem {
pattern_display?: string; pattern_display?: string;
is_active: boolean; is_active: boolean;
created_at: string; created_at: string;
approval_status?: ApprovalStatus;
reviewed_by?: number;
reviewed_by_name?: string;
reviewed_at?: string;
review_notes?: string;
created_by_name?: string;
} }
export interface BlockedDate { export interface BlockedDate {
@@ -648,6 +656,7 @@ export interface TimeBlockConflictCheck {
export interface MyBlocksResponse { export interface MyBlocksResponse {
business_blocks: TimeBlockListItem[]; business_blocks: TimeBlockListItem[];
my_blocks: TimeBlockListItem[]; my_blocks: TimeBlockListItem[];
resource_id: string; resource_id: string | null;
resource_name: string; resource_name: string | null;
can_self_approve: boolean;
} }

View File

@@ -60,7 +60,6 @@ export interface StatusHistoryItem {
export interface JobDetail { export interface JobDetail {
id: number; id: number;
title: string; title: string;
description: string | null;
start_time: string; start_time: string;
end_time: string; end_time: string;
status: JobStatus; status: JobStatus;
@@ -70,23 +69,29 @@ export interface JobDetail {
id: number; id: number;
name: string; name: string;
email: string | null; email: string | null;
phone: string | null; phone_masked: string | null;
phone_masked?: string;
} | null; } | null;
address: string | null;
latitude: number | null;
longitude: number | null;
service: { service: {
id: number; id: number;
name: string; name: string;
duration_minutes: number; duration: number;
price: string | null; price: string | null;
} | null; } | null;
notes: string | null; notes: string | null;
available_transitions: JobStatus[];
allowed_transitions: JobStatus[]; allowed_transitions: JobStatus[];
is_tracking_location: boolean; can_track_location: boolean;
has_active_call_session: boolean;
status_history?: StatusHistoryItem[]; status_history?: StatusHistoryItem[];
latest_location?: {
latitude: number;
longitude: number;
timestamp: string;
accuracy: number | null;
} | null;
deposit_amount?: string | null;
final_price?: string | null;
created_at: string;
updated_at: string;
can_edit_schedule?: boolean; can_edit_schedule?: boolean;
} }
@@ -125,10 +130,8 @@ export interface LocationPoint {
export interface RouteResponse { export interface RouteResponse {
job_id: number; job_id: number;
status: JobStatus;
is_tracking: boolean;
route: LocationPoint[]; route: LocationPoint[];
latest_location: LocationPoint | null; point_count: number;
} }
export interface CallResponse { export interface CallResponse {
@@ -151,11 +154,16 @@ export interface SMSResponse {
export interface CallHistoryItem { export interface CallHistoryItem {
id: number; id: number;
call_type: 'OUTBOUND_CALL' | 'INBOUND_CALL' | 'OUTBOUND_SMS' | 'INBOUND_SMS'; call_type: 'OUTBOUND_CALL' | 'INBOUND_CALL' | 'OUTBOUND_SMS' | 'INBOUND_SMS';
type_display: string;
direction: 'outbound' | 'inbound'; direction: 'outbound' | 'inbound';
duration_seconds: number | null; direction_display: string;
status: string; status: string;
created_at: string; status_display: string;
sms_body: string | null; duration_seconds: number | null;
initiated_at: string;
answered_at: string | null;
ended_at: string | null;
employee_name: string | null;
} }
export interface ApiError { export interface ApiError {

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

View File

@@ -78,10 +78,10 @@ urlpatterns = [
# Payment operations (existing) # Payment operations (existing)
path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'), path('payment-intents/', CreatePaymentIntentView.as_view(), name='create-payment-intent'),
path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'), path('terminal/connection-token/', TerminalConnectionTokenView.as_view(), name='terminal-connection-token'), # UNUSED_ENDPOINT: For Stripe Terminal POS hardware
path('refunds/', RefundPaymentView.as_view(), name='create-refund'), path('refunds/', RefundPaymentView.as_view(), name='create-refund'), # UNUSED_ENDPOINT: Refunds done via transactions/{id}/refund
# Customer billing endpoints # Customer billing endpoints (used by customer portal)
path('customer/billing/', CustomerBillingView.as_view(), name='customer-billing'), path('customer/billing/', CustomerBillingView.as_view(), name='customer-billing'),
path('customer/payment-methods/', CustomerPaymentMethodsView.as_view(), name='customer-payment-methods'), path('customer/payment-methods/', CustomerPaymentMethodsView.as_view(), name='customer-payment-methods'),
path('customer/setup-intent/', CustomerSetupIntentView.as_view(), name='customer-setup-intent'), path('customer/setup-intent/', CustomerSetupIntentView.as_view(), name='customer-setup-intent'),
@@ -89,6 +89,6 @@ urlpatterns = [
path('customer/payment-methods/<str:payment_method_id>/default/', CustomerPaymentMethodDefaultView.as_view(), name='customer-payment-method-default'), path('customer/payment-methods/<str:payment_method_id>/default/', CustomerPaymentMethodDefaultView.as_view(), name='customer-payment-method-default'),
# Variable pricing / final charge endpoints # Variable pricing / final charge endpoints
path('events/<int:event_id>/final-price/', SetFinalPriceView.as_view(), name='set-final-price'), path('events/<int:event_id>/final-price/', SetFinalPriceView.as_view(), name='set-final-price'), # UNUSED_ENDPOINT: For setting final price on variable-priced services
path('events/<int:event_id>/pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'), path('events/<int:event_id>/pricing/', EventPricingInfoView.as_view(), name='event-pricing-info'), # UNUSED_ENDPOINT: Get pricing info for variable-priced events
] ]

View File

@@ -11,6 +11,7 @@ from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework import status from rest_framework import status
from core.permissions import HasFeaturePermission from core.permissions import HasFeaturePermission
from core.mixins import TenantAPIView, TenantRequiredAPIView
from decimal import Decimal from decimal import Decimal
from .services import get_stripe_service_for_tenant from .services import get_stripe_service_for_tenant
from .models import TransactionLink from .models import TransactionLink
@@ -18,11 +19,24 @@ from schedule.models import Event
from platform_admin.models import SubscriptionPlan from platform_admin.models import SubscriptionPlan
# ============================================================================
# Helper Functions
# ============================================================================
def mask_key(key):
"""Mask a key showing only first 7 and last 4 characters."""
if not key:
return ''
if len(key) <= 12:
return '*' * len(key)
return key[:7] + '*' * (len(key) - 11) + key[-4:]
# ============================================================================ # ============================================================================
# Payment Configuration Status # Payment Configuration Status
# ============================================================================ # ============================================================================
class PaymentConfigStatusView(APIView): class PaymentConfigStatusView(TenantRequiredAPIView, APIView):
""" """
Get unified payment configuration status. Get unified payment configuration status.
@@ -38,7 +52,7 @@ class PaymentConfigStatusView(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
def get(self, request): def get(self, request):
tenant = request.tenant tenant = self.tenant
# Build API keys info if configured # Build API keys info if configured
api_keys = None api_keys = None
@@ -46,8 +60,8 @@ class PaymentConfigStatusView(APIView):
api_keys = { api_keys = {
'id': tenant.id, 'id': tenant.id,
'status': tenant.stripe_api_key_status, 'status': tenant.stripe_api_key_status,
'secret_key_masked': self._mask_key(tenant.stripe_secret_key), 'secret_key_masked': mask_key(tenant.stripe_secret_key),
'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key), 'publishable_key_masked': mask_key(tenant.stripe_publishable_key),
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None, 'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
'stripe_account_id': tenant.stripe_api_key_account_id, 'stripe_account_id': tenant.stripe_api_key_account_id,
'stripe_account_name': tenant.stripe_api_key_account_name, 'stripe_account_name': tenant.stripe_api_key_account_name,
@@ -98,14 +112,6 @@ class PaymentConfigStatusView(APIView):
'connect_account': connect_account, 'connect_account': connect_account,
}) })
def _mask_key(self, key):
"""Mask a key showing only first 7 and last 4 characters."""
if not key:
return ''
if len(key) <= 12:
return '*' * len(key)
return key[:7] + '*' * (len(key) - 11) + key[-4:]
# ============================================================================ # ============================================================================
# Subscription Plans & Add-ons # Subscription Plans & Add-ons
@@ -511,7 +517,7 @@ class ReactivateSubscriptionView(APIView):
# API Keys Endpoints (Free Tier) # API Keys Endpoints (Free Tier)
# ============================================================================ # ============================================================================
class ApiKeysView(APIView): class ApiKeysView(TenantRequiredAPIView, APIView):
""" """
Manage Stripe API keys for direct integration (free tier). Manage Stripe API keys for direct integration (free tier).
@@ -522,7 +528,7 @@ class ApiKeysView(APIView):
def get(self, request): def get(self, request):
"""Get current API key configuration.""" """Get current API key configuration."""
tenant = request.tenant tenant = self.tenant
if not tenant.stripe_secret_key: if not tenant.stripe_secret_key:
return Response({ return Response({
@@ -534,8 +540,8 @@ class ApiKeysView(APIView):
'configured': True, 'configured': True,
'id': tenant.id, 'id': tenant.id,
'status': tenant.stripe_api_key_status, 'status': tenant.stripe_api_key_status,
'secret_key_masked': self._mask_key(tenant.stripe_secret_key), 'secret_key_masked': mask_key(tenant.stripe_secret_key),
'publishable_key_masked': self._mask_key(tenant.stripe_publishable_key), 'publishable_key_masked': mask_key(tenant.stripe_publishable_key),
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None, 'last_validated_at': tenant.stripe_api_key_validated_at.isoformat() if tenant.stripe_api_key_validated_at else None,
'stripe_account_id': tenant.stripe_api_key_account_id, 'stripe_account_id': tenant.stripe_api_key_account_id,
'stripe_account_name': tenant.stripe_api_key_account_name, 'stripe_account_name': tenant.stripe_api_key_account_name,
@@ -548,22 +554,16 @@ class ApiKeysView(APIView):
publishable_key = request.data.get('publishable_key', '').strip() publishable_key = request.data.get('publishable_key', '').strip()
if not secret_key or not publishable_key: if not secret_key or not publishable_key:
return Response( return self.error_response('Both secret_key and publishable_key are required')
{'error': 'Both secret_key and publishable_key are required'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate keys against Stripe # Validate keys against Stripe
validation = self._validate_keys(secret_key, publishable_key) validation = validate_stripe_keys(secret_key, publishable_key)
if not validation['valid']: if not validation['valid']:
return Response( return self.error_response(validation.get('error', 'Invalid API keys'))
{'error': validation.get('error', 'Invalid API keys')},
status=status.HTTP_400_BAD_REQUEST
)
# Save keys to tenant # Save keys to tenant
tenant = request.tenant tenant = self.tenant
tenant.stripe_secret_key = secret_key tenant.stripe_secret_key = secret_key
tenant.stripe_publishable_key = publishable_key tenant.stripe_publishable_key = publishable_key
tenant.stripe_api_key_status = 'active' tenant.stripe_api_key_status = 'active'
@@ -577,16 +577,17 @@ class ApiKeysView(APIView):
return Response({ return Response({
'id': tenant.id, 'id': tenant.id,
'status': 'active', 'status': 'active',
'secret_key_masked': self._mask_key(secret_key), 'secret_key_masked': mask_key(secret_key),
'publishable_key_masked': self._mask_key(publishable_key), 'publishable_key_masked': mask_key(publishable_key),
'last_validated_at': tenant.stripe_api_key_validated_at.isoformat(), 'last_validated_at': tenant.stripe_api_key_validated_at.isoformat(),
'stripe_account_id': tenant.stripe_api_key_account_id, 'stripe_account_id': tenant.stripe_api_key_account_id,
'stripe_account_name': tenant.stripe_api_key_account_name, 'stripe_account_name': tenant.stripe_api_key_account_name,
'validation_error': '', 'validation_error': '',
}, status=status.HTTP_201_CREATED) }, status=status.HTTP_201_CREATED)
def _validate_keys(self, secret_key, publishable_key):
"""Validate Stripe API keys.""" def validate_stripe_keys(secret_key, publishable_key):
"""Validate Stripe API keys. Returns dict with 'valid' key and validation info."""
try: try:
# Test the secret key by retrieving account info # Test the secret key by retrieving account info
stripe.api_key = secret_key stripe.api_key = secret_key
@@ -613,16 +614,8 @@ class ApiKeysView(APIView):
# Reset to platform key # Reset to platform key
stripe.api_key = settings.STRIPE_SECRET_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(TenantAPIView, APIView):
class ApiKeysValidateView(APIView):
""" """
Validate API keys without saving. Validate API keys without saving.
@@ -641,30 +634,8 @@ class ApiKeysValidateView(APIView):
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
try: validation = validate_stripe_keys(secret_key, publishable_key)
stripe.api_key = secret_key return Response(validation)
account = stripe.Account.retrieve()
if not publishable_key.startswith('pk_'):
return Response({
'valid': False,
'error': 'Invalid publishable key format'
})
is_test = secret_key.startswith('sk_test_')
return Response({
'valid': True,
'account_id': account.id,
'account_name': account.get('business_profile', {}).get('name', '') or account.get('email', ''),
'environment': 'test' if is_test else 'live',
})
except stripe.error.AuthenticationError:
return Response({'valid': False, 'error': 'Invalid secret key'})
except stripe.error.StripeError as e:
return Response({'valid': False, 'error': str(e)})
finally:
stripe.api_key = settings.STRIPE_SECRET_KEY
class ApiKeysRevalidateView(APIView): class ApiKeysRevalidateView(APIView):

View File

@@ -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'),
),
]

View File

@@ -1663,6 +1663,11 @@ class TimeBlock(models.Model):
YEARLY = 'YEARLY', 'Yearly (specific days of year)' YEARLY = 'YEARLY', 'Yearly (specific days of year)'
HOLIDAY = 'HOLIDAY', 'Holiday (floating dates)' HOLIDAY = 'HOLIDAY', 'Holiday (floating dates)'
class ApprovalStatus(models.TextChoices):
APPROVED = 'APPROVED', 'Approved'
PENDING = 'PENDING', 'Pending Review'
DENIED = 'DENIED', 'Denied'
# Core identification # Core identification
title = models.CharField( title = models.CharField(
max_length=200, max_length=200,
@@ -1755,6 +1760,32 @@ class TimeBlock(models.Model):
# Status # Status
is_active = models.BooleanField(default=True, db_index=True) is_active = models.BooleanField(default=True, db_index=True)
# Approval workflow (for staff time-off requests)
approval_status = models.CharField(
max_length=20,
choices=ApprovalStatus.choices,
default=ApprovalStatus.APPROVED,
db_index=True,
help_text="Approval status for time-off requests"
)
reviewed_by = models.ForeignKey(
'users.User',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='reviewed_time_blocks',
help_text="Manager/owner who reviewed the request"
)
reviewed_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the request was reviewed"
)
review_notes = models.TextField(
blank=True,
help_text="Optional notes from reviewer"
)
# Audit # Audit
created_by = models.ForeignKey( created_by = models.ForeignKey(
'users.User', 'users.User',
@@ -1771,6 +1802,7 @@ class TimeBlock(models.Model):
models.Index(fields=['resource', 'is_active']), models.Index(fields=['resource', 'is_active']),
models.Index(fields=['recurrence_type', 'is_active']), models.Index(fields=['recurrence_type', 'is_active']),
models.Index(fields=['start_date', 'end_date']), models.Index(fields=['start_date', 'end_date']),
models.Index(fields=['approval_status']),
] ]
def __str__(self): def __str__(self):
@@ -1782,6 +1814,16 @@ class TimeBlock(models.Model):
"""Check if this is a business-level block (affects all resources).""" """Check if this is a business-level block (affects all resources)."""
return self.resource is None return self.resource is None
@property
def is_effective(self):
"""Check if this block is effective (active and approved)."""
return self.is_active and self.approval_status == self.ApprovalStatus.APPROVED
@property
def is_pending_approval(self):
"""Check if this block is pending approval."""
return self.approval_status == self.ApprovalStatus.PENDING
def blocks_date(self, check_date): def blocks_date(self, check_date):
""" """
Check if this block applies to a given date. Check if this block applies to a given date.
@@ -1794,7 +1836,8 @@ class TimeBlock(models.Model):
""" """
from datetime import date from datetime import date
if not self.is_active: # Block must be both active and approved to be effective
if not self.is_effective:
return False return False
# Check recurrence bounds # Check recurrence bounds

View File

@@ -1164,6 +1164,7 @@ class TimeBlockSerializer(serializers.ModelSerializer):
"""Full serializer for TimeBlock CRUD operations""" """Full serializer for TimeBlock CRUD operations"""
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True) resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField() created_by_name = serializers.SerializerMethodField()
reviewed_by_name = serializers.SerializerMethodField()
level = serializers.SerializerMethodField() level = serializers.SerializerMethodField()
pattern_display = serializers.SerializerMethodField() pattern_display = serializers.SerializerMethodField()
holiday_name = serializers.SerializerMethodField() holiday_name = serializers.SerializerMethodField()
@@ -1178,16 +1179,23 @@ class TimeBlockSerializer(serializers.ModelSerializer):
'start_date', 'end_date', 'all_day', 'start_time', 'end_time', 'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
'recurrence_pattern', 'pattern_display', 'holiday_name', 'recurrence_pattern', 'pattern_display', 'holiday_name',
'recurrence_start', 'recurrence_end', 'recurrence_start', 'recurrence_end',
'is_active', 'created_by', 'created_by_name', 'is_active', 'approval_status', 'reviewed_by', 'reviewed_by_name',
'reviewed_at', 'review_notes',
'created_by', 'created_by_name',
'conflict_count', 'created_at', 'updated_at', 'conflict_count', 'created_at', 'updated_at',
] ]
read_only_fields = ['created_by', 'created_at', 'updated_at'] read_only_fields = ['created_by', 'created_at', 'updated_at', 'reviewed_by', 'reviewed_at']
def get_created_by_name(self, obj): def get_created_by_name(self, obj):
if obj.created_by: if obj.created_by:
return obj.created_by.get_full_name() or obj.created_by.email return obj.created_by.get_full_name() or obj.created_by.email
return None return None
def get_reviewed_by_name(self, obj):
if obj.reviewed_by:
return obj.reviewed_by.get_full_name() or obj.reviewed_by.email
return None
def get_level(self, obj): def get_level(self, obj):
"""Return 'business' if no resource, otherwise 'resource'""" """Return 'business' if no resource, otherwise 'resource'"""
return 'business' if obj.resource is None else 'resource' return 'business' if obj.resource is None else 'resource'
@@ -1396,7 +1404,7 @@ class TimeBlockSerializer(serializers.ModelSerializer):
# Staff can only create blocks for their own resource # Staff can only create blocks for their own resource
if resource: if resource:
if not hasattr(user, 'resource') or user.resource != resource: if not user.staff_resources.filter(pk=resource.pk).exists():
raise serializers.ValidationError({ raise serializers.ValidationError({
'resource': 'Staff can only create blocks for their own resource' 'resource': 'Staff can only create blocks for their own resource'
}) })
@@ -1421,6 +1429,8 @@ class TimeBlockSerializer(serializers.ModelSerializer):
class TimeBlockListSerializer(serializers.ModelSerializer): class TimeBlockListSerializer(serializers.ModelSerializer):
"""Serializer for time block lists - includes fields needed for editing""" """Serializer for time block lists - includes fields needed for editing"""
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True) resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField()
reviewed_by_name = serializers.SerializerMethodField()
level = serializers.SerializerMethodField() level = serializers.SerializerMethodField()
pattern_display = serializers.SerializerMethodField() pattern_display = serializers.SerializerMethodField()
@@ -1431,9 +1441,21 @@ class TimeBlockListSerializer(serializers.ModelSerializer):
'block_type', 'recurrence_type', 'start_date', 'end_date', 'block_type', 'recurrence_type', 'start_date', 'end_date',
'all_day', 'start_time', 'end_time', 'recurrence_pattern', 'all_day', 'start_time', 'end_time', 'recurrence_pattern',
'recurrence_start', 'recurrence_end', 'pattern_display', 'recurrence_start', 'recurrence_end', 'pattern_display',
'is_active', 'created_at', 'is_active', 'approval_status', 'reviewed_by', 'reviewed_by_name',
'reviewed_at', 'review_notes',
'created_by', 'created_by_name', 'created_at',
] ]
def get_created_by_name(self, obj):
if obj.created_by:
return obj.created_by.get_full_name() or obj.created_by.email
return None
def get_reviewed_by_name(self, obj):
if obj.reviewed_by:
return obj.reviewed_by.get_full_name() or obj.reviewed_by.email
return None
def get_level(self, obj): def get_level(self, obj):
return 'business' if obj.resource is None else 'resource' return 'business' if obj.resource is None else 'resource'

View File

@@ -7,14 +7,28 @@ Handles:
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified 3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
4. Cancelling tasks when Events are deleted or cancelled 4. Cancelling tasks when Events are deleted or cancelled
5. Broadcasting real-time updates via WebSocket for calendar sync 5. Broadcasting real-time updates via WebSocket for calendar sync
6. Customer notification hooks on status changes
""" """
import logging import logging
from django.db.models.signals import post_save, pre_save, post_delete, pre_delete from django.db.models.signals import post_save, pre_save, post_delete, pre_delete
from django.dispatch import receiver from django.dispatch import Signal, receiver
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# =============================================================================
# Custom Signals for Status Changes
# =============================================================================
# Fired when an event status changes (hook point for custom behavior)
# Provides: event, old_status, new_status, changed_by, tenant, skip_notifications
event_status_changed = Signal()
# Fired when a customer should be notified about an event
# Provides: event, notification_type, tenant
customer_notification_requested = Signal()
# ============================================================================ # ============================================================================
# WebSocket Broadcasting Helpers # WebSocket Broadcasting Helpers
# ============================================================================ # ============================================================================
@@ -402,3 +416,268 @@ def broadcast_event_delete(sender, instance, **kwargs):
# Store the event data before deletion for broadcasting # Store the event data before deletion for broadcasting
broadcast_event_change_sync(instance, 'event_deleted') broadcast_event_change_sync(instance, 'event_deleted')
logger.info(f"Broadcast event_deleted for event {instance.id}") logger.info(f"Broadcast event_deleted for event {instance.id}")
# =============================================================================
# Customer Notification Signal Handlers
# =============================================================================
@receiver(event_status_changed)
def handle_event_status_change_notifications(sender, event, old_status, new_status, changed_by, tenant, **kwargs):
"""
Handle customer notifications when event status changes.
This is a hook point for custom notification behavior.
Default behavior: queue customer notifications for certain transitions.
"""
from .models import Event
skip_notifications = kwargs.get('skip_notifications', False)
if skip_notifications:
logger.debug(f"Notifications skipped for event {event.id} status change")
return
# Define which transitions should notify customers
NOTIFY_TRANSITIONS = {
(Event.Status.SCHEDULED, Event.Status.EN_ROUTE): 'en_route_notification',
(Event.Status.EN_ROUTE, Event.Status.IN_PROGRESS): 'arrived_notification',
(Event.Status.IN_PROGRESS, Event.Status.COMPLETED): 'completed_notification',
}
notification_type = NOTIFY_TRANSITIONS.get((old_status, new_status))
if notification_type:
# Fire the notification signal
customer_notification_requested.send(
sender=sender,
event=event,
notification_type=notification_type,
tenant=tenant,
)
logger.info(f"Requested {notification_type} for event {event.id}")
@receiver(event_status_changed)
def handle_event_status_change_plugins(sender, event, old_status, new_status, changed_by, tenant, **kwargs):
"""
Execute plugins attached to status change events.
"""
from .models import Event
try:
if new_status == Event.Status.COMPLETED:
event.execute_plugins(trigger='on_complete')
elif new_status == Event.Status.CANCELED:
event.execute_plugins(trigger='on_cancel')
except Exception as e:
logger.warning(f"Error executing event plugins on status change: {e}")
@receiver(customer_notification_requested)
def send_customer_notification_task(sender, event, notification_type, tenant, **kwargs):
"""
Queue customer notification via Celery task.
This is the default handler that queues notifications.
Can be replaced or supplemented by custom handlers.
"""
try:
from smoothschedule.field_mobile.tasks import send_customer_status_notification
send_customer_status_notification.delay(
tenant_id=tenant.id,
event_id=event.id,
notification_type=notification_type,
)
logger.debug(f"Queued {notification_type} for event {event.id}")
except Exception as e:
# Don't fail the status change if notification fails
logger.warning(f"Failed to queue customer notification: {e}")
# =============================================================================
# Helper function for status changes
# =============================================================================
def emit_status_change(event, old_status, new_status, changed_by, tenant, skip_notifications=False):
"""
Emit the event_status_changed signal.
This should be called after a successful status transition.
The StatusMachine uses this to trigger hooks.
Args:
event: The Event instance
old_status: Previous status value
new_status: New status value
changed_by: User who made the change
tenant: Business tenant
skip_notifications: If True, signal receivers should skip notifications
"""
event_status_changed.send(
sender=event.__class__,
event=event,
old_status=old_status,
new_status=new_status,
changed_by=changed_by,
tenant=tenant,
skip_notifications=skip_notifications,
)
# =============================================================================
# TimeBlock (Time-Off Request) Signals
# =============================================================================
# Custom signal for time-off request notifications
time_off_request_submitted = Signal()
def is_notifications_available():
"""Check if the notifications app is installed and migrated."""
try:
from notifications.models import Notification
Notification.objects.exists()
return True
except Exception:
return False
def create_notification_safe(recipient, actor, verb, action_object=None, target=None, data=None):
"""Safely create a notification, handling import and creation errors."""
if not is_notifications_available():
logger.debug("notifications app not available, skipping notification creation")
return None
try:
from notifications.models import Notification
return Notification.objects.create(
recipient=recipient,
actor=actor,
verb=verb,
action_object=action_object,
target=target,
data=data or {}
)
except Exception as e:
logger.error(f"Failed to create notification for {recipient}: {e}")
return None
@receiver(post_save, sender='schedule.TimeBlock')
def notify_managers_on_pending_time_off(sender, instance, created, **kwargs):
"""
When a TimeBlock is created with PENDING approval status,
notify all managers and owners in the business.
"""
if not created:
return
from .models import TimeBlock
# Only notify for pending requests (staff time-off that needs approval)
if instance.approval_status != TimeBlock.ApprovalStatus.PENDING:
return
# Only for resource-level blocks (staff time-off)
if not instance.resource:
return
# Get the requester
requester = instance.created_by
if not requester:
return
logger.info(
f"Time-off request submitted by {requester.get_full_name() or requester.email} "
f"for resource '{instance.resource.name}'"
)
# Find all managers and owners to notify
from smoothschedule.users.models import User
reviewers = User.objects.filter(
role__in=[User.Role.TENANT_OWNER, User.Role.TENANT_MANAGER],
is_active=True
)
# Create in-app notifications for each reviewer
for reviewer in reviewers:
notification = create_notification_safe(
recipient=reviewer,
actor=requester,
verb='requested time off',
action_object=instance,
target=instance.resource,
data={
'time_block_id': instance.id,
'title': instance.title,
'resource_name': instance.resource.name if instance.resource else None,
'requester_name': requester.get_full_name() or requester.email,
'type': 'time_off_request',
}
)
if notification:
logger.debug(f"Created time-off notification for {reviewer.email}")
# Fire custom signal for additional handlers (e.g., email notifications)
time_off_request_submitted.send(
sender=sender,
time_block=instance,
requester=requester,
reviewers=list(reviewers),
)
@receiver(time_off_request_submitted)
def send_time_off_email_notifications(sender, time_block, requester, reviewers, **kwargs):
"""
Send email notifications to reviewers when a time-off request is submitted.
"""
from django.core.mail import send_mail
from django.conf import settings
# Get the resource name for the email
resource_name = time_block.resource.name if time_block.resource else 'Unknown'
requester_name = requester.get_full_name() or requester.email
# Build date description
if time_block.recurrence_type == 'NONE':
if time_block.start_date == time_block.end_date:
date_desc = time_block.start_date.strftime('%B %d, %Y') if time_block.start_date else 'Date not specified'
else:
start = time_block.start_date.strftime('%B %d, %Y') if time_block.start_date else ''
end = time_block.end_date.strftime('%B %d, %Y') if time_block.end_date else ''
date_desc = f"{start} - {end}"
else:
date_desc = f"Recurring ({time_block.get_recurrence_type_display()})"
subject = f"Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}"
message = f"""
A new time-off request has been submitted and needs your review.
Requester: {requester_name}
Resource: {resource_name}
Title: {time_block.title or 'Time Off'}
Date(s): {date_desc}
Description: {time_block.description or 'No description provided'}
Please log in to review this request.
"""
# Send email to each reviewer
for reviewer in reviewers:
if reviewer.email:
try:
send_mail(
subject=subject,
message=message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[reviewer.email],
fail_silently=True,
)
logger.info(f"Sent time-off request email to {reviewer.email}")
except Exception as e:
logger.warning(f"Failed to send time-off email to {reviewer.email}: {e}")

View File

@@ -1,5 +1,8 @@
""" """
Schedule App URLs Schedule App URLs
UNUSED_ENDPOINT tags mark endpoints not currently used by the frontend.
Search for "UNUSED_ENDPOINT" to find all unused endpoints in the codebase.
""" """
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
@@ -19,13 +22,13 @@ router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype')
router.register(r'resources', ResourceViewSet, basename='resource') router.register(r'resources', ResourceViewSet, basename='resource')
router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend
router.register(r'events', EventViewSet, basename='event') router.register(r'events', EventViewSet, basename='event')
router.register(r'participants', ParticipantViewSet, basename='participant') router.register(r'participants', ParticipantViewSet, basename='participant') # UNUSED_ENDPOINT: Participants managed via Event serializer
router.register(r'customers', CustomerViewSet, basename='customer') router.register(r'customers', CustomerViewSet, basename='customer')
router.register(r'services', ServiceViewSet, basename='service') router.register(r'services', ServiceViewSet, basename='service')
router.register(r'staff', StaffViewSet, basename='staff') router.register(r'staff', StaffViewSet, basename='staff')
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask') router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask')
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog') router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog') # UNUSED_ENDPOINT: Logs accessed via scheduled-tasks/{id}/logs action
router.register(r'plugins', PluginViewSet, basename='plugin') router.register(r'plugins', PluginViewSet, basename='plugin') # UNUSED_ENDPOINT: Frontend uses plugin-templates instead
router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemplate') router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemplate')
router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation') router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation')
router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin') router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')

File diff suppressed because it is too large Load Diff

View File

@@ -234,13 +234,16 @@ class StatusMachine:
if new_status not in self.TRACKING_STATUSES: if new_status not in self.TRACKING_STATUSES:
self._stop_location_tracking(event) self._stop_location_tracking(event)
# Trigger notifications if needed # Emit status change signal (triggers notifications and plugin hooks)
if not skip_notifications: from schedule.signals import emit_status_change
notification_type = self.NOTIFY_CUSTOMER_TRANSITIONS.get( emit_status_change(
(old_status, new_status) event=event,
old_status=old_status,
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 return event
@@ -255,29 +258,6 @@ class StatusMachine:
# For now, the app will check the event status before sending updates # For now, the app will check the event status before sending updates
pass pass
def _send_customer_notification(self, event: Event, notification_type: str):
"""
Send a notification to the customer about the status change.
Args:
event: The event that changed
notification_type: Type of notification to send
"""
# Import here to avoid circular imports
from smoothschedule.field_mobile.tasks import send_customer_status_notification
try:
# Queue the notification task
send_customer_status_notification.delay(
tenant_id=self.tenant.id,
event_id=event.id,
notification_type=notification_type,
)
except Exception:
# Don't fail the status change if notification fails
# The notification system should handle retries
pass
def get_allowed_transitions(self, event: Event) -> list: def get_allowed_transitions(self, event: Event) -> list:
""" """
Get the list of statuses this event can transition to. Get the list of statuses this event can transition to.

View File

@@ -2,6 +2,10 @@
Field Mobile URL Configuration Field Mobile URL Configuration
All endpoints are mounted under /api/mobile/ All endpoints are mounted under /api/mobile/
UNUSED_ENDPOINT: All endpoints in this file are for a separate mobile app (React Native/Flutter)
and are not used by the main React web frontend. These are kept for the field technician
mobile application.
""" """
from django.urls import path from django.urls import path
@@ -9,8 +13,7 @@ from .views import (
# Employee profile # Employee profile
employee_profile_view, employee_profile_view,
logout_view, logout_view,
# Job endpoints # Job endpoints (list uses /api/appointments/ with date range filtering)
job_list_view,
job_detail_view, job_detail_view,
# Status management # Status management
set_status_view, set_status_view,
@@ -37,8 +40,7 @@ urlpatterns = [
path('me/', employee_profile_view, name='employee_profile'), path('me/', employee_profile_view, name='employee_profile'),
path('logout/', logout_view, name='logout'), path('logout/', logout_view, name='logout'),
# Job management # Job management (list uses /api/appointments/ with date range filtering)
path('jobs/', job_list_view, name='job_list'),
path('jobs/<int:job_id>/', job_detail_view, name='job_detail'), path('jobs/<int:job_id>/', job_detail_view, name='job_detail'),
# Status management # Status management

View File

@@ -153,92 +153,10 @@ def logout_view(request):
# ============================================================================= # =============================================================================
# Job List and Detail Endpoints # Job Detail Endpoint
# Note: Job list uses /api/appointments/ with date range filtering (start_date, end_date)
# ============================================================================= # =============================================================================
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def job_list_view(request):
"""
List jobs assigned to the current employee.
GET /api/mobile/jobs/
Query params:
- date: Filter by date (YYYY-MM-DD). Defaults to today.
- status: Filter by status (comma-separated)
- upcoming: If true, include future jobs
Returns jobs sorted by start time.
"""
user = request.user
tenant = get_tenant_from_user(user)
if not tenant:
return Response(
{'error': 'No business associated with your account'},
status=status.HTTP_400_BAD_REQUEST
)
if not is_field_employee(user):
return Response(
{'error': 'This app is for field employees only'},
status=status.HTTP_403_FORBIDDEN
)
with schema_context(tenant.schema_name):
queryset = get_employee_jobs_queryset(user, tenant)
# Date filtering
date_str = request.query_params.get('date')
include_upcoming = request.query_params.get('upcoming', 'false').lower() == 'true'
if date_str:
try:
from datetime import datetime
filter_date = datetime.strptime(date_str, '%Y-%m-%d').date()
queryset = queryset.filter(
start_time__date=filter_date
)
except ValueError:
return Response(
{'error': 'Invalid date format. Use YYYY-MM-DD'},
status=status.HTTP_400_BAD_REQUEST
)
elif include_upcoming:
# Show today and future jobs (using business timezone)
import pytz
business_tz = pytz.timezone(tenant.timezone) if tenant.timezone else pytz.UTC
now_business = timezone.now().astimezone(business_tz)
today_start = now_business.replace(hour=0, minute=0, second=0, microsecond=0)
queryset = queryset.filter(start_time__gte=today_start)
else:
# Default to today only (using business timezone)
import pytz
business_tz = pytz.timezone(tenant.timezone) if tenant.timezone else pytz.UTC
now_business = timezone.now().astimezone(business_tz)
today = now_business.date()
queryset = queryset.filter(start_time__date=today)
# Status filtering
status_filter = request.query_params.get('status')
if status_filter:
statuses = [s.strip().upper() for s in status_filter.split(',')]
queryset = queryset.filter(status__in=statuses)
# Order by start time
queryset = queryset.order_by('start_time')
# Prefetch for efficiency
queryset = queryset.select_related('service').prefetch_related('participants')
serializer = JobListSerializer(queryset, many=True)
return Response({
'jobs': serializer.data,
'count': queryset.count(),
})
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def job_detail_view(request, job_id): def job_detail_view(request, job_id):

View File

@@ -153,18 +153,20 @@ def current_user_view(request):
} }
frontend_role = role_mapping.get(user.role.lower(), user.role.lower()) frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
# Get linked resource info for staff users # Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
linked_resource_id = None linked_resource_id = None
can_edit_schedule = False can_edit_schedule = False
if user.tenant and user.role == User.Role.TENANT_STAFF: if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
try: try:
with schema_context(user.tenant.schema_name): with schema_context(user.tenant.schema_name):
linked_resource = Resource.objects.filter(user=user).first() linked_resource = Resource.objects.filter(user=user).first()
if linked_resource: if linked_resource:
linked_resource_id = linked_resource.id linked_resource_id = linked_resource.id
can_edit_schedule = linked_resource.user_can_edit_schedule can_edit_schedule = linked_resource.user_can_edit_schedule
except Exception: except Exception as e:
pass import logging
logger = logging.getLogger(__name__)
logger.error(f"Error getting linked resource for user {user.id}: {e}")
user_data = { user_data = {
'id': user.id, 'id': user.id,
@@ -313,18 +315,20 @@ def _get_user_data(user):
} }
frontend_role = role_mapping.get(user.role.lower(), user.role.lower()) frontend_role = role_mapping.get(user.role.lower(), user.role.lower())
# Get linked resource info for staff users # Get linked resource info for tenant users (staff, managers, owners can all be linked to resources)
linked_resource_id = None linked_resource_id = None
can_edit_schedule = False can_edit_schedule = False
if user.tenant and user.role == User.Role.TENANT_STAFF: if user.tenant and user.role in [User.Role.TENANT_STAFF, User.Role.TENANT_MANAGER, User.Role.TENANT_OWNER]:
try: try:
with schema_context(user.tenant.schema_name): with schema_context(user.tenant.schema_name):
linked_resource = Resource.objects.filter(user=user).first() linked_resource = Resource.objects.filter(user=user).first()
if linked_resource: if linked_resource:
linked_resource_id = linked_resource.id linked_resource_id = linked_resource.id
can_edit_schedule = linked_resource.user_can_edit_schedule can_edit_schedule = linked_resource.user_can_edit_schedule
except Exception: except Exception as e:
pass import logging
logger = logging.getLogger(__name__)
logger.error(f"Error getting linked resource for user {user.id} in _get_user_data: {e}")
return { return {
'id': user.id, 'id': user.id,

View File

@@ -260,6 +260,27 @@ class User(AbstractUser):
# All others cannot whitelist # All others cannot whitelist
return False return False
def can_self_approve_time_off(self):
"""
Check if user can self-approve time off requests.
Owners and managers can always self-approve.
Staff need explicit permission.
"""
# Owners and managers can always self-approve
if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]:
return True
# Staff can self-approve if granted permission
if self.role == self.Role.TENANT_STAFF:
return self.permissions.get('can_self_approve_time_off', False)
return False
def can_review_time_off_requests(self):
"""
Check if user can review (approve/deny) time off requests from others.
Only owners and managers can review.
"""
return self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]
def get_accessible_tenants(self): def get_accessible_tenants(self):
""" """
Get list of tenants this user can access. Get list of tenants this user can access.

View File

@@ -1,3 +1,9 @@
"""
Legacy user URLs from cookiecutter-django template.
UNUSED_ENDPOINT: All endpoints in this file are legacy views from the cookiecutter template
and are not used by the React frontend. The API uses /auth/ and /api/ endpoints instead.
"""
from django.urls import path from django.urls import path
from .views import user_detail_view from .views import user_detail_view
@@ -6,7 +12,7 @@ from .views import user_update_view
app_name = "users" app_name = "users"
urlpatterns = [ urlpatterns = [
path("~redirect/", view=user_redirect_view, name="redirect"), path("~redirect/", view=user_redirect_view, name="redirect"), # UNUSED_ENDPOINT: Legacy template view
path("~update/", view=user_update_view, name="update"), path("~update/", view=user_update_view, name="update"), # UNUSED_ENDPOINT: Legacy template view
path("<str:username>/", view=user_detail_view, name="detail"), path("<str:username>/", view=user_detail_view, name="detail"), # UNUSED_ENDPOINT: Legacy template view
] ]