From fa7ecf16b123bfa7e3c7313cad350d96b5059dd7 Mon Sep 17 00:00:00 2001 From: poduck Date: Tue, 23 Dec 2025 21:27:24 -0500 Subject: [PATCH] Add service addons and manual scheduling features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Service Addons: - Add ServiceAddon model with optional resource assignment - Create AddonSelection component for booking flow - Add ServiceAddonManager for service configuration - Include addon API endpoints and serializers Manual Scheduling: - Add requires_manual_scheduling and capture_preferred_time to Service model - Add preferred_datetime and preferred_time_notes to Event model - Create ManualSchedulingRequest component for booking callback flow - Auto-open pending sidebar when requests exist or arrive via websocket - Show preferred times on pending items with detail modal popup - Add interactive UnscheduledBookingDemo component for help docs Scheduler Improvements: - Consolidate Create/EditAppointmentModal into single AppointmentModal - Update pending sidebar to show preferred schedule info - Add modal for pending request details with Schedule Now action Documentation: - Add Manual Scheduling section to HelpScheduler with interactive demo - Add Manual Scheduling section to HelpServices with interactive demo 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/App.tsx | 5 + frontend/src/components/AppointmentModal.tsx | 738 ++++++++++++++++++ .../src/components/CreateAppointmentModal.tsx | 352 --------- .../src/components/EditAppointmentModal.tsx | 302 ------- .../src/components/booking/AddonSelection.tsx | 157 ++++ .../components/booking/DateTimeSelection.tsx | 9 +- .../booking/ManualSchedulingRequest.tsx | 150 ++++ .../help/UnscheduledBookingDemo.tsx | 240 ++++++ .../services/ServiceAddonManager.tsx | 485 ++++++++++++ frontend/src/hooks/useAppointments.ts | 94 ++- frontend/src/hooks/useBooking.ts | 21 +- frontend/src/hooks/useServiceAddons.ts | 186 +++++ frontend/src/pages/BookingFlow.tsx | 128 ++- frontend/src/pages/OwnerScheduler.tsx | 493 ++++++++++-- frontend/src/pages/Services.tsx | 92 ++- frontend/src/pages/help/HelpScheduler.tsx | 280 ++++++- frontend/src/pages/help/HelpServices.tsx | 462 +++++++++++ frontend/src/types.ts | 56 ++ .../platform/tenant_sites/serializers.py | 5 +- .../platform/tenant_sites/urls.py | 3 +- .../platform/tenant_sites/views.py | 95 ++- .../management/commands/reseed_demo.py | 129 ++- .../migrations/0043_add_service_addon.py | 46 ++ ...044_make_serviceaddon_resource_optional.py | 24 + .../0045_add_preferred_time_to_event.py | 23 + .../0046_add_manual_scheduling_to_service.py | 23 + .../scheduling/schedule/models.py | 183 +++++ .../scheduling/schedule/serializers.py | 328 +++++++- .../schedule/tests/test_service_addons.py | 526 +++++++++++++ .../scheduling/schedule/urls.py | 3 +- .../scheduling/schedule/views.py | 120 ++- 31 files changed, 4955 insertions(+), 803 deletions(-) create mode 100644 frontend/src/components/AppointmentModal.tsx delete mode 100644 frontend/src/components/CreateAppointmentModal.tsx delete mode 100644 frontend/src/components/EditAppointmentModal.tsx create mode 100644 frontend/src/components/booking/AddonSelection.tsx create mode 100644 frontend/src/components/booking/ManualSchedulingRequest.tsx create mode 100644 frontend/src/components/help/UnscheduledBookingDemo.tsx create mode 100644 frontend/src/components/services/ServiceAddonManager.tsx create mode 100644 frontend/src/hooks/useServiceAddons.ts create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/migrations/0043_add_service_addon.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/migrations/0044_make_serviceaddon_resource_optional.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/migrations/0045_add_preferred_time_to_event.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/migrations/0046_add_manual_scheduling_to_service.py create mode 100644 smoothschedule/smoothschedule/scheduling/schedule/tests/test_service_addons.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34a96062..26bab8b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -115,6 +115,9 @@ const HelpSettingsEmailTemplates = React.lazy(() => import('./pages/help/HelpSet const HelpSettingsEmbedWidget = React.lazy(() => import('./pages/help/HelpSettingsEmbedWidget')); const HelpSettingsStaffRoles = React.lazy(() => import('./pages/help/HelpSettingsStaffRoles')); const HelpSettingsCommunication = React.lazy(() => import('./pages/help/HelpSettingsCommunication')); + +// TEMP: Demo page for UI options - DELETE AFTER DECISION +const TempUIDemo = React.lazy(() => import('./pages/TempUIDemo')); const HelpComprehensive = React.lazy(() => import('./pages/help/HelpComprehensive')); const StaffHelp = React.lazy(() => import('./pages/help/StaffHelp')); const PlatformSupport = React.lazy(() => import('./pages/PlatformSupport')); // Import Platform Support page (for businesses to contact SmoothSchedule) @@ -799,6 +802,8 @@ const AppContent: React.FC = () => { /> } /> } /> + {/* TEMP: Demo page for UI options - DELETE AFTER DECISION */} + } /> void; +} + +interface CreateModeProps extends BaseModalProps { + mode: 'create'; + initialDate?: Date; + initialResourceId?: string | null; + onCreate: (appointmentData: { + serviceId: string; + customerIds: string[]; + startTime: Date; + resourceId?: string | null; + durationMinutes: number; + notes?: string; + participantsInput?: ParticipantInput[]; + addonIds?: number[]; + }) => void; + isSubmitting?: boolean; +} + +interface EditModeProps extends BaseModalProps { + mode: 'edit'; + appointment: Appointment & { + customerEmail?: string; + customerPhone?: string; + }; + onSave: (updates: { + serviceId?: string; + customerIds?: string[]; + startTime?: Date; + resourceId?: string | null; + durationMinutes?: number; + status?: AppointmentStatus; + notes?: string; + participantsInput?: ParticipantInput[]; + addonIds?: number[]; + }) => void; + isSubmitting?: boolean; +} + +type AppointmentModalProps = CreateModeProps | EditModeProps; + +// Mini form for creating a new customer inline +interface NewCustomerFormData { + name: string; + email: string; + phone: string; +} + +export const AppointmentModal: React.FC = (props) => { + const { resources, services, onClose } = props; + const isEditMode = props.mode === 'edit'; + const { t } = useTranslation(); + + // Form state + const [selectedServiceId, setSelectedServiceId] = useState(''); + const [selectedCustomers, setSelectedCustomers] = useState([]); + const [customerSearch, setCustomerSearch] = useState(''); + const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); + const [selectedDateTime, setSelectedDateTime] = useState(''); + const [selectedResourceId, setSelectedResourceId] = useState(''); + const [duration, setDuration] = useState(30); + const [notes, setNotes] = useState(''); + const [participants, setParticipants] = useState([]); + const [selectedAddonIds, setSelectedAddonIds] = useState([]); + const [status, setStatus] = useState('SCHEDULED'); + + // New customer form state + const [showNewCustomerForm, setShowNewCustomerForm] = useState(false); + const [newCustomerData, setNewCustomerData] = useState({ + name: '', + email: '', + phone: '', + }); + + // Initialize form state based on mode + useEffect(() => { + if (isEditMode) { + const appointment = (props as EditModeProps).appointment; + + // Set service + setSelectedServiceId(appointment.serviceId || ''); + + // Set customer(s) - convert from appointment data + if (appointment.customerName) { + const customerData: Customer = { + id: appointment.customerId || '', + name: appointment.customerName, + email: appointment.customerEmail || '', + phone: appointment.customerPhone || '', + userId: appointment.customerId || '', + }; + setSelectedCustomers([customerData]); + } + + // Set date/time + const startTime = appointment.startTime; + const localDateTime = new Date(startTime.getTime() - startTime.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16); + setSelectedDateTime(localDateTime); + + // Set other fields + setSelectedResourceId(appointment.resourceId || ''); + setDuration(appointment.durationMinutes || 30); + setNotes(appointment.notes || ''); + setStatus(appointment.status || 'SCHEDULED'); + + // Set addons if present + if (appointment.addonIds) { + setSelectedAddonIds(appointment.addonIds); + } + + // Initialize staff participants from existing appointment participants + if (appointment.participants) { + const staffParticipants: ParticipantInput[] = appointment.participants + .filter(p => p.role === 'STAFF') + .map(p => ({ + role: 'STAFF' as const, + userId: p.userId ? parseInt(p.userId) : undefined, + resourceId: p.resourceId ? parseInt(p.resourceId) : undefined, + externalEmail: p.externalEmail, + externalName: p.externalName, + })); + setParticipants(staffParticipants); + } + } else { + // Create mode - set defaults + const createProps = props as CreateModeProps; + const date = createProps.initialDate || new Date(); + const minutes = Math.ceil(date.getMinutes() / 15) * 15; + date.setMinutes(minutes, 0, 0); + const localDateTime = new Date(date.getTime() - date.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16); + setSelectedDateTime(localDateTime); + setSelectedResourceId(createProps.initialResourceId || ''); + } + }, [isEditMode, props]); + + // Fetch customers for search + const { data: customers = [] } = useCustomers({ search: customerSearch }); + + // Create customer mutation + const createCustomer = useCreateCustomer(); + + // Fetch addons for selected service + const { data: serviceAddons = [], isLoading: addonsLoading } = useServiceAddons( + selectedServiceId ? selectedServiceId : null + ); + + // Filter to only active addons + const activeAddons = useMemo(() => { + return serviceAddons.filter(addon => addon.is_active); + }, [serviceAddons]); + + // Get selected service details + const selectedService = useMemo(() => { + return services.find(s => s.id === selectedServiceId); + }, [services, selectedServiceId]); + + // When service changes, update duration to service default and reset addons + const handleServiceChange = useCallback((serviceId: string) => { + setSelectedServiceId(serviceId); + setSelectedAddonIds([]); // Reset addon selections when service changes + const service = services.find(s => s.id === serviceId); + if (service) { + setDuration(service.durationMinutes); + } + }, [services]); + + // Handle customer selection from search + const handleSelectCustomer = useCallback((customer: Customer) => { + // Don't add duplicates + if (!selectedCustomers.find(c => c.id === customer.id)) { + setSelectedCustomers(prev => [...prev, customer]); + } + setCustomerSearch(''); + setShowCustomerDropdown(false); + }, [selectedCustomers]); + + // Remove a selected customer + const handleRemoveCustomer = useCallback((customerId: string) => { + setSelectedCustomers(prev => prev.filter(c => c.id !== customerId)); + }, []); + + // Handle creating a new customer + const handleCreateCustomer = useCallback(async () => { + if (!newCustomerData.name.trim() || !newCustomerData.email.trim()) return; + + try { + const result = await createCustomer.mutateAsync({ + name: newCustomerData.name.trim(), + email: newCustomerData.email.trim(), + phone: newCustomerData.phone.trim(), + }); + + // Add the newly created customer to selection + const newCustomer: Customer = { + id: String(result.id), + name: newCustomerData.name.trim(), + email: newCustomerData.email.trim(), + phone: newCustomerData.phone.trim(), + userId: String(result.user_id || result.user), + }; + setSelectedCustomers(prev => [...prev, newCustomer]); + + // Reset and close form + setNewCustomerData({ name: '', email: '', phone: '' }); + setShowNewCustomerForm(false); + setCustomerSearch(''); + } catch (error) { + console.error('Failed to create customer:', error); + } + }, [newCustomerData, createCustomer]); + + // Toggle addon selection + const handleToggleAddon = useCallback((addonId: number) => { + setSelectedAddonIds(prev => + prev.includes(addonId) + ? prev.filter(id => id !== addonId) + : [...prev, addonId] + ); + }, []); + + // Calculate total duration including addons + const totalDuration = useMemo(() => { + let total = duration; + selectedAddonIds.forEach(addonId => { + const addon = activeAddons.find(a => a.id === addonId); + if (addon && addon.duration_mode === 'SEQUENTIAL') { + total += addon.additional_duration || 0; + } + }); + return total; + }, [duration, selectedAddonIds, activeAddons]); + + // Filter customers based on search (exclude already selected) + const filteredCustomers = useMemo(() => { + if (!customerSearch.trim()) return []; + const selectedIds = new Set(selectedCustomers.map(c => c.id)); + return customers.filter(c => !selectedIds.has(c.id)).slice(0, 10); + }, [customers, customerSearch, selectedCustomers]); + + // Validation + const canSubmit = useMemo(() => { + return selectedServiceId && selectedCustomers.length > 0 && selectedDateTime && duration >= 15; + }, [selectedServiceId, selectedCustomers, selectedDateTime, duration]); + + // Handle submit + const handleSubmit = useCallback(() => { + if (!canSubmit) return; + + const startTime = new Date(selectedDateTime); + + if (isEditMode) { + (props as EditModeProps).onSave({ + serviceId: selectedServiceId, + customerIds: selectedCustomers.map(c => c.id), + startTime, + resourceId: selectedResourceId || null, + durationMinutes: totalDuration, + status, + notes: notes.trim() || undefined, + participantsInput: participants.length > 0 ? participants : undefined, + addonIds: selectedAddonIds.length > 0 ? selectedAddonIds : undefined, + }); + } else { + (props as CreateModeProps).onCreate({ + serviceId: selectedServiceId, + customerIds: selectedCustomers.map(c => c.id), + startTime, + resourceId: selectedResourceId || null, + durationMinutes: totalDuration, + notes: notes.trim() || undefined, + participantsInput: participants.length > 0 ? participants : undefined, + addonIds: selectedAddonIds.length > 0 ? selectedAddonIds : undefined, + }); + } + }, [canSubmit, selectedServiceId, selectedCustomers, selectedDateTime, selectedResourceId, totalDuration, status, notes, participants, selectedAddonIds, isEditMode, props]); + + // Format price for display + const formatPrice = (cents: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(cents / 100); + }; + + const isSubmitting = props.isSubmitting || false; + + const modalContent = ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+

+ {isEditMode + ? t('scheduler.editAppointment', 'Edit Appointment') + : t('scheduler.newAppointment', 'New Appointment') + } +

+
+ +
+ + {/* Scrollable content */} +
+ {/* Service Selection */} +
+ + +
+ + {/* Service Addons - Only show when service has addons */} + {selectedServiceId && activeAddons.length > 0 && ( +
+
+ + + {t('scheduler.addons', 'Add-ons')} + + {addonsLoading && } +
+
+ {activeAddons.map(addon => ( + + ))} +
+
+ )} + + {/* Customer Selection - Multi-select with autocomplete */} +
+ + + {/* Selected customers chips */} + {selectedCustomers.length > 0 && ( +
+ {selectedCustomers.map(customer => ( +
+ + {customer.name} + +
+ ))} +
+ )} + + {/* Search input */} +
+ + { + setCustomerSearch(e.target.value); + setShowCustomerDropdown(true); + setShowNewCustomerForm(false); + }} + onFocus={() => setShowCustomerDropdown(true)} + placeholder={t('customers.searchPlaceholder', 'Search customers by name or email...')} + className="w-full pl-10 pr-4 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+ + {/* Customer search results dropdown */} + {showCustomerDropdown && customerSearch.trim() && !showNewCustomerForm && ( +
+ {filteredCustomers.length === 0 ? ( +
+
+ {t('common.noResults', 'No results found')} +
+ +
+ ) : ( + <> + {filteredCustomers.map((customer) => ( + + ))} +
+ +
+ + )} +
+ )} + + {/* New customer inline form */} + {showNewCustomerForm && ( +
+
+

+ {t('customers.addNewCustomer', 'Add New Customer')} +

+ +
+
+ setNewCustomerData(prev => ({ ...prev, name: e.target.value }))} + placeholder={t('customers.name', 'Name')} + className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" + autoFocus + /> + setNewCustomerData(prev => ({ ...prev, email: e.target.value }))} + placeholder={t('customers.email', 'Email')} + className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" + /> + setNewCustomerData(prev => ({ ...prev, phone: e.target.value }))} + placeholder={t('customers.phone', 'Phone (optional)')} + className="w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+ + +
+
+
+ )} + + {/* Click outside to close dropdown */} + {(showCustomerDropdown || showNewCustomerForm) && ( +
{ + setShowCustomerDropdown(false); + setShowNewCustomerForm(false); + }} + /> + )} +
+ + {/* Status (Edit mode only) */} + {isEditMode && ( +
+ + +
+ )} + + {/* Date, Time & Duration */} +
+
+ + setSelectedDateTime(e.target.value)} + className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" + /> +
+
+ + { + const value = parseInt(e.target.value); + setDuration(value >= 15 ? value : 15); + }} + className="w-full px-3 py-2.5 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500" + /> + {selectedAddonIds.length > 0 && totalDuration !== duration && ( +
+ {t('scheduler.totalWithAddons', 'Total with add-ons')}: {totalDuration} min +
+ )} +
+
+ + {/* Resource Assignment */} +
+ + +
+ + {/* Additional Staff Section */} +
+
+ + + {t('scheduler.additionalStaff', 'Additional Staff')} + +
+ +
+ + {/* Notes */} +
+ +