Add service addons and manual scheduling features

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 <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-23 21:27:24 -05:00
parent 2bfa01e0d4
commit fa7ecf16b1
31 changed files with 4955 additions and 803 deletions

View File

@@ -1,7 +1,7 @@
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useOutletContext } from 'react-router-dom';
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image, AlertTriangle, Users, Bell, Mail, MessageSquare, Heart, Check } from 'lucide-react';
import { Plus, Pencil, Trash2, Clock, DollarSign, X, Loader2, GripVertical, Eye, ChevronRight, Upload, ImagePlus, Image, AlertTriangle, Users, Bell, Mail, MessageSquare, Heart, Check, Phone, Calendar } from 'lucide-react';
import { useServices, useCreateService, useUpdateService, useDeleteService, useReorderServices } from '../hooks/useServices';
import { useResources } from '../hooks/useResources';
import { useUpdateBusiness } from '../hooks/useBusiness';
@@ -9,6 +9,7 @@ import { Service, User, Business } from '../types';
import { getOverQuotaServiceIds } from '../utils/quotaUtils';
import { CurrencyInput } from '../components/ui';
import CustomerPreview from '../components/services/CustomerPreview';
import ServiceAddonManager from '../components/services/ServiceAddonManager';
interface ServiceFormData {
name: string;
@@ -25,6 +26,9 @@ interface ServiceFormData {
// Resource assignment fields
all_resources: boolean;
resource_ids: string[];
// Manual scheduling (unscheduled booking)
requires_manual_scheduling: boolean;
capture_preferred_time: boolean;
// Timing fields
prep_time: number;
takedown_time: number;
@@ -283,6 +287,8 @@ const Services: React.FC = () => {
deposit_percent: null,
all_resources: true,
resource_ids: [],
requires_manual_scheduling: false,
capture_preferred_time: true,
prep_time: 0,
takedown_time: 0,
reminder_enabled: false,
@@ -314,6 +320,8 @@ const Services: React.FC = () => {
deposit_percent: service.deposit_percent || null,
all_resources: service.all_resources ?? true,
resource_ids: service.resource_ids || [],
requires_manual_scheduling: service.requires_manual_scheduling || false,
capture_preferred_time: service.capture_preferred_time ?? true,
prep_time: service.prep_time || 0,
takedown_time: service.takedown_time || 0,
reminder_enabled: service.reminder_enabled || false,
@@ -351,6 +359,9 @@ const Services: React.FC = () => {
// Resource assignment
all_resources: formData.all_resources,
resource_ids: formData.all_resources ? [] : formData.resource_ids,
// Manual scheduling (unscheduled booking)
requires_manual_scheduling: formData.requires_manual_scheduling,
capture_preferred_time: formData.requires_manual_scheduling ? formData.capture_preferred_time : true,
// Timing fields
prep_time: formData.prep_time,
takedown_time: formData.takedown_time,
@@ -1053,6 +1064,77 @@ const Services: React.FC = () => {
)}
</div>
{/* Manual Scheduling (Unscheduled Booking) */}
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-700">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Phone className="h-4 w-4 text-orange-600 dark:text-orange-400" />
<div>
<label className="text-sm font-medium text-orange-900 dark:text-orange-100">
{t('services.requiresManualScheduling', 'Requires Manual Scheduling')}
</label>
<p className="text-xs text-orange-600 dark:text-orange-300">
{t('services.manualSchedulingDescription', 'Online bookings go to Pending Requests for staff to call and schedule')}
</p>
</div>
</div>
<button
type="button"
onClick={() => setFormData({ ...formData, requires_manual_scheduling: !formData.requires_manual_scheduling })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.requires_manual_scheduling ? 'bg-orange-600' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.requires_manual_scheduling ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Nested option - Capture Preferred Time */}
{formData.requires_manual_scheduling && (
<div className="mt-3 pt-3 border-t border-orange-200 dark:border-orange-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-orange-500 dark:text-orange-400" />
<div>
<span className="text-sm text-orange-800 dark:text-orange-200">
{t('services.capturePreferredTime', 'Ask for Preferred Time')}
</span>
<p className="text-xs text-orange-600 dark:text-orange-300">
{t('services.capturePreferredTimeDescription', 'Let customers indicate when they prefer to be scheduled')}
</p>
</div>
</div>
<button
type="button"
onClick={() => setFormData({ ...formData, capture_preferred_time: !formData.capture_preferred_time })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
formData.capture_preferred_time ? 'bg-orange-500' : 'bg-gray-300 dark:bg-gray-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
formData.capture_preferred_time ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
)}
{/* Info box when enabled */}
{formData.requires_manual_scheduling && (
<div className="mt-3 p-2 bg-orange-100 dark:bg-orange-800/30 rounded-lg">
<p className="text-xs text-orange-800 dark:text-orange-200">
{t('services.manualSchedulingNote', 'When a customer books this service, they won\'t select a time slot. The booking goes to Pending Requests for staff to call and schedule manually.')}
</p>
</div>
)}
</div>
{/* Prep Time and Takedown Time */}
<div className="p-4 bg-gray-50 dark:bg-gray-900/50 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 mb-3">
@@ -1313,6 +1395,14 @@ const Services: React.FC = () => {
{t('services.photosHint', 'Drag photos to reorder. First photo is the primary image.')}
</p>
</div>
{/* Service Addons - Only show when editing an existing service */}
{editingService && (
<ServiceAddonManager
serviceId={parseInt(editingService.id)}
serviceName={editingService.name}
/>
)}
</div>
<div className="flex justify-end gap-3 p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50 shrink-0">