4 Commits

Author SHA1 Message Date
poduck
8440ac945a feat(time-off): Reset approval when staff edits approved request
- Add pre_save signal to track changes to approved time blocks
- Reset to PENDING status when staff modifies approved time-off
- Send re-approval notifications to managers with changed fields
- Update email templates for modified requests
- Allow managers to have self-approval permission revoked (default: allowed)

A changed request is treated as a new request requiring re-approval.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 20:35:47 -05:00
poduck
f4332153f4 feat: Add timezone architecture for consistent date/time handling
- Create dateUtils.ts with helpers for UTC conversion and timezone display
- Add TimezoneSerializerMixin to include business_timezone in API responses
- Update GeneralSettings timezone dropdown with IANA identifiers
- Apply timezone mixin to Event, TimeBlock, and field mobile serializers
- Document timezone architecture in CLAUDE.md

All times stored in UTC, converted for display based on business timezone.
If business_timezone is null, uses user's local timezone.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 19:39:36 -05:00
poduck
897a336d0b feat: Add click navigation for time-off request notifications
Clicking a time-off request notification now navigates to the
time blocks page where pending requests can be reviewed.

- Added Clock icon for time-off request notifications
- Handle notification.data.type === 'time_off_request' to navigate to /time-blocks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-07 17:54:20 -05:00
poduck
410b46a896 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>
2025-12-07 17:49:37 -05:00
34 changed files with 4100 additions and 1281 deletions

253
CLAUDE.md
View File

@@ -61,6 +61,122 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
| `frontend/src/api/client.ts` | Axios API client |
| `frontend/src/types.ts` | TypeScript interfaces |
| `frontend/src/i18n/locales/en.json` | Translations |
| `frontend/src/utils/dateUtils.ts` | Date formatting utilities |
## Timezone Architecture (CRITICAL)
All date/time handling follows this architecture to ensure consistency across timezones.
### Core Principles
1. **Database**: All times stored in UTC
2. **API Communication**: Always use UTC (both directions)
3. **API Responses**: Include `business_timezone` field
4. **Frontend Display**: Convert UTC based on `business_timezone`
- If `business_timezone` is set → display in that timezone
- If `business_timezone` is null/blank → display in user's local timezone
### Data Flow
```
FRONTEND (User in Eastern Time selects "Dec 8, 2:00 PM")
Convert to UTC: "2024-12-08T19:00:00Z"
Send to API (always UTC)
DATABASE (stores UTC)
API RESPONSE:
{
"start_time": "2024-12-08T19:00:00Z", // Always UTC
"business_timezone": "America/Denver" // IANA timezone (or null for local)
}
FRONTEND CONVERTS:
- If business_timezone set: UTC → Mountain Time → "Dec 8, 12:00 PM MST"
- If business_timezone null: UTC → User local → "Dec 8, 2:00 PM EST"
```
### Frontend Helper Functions
Located in `frontend/src/utils/dateUtils.ts`:
```typescript
import {
toUTC,
fromUTC,
formatForDisplay,
formatDateForDisplay,
getDisplayTimezone,
} from '../utils/dateUtils';
// SENDING TO API - Always convert to UTC
const apiPayload = {
start_time: toUTC(selectedDateTime), // "2024-12-08T19:00:00Z"
};
// RECEIVING FROM API - Convert for display
const displayTime = formatForDisplay(
response.start_time, // UTC from API
response.business_timezone // "America/Denver" or null
);
// Result: "Dec 8, 2024 12:00 PM" (in business or local timezone)
// DATE-ONLY fields (time blocks)
const displayDate = formatDateForDisplay(
response.start_date,
response.business_timezone
);
```
### API Response Requirements
All endpoints returning date/time data MUST include:
```python
# In serializers or views
{
"start_time": "2024-12-08T19:00:00Z",
"business_timezone": business.timezone, # "America/Denver" or None
}
```
### Backend Serializer Mixin
Use `TimezoneSerializerMixin` from `core/mixins.py` to automatically add the timezone field:
```python
from core.mixins import TimezoneSerializerMixin
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Event
fields = [
'id', 'start_time', 'end_time',
# ... other fields ...
'business_timezone', # Provided by mixin
]
read_only_fields = ['business_timezone']
```
The mixin automatically retrieves the timezone from the tenant context.
- Returns the IANA timezone string if set (e.g., "America/Denver")
- Returns `null` if not set (frontend uses user's local timezone)
### Common Mistakes to Avoid
```typescript
// BAD - Uses browser local time, not UTC
date.toISOString().split('T')[0]
// BAD - Doesn't respect business timezone setting
new Date(utcString).toLocaleString()
// GOOD - Use helper functions
toUTC(date) // For API requests
formatForDisplay(utcString, businessTimezone) // For displaying
```
## Key Django Apps
@@ -69,6 +185,143 @@ docker compose -f docker-compose.local.yml exec django python manage.py <command
| `schedule` | `smoothschedule/smoothschedule/schedule/` | Resources, Events, Services |
| `users` | `smoothschedule/smoothschedule/users/` | Authentication, User model |
| `tenants` | `smoothschedule/smoothschedule/tenants/` | Multi-tenancy (Business model) |
| `core` | `smoothschedule/core/` | Shared mixins, permissions, middleware |
| `payments` | `smoothschedule/payments/` | Stripe integration, subscriptions |
| `platform_admin` | `smoothschedule/platform_admin/` | Platform administration |
## Core Mixins & Base Classes
Located in `smoothschedule/core/mixins.py`. Use these to avoid code duplication.
### Permission Classes
```python
from core.mixins import DenyStaffWritePermission, DenyStaffAllAccessPermission, DenyStaffListPermission
class MyViewSet(ModelViewSet):
# Block write operations for staff (GET allowed)
permission_classes = [IsAuthenticated, DenyStaffWritePermission]
# Block ALL operations for staff
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
# Block list/create/update/delete but allow retrieve
permission_classes = [IsAuthenticated, DenyStaffListPermission]
```
#### Per-User Permission Overrides
Staff permissions can be overridden on a per-user basis using the `user.permissions` JSONField.
Permission keys are auto-derived from the view's basename or model name:
| Permission Class | Auto-derived Key | Example |
|-----------------|------------------|---------|
| `DenyStaffWritePermission` | `can_write_{basename}` | `can_write_resources` |
| `DenyStaffAllAccessPermission` | `can_access_{basename}` | `can_access_services` |
| `DenyStaffListPermission` | `can_list_{basename}` or `can_access_{basename}` | `can_list_customers` |
**Current ViewSet permission keys:**
| ViewSet | Permission Class | Override Key |
|---------|-----------------|--------------|
| `ResourceViewSet` | `DenyStaffAllAccessPermission` | `can_access_resources` |
| `ServiceViewSet` | `DenyStaffAllAccessPermission` | `can_access_services` |
| `CustomerViewSet` | `DenyStaffListPermission` | `can_list_customers` or `can_access_customers` |
| `ScheduledTaskViewSet` | `DenyStaffAllAccessPermission` | `can_access_scheduled-tasks` |
**Granting a specific staff member access:**
```bash
# Open Django shell
docker compose -f docker-compose.local.yml exec django python manage.py shell
```
```python
from smoothschedule.users.models import User
# Find the staff member
staff = User.objects.get(email='john@example.com')
# Grant read access to resources
staff.permissions['can_access_resources'] = True
staff.save()
# Or grant list access to customers (but not full CRUD)
staff.permissions['can_list_customers'] = True
staff.save()
```
**Custom permission keys (optional):**
```python
class ResourceViewSet(ModelViewSet):
permission_classes = [IsAuthenticated, DenyStaffAllAccessPermission]
# Override the auto-derived key
staff_access_permission_key = 'can_manage_equipment'
```
Then grant via: `staff.permissions['can_manage_equipment'] = True`
### QuerySet Mixins
```python
from core.mixins import TenantFilteredQuerySetMixin, UserTenantFilteredMixin
# For tenant-scoped models (automatic django-tenants filtering)
class ResourceViewSet(TenantFilteredQuerySetMixin, ModelViewSet):
queryset = Resource.objects.all()
deny_staff_queryset = True # Optional: also filter staff at queryset level
def filter_queryset_for_tenant(self, queryset):
# Override for custom filtering
return queryset.filter(is_active=True)
# For User model (shared schema, needs explicit tenant filter)
class CustomerViewSet(UserTenantFilteredMixin, ModelViewSet):
queryset = User.objects.filter(role=User.Role.CUSTOMER)
```
### Feature Permission Mixins
```python
from core.mixins import PluginFeatureRequiredMixin, TaskFeatureRequiredMixin
# Checks can_use_plugins feature on list/retrieve/create
class PluginViewSet(PluginFeatureRequiredMixin, ModelViewSet):
pass
# Checks both can_use_plugins AND can_use_tasks
class ScheduledTaskViewSet(TaskFeatureRequiredMixin, TenantFilteredQuerySetMixin, ModelViewSet):
pass
```
### Base API Views (for non-ViewSet views)
```python
from rest_framework.views import APIView
from core.mixins import TenantAPIView, TenantRequiredAPIView
# Optional tenant - use self.get_tenant()
class MyView(TenantAPIView, APIView):
def get(self, request):
tenant = self.get_tenant() # May be None
return self.success_response({'data': 'value'})
# or: return self.error_response('Something went wrong', status_code=400)
# Required tenant - self.tenant always available
class MyTenantView(TenantRequiredAPIView, APIView):
def get(self, request):
# self.tenant is guaranteed to exist (returns 400 if missing)
return Response({'name': self.tenant.name})
```
### Helper Methods Available
| Method | Description |
|--------|-------------|
| `self.get_tenant()` | Get tenant from request (may be None) |
| `self.get_tenant_or_error()` | Returns (tenant, error_response) tuple |
| `self.error_response(msg, status_code)` | Standard error response |
| `self.success_response(data, status_code)` | Standard success response |
| `self.check_feature(key, name)` | Check feature permission, returns error or None |
## Common Tasks

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from '../api/client';
import { X, Calendar, Clock, RotateCw, Zap } from 'lucide-react';
import { formatLocalDate } from '../utils/dateUtils';
interface ScheduledTask {
id: string;
@@ -79,7 +80,7 @@ const EditTaskModal: React.FC<EditTaskModalProps> = ({ task, isOpen, onClose, on
setScheduleMode('onetime');
if (task.run_at) {
const date = new Date(task.run_at);
setRunAtDate(date.toISOString().split('T')[0]);
setRunAtDate(formatLocalDate(date));
setRunAtTime(date.toTimeString().slice(0, 5));
}
} else if (task.schedule_type === 'INTERVAL') {

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare } from 'lucide-react';
import { Bell, Check, CheckCheck, Trash2, X, Ticket, Calendar, MessageSquare, Clock } from 'lucide-react';
import {
useNotifications,
useUnreadNotificationCount,
@@ -56,6 +56,14 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
}
}
// Handle time-off request notifications - navigate to time blocks page
// Includes both new requests and modified requests that need re-approval
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
navigate('/time-blocks');
setIsOpen(false);
return;
}
// Navigate to target if available
if (notification.target_url) {
navigate(notification.target_url);
@@ -71,8 +79,13 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
clearAllMutation.mutate();
};
const getNotificationIcon = (targetType: string | null) => {
switch (targetType) {
const getNotificationIcon = (notification: Notification) => {
// Check for time-off request type in data (new or modified)
if (notification.data?.type === 'time_off_request' || notification.data?.type === 'time_off_request_modified') {
return <Clock size={16} className="text-amber-500" />;
}
switch (notification.target_type) {
case 'ticket':
return <Ticket size={16} className="text-blue-500" />;
case 'event':
@@ -171,7 +184,7 @@ const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ variant = '
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
{getNotificationIcon(notification.target_type)}
{getNotificationIcon(notification)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm ${!notification.read ? 'font-medium' : ''} text-gray-900 dark:text-white`}>

View File

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

View File

@@ -41,6 +41,7 @@ import {
Resource,
TimeBlockListItem,
} from '../../types';
import { formatLocalDate } from '../../utils/dateUtils';
// Preset block types
const PRESETS = [
@@ -155,6 +156,10 @@ interface TimeBlockCreatorModalProps {
holidays: Holiday[];
resources: Resource[];
isResourceLevel?: boolean;
/** Staff mode: hides level selector, locks to resource, pre-selects resource */
staffMode?: boolean;
/** Pre-selected resource ID for staff mode */
staffResourceId?: string | number | null;
}
type Step = 'preset' | 'details' | 'schedule' | 'review';
@@ -168,6 +173,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
holidays,
resources,
isResourceLevel: initialIsResourceLevel = false,
staffMode = false,
staffResourceId = null,
}) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>(editingBlock ? 'details' : 'preset');
@@ -177,7 +184,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
// Form state
const [title, setTitle] = useState(editingBlock?.title || '');
const [description, setDescription] = useState(editingBlock?.description || '');
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || 'HARD');
// In staff mode, default to SOFT blocks (time-off requests)
const [blockType, setBlockType] = useState<BlockType>(editingBlock?.block_type || (staffMode ? 'SOFT' : 'HARD'));
const [recurrenceType, setRecurrenceType] = useState<RecurrenceType>(editingBlock?.recurrence_type || 'NONE');
const [allDay, setAllDay] = useState(editingBlock?.all_day ?? true);
const [startTime, setStartTime] = useState(editingBlock?.start_time || '09:00');
@@ -270,7 +278,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
setAllDay(true);
setStartTime('09:00');
setEndTime('17:00');
setResourceId(null);
// In staff mode, pre-select the staff's resource
setResourceId(staffMode && staffResourceId ? String(staffResourceId) : null);
setSelectedDates([]);
setDaysOfWeek([]);
setDaysOfMonth([]);
@@ -279,10 +288,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
setHolidayCodes([]);
setRecurrenceStart('');
setRecurrenceEnd('');
setIsResourceLevel(initialIsResourceLevel);
// In staff mode, always resource-level
setIsResourceLevel(staffMode ? true : initialIsResourceLevel);
}
}
}, [isOpen, editingBlock, initialIsResourceLevel]);
}, [isOpen, editingBlock, initialIsResourceLevel, staffMode, staffResourceId]);
// Apply preset configuration
const applyPreset = (presetId: string) => {
@@ -293,7 +303,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
setTitle(preset.config.title);
setRecurrenceType(preset.config.recurrence_type);
setAllDay(preset.config.all_day);
setBlockType(preset.config.block_type);
// In staff mode, always use SOFT blocks regardless of preset
setBlockType(staffMode ? 'SOFT' : preset.config.block_type);
if (preset.config.start_time) setStartTime(preset.config.start_time);
if (preset.config.end_time) setEndTime(preset.config.end_time);
@@ -367,12 +378,15 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
};
const handleSubmit = () => {
// In staff mode, always use the staff's resource ID
const effectiveResourceId = staffMode ? staffResourceId : resourceId;
const baseData: any = {
description: description || undefined,
block_type: blockType,
recurrence_type: recurrenceType,
all_day: allDay,
resource: isResourceLevel ? resourceId : null,
resource: isResourceLevel ? effectiveResourceId : null,
};
if (!allDay) {
@@ -405,8 +419,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
if (recurrenceType === 'NONE') {
if (selectedDates.length > 0) {
const sorted = [...selectedDates].sort((a, b) => a.getTime() - b.getTime());
data.start_date = sorted[0].toISOString().split('T')[0];
data.end_date = sorted[sorted.length - 1].toISOString().split('T')[0];
data.start_date = formatLocalDate(sorted[0]);
data.end_date = formatLocalDate(sorted[sorted.length - 1]);
}
} else if (recurrenceType === 'WEEKLY') {
data.recurrence_pattern = { days_of_week: daysOfWeek };
@@ -425,7 +439,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
return true;
case 'details':
if (!title.trim()) return false;
if (isResourceLevel && !resourceId) return false;
// In staff mode, resource is auto-selected; otherwise check if selected
if (isResourceLevel && !staffMode && !resourceId) return false;
return true;
case 'schedule':
if (recurrenceType === 'NONE' && selectedDates.length === 0) return false;
@@ -556,63 +571,65 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
{/* Step 2: Details */}
{step === 'details' && (
<div className="space-y-6">
{/* Block Level Selector */}
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Level
</label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => {
setIsResourceLevel(false);
setResourceId(null);
}}
className={`p-4 rounded-xl border-2 transition-all text-left ${
!isResourceLevel
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
{/* Block Level Selector - Hidden in staff mode */}
{!staffMode && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Level
</label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => {
setIsResourceLevel(false);
setResourceId(null);
}}
className={`p-4 rounded-xl border-2 transition-all text-left ${
!isResourceLevel
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${!isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<Building2 size={20} className={!isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
</div>
<div>
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Business-wide
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Affects all resources
</p>
</div>
</div>
<div>
<p className={`font-semibold ${!isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Business-wide
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Affects all resources
</p>
</button>
<button
type="button"
onClick={() => setIsResourceLevel(true)}
className={`p-4 rounded-xl border-2 transition-all text-left ${
isResourceLevel
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
</div>
<div>
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Specific Resource
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Affects one resource
</p>
</div>
</div>
</div>
</button>
<button
type="button"
onClick={() => setIsResourceLevel(true)}
className={`p-4 rounded-xl border-2 transition-all text-left ${
isResourceLevel
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/30'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isResourceLevel ? 'bg-brand-100 dark:bg-brand-800' : 'bg-gray-100 dark:bg-gray-700'}`}>
<User size={20} className={isResourceLevel ? 'text-brand-600 dark:text-brand-400' : 'text-gray-500 dark:text-gray-400'} />
</div>
<div>
<p className={`font-semibold ${isResourceLevel ? 'text-brand-700 dark:text-brand-300' : 'text-gray-900 dark:text-white'}`}>
Specific Resource
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
Affects one resource
</p>
</div>
</div>
</button>
</button>
</div>
</div>
</div>
)}
{/* Title */}
<div>
@@ -642,8 +659,8 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
/>
</div>
{/* Resource (if resource-level) */}
{isResourceLevel && (
{/* Resource (if resource-level) - Hidden in staff mode since it's auto-selected */}
{isResourceLevel && !staffMode && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-2">
Resource
@@ -661,52 +678,54 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
</div>
)}
{/* Block Type */}
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Type
</label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setBlockType('HARD')}
className={`p-4 rounded-xl border-2 text-left transition-all ${
blockType === 'HARD'
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
{/* Block Type - hidden in staff mode (always SOFT for time-off requests) */}
{!staffMode && (
<div>
<label className="block text-base font-semibold text-gray-900 dark:text-white mb-3">
Block Type
</label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setBlockType('HARD')}
className={`p-4 rounded-xl border-2 text-left transition-all ${
blockType === 'HARD'
? 'border-red-500 bg-red-50 dark:bg-red-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-red-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<Ban className="w-5 h-5 text-red-600 dark:text-red-400" />
</div>
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
</div>
<span className="font-semibold text-gray-900 dark:text-white">Hard Block</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Completely prevents bookings. Cannot be overridden.
</p>
</button>
<button
type="button"
onClick={() => setBlockType('SOFT')}
className={`p-4 rounded-xl border-2 text-left transition-all ${
blockType === 'SOFT'
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
<p className="text-sm text-gray-500 dark:text-gray-400">
Completely prevents bookings. Cannot be overridden.
</p>
</button>
<button
type="button"
onClick={() => setBlockType('SOFT')}
className={`p-4 rounded-xl border-2 text-left transition-all ${
blockType === 'SOFT'
? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-yellow-300'
}`}
>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-lg bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
</div>
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
</div>
<span className="font-semibold text-gray-900 dark:text-white">Soft Block</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
Shows a warning but allows bookings with override.
</p>
</button>
<p className="text-sm text-gray-500 dark:text-gray-400">
Shows a warning but allows bookings with override.
</p>
</button>
</div>
</div>
</div>
)}
{/* All Day Toggle & Time */}
<div>
@@ -1188,11 +1207,11 @@ const TimeBlockCreatorModal: React.FC<TimeBlockCreatorModalProps> = ({
)}
</dd>
</div>
{isResourceLevel && resourceId && (
{isResourceLevel && (resourceId || staffResourceId) && (
<div className="flex justify-between py-2">
<dt className="text-gray-500 dark:text-gray-400">Resource</dt>
<dd className="font-medium text-gray-900 dark:text-white">
{resources.find(r => r.id === resourceId)?.name || resourceId}
{resources.find(r => String(r.id) === String(staffMode ? staffResourceId : resourceId))?.name || (staffMode ? staffResourceId : resourceId)}
</dd>
</div>
)}

View File

@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import { ChevronLeft, ChevronRight, CalendarDays, X } from 'lucide-react';
import { BlockedDate, TimeBlockListItem } from '../../types';
import { useBlockedDates, useTimeBlock } from '../../hooks/useTimeBlocks';
import { formatLocalDate } from '../../utils/dateUtils';
interface YearlyBlockCalendarProps {
resourceId?: string;
@@ -134,7 +135,7 @@ const YearlyBlockCalendar: React.FC<YearlyBlockCalendarProps> = ({
return <div key={`empty-${i}`} className="aspect-square" />;
}
const dateKey = day.toISOString().split('T')[0];
const dateKey = formatLocalDate(day);
const blocks = blockedDateMap.get(dateKey) || [];
const hasBlocks = blocks.length > 0;
const isToday = new Date().toDateString() === day.toDateString();

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import Portal from '../components/Portal';
import EventAutomations from '../components/EventAutomations';
import TimeBlockCalendarOverlay from '../components/time-blocks/TimeBlockCalendarOverlay';
import { getOverQuotaResourceIds } from '../utils/quotaUtils';
import { formatLocalDate } from '../utils/dateUtils';
// Time settings
const START_HOUR = 0; // Midnight
@@ -87,8 +88,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
// Fetch blocked dates for the calendar overlay
const blockedDatesParams = useMemo(() => ({
start_date: dateRange.startDate.toISOString().split('T')[0],
end_date: dateRange.endDate.toISOString().split('T')[0],
start_date: formatLocalDate(dateRange.startDate),
end_date: formatLocalDate(dateRange.endDate),
include_business: true,
}), [dateRange]);
const { data: blockedDates = [] } = useBlockedDates(blockedDatesParams);

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

View File

@@ -33,28 +33,134 @@ const GeneralSettings: React.FC = () => {
setFormState(prev => ({ ...prev, [name]: value }));
};
// Common timezones grouped by region
const commonTimezones = [
{ value: 'America/New_York', label: 'Eastern Time (New York)' },
{ value: 'America/Chicago', label: 'Central Time (Chicago)' },
{ value: 'America/Denver', label: 'Mountain Time (Denver)' },
{ value: 'America/Los_Angeles', label: 'Pacific Time (Los Angeles)' },
{ value: 'America/Anchorage', label: 'Alaska Time' },
{ value: 'Pacific/Honolulu', label: 'Hawaii Time' },
{ value: 'America/Phoenix', label: 'Arizona (no DST)' },
{ value: 'America/Toronto', label: 'Eastern Time (Toronto)' },
{ value: 'America/Vancouver', label: 'Pacific Time (Vancouver)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Paris', label: 'Central European Time' },
{ value: 'Europe/Berlin', label: 'Berlin' },
{ value: 'Asia/Tokyo', label: 'Japan Time' },
{ value: 'Asia/Shanghai', label: 'China Time' },
{ value: 'Asia/Singapore', label: 'Singapore Time' },
{ value: 'Asia/Dubai', label: 'Dubai (GST)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
{ value: 'Australia/Melbourne', label: 'Melbourne (AEST)' },
{ value: 'Pacific/Auckland', label: 'New Zealand Time' },
{ value: 'UTC', label: 'UTC' },
// IANA timezones grouped by region
const timezoneGroups = [
{
label: 'United States',
timezones: [
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Anchorage',
'Pacific/Honolulu',
'America/Phoenix',
'America/Detroit',
'America/Indiana/Indianapolis',
'America/Boise',
],
},
{
label: 'Canada',
timezones: [
'America/Toronto',
'America/Vancouver',
'America/Edmonton',
'America/Winnipeg',
'America/Halifax',
'America/St_Johns',
],
},
{
label: 'Mexico & Central America',
timezones: [
'America/Mexico_City',
'America/Tijuana',
'America/Cancun',
'America/Guatemala',
'America/Costa_Rica',
'America/Panama',
],
},
{
label: 'South America',
timezones: [
'America/Sao_Paulo',
'America/Argentina/Buenos_Aires',
'America/Santiago',
'America/Bogota',
'America/Lima',
'America/Caracas',
],
},
{
label: 'Europe',
timezones: [
'Europe/London',
'Europe/Dublin',
'Europe/Paris',
'Europe/Berlin',
'Europe/Madrid',
'Europe/Rome',
'Europe/Amsterdam',
'Europe/Brussels',
'Europe/Vienna',
'Europe/Zurich',
'Europe/Stockholm',
'Europe/Oslo',
'Europe/Copenhagen',
'Europe/Helsinki',
'Europe/Athens',
'Europe/Bucharest',
'Europe/Warsaw',
'Europe/Prague',
'Europe/Moscow',
'Europe/Istanbul',
],
},
{
label: 'Asia',
timezones: [
'Asia/Tokyo',
'Asia/Seoul',
'Asia/Shanghai',
'Asia/Hong_Kong',
'Asia/Taipei',
'Asia/Singapore',
'Asia/Kuala_Lumpur',
'Asia/Bangkok',
'Asia/Ho_Chi_Minh',
'Asia/Jakarta',
'Asia/Manila',
'Asia/Kolkata',
'Asia/Mumbai',
'Asia/Dubai',
'Asia/Riyadh',
'Asia/Jerusalem',
'Asia/Karachi',
'Asia/Dhaka',
],
},
{
label: 'Australia & Pacific',
timezones: [
'Australia/Sydney',
'Australia/Melbourne',
'Australia/Brisbane',
'Australia/Perth',
'Australia/Adelaide',
'Australia/Darwin',
'Pacific/Auckland',
'Pacific/Fiji',
'Pacific/Guam',
],
},
{
label: 'Africa',
timezones: [
'Africa/Johannesburg',
'Africa/Cairo',
'Africa/Lagos',
'Africa/Nairobi',
'Africa/Casablanca',
],
},
{
label: 'Other',
timezones: [
'UTC',
],
},
];
const handleSave = async () => {
@@ -146,10 +252,14 @@ const GeneralSettings: React.FC = () => {
onChange={handleChange}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg focus:ring-2 focus:ring-brand-500"
>
{commonTimezones.map(tz => (
<option key={tz.value} value={tz.value}>
{tz.label}
</option>
{timezoneGroups.map(group => (
<optgroup key={group.label} label={group.label}>
{group.timezones.map(tz => (
<option key={tz} value={tz}>
{tz}
</option>
))}
</optgroup>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">

View File

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

View File

@@ -0,0 +1,393 @@
/**
* Date/Time Utility Functions
*
* TIMEZONE ARCHITECTURE:
* - Database: All times stored in UTC
* - API Communication: Always UTC (both directions)
* - Frontend Display: Convert based on business_timezone setting
* - If business_timezone is set: Display in that timezone
* - If business_timezone is blank/null: Display in user's local timezone
*
* See CLAUDE.md for full architecture documentation.
*/
// ============================================================================
// SENDING TO API - Convert to UTC
// ============================================================================
/**
* Convert a local Date to UTC ISO string for API requests.
* Use this when sending datetime values to the API.
*
* @example
* const payload = { start_time: toUTC(selectedDate) };
* // Returns: "2024-12-08T19:00:00.000Z"
*/
export const toUTC = (date: Date): string => {
return date.toISOString();
};
/**
* Convert a date and time (in display timezone) to UTC ISO string.
* Use when the user selects a time that should be interpreted in a specific timezone.
*
* @param date - The date portion
* @param time - Time string "HH:MM"
* @param timezone - IANA timezone the user is selecting in (business or local)
*
* @example
* // User in Eastern selects 2pm, but display mode is "business" (Mountain)
* // This means they selected 2pm Mountain time
* toUTCFromTimezone(date, "14:00", "America/Denver")
*/
export const toUTCFromTimezone = (
date: Date,
time: string,
timezone: string
): string => {
const [hours, minutes] = time.split(':').map(Number);
const dateStr = formatLocalDate(date);
// Create a date string that we'll parse in the target timezone
const dateTimeStr = `${dateStr}T${time}:00`;
// Use Intl to get the UTC offset for this timezone at this date/time
const targetDate = new Date(dateTimeStr);
const utcDate = convertTimezoneToUTC(targetDate, timezone);
return utcDate.toISOString();
};
/**
* Convert a Date object from a specific timezone to UTC.
* The input Date's time values are interpreted as being in the given timezone.
*/
export const convertTimezoneToUTC = (date: Date, timezone: string): Date => {
// Get the date/time components as they appear (treating as target timezone)
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
// Create a formatter for the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
// Find the UTC time that displays as our target time in the target timezone
// We do this by creating a UTC date and adjusting based on offset
const tempDate = new Date(Date.UTC(year, month, day, hours, minutes, seconds));
// Get what this UTC time displays as in the target timezone
const parts = formatter.formatToParts(tempDate);
const getPart = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0');
const displayedHour = getPart('hour');
const displayedMinute = getPart('minute');
const displayedDay = getPart('day');
// Calculate the offset in minutes
const displayedMinutes = displayedDay * 24 * 60 + displayedHour * 60 + displayedMinute;
const targetMinutes = day * 24 * 60 + hours * 60 + minutes;
const offsetMinutes = displayedMinutes - targetMinutes;
// Adjust the UTC time by the offset
return new Date(tempDate.getTime() - offsetMinutes * 60 * 1000);
};
// ============================================================================
// RECEIVING FROM API - Convert from UTC for Display
// ============================================================================
/**
* Convert a UTC datetime string from API to a Date object in the display timezone.
*
* @param utcString - ISO string from API (always UTC)
* @param businessTimezone - IANA timezone of the business (null = use local)
*/
export const fromUTC = (
utcString: string,
businessTimezone?: string | null
): Date => {
const utcDate = new Date(utcString);
const targetTimezone = getDisplayTimezone(businessTimezone);
return convertUTCToTimezone(utcDate, targetTimezone);
};
/**
* Convert a UTC Date to display in a specific timezone.
* Returns a Date object with values adjusted for the target timezone.
*/
export const convertUTCToTimezone = (utcDate: Date, timezone: string): Date => {
// Format the UTC date in the target timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = formatter.formatToParts(utcDate);
const getPart = (type: string) => parseInt(parts.find(p => p.type === type)?.value || '0');
// Create a new Date with the timezone-adjusted values
// Note: This Date object's internal UTC value won't match, but the displayed values will be correct
return new Date(
getPart('year'),
getPart('month') - 1,
getPart('day'),
getPart('hour'),
getPart('minute'),
getPart('second')
);
};
/**
* Get the timezone to use for display.
* If businessTimezone is set, use it. Otherwise use user's local timezone.
*/
export const getDisplayTimezone = (businessTimezone?: string | null): string => {
if (businessTimezone) {
return businessTimezone;
}
// No business timezone set - use browser's local timezone
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};
/**
* Get the user's local timezone (browser timezone).
*/
export const getUserTimezone = (): string => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};
// ============================================================================
// FORMATTING FOR DISPLAY
// ============================================================================
/**
* Format a UTC datetime string for display, respecting timezone settings.
*
* @param utcString - ISO string from API
* @param businessTimezone - IANA timezone of the business (null = use local)
* @param options - Intl.DateTimeFormat options for customizing output
*
* @example
* formatForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "Dec 8, 2024, 12:00 PM" (Mountain Time)
*
* formatForDisplay("2024-12-08T19:00:00Z", null)
* // Returns time in user's local timezone
*/
export const formatForDisplay = (
utcString: string,
businessTimezone?: string | null,
options?: Intl.DateTimeFormatOptions
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
const defaultOptions: Intl.DateTimeFormatOptions = {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
...options,
};
return utcDate.toLocaleString('en-US', defaultOptions);
};
/**
* Format just the time portion for display.
*
* @example
* formatTimeForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "12:00 PM"
*/
export const formatTimeForDisplay = (
utcString: string,
businessTimezone?: string | null
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
return utcDate.toLocaleString('en-US', {
timeZone: timezone,
hour: 'numeric',
minute: '2-digit',
hour12: true,
});
};
/**
* Format just the date portion for display.
*
* @example
* formatDateForDisplay("2024-12-08T19:00:00Z", "America/Denver")
* // Returns: "Dec 8, 2024"
*/
export const formatDateForDisplay = (
utcString: string,
businessTimezone?: string | null,
options?: Intl.DateTimeFormatOptions
): string => {
const utcDate = new Date(utcString);
const timezone = getDisplayTimezone(businessTimezone);
const defaultOptions: Intl.DateTimeFormatOptions = {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
...options,
};
return utcDate.toLocaleDateString('en-US', defaultOptions);
};
/**
* Format a datetime for datetime-local input, in the display timezone.
* Returns: "YYYY-MM-DDTHH:MM"
*/
export const formatForDateTimeInput = (
utcString: string,
businessTimezone?: string | null
): string => {
const displayDate = fromUTC(utcString, businessTimezone);
return formatLocalDateTime(displayDate);
};
// ============================================================================
// DATE-ONLY HELPERS (for fields like time block start_date/end_date)
// ============================================================================
/**
* Format a Date object as YYYY-MM-DD string.
* Uses the Date's local values (not UTC).
*
* For date-only fields, use this when you have a Date object
* representing a calendar date selection.
*/
export const formatLocalDate = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
/**
* Parse a YYYY-MM-DD string as a local date (at midnight local time).
*/
export const parseLocalDate = (dateString: string): Date => {
const [year, month, day] = dateString.split('-').map(Number);
return new Date(year, month - 1, day);
};
/**
* Format a Date as YYYY-MM-DDTHH:MM for datetime-local inputs.
*/
export const formatLocalDateTime = (date: Date): string => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
/**
* Get today's date as YYYY-MM-DD in a specific timezone.
* Useful for determining "today" in the business timezone.
*/
export const getTodayInTimezone = (timezone: string): string => {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return formatter.format(now); // Returns YYYY-MM-DD format
};
/**
* Check if a date string (YYYY-MM-DD) is today in the given timezone.
*/
export const isToday = (dateString: string, timezone: string): boolean => {
return dateString === getTodayInTimezone(timezone);
};
// ============================================================================
// UTILITY HELPERS
// ============================================================================
/**
* Check if two dates are the same day (ignoring time).
*/
export const isSameDay = (date1: Date, date2: Date): boolean => {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
};
/**
* Get the start of a day (midnight local time).
*/
export const startOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(0, 0, 0, 0);
return result;
};
/**
* Get the end of a day (23:59:59.999 local time).
*/
export const endOfDay = (date: Date): Date => {
const result = new Date(date);
result.setHours(23, 59, 59, 999);
return result;
};
/**
* Get timezone abbreviation for display (e.g., "MST", "EST").
*/
export const getTimezoneAbbreviation = (
timezone: string,
date: Date = new Date()
): string => {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
timeZoneName: 'short',
});
const parts = formatter.formatToParts(date);
return parts.find(p => p.type === 'timeZoneName')?.value || timezone;
};
/**
* Format timezone for display (e.g., "Mountain Time (MST)").
*/
export const formatTimezoneDisplay = (timezone: string): string => {
const abbr = getTimezoneAbbreviation(timezone);
const cityName = timezone.split('/').pop()?.replace(/_/g, ' ') || timezone;
return `${cityName} (${abbr})`;
};

View File

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

View File

@@ -0,0 +1,637 @@
"""
Core Mixins for DRF ViewSets and Serializers
Reusable mixins to reduce code duplication across ViewSets and Serializers.
"""
from rest_framework.permissions import BasePermission
from rest_framework.exceptions import PermissionDenied
from rest_framework import serializers
# ==============================================================================
# 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)
# ==============================================================================
# Serializer Mixins
# ==============================================================================
class TimezoneSerializerMixin:
"""
Mixin that adds timezone context field to serializer responses.
TIMEZONE ARCHITECTURE:
- Database: All times stored in UTC
- API Communication: Always UTC (both directions)
- Frontend Display: Convert based on business_timezone
- If business_timezone is set: Display in that timezone
- If business_timezone is null/blank: Display in user's local timezone
This mixin adds one field to every response:
- business_timezone: IANA timezone string (e.g., "America/Denver") or null
The frontend uses this to convert UTC times for display.
Usage:
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
class Meta:
model = Event
fields = [..., 'business_timezone']
Or for nested/list serializers, ensure the view passes tenant context:
class EventViewSet(ModelViewSet):
def get_serializer_context(self):
context = super().get_serializer_context()
context['tenant'] = getattr(self.request, 'tenant', None)
return context
"""
business_timezone = serializers.SerializerMethodField()
def get_business_timezone(self, obj):
"""
Get the business timezone from tenant.
Returns the IANA timezone string, or None to use user's local timezone.
"""
tenant = self._get_tenant_from_context()
if tenant:
# Return the timezone if set, None otherwise (frontend will use local)
return getattr(tenant, 'timezone', None) or None
return None
def _get_tenant_from_context(self):
"""Get tenant from serializer context or database connection."""
# Try from context first (passed by view)
tenant = self.context.get('tenant')
if tenant:
return tenant
# Try from request in context
request = self.context.get('request')
if request:
tenant = getattr(request, 'tenant', None)
if tenant:
return tenant
# Fallback to connection.tenant (django-tenants)
try:
from django.db import connection
if hasattr(connection, 'tenant'):
return connection.tenant
except Exception:
pass
return None
class TimezoneContextMixin:
"""
ViewSet mixin that passes tenant context to serializers.
Use this with serializers that use TimezoneSerializerMixin.
Usage:
class EventViewSet(TimezoneContextMixin, ModelViewSet):
serializer_class = EventSerializer
"""
def get_serializer_context(self):
"""Add tenant to serializer context."""
context = super().get_serializer_context()
context['tenant'] = getattr(self.request, 'tenant', None)
return context

View File

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

View File

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

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

View File

@@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError as DjangoValidationError
from .models import Resource, Event, Participant, Service, ResourceType, ScheduledTask, TaskExecutionLog, PluginTemplate, PluginInstallation, EventPlugin, GlobalEventPlugin, EmailTemplate, Holiday, TimeBlock
from .services import AvailabilityService
from smoothschedule.users.models import User
from core.mixins import TimezoneSerializerMixin
class ResourceTypeSerializer(serializers.ModelSerializer):
@@ -285,12 +286,16 @@ class ParticipantSerializer(serializers.ModelSerializer):
return str(obj.content_object) if obj.content_object else None
class EventSerializer(serializers.ModelSerializer):
class EventSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""
Serializer for Event model with availability validation.
CRITICAL: Validates resource availability before saving via AvailabilityService.
Includes timezone context via TimezoneSerializerMixin:
- timezone_display_mode: "business" or "viewer"
- business_timezone: IANA timezone string
Status mapping (frontend -> backend):
- PENDING -> SCHEDULED
- CONFIRMED -> SCHEDULED
@@ -368,9 +373,12 @@ class EventSerializer(serializers.ModelSerializer):
'service', 'deposit_amount', 'deposit_transaction_id', 'final_price',
'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount',
'created_at', 'updated_at', 'created_by',
# Timezone context (from TimezoneSerializerMixin)
'business_timezone',
]
read_only_fields = ['created_at', 'updated_at', 'created_by', 'deposit_transaction_id',
'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount']
'final_charge_transaction_id', 'is_variable_pricing', 'remaining_balance', 'overpaid_amount',
'business_timezone']
def get_duration_minutes(self, obj):
return int(obj.duration.total_seconds() / 60)
@@ -1160,10 +1168,15 @@ class HolidayListSerializer(serializers.ModelSerializer):
fields = ['code', 'name', 'country']
class TimeBlockSerializer(serializers.ModelSerializer):
"""Full serializer for TimeBlock CRUD operations"""
class TimeBlockSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""Full serializer for TimeBlock CRUD operations.
Includes timezone context via TimezoneSerializerMixin:
- business_timezone: IANA timezone string (null = use user's local)
"""
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField()
reviewed_by_name = serializers.SerializerMethodField()
level = serializers.SerializerMethodField()
pattern_display = serializers.SerializerMethodField()
holiday_name = serializers.SerializerMethodField()
@@ -1178,16 +1191,26 @@ class TimeBlockSerializer(serializers.ModelSerializer):
'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
'recurrence_pattern', 'pattern_display', 'holiday_name',
'recurrence_start', 'recurrence_end',
'is_active', 'created_by', 'created_by_name',
'is_active', 'approval_status', 'reviewed_by', 'reviewed_by_name',
'reviewed_at', 'review_notes',
'created_by', 'created_by_name',
'conflict_count', 'created_at', 'updated_at',
# Timezone context (from TimezoneSerializerMixin)
'business_timezone',
]
read_only_fields = ['created_by', 'created_at', 'updated_at']
read_only_fields = ['created_by', 'created_at', 'updated_at', 'reviewed_by', 'reviewed_at',
'business_timezone']
def get_created_by_name(self, obj):
if obj.created_by:
return obj.created_by.get_full_name() or obj.created_by.email
return None
def get_reviewed_by_name(self, obj):
if obj.reviewed_by:
return obj.reviewed_by.get_full_name() or obj.reviewed_by.email
return None
def get_level(self, obj):
"""Return 'business' if no resource, otherwise 'resource'"""
return 'business' if obj.resource is None else 'resource'
@@ -1396,7 +1419,7 @@ class TimeBlockSerializer(serializers.ModelSerializer):
# Staff can only create blocks for their own resource
if resource:
if not hasattr(user, 'resource') or user.resource != resource:
if not user.staff_resources.filter(pk=resource.pk).exists():
raise serializers.ValidationError({
'resource': 'Staff can only create blocks for their own resource'
})
@@ -1418,9 +1441,15 @@ class TimeBlockSerializer(serializers.ModelSerializer):
return super().create(validated_data)
class TimeBlockListSerializer(serializers.ModelSerializer):
"""Serializer for time block lists - includes fields needed for editing"""
class TimeBlockListSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""Serializer for time block lists - includes fields needed for editing.
Includes timezone context via TimezoneSerializerMixin:
- business_timezone: IANA timezone string (null = use user's local)
"""
resource_name = serializers.CharField(source='resource.name', read_only=True, allow_null=True)
created_by_name = serializers.SerializerMethodField()
reviewed_by_name = serializers.SerializerMethodField()
level = serializers.SerializerMethodField()
pattern_display = serializers.SerializerMethodField()
@@ -1431,9 +1460,23 @@ class TimeBlockListSerializer(serializers.ModelSerializer):
'block_type', 'recurrence_type', 'start_date', 'end_date',
'all_day', 'start_time', 'end_time', 'recurrence_pattern',
'recurrence_start', 'recurrence_end', 'pattern_display',
'is_active', 'created_at',
'is_active', 'approval_status', 'reviewed_by', 'reviewed_by_name',
'reviewed_at', 'review_notes',
'created_by', 'created_by_name', 'created_at',
# Timezone context (from TimezoneSerializerMixin)
'business_timezone',
]
def get_created_by_name(self, obj):
if obj.created_by:
return obj.created_by.get_full_name() or obj.created_by.email
return None
def get_reviewed_by_name(self, obj):
if obj.reviewed_by:
return obj.reviewed_by.get_full_name() or obj.reviewed_by.email
return None
def get_level(self, obj):
return 'business' if obj.resource is None else 'resource'

View File

@@ -7,14 +7,28 @@ Handles:
3. Scheduling/cancelling Celery tasks when EventPlugins are created/deleted/modified
4. Cancelling tasks when Events are deleted or cancelled
5. Broadcasting real-time updates via WebSocket for calendar sync
6. Customer notification hooks on status changes
"""
import logging
from django.db.models.signals import post_save, pre_save, post_delete, pre_delete
from django.dispatch import receiver
from django.dispatch import Signal, receiver
logger = logging.getLogger(__name__)
# =============================================================================
# Custom Signals for Status Changes
# =============================================================================
# Fired when an event status changes (hook point for custom behavior)
# Provides: event, old_status, new_status, changed_by, tenant, skip_notifications
event_status_changed = Signal()
# Fired when a customer should be notified about an event
# Provides: event, notification_type, tenant
customer_notification_requested = Signal()
# ============================================================================
# WebSocket Broadcasting Helpers
# ============================================================================
@@ -402,3 +416,375 @@ def broadcast_event_delete(sender, instance, **kwargs):
# Store the event data before deletion for broadcasting
broadcast_event_change_sync(instance, 'event_deleted')
logger.info(f"Broadcast event_deleted for event {instance.id}")
# =============================================================================
# Customer Notification Signal Handlers
# =============================================================================
@receiver(event_status_changed)
def handle_event_status_change_notifications(sender, event, old_status, new_status, changed_by, tenant, **kwargs):
"""
Handle customer notifications when event status changes.
This is a hook point for custom notification behavior.
Default behavior: queue customer notifications for certain transitions.
"""
from .models import Event
skip_notifications = kwargs.get('skip_notifications', False)
if skip_notifications:
logger.debug(f"Notifications skipped for event {event.id} status change")
return
# Define which transitions should notify customers
NOTIFY_TRANSITIONS = {
(Event.Status.SCHEDULED, Event.Status.EN_ROUTE): 'en_route_notification',
(Event.Status.EN_ROUTE, Event.Status.IN_PROGRESS): 'arrived_notification',
(Event.Status.IN_PROGRESS, Event.Status.COMPLETED): 'completed_notification',
}
notification_type = NOTIFY_TRANSITIONS.get((old_status, new_status))
if notification_type:
# Fire the notification signal
customer_notification_requested.send(
sender=sender,
event=event,
notification_type=notification_type,
tenant=tenant,
)
logger.info(f"Requested {notification_type} for event {event.id}")
@receiver(event_status_changed)
def handle_event_status_change_plugins(sender, event, old_status, new_status, changed_by, tenant, **kwargs):
"""
Execute plugins attached to status change events.
"""
from .models import Event
try:
if new_status == Event.Status.COMPLETED:
event.execute_plugins(trigger='on_complete')
elif new_status == Event.Status.CANCELED:
event.execute_plugins(trigger='on_cancel')
except Exception as e:
logger.warning(f"Error executing event plugins on status change: {e}")
@receiver(customer_notification_requested)
def send_customer_notification_task(sender, event, notification_type, tenant, **kwargs):
"""
Queue customer notification via Celery task.
This is the default handler that queues notifications.
Can be replaced or supplemented by custom handlers.
"""
try:
from smoothschedule.field_mobile.tasks import send_customer_status_notification
send_customer_status_notification.delay(
tenant_id=tenant.id,
event_id=event.id,
notification_type=notification_type,
)
logger.debug(f"Queued {notification_type} for event {event.id}")
except Exception as e:
# Don't fail the status change if notification fails
logger.warning(f"Failed to queue customer notification: {e}")
# =============================================================================
# Helper function for status changes
# =============================================================================
def emit_status_change(event, old_status, new_status, changed_by, tenant, skip_notifications=False):
"""
Emit the event_status_changed signal.
This should be called after a successful status transition.
The StatusMachine uses this to trigger hooks.
Args:
event: The Event instance
old_status: Previous status value
new_status: New status value
changed_by: User who made the change
tenant: Business tenant
skip_notifications: If True, signal receivers should skip notifications
"""
event_status_changed.send(
sender=event.__class__,
event=event,
old_status=old_status,
new_status=new_status,
changed_by=changed_by,
tenant=tenant,
skip_notifications=skip_notifications,
)
# =============================================================================
# TimeBlock (Time-Off Request) Signals
# =============================================================================
# Custom signal for time-off request notifications
time_off_request_submitted = Signal()
# Fields that trigger re-approval when changed on an approved block
TIME_BLOCK_APPROVAL_FIELDS = [
'title', 'start_date', 'end_date', 'all_day', 'start_time', 'end_time',
'recurrence_type', 'recurrence_pattern', 'recurrence_start', 'recurrence_end',
]
@receiver(pre_save, sender='schedule.TimeBlock')
def track_time_block_changes(sender, instance, **kwargs):
"""
Track changes to TimeBlock before save and reset approval if needed.
When a staff member edits an already-approved time block:
1. Tracks which fields changed
2. If approval-sensitive fields changed, resets status to PENDING
3. Marks the instance so post_save knows to send notifications
This ensures that any edit to an approved time-off request triggers
a new approval workflow.
"""
instance._needs_re_approval_notification = False
if instance.pk:
try:
from .models import TimeBlock
old_instance = TimeBlock.objects.get(pk=instance.pk)
instance._old_approval_status = old_instance.approval_status
instance._was_approved = old_instance.approval_status == TimeBlock.ApprovalStatus.APPROVED
# Track which approval-sensitive fields changed
instance._changed_fields = []
for field in TIME_BLOCK_APPROVAL_FIELDS:
old_value = getattr(old_instance, field)
new_value = getattr(instance, field)
if old_value != new_value:
instance._changed_fields.append(field)
# If this was an approved block and significant fields changed,
# reset to PENDING status (treated as a new request)
if instance._was_approved and instance._changed_fields:
# Only reset if the user editing is not self-approving
# (owners/managers edits stay approved)
created_by = instance.created_by
if created_by and not created_by.can_self_approve_time_off():
logger.info(
f"TimeBlock {instance.id} was approved but modified "
f"(changed: {instance._changed_fields}). "
f"Resetting to PENDING for re-approval."
)
instance.approval_status = TimeBlock.ApprovalStatus.PENDING
instance.reviewed_by = None
instance.reviewed_at = None
instance.review_notes = ''
instance._needs_re_approval_notification = True
except sender.DoesNotExist:
instance._old_approval_status = None
instance._was_approved = False
instance._changed_fields = []
else:
instance._old_approval_status = None
instance._was_approved = False
instance._changed_fields = []
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 or when
an approved TimeBlock is modified, notify all managers and owners.
Handles two scenarios:
1. New time-off request created with PENDING status
2. Approved time-off request modified (reset to PENDING)
"""
from .models import TimeBlock
# Check if this is a new pending request OR a modified approved request
is_new_pending = created and instance.approval_status == TimeBlock.ApprovalStatus.PENDING
needs_re_approval = getattr(instance, '_needs_re_approval_notification', False)
if not is_new_pending and not needs_re_approval:
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
# Determine notification type for logging and data
if needs_re_approval:
changed_fields = getattr(instance, '_changed_fields', [])
verb = 'modified time off request'
notification_type = 'time_off_request_modified'
logger.info(
f"Time-off request modified by {requester.get_full_name() or requester.email} "
f"for resource '{instance.resource.name}' (changed: {changed_fields}). "
f"Request returned to pending status for re-approval."
)
else:
verb = 'requested time off'
notification_type = 'time_off_request'
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=verb,
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': notification_type,
'is_modification': needs_re_approval,
'changed_fields': getattr(instance, '_changed_fields', []) if needs_re_approval else [],
}
)
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),
is_modification=needs_re_approval,
changed_fields=getattr(instance, '_changed_fields', []) if needs_re_approval else [],
)
@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 or modified.
"""
from django.core.mail import send_mail
from django.conf import settings
# Check if this is a modification of an existing approved request
is_modification = kwargs.get('is_modification', False)
changed_fields = kwargs.get('changed_fields', [])
# 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()})"
# Different subject and message for new vs modified requests
if is_modification:
subject = f"Modified Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}"
changed_fields_str = ', '.join(changed_fields) if changed_fields else 'unspecified fields'
message = f"""
A previously approved time-off request has been modified and requires re-approval.
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'}
Modified fields: {changed_fields_str}
The request has been returned to pending status and needs your review.
Please log in to review this request.
"""
else:
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
UNUSED_ENDPOINT tags mark endpoints not currently used by the frontend.
Search for "UNUSED_ENDPOINT" to find all unused endpoints in the codebase.
"""
from django.urls import path, include
from rest_framework.routers import DefaultRouter
@@ -19,13 +22,13 @@ router.register(r'resource-types', ResourceTypeViewSet, basename='resourcetype')
router.register(r'resources', ResourceViewSet, basename='resource')
router.register(r'appointments', EventViewSet, basename='appointment') # Alias for frontend
router.register(r'events', EventViewSet, basename='event')
router.register(r'participants', ParticipantViewSet, basename='participant')
router.register(r'participants', ParticipantViewSet, basename='participant') # UNUSED_ENDPOINT: Participants managed via Event serializer
router.register(r'customers', CustomerViewSet, basename='customer')
router.register(r'services', ServiceViewSet, basename='service')
router.register(r'staff', StaffViewSet, basename='staff')
router.register(r'scheduled-tasks', ScheduledTaskViewSet, basename='scheduledtask')
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog')
router.register(r'plugins', PluginViewSet, basename='plugin')
router.register(r'task-logs', TaskExecutionLogViewSet, basename='tasklog') # UNUSED_ENDPOINT: Logs accessed via scheduled-tasks/{id}/logs action
router.register(r'plugins', PluginViewSet, basename='plugin') # UNUSED_ENDPOINT: Frontend uses plugin-templates instead
router.register(r'plugin-templates', PluginTemplateViewSet, basename='plugintemplate')
router.register(r'plugin-installations', PluginInstallationViewSet, basename='plugininstallation')
router.register(r'event-plugins', EventPluginViewSet, basename='eventplugin')

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ from smoothschedule.field_mobile.models import (
EmployeeLocationUpdate,
FieldCallLog,
)
from core.mixins import TimezoneSerializerMixin
class ServiceSummarySerializer(serializers.ModelSerializer):
@@ -49,11 +50,12 @@ class CustomerInfoSerializer(serializers.Serializer):
return None
class JobListSerializer(serializers.ModelSerializer):
class JobListSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""
Serializer for job list (today's and upcoming jobs).
Optimized for quick loading on mobile.
Includes business_timezone for proper time display.
"""
service_name = serializers.SerializerMethodField()
customer_name = serializers.SerializerMethodField()
@@ -76,6 +78,7 @@ class JobListSerializer(serializers.ModelSerializer):
'address',
'duration_minutes',
'allowed_transitions',
'business_timezone',
]
def get_service_name(self, obj):
@@ -142,11 +145,12 @@ class JobListSerializer(serializers.ModelSerializer):
return None
class JobDetailSerializer(serializers.ModelSerializer):
class JobDetailSerializer(TimezoneSerializerMixin, serializers.ModelSerializer):
"""
Full job details for the job detail screen.
Includes all information needed to work on the job.
Includes business_timezone for proper time display.
"""
service = ServiceSummarySerializer(read_only=True)
customer = serializers.SerializerMethodField()
@@ -184,6 +188,7 @@ class JobDetailSerializer(serializers.ModelSerializer):
'created_at',
'updated_at',
'can_edit_schedule',
'business_timezone',
]
def get_customer(self, obj):

View File

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

View File

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

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

View File

@@ -259,6 +259,31 @@ class User(AbstractUser):
return self.permissions.get('can_whitelist_urls', False)
# All others cannot whitelist
return False
def can_self_approve_time_off(self):
"""
Check if user can self-approve time off requests.
Owners can always self-approve.
Managers can self-approve by default but can be denied.
Staff need explicit permission.
"""
# Owners can always self-approve
if self.role == self.Role.TENANT_OWNER:
return True
# Managers can self-approve by default, but can be denied
if self.role == self.Role.TENANT_MANAGER:
return self.permissions.get('can_self_approve_time_off', True)
# Staff can self-approve if granted permission (default: False)
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):
"""

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 .views import user_detail_view
@@ -6,7 +12,7 @@ from .views import user_update_view
app_name = "users"
urlpatterns = [
path("~redirect/", view=user_redirect_view, name="redirect"),
path("~update/", view=user_update_view, name="update"),
path("<str:username>/", view=user_detail_view, name="detail"),
path("~redirect/", view=user_redirect_view, name="redirect"), # UNUSED_ENDPOINT: Legacy template view
path("~update/", view=user_update_view, name="update"), # UNUSED_ENDPOINT: Legacy template view
path("<str:username>/", view=user_detail_view, name="detail"), # UNUSED_ENDPOINT: Legacy template view
]