diff --git a/CLAUDE.md b/CLAUDE.md index a37da0b..5bff47e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,122 @@ docker compose -f docker-compose.local.yml exec django python manage.py = ({ 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') { diff --git a/frontend/src/components/NotificationDropdown.tsx b/frontend/src/components/NotificationDropdown.tsx index 4b509dd..6963f94 100644 --- a/frontend/src/components/NotificationDropdown.tsx +++ b/frontend/src/components/NotificationDropdown.tsx @@ -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 = ({ 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 = ({ 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 ; + } + + switch (notification.target_type) { case 'ticket': return ; case 'event': @@ -171,7 +184,7 @@ const NotificationDropdown: React.FC = ({ variant = ' >
- {getNotificationIcon(notification.target_type)} + {getNotificationIcon(notification)}

diff --git a/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx b/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx index 6a64a2a..3403290 100644 --- a/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx +++ b/frontend/src/components/time-blocks/TimeBlockCreatorModal.tsx @@ -41,6 +41,7 @@ import { Resource, TimeBlockListItem, } from '../../types'; +import { formatLocalDate } from '../../utils/dateUtils'; // Preset block types const PRESETS = [ @@ -418,8 +419,8 @@ const TimeBlockCreatorModal: React.FC = ({ 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 }; diff --git a/frontend/src/components/time-blocks/YearlyBlockCalendar.tsx b/frontend/src/components/time-blocks/YearlyBlockCalendar.tsx index 0565944..5aee1d9 100644 --- a/frontend/src/components/time-blocks/YearlyBlockCalendar.tsx +++ b/frontend/src/components/time-blocks/YearlyBlockCalendar.tsx @@ -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 = ({ return

; } - 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(); diff --git a/frontend/src/pages/OwnerScheduler.tsx b/frontend/src/pages/OwnerScheduler.tsx index 4bfd3c1..1f23b78 100644 --- a/frontend/src/pages/OwnerScheduler.tsx +++ b/frontend/src/pages/OwnerScheduler.tsx @@ -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 = ({ 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); diff --git a/frontend/src/pages/settings/GeneralSettings.tsx b/frontend/src/pages/settings/GeneralSettings.tsx index 98323f2..7d72345 100644 --- a/frontend/src/pages/settings/GeneralSettings.tsx +++ b/frontend/src/pages/settings/GeneralSettings.tsx @@ -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 => ( - + {timezoneGroups.map(group => ( + + {group.timezones.map(tz => ( + + ))} + ))}

diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts new file mode 100644 index 0000000..ac62159 --- /dev/null +++ b/frontend/src/utils/dateUtils.ts @@ -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})`; +}; diff --git a/smoothschedule/smoothschedule/communication/mobile/serializers.py b/smoothschedule/smoothschedule/communication/mobile/serializers.py index 41607d8..6872d44 100644 --- a/smoothschedule/smoothschedule/communication/mobile/serializers.py +++ b/smoothschedule/smoothschedule/communication/mobile/serializers.py @@ -12,6 +12,7 @@ from smoothschedule.communication.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): diff --git a/smoothschedule/smoothschedule/identity/core/mixins.py b/smoothschedule/smoothschedule/identity/core/mixins.py index d17e4e9..441c8a7 100644 --- a/smoothschedule/smoothschedule/identity/core/mixins.py +++ b/smoothschedule/smoothschedule/identity/core/mixins.py @@ -1,10 +1,11 @@ """ -Core Mixins for DRF ViewSets +Core Mixins for DRF ViewSets and Serializers -Reusable mixins to reduce code duplication across ViewSets. +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 # ============================================================================== @@ -544,3 +545,93 @@ class TenantRequiredAPIView(TenantAPIView): 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 diff --git a/smoothschedule/smoothschedule/identity/users/models.py b/smoothschedule/smoothschedule/identity/users/models.py index cdc9ee7..2d624f0 100644 --- a/smoothschedule/smoothschedule/identity/users/models.py +++ b/smoothschedule/smoothschedule/identity/users/models.py @@ -264,13 +264,17 @@ class User(AbstractUser): def can_self_approve_time_off(self): """ Check if user can self-approve time off requests. - Owners and managers can always self-approve. + Owners can always self-approve. + Managers can self-approve by default but can be denied. Staff need explicit permission. """ - # Owners and managers can always self-approve - if self.role in [self.Role.TENANT_OWNER, self.Role.TENANT_MANAGER]: + # Owners can always self-approve + if self.role == self.Role.TENANT_OWNER: return True - # Staff can self-approve if granted permission + # 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 diff --git a/smoothschedule/smoothschedule/scheduling/schedule/serializers.py b/smoothschedule/smoothschedule/scheduling/schedule/serializers.py index c0a58f1..8034e02 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/serializers.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/serializers.py @@ -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.identity.users.models import User +from smoothschedule.identity.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,8 +1168,12 @@ 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() @@ -1183,8 +1195,11 @@ class TimeBlockSerializer(serializers.ModelSerializer): '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', 'reviewed_by', 'reviewed_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: @@ -1426,8 +1441,12 @@ 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() @@ -1444,6 +1463,8 @@ class TimeBlockListSerializer(serializers.ModelSerializer): '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): diff --git a/smoothschedule/smoothschedule/scheduling/schedule/signals.py b/smoothschedule/smoothschedule/scheduling/schedule/signals.py index c034e27..08f07e0 100644 --- a/smoothschedule/smoothschedule/scheduling/schedule/signals.py +++ b/smoothschedule/smoothschedule/scheduling/schedule/signals.py @@ -533,6 +533,70 @@ def emit_status_change(event, old_status, new_status, changed_by, tenant, skip_n # 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.""" @@ -568,16 +632,20 @@ def create_notification_safe(recipient, actor, verb, action_object=None, target= @receiver(post_save, sender='schedule.TimeBlock') def notify_managers_on_pending_time_off(sender, instance, created, **kwargs): """ - When a TimeBlock is created with PENDING approval status, - notify all managers and owners in the business. - """ - if not created: - return + 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 - # Only notify for pending requests (staff time-off that needs approval) - if instance.approval_status != TimeBlock.ApprovalStatus.PENDING: + # 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) @@ -589,10 +657,23 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs): if not requester: return - logger.info( - f"Time-off request submitted by {requester.get_full_name() or requester.email} " - f"for resource '{instance.resource.name}'" - ) + # 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.identity.users.models import User @@ -607,7 +688,7 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs): notification = create_notification_safe( recipient=reviewer, actor=requester, - verb='requested time off', + verb=verb, action_object=instance, target=instance.resource, data={ @@ -615,7 +696,9 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs): 'title': instance.title, 'resource_name': instance.resource.name if instance.resource else None, 'requester_name': requester.get_full_name() or requester.email, - 'type': 'time_off_request', + 'type': notification_type, + 'is_modification': needs_re_approval, + 'changed_fields': getattr(instance, '_changed_fields', []) if needs_re_approval else [], } ) if notification: @@ -627,17 +710,23 @@ def notify_managers_on_pending_time_off(sender, instance, created, **kwargs): 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. + 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 @@ -653,9 +742,27 @@ def send_time_off_email_notifications(sender, time_block, requester, reviewers, else: date_desc = f"Recurring ({time_block.get_recurrence_type_display()})" - subject = f"Time-Off Request: {requester_name} - {time_block.title or 'Time Off'}" + # 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. - message = f""" +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}