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

@@ -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 = () => {
/>
<Route path="/dashboard/scheduler" element={<Scheduler />} />
<Route path="/dashboard/tickets" element={<Tickets />} />
{/* TEMP: Demo page for UI options - DELETE AFTER DECISION */}
<Route path="/dashboard/temp-ui-demo" element={<TempUIDemo />} />
<Route
path="/dashboard/help"
element={

View File

@@ -0,0 +1,738 @@
/**
* AppointmentModal Component
*
* Unified modal for creating and editing appointments.
* Features:
* - Multi-select customer autocomplete with "Add new customer" option
* - Service selection with addon support
* - Participant management (additional staff)
* - Status management (edit mode only)
*/
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Search, User as UserIcon, Calendar, Clock, Users, Plus, Package, Check, Loader2 } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Resource, Service, ParticipantInput, Customer, Appointment, AppointmentStatus } from '../types';
import { useCustomers, useCreateCustomer } from '../hooks/useCustomers';
import { useServiceAddons } from '../hooks/useServiceAddons';
import { ParticipantSelector } from './ParticipantSelector';
interface BaseModalProps {
resources: Resource[];
services: Service[];
onClose: () => 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<AppointmentModalProps> = (props) => {
const { resources, services, onClose } = props;
const isEditMode = props.mode === 'edit';
const { t } = useTranslation();
// Form state
const [selectedServiceId, setSelectedServiceId] = useState('');
const [selectedCustomers, setSelectedCustomers] = useState<Customer[]>([]);
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<ParticipantInput[]>([]);
const [selectedAddonIds, setSelectedAddonIds] = useState<number[]>([]);
const [status, setStatus] = useState<AppointmentStatus>('SCHEDULED');
// New customer form state
const [showNewCustomerForm, setShowNewCustomerForm] = useState(false);
const [newCustomerData, setNewCustomerData] = useState<NewCustomerFormData>({
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 = (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden max-h-[90vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-500 rounded-lg">
<Calendar className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEditMode
? t('scheduler.editAppointment', 'Edit Appointment')
: t('scheduler.newAppointment', 'New Appointment')
}
</h3>
</div>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
{/* Scrollable content */}
<div className="overflow-y-auto flex-1 p-6 space-y-5">
{/* Service Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.service', 'Service')} <span className="text-red-500">*</span>
</label>
<select
value={selectedServiceId}
onChange={(e) => handleServiceChange(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"
>
<option value="">{t('scheduler.selectService', 'Select a service...')}</option>
{services.filter(s => s.is_active !== false).map(service => (
<option key={service.id} value={service.id}>
{service.name} ({service.durationMinutes} min)
</option>
))}
</select>
</div>
{/* Service Addons - Only show when service has addons */}
{selectedServiceId && activeAddons.length > 0 && (
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<div className="flex items-center gap-2 mb-3">
<Package className="w-4 h-4 text-purple-600 dark:text-purple-400" />
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">
{t('scheduler.addons', 'Add-ons')}
</span>
{addonsLoading && <Loader2 className="w-4 h-4 animate-spin text-purple-500" />}
</div>
<div className="space-y-2">
{activeAddons.map(addon => (
<button
key={addon.id}
type="button"
onClick={() => handleToggleAddon(addon.id)}
className={`w-full flex items-center justify-between p-3 rounded-lg border transition-all ${
selectedAddonIds.includes(addon.id)
? 'bg-purple-100 dark:bg-purple-800/40 border-purple-400 dark:border-purple-600'
: 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600 hover:border-purple-300 dark:hover:border-purple-700'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
selectedAddonIds.includes(addon.id)
? 'bg-purple-500 border-purple-500'
: 'border-gray-300 dark:border-gray-500'
}`}>
{selectedAddonIds.includes(addon.id) && (
<Check className="w-3 h-3 text-white" />
)}
</div>
<div className="text-left">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{addon.name}
</div>
{addon.description && (
<div className="text-xs text-gray-500 dark:text-gray-400">
{addon.description}
</div>
)}
{addon.duration_mode === 'SEQUENTIAL' && addon.additional_duration > 0 && (
<div className="text-xs text-purple-600 dark:text-purple-400">
+{addon.additional_duration} min
</div>
)}
</div>
</div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">
{formatPrice(addon.price_cents)}
</div>
</button>
))}
</div>
</div>
)}
{/* Customer Selection - Multi-select with autocomplete */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.customers', 'Customers')} <span className="text-red-500">*</span>
</label>
{/* Selected customers chips */}
{selectedCustomers.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{selectedCustomers.map(customer => (
<div
key={customer.id}
className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-brand-100 dark:bg-brand-900/40 text-brand-700 dark:text-brand-300 rounded-full text-sm"
>
<UserIcon className="w-3.5 h-3.5" />
<span>{customer.name}</span>
<button
onClick={() => handleRemoveCustomer(customer.id)}
className="p-0.5 hover:bg-brand-200 dark:hover:bg-brand-800 rounded-full transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{/* Search input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={customerSearch}
onChange={(e) => {
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"
/>
</div>
{/* Customer search results dropdown */}
{showCustomerDropdown && customerSearch.trim() && !showNewCustomerForm && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filteredCustomers.length === 0 ? (
<div className="p-2">
<div className="px-2 py-2 text-sm text-gray-500 dark:text-gray-400">
{t('common.noResults', 'No results found')}
</div>
<button
type="button"
onClick={() => {
setShowNewCustomerForm(true);
setNewCustomerData(prev => ({ ...prev, name: customerSearch }));
}}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-green-50 dark:hover:bg-green-900/30 text-green-600 dark:text-green-400 text-left rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
<span className="text-sm font-medium">
{t('customers.addNew', 'Add new customer')} "{customerSearch}"
</span>
</button>
</div>
) : (
<>
{filteredCustomers.map((customer) => (
<button
key={customer.id}
type="button"
onClick={() => handleSelectCustomer(customer)}
className="w-full px-4 py-2 flex items-center gap-3 hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
>
<div className="flex-shrink-0 w-8 h-8 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center">
<UserIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</div>
<div className="flex-grow min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{customer.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{customer.email}
</div>
</div>
</button>
))}
<div className="border-t dark:border-gray-700">
<button
type="button"
onClick={() => {
setShowNewCustomerForm(true);
setNewCustomerData(prev => ({ ...prev, name: customerSearch }));
}}
className="w-full px-4 py-2 flex items-center gap-2 hover:bg-green-50 dark:hover:bg-green-900/30 text-green-600 dark:text-green-400 text-left transition-colors"
>
<Plus className="w-4 h-4" />
<span className="text-sm font-medium">
{t('customers.addNew', 'Add new customer')}
</span>
</button>
</div>
</>
)}
</div>
)}
{/* New customer inline form */}
{showNewCustomerForm && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
{t('customers.addNewCustomer', 'Add New Customer')}
</h4>
<button
onClick={() => setShowNewCustomerForm(false)}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X size={16} />
</button>
</div>
<div className="space-y-3">
<input
type="text"
value={newCustomerData.name}
onChange={(e) => 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
/>
<input
type="email"
value={newCustomerData.email}
onChange={(e) => 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"
/>
<input
type="tel"
value={newCustomerData.phone}
onChange={(e) => 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"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowNewCustomerForm(false)}
className="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={handleCreateCustomer}
disabled={!newCustomerData.name.trim() || !newCustomerData.email.trim() || createCustomer.isPending}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1"
>
{createCustomer.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
{t('common.add', 'Add')}
</button>
</div>
</div>
</div>
)}
{/* Click outside to close dropdown */}
{(showCustomerDropdown || showNewCustomerForm) && (
<div
className="fixed inset-0 z-40"
onClick={() => {
setShowCustomerDropdown(false);
setShowNewCustomerForm(false);
}}
/>
)}
</div>
{/* Status (Edit mode only) */}
{isEditMode && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.status', 'Status')}
</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value as AppointmentStatus)}
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"
>
<option value="SCHEDULED">{t('scheduler.confirmed', 'Scheduled')}</option>
<option value="EN_ROUTE">En Route</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">{t('scheduler.completed', 'Completed')}</option>
<option value="AWAITING_PAYMENT">Awaiting Payment</option>
<option value="CANCELLED">{t('scheduler.cancelled', 'Cancelled')}</option>
<option value="NO_SHOW">{t('scheduler.noShow', 'No Show')}</option>
</select>
</div>
)}
{/* Date, Time & Duration */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.selectDate', 'Date')} & {t('scheduler.selectTime', 'Time')} <span className="text-red-500">*</span>
</label>
<input
type="datetime-local"
value={selectedDateTime}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<Clock className="w-4 h-4 inline mr-1" />
{t('scheduler.duration', 'Duration')} (min) <span className="text-red-500">*</span>
</label>
<input
type="number"
min="15"
step="15"
value={duration}
onChange={(e) => {
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 && (
<div className="text-xs text-purple-600 dark:text-purple-400 mt-1">
{t('scheduler.totalWithAddons', 'Total with add-ons')}: {totalDuration} min
</div>
)}
</div>
</div>
{/* Resource Assignment */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.selectResource', 'Assign to Resource')}
</label>
<select
value={selectedResourceId}
onChange={(e) => setSelectedResourceId(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"
>
<option value="">{t('scheduler.unassigned', 'Unassigned')}</option>
{resources.map(resource => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
</div>
{/* Additional Staff Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('scheduler.additionalStaff', 'Additional Staff')}
</span>
</div>
<ParticipantSelector
value={participants}
onChange={setParticipants}
allowedRoles={['STAFF']}
allowExternalEmail={false}
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.notes', 'Notes')}
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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 resize-none"
placeholder={t('scheduler.notesPlaceholder', 'Add notes about this appointment...')}
/>
</div>
</div>
{/* Footer with action buttons */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 bg-white dark:bg-gray-800">
<button
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={handleSubmit}
disabled={!canSubmit || isSubmitting}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting
? (isEditMode ? t('common.saving', 'Saving...') : t('common.creating', 'Creating...'))
: (isEditMode ? t('scheduler.saveChanges', 'Save Changes') : t('scheduler.createAppointment', 'Create Appointment'))
}
</button>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
};
export default AppointmentModal;

View File

@@ -1,352 +0,0 @@
/**
* CreateAppointmentModal Component
*
* Modal for creating new appointments with customer, service, and participant selection.
* Supports both linked customers and participants with external email addresses.
*/
import React, { useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Search, User as UserIcon, Calendar, Clock, Users } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Resource, Service, ParticipantInput } from '../types';
import { useCustomers } from '../hooks/useCustomers';
import { ParticipantSelector } from './ParticipantSelector';
interface CreateAppointmentModalProps {
resources: Resource[];
services: Service[];
initialDate?: Date;
initialResourceId?: string | null;
onCreate: (appointmentData: {
serviceId: string;
customerId: string;
startTime: Date;
resourceId?: string | null;
durationMinutes: number;
notes?: string;
participantsInput?: ParticipantInput[];
}) => void;
onClose: () => void;
isCreating?: boolean;
}
export const CreateAppointmentModal: React.FC<CreateAppointmentModalProps> = ({
resources,
services,
initialDate,
initialResourceId,
onCreate,
onClose,
isCreating = false,
}) => {
const { t } = useTranslation();
// Form state
const [selectedServiceId, setSelectedServiceId] = useState('');
const [selectedCustomerId, setSelectedCustomerId] = useState('');
const [customerSearch, setCustomerSearch] = useState('');
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
const [selectedDateTime, setSelectedDateTime] = useState(() => {
// Default to initial date or now, rounded to nearest 15 min
const date = initialDate || new Date();
const minutes = Math.ceil(date.getMinutes() / 15) * 15;
date.setMinutes(minutes, 0, 0);
// Convert to datetime-local format
const localDateTime = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
return localDateTime;
});
const [selectedResourceId, setSelectedResourceId] = useState(initialResourceId || '');
const [duration, setDuration] = useState(30);
const [notes, setNotes] = useState('');
const [participants, setParticipants] = useState<ParticipantInput[]>([]);
// Fetch customers for search
const { data: customers = [] } = useCustomers({ search: customerSearch });
// Get selected customer details
const selectedCustomer = useMemo(() => {
return customers.find(c => c.id === selectedCustomerId);
}, [customers, selectedCustomerId]);
// Get selected service details
const selectedService = useMemo(() => {
return services.find(s => s.id === selectedServiceId);
}, [services, selectedServiceId]);
// When service changes, update duration to service default
const handleServiceChange = useCallback((serviceId: string) => {
setSelectedServiceId(serviceId);
const service = services.find(s => s.id === serviceId);
if (service) {
setDuration(service.durationMinutes);
}
}, [services]);
// Handle customer selection from search
const handleSelectCustomer = useCallback((customerId: string, customerName: string) => {
setSelectedCustomerId(customerId);
setCustomerSearch(customerName);
setShowCustomerDropdown(false);
}, []);
// Filter customers based on search
const filteredCustomers = useMemo(() => {
if (!customerSearch.trim()) return [];
return customers.slice(0, 10);
}, [customers, customerSearch]);
// Validation
const canCreate = useMemo(() => {
return selectedServiceId && selectedCustomerId && selectedDateTime && duration >= 15;
}, [selectedServiceId, selectedCustomerId, selectedDateTime, duration]);
// Handle create
const handleCreate = useCallback(() => {
if (!canCreate) return;
const startTime = new Date(selectedDateTime);
onCreate({
serviceId: selectedServiceId,
customerId: selectedCustomerId,
startTime,
resourceId: selectedResourceId || null,
durationMinutes: duration,
notes: notes.trim() || undefined,
participantsInput: participants.length > 0 ? participants : undefined,
});
}, [canCreate, selectedServiceId, selectedCustomerId, selectedDateTime, selectedResourceId, duration, notes, participants, onCreate]);
const modalContent = (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden max-h-[90vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
<div className="flex items-center gap-3">
<div className="p-2 bg-brand-500 rounded-lg">
<Calendar className="w-5 h-5 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('scheduler.newAppointment', 'New Appointment')}
</h3>
</div>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
{/* Scrollable content */}
<div className="overflow-y-auto flex-1 p-6 space-y-5">
{/* Service Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('services.title', 'Service')} <span className="text-red-500">*</span>
</label>
<select
value={selectedServiceId}
onChange={(e) => handleServiceChange(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"
>
<option value="">{t('scheduler.selectService', 'Select a service...')}</option>
{services.filter(s => s.is_active !== false).map(service => (
<option key={service.id} value={service.id}>
{service.name} ({service.durationMinutes} min)
</option>
))}
</select>
</div>
{/* Customer Selection */}
<div className="relative">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('customers.title', 'Customer')} <span className="text-red-500">*</span>
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
value={customerSearch}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
if (!e.target.value.trim()) {
setSelectedCustomerId('');
}
}}
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"
/>
{selectedCustomer && (
<button
onClick={() => {
setSelectedCustomerId('');
setCustomerSearch('');
}}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
>
<X size={14} />
</button>
)}
</div>
{/* Customer search results dropdown */}
{showCustomerDropdown && customerSearch.trim() && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{filteredCustomers.length === 0 ? (
<div className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
{t('common.noResults', 'No results found')}
</div>
) : (
filteredCustomers.map((customer) => (
<button
key={customer.id}
type="button"
onClick={() => handleSelectCustomer(customer.id, customer.name)}
className="w-full px-4 py-2 flex items-center gap-3 hover:bg-gray-100 dark:hover:bg-gray-700 text-left"
>
<div className="flex-shrink-0 w-8 h-8 bg-gray-200 dark:bg-gray-600 rounded-full flex items-center justify-center">
<UserIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</div>
<div className="flex-grow min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{customer.name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{customer.email}
</div>
</div>
</button>
))
)}
</div>
)}
{/* Click outside to close dropdown */}
{showCustomerDropdown && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowCustomerDropdown(false)}
/>
)}
</div>
{/* Date, Time & Duration */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.selectDate', 'Date')} & {t('scheduler.selectTime', 'Time')} <span className="text-red-500">*</span>
</label>
<input
type="datetime-local"
value={selectedDateTime}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<Clock className="w-4 h-4 inline mr-1" />
{t('scheduler.duration', 'Duration')} (min) <span className="text-red-500">*</span>
</label>
<input
type="number"
min="15"
step="15"
value={duration}
onChange={(e) => {
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"
/>
</div>
</div>
{/* Resource Assignment */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.selectResource', 'Assign to Resource')}
</label>
<select
value={selectedResourceId}
onChange={(e) => setSelectedResourceId(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"
>
<option value="">{t('scheduler.unassigned', 'Unassigned')}</option>
{resources.map(resource => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
</div>
{/* Participants Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{t('participants.additionalParticipants', 'Additional Participants')}
</span>
</div>
<ParticipantSelector
value={participants}
onChange={setParticipants}
allowedRoles={['STAFF', 'CUSTOMER', 'OBSERVER']}
allowExternalEmail={true}
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.notes', 'Notes')}
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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 resize-none"
placeholder={t('scheduler.notesPlaceholder', 'Add notes about this appointment...')}
/>
</div>
</div>
{/* Footer with action buttons */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 bg-white dark:bg-gray-800">
<button
onClick={onClose}
disabled={isCreating}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
>
{t('common.cancel', 'Cancel')}
</button>
<button
onClick={handleCreate}
disabled={!canCreate || isCreating}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isCreating ? t('common.creating', 'Creating...') : t('scheduler.createAppointment', 'Create Appointment')}
</button>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
};
export default CreateAppointmentModal;

View File

@@ -1,302 +0,0 @@
/**
* EditAppointmentModal Component
*
* Modal for editing existing appointments, including participant management.
* Extracted from OwnerScheduler for reusability and enhanced with participant selector.
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { X, User as UserIcon, Mail, Phone } from 'lucide-react';
import { createPortal } from 'react-dom';
import { Appointment, AppointmentStatus, Resource, Service, ParticipantInput } from '../types';
import { ParticipantSelector } from './ParticipantSelector';
interface EditAppointmentModalProps {
appointment: Appointment & {
customerEmail?: string;
customerPhone?: string;
};
resources: Resource[];
services: Service[];
onSave: (updates: {
startTime?: Date;
resourceId?: string | null;
durationMinutes?: number;
status?: AppointmentStatus;
notes?: string;
participantsInput?: ParticipantInput[];
}) => void;
onClose: () => void;
isSaving?: boolean;
}
export const EditAppointmentModal: React.FC<EditAppointmentModalProps> = ({
appointment,
resources,
services,
onSave,
onClose,
isSaving = false,
}) => {
const { t } = useTranslation();
// Form state
const [editDateTime, setEditDateTime] = useState('');
const [editResource, setEditResource] = useState('');
const [editDuration, setEditDuration] = useState(15);
const [editStatus, setEditStatus] = useState<AppointmentStatus>('SCHEDULED');
const [editNotes, setEditNotes] = useState('');
const [participants, setParticipants] = useState<ParticipantInput[]>([]);
// Initialize form state from appointment
useEffect(() => {
if (appointment) {
// Convert Date to datetime-local format
const startTime = appointment.startTime;
const localDateTime = new Date(startTime.getTime() - startTime.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
setEditDateTime(localDateTime);
setEditResource(appointment.resourceId || '');
setEditDuration(appointment.durationMinutes || 15);
setEditStatus(appointment.status || 'SCHEDULED');
setEditNotes(appointment.notes || '');
// Initialize participants from existing appointment participants
if (appointment.participants) {
const existingParticipants: ParticipantInput[] = appointment.participants.map(p => ({
role: p.role,
userId: p.userId ? parseInt(p.userId) : undefined,
resourceId: p.resourceId ? parseInt(p.resourceId) : undefined,
externalEmail: p.externalEmail,
externalName: p.externalName,
}));
setParticipants(existingParticipants);
}
}
}, [appointment]);
// Get service name
const serviceName = useMemo(() => {
const service = services.find(s => s.id === appointment.serviceId);
return service?.name || 'Unknown Service';
}, [services, appointment.serviceId]);
// Check if appointment is unassigned (pending)
const isUnassigned = !appointment.resourceId;
// Handle save
const handleSave = () => {
const startTime = new Date(editDateTime);
onSave({
startTime,
resourceId: editResource || null,
durationMinutes: editDuration,
status: editStatus,
notes: editNotes,
participantsInput: participants,
});
};
// Validation
const canSave = useMemo(() => {
if (isUnassigned) {
// For unassigned appointments, require resource and valid duration
return editResource && editDuration >= 15;
}
return true;
}, [isUnassigned, editResource, editDuration]);
const modalContent = (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={onClose}
>
<div
className="w-full max-w-lg bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 overflow-hidden max-h-[90vh] flex flex-col"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-brand-50 to-brand-100 dark:from-brand-900/30 dark:to-brand-800/30">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{isUnassigned ? t('scheduler.scheduleAppointment') : t('scheduler.editAppointment')}
</h3>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:bg-white/50 dark:hover:bg-gray-700/50 rounded-full transition-colors"
>
<X size={20} />
</button>
</div>
{/* Scrollable content */}
<div className="overflow-y-auto flex-1 p-6 space-y-4">
{/* Customer Info */}
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-brand-100 dark:bg-brand-900/50 flex items-center justify-center">
<UserIcon size={20} className="text-brand-600 dark:text-brand-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">
{t('customers.title', 'Customer')}
</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{appointment.customerName}
</p>
{appointment.customerEmail && (
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
<Mail size={14} />
<span>{appointment.customerEmail}</span>
</div>
)}
{appointment.customerPhone && (
<div className="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-300">
<Phone size={14} />
<span>{appointment.customerPhone}</span>
</div>
)}
</div>
</div>
{/* Service & Status */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1">
{t('services.title', 'Service')}
</p>
<p className="text-sm font-semibold text-gray-900 dark:text-white">{serviceName}</p>
</div>
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 block">
{t('scheduler.status', 'Status')}
</label>
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value as AppointmentStatus)}
className="w-full px-2 py-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-sm font-semibold text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value="SCHEDULED">{t('scheduler.confirmed', 'Scheduled')}</option>
<option value="EN_ROUTE">En Route</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="COMPLETED">{t('scheduler.completed', 'Completed')}</option>
<option value="AWAITING_PAYMENT">Awaiting Payment</option>
<option value="CANCELLED">{t('scheduler.cancelled', 'Cancelled')}</option>
<option value="NO_SHOW">{t('scheduler.noShow', 'No Show')}</option>
</select>
</div>
</div>
{/* Editable Fields */}
<div className="space-y-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
{t('scheduler.scheduleDetails', 'Schedule Details')}
</h4>
{/* Date & Time Picker */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.selectDate', 'Date')} & {t('scheduler.selectTime', 'Time')}
</label>
<input
type="datetime-local"
value={editDateTime}
onChange={(e) => setEditDateTime(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 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"
/>
</div>
{/* Resource Selector */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.selectResource', 'Assign to Resource')}
{isUnassigned && <span className="text-red-500 ml-1">*</span>}
</label>
<select
value={editResource}
onChange={(e) => setEditResource(e.target.value)}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 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"
>
<option value="">Unassigned</option>
{resources.map(resource => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
</div>
{/* Duration Input */}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('scheduler.duration', 'Duration')} (minutes)
{isUnassigned && <span className="text-red-500 ml-1">*</span>}
</label>
<input
type="number"
min="15"
step="15"
value={editDuration || 15}
onChange={(e) => {
const value = parseInt(e.target.value);
setEditDuration(value >= 15 ? value : 15);
}}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 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"
/>
</div>
</div>
{/* Participants Section */}
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<ParticipantSelector
value={participants}
onChange={setParticipants}
allowedRoles={['STAFF', 'CUSTOMER', 'OBSERVER']}
allowExternalEmail={true}
existingParticipants={appointment.participants}
/>
</div>
{/* Notes */}
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<label className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-1 block">
{t('scheduler.notes', 'Notes')}
</label>
<textarea
value={editNotes}
onChange={(e) => setEditNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-white dark:bg-gray-800 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 resize-none"
placeholder={t('scheduler.notesPlaceholder', 'Add notes about this appointment...')}
/>
</div>
</div>
{/* Footer with action buttons */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3 bg-white dark:bg-gray-800">
<button
onClick={onClose}
disabled={isSaving}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors disabled:opacity-50"
>
{t('scheduler.cancel', 'Cancel')}
</button>
<button
onClick={handleSave}
disabled={!canSave || isSaving}
className="px-4 py-2 text-sm font-medium text-white bg-brand-600 rounded-lg hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? t('common.saving', 'Saving...') : (
isUnassigned ? t('scheduler.scheduleAppointment', 'Schedule Appointment') : t('scheduler.saveChanges', 'Save Changes')
)}
</button>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
};
export default EditAppointmentModal;

View File

@@ -0,0 +1,157 @@
/**
* AddonSelection - Component for selecting service addons during booking
*
* Displays available addons for a service and allows customers to select
* which ones they want to include in their booking.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Minus, Clock, Check } from 'lucide-react';
import { usePublicServiceAddons } from '../../hooks/useServiceAddons';
import { ServiceAddon, SelectedAddon } from '../../types';
interface AddonSelectionProps {
serviceId: number | string;
selectedAddons: SelectedAddon[];
onAddonsChange: (addons: SelectedAddon[]) => void;
}
export const AddonSelection: React.FC<AddonSelectionProps> = ({
serviceId,
selectedAddons,
onAddonsChange,
}) => {
const { t } = useTranslation();
const { data, isLoading } = usePublicServiceAddons(serviceId);
const formatPrice = (cents: number) => {
return `$${(cents / 100).toFixed(2)}`;
};
const isSelected = (addon: ServiceAddon) => {
return selectedAddons.some(sa => sa.addon_id === addon.id);
};
const toggleAddon = (addon: ServiceAddon) => {
if (isSelected(addon)) {
// Remove addon
onAddonsChange(selectedAddons.filter(sa => sa.addon_id !== addon.id));
} else {
// Add addon
const selected: SelectedAddon = {
addon_id: addon.id,
resource_id: addon.resource,
name: addon.name,
price_cents: addon.price_cents,
duration_mode: addon.duration_mode,
additional_duration: addon.additional_duration,
};
onAddonsChange([...selectedAddons, selected]);
}
};
const totalAddonPrice = selectedAddons.reduce((sum, addon) => sum + addon.price_cents, 0);
const totalAddonDuration = selectedAddons
.filter(a => a.duration_mode === 'SEQUENTIAL')
.reduce((sum, a) => sum + a.additional_duration, 0);
// Don't render if no addons available
if (!isLoading && (!data || data.count === 0)) {
return null;
}
// Show loading state
if (isLoading) {
return (
<div className="mt-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="animate-pulse flex items-center gap-3">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-32"></div>
</div>
</div>
);
}
return (
<div className="mt-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
{t('booking.addExtras', 'Add extras to your appointment')}
</h3>
<div className="space-y-3">
{data?.addons.map((addon) => {
const selected = isSelected(addon);
return (
<button
key={addon.id}
type="button"
onClick={() => toggleAddon(addon)}
className={`w-full p-4 rounded-xl border-2 text-left transition-all ${
selected
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-start gap-4">
{/* Checkbox indicator */}
<div
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center transition-colors ${
selected
? 'bg-indigo-600 border-indigo-600'
: 'border-gray-300 dark:border-gray-600'
}`}
>
{selected && <Check className="w-4 h-4 text-white" />}
</div>
{/* Addon info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{addon.name}
</span>
<span className="text-indigo-600 dark:text-indigo-400 font-semibold whitespace-nowrap">
+{formatPrice(addon.price_cents)}
</span>
</div>
{addon.description && (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{addon.description}
</p>
)}
<div className="mt-2 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{addon.duration_mode === 'CONCURRENT'
? t('booking.sameTime', 'Same time slot')
: `+${addon.additional_duration} ${t('booking.minutes', 'min')}`}
</span>
</div>
</div>
</div>
</button>
);
})}
</div>
{/* Selected addons summary */}
{selectedAddons.length > 0 && (
<div className="mt-4 p-3 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<div className="flex items-center justify-between text-sm">
<span className="text-indigo-700 dark:text-indigo-300">
{selectedAddons.length} {t('booking.extrasSelected', 'extra(s) selected')}
{totalAddonDuration > 0 && ` (+${totalAddonDuration} min)`}
</span>
<span className="font-semibold text-indigo-700 dark:text-indigo-300">
+{formatPrice(totalAddonPrice)}
</span>
</div>
</div>
)}
</div>
);
};
export default AddonSelection;

View File

@@ -7,6 +7,7 @@ interface DateTimeSelectionProps {
serviceId?: number;
selectedDate: Date | null;
selectedTimeSlot: string | null;
selectedAddonIds?: number[];
onDateChange: (date: Date) => void;
onTimeChange: (time: string) => void;
}
@@ -15,6 +16,7 @@ export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
serviceId,
selectedDate,
selectedTimeSlot,
selectedAddonIds = [],
onDateChange,
onTimeChange
}) => {
@@ -52,7 +54,12 @@ export const DateTimeSelection: React.FC<DateTimeSelectionProps> = ({
: undefined;
// Fetch availability when both serviceId and date are set
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(serviceId, dateString);
// Pass addon IDs to check availability for addon resources too
const { data: availability, isLoading: availabilityLoading, isError, error } = usePublicAvailability(
serviceId,
dateString,
selectedAddonIds.length > 0 ? selectedAddonIds : undefined
);
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const firstDayOfMonth = new Date(currentYear, currentMonth, 1).getDay();

View File

@@ -0,0 +1,150 @@
import React, { useState } from 'react';
import { Phone, Calendar, Clock, Check } from 'lucide-react';
import { PublicService } from '../../hooks/useBooking';
interface ManualSchedulingRequestProps {
service: PublicService;
onPreferredTimeChange: (preferredDate: string | null, preferredTimeNotes: string) => void;
preferredDate: string | null;
preferredTimeNotes: string;
}
export const ManualSchedulingRequest: React.FC<ManualSchedulingRequestProps> = ({
service,
onPreferredTimeChange,
preferredDate,
preferredTimeNotes,
}) => {
const [hasPreferredTime, setHasPreferredTime] = useState(!!preferredDate || !!preferredTimeNotes);
const capturePreferredTime = service.capture_preferred_time !== false;
const handleTogglePreferredTime = () => {
const newValue = !hasPreferredTime;
setHasPreferredTime(newValue);
if (!newValue) {
onPreferredTimeChange(null, '');
}
};
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onPreferredTimeChange(e.target.value || null, preferredTimeNotes);
};
const handleNotesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onPreferredTimeChange(preferredDate, e.target.value);
};
// Get tomorrow's date as min date for the date picker
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const minDate = tomorrow.toISOString().split('T')[0];
return (
<div className="space-y-6">
{/* Info message */}
<div className="p-4 bg-orange-50 dark:bg-orange-900/20 rounded-xl border border-orange-200 dark:border-orange-800">
<div className="flex items-start gap-3">
<div className="p-2 bg-orange-100 dark:bg-orange-800/50 rounded-lg">
<Phone className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h3 className="font-medium text-orange-900 dark:text-orange-100">
We'll call you to schedule
</h3>
<p className="text-sm text-orange-700 dark:text-orange-300 mt-1">
Our team will contact you within 24 hours to find the perfect time for your <span className="font-medium">{service.name}</span>.
</p>
</div>
</div>
</div>
{/* Preferred time section - only if service allows it */}
{capturePreferredTime && (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-5">
<div
className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
hasPreferredTime
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300'
}`}
onClick={handleTogglePreferredTime}
>
<label className="flex items-center gap-3 cursor-pointer">
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
hasPreferredTime
? 'bg-blue-500 border-blue-500'
: 'border-gray-300 dark:border-gray-600'
}`}>
{hasPreferredTime && <Check size={14} className="text-white" />}
</div>
<div>
<span className="font-medium text-gray-900 dark:text-white">
I have a preferred time
</span>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Let us know when works best for you
</p>
</div>
</label>
</div>
{/* Preferred time inputs */}
{hasPreferredTime && (
<div className="mt-4 space-y-4 pl-4 border-l-2 border-blue-300 dark:border-blue-700 ml-2">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<Calendar className="h-4 w-4" />
Preferred Date
</label>
<input
type="date"
value={preferredDate || ''}
onChange={handleDateChange}
min={minDate}
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<Clock className="h-4 w-4" />
Time Preference
</label>
<input
type="text"
value={preferredTimeNotes}
onChange={handleNotesChange}
placeholder="e.g., Morning, After 2pm, Weekends only"
className="w-full px-4 py-2.5 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 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Any general time preferences that would work for you
</p>
</div>
</div>
)}
</div>
)}
{/* What happens next */}
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-5">
<h4 className="font-medium text-gray-900 dark:text-white mb-3">What happens next?</h4>
<ol className="space-y-3">
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 rounded-full flex items-center justify-center text-sm font-medium">1</span>
<span className="text-sm text-gray-600 dark:text-gray-300">Complete your booking request</span>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 rounded-full flex items-center justify-center text-sm font-medium">2</span>
<span className="text-sm text-gray-600 dark:text-gray-300">We'll call you within 24 hours to schedule</span>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400 rounded-full flex items-center justify-center text-sm font-medium">3</span>
<span className="text-sm text-gray-600 dark:text-gray-300">Confirm your appointment time over the phone</span>
</li>
</ol>
</div>
</div>
);
};
export default ManualSchedulingRequest;

View File

@@ -0,0 +1,240 @@
/**
* UnscheduledBookingDemo - Interactive demo for help documentation
*
* This component provides an interactive visualization of the
* "Requires Manual Scheduling" feature for services.
*/
import React, { useState } from 'react';
import { Phone, Calendar, Clock, Check, ChevronRight, Settings, Globe } from 'lucide-react';
interface UnscheduledBookingDemoProps {
/** Which view to show: 'service' | 'customer' | 'pending' | 'all' */
view?: 'service' | 'customer' | 'pending' | 'all';
}
export const UnscheduledBookingDemo: React.FC<UnscheduledBookingDemoProps> = ({
view = 'all'
}) => {
// Service configuration state
const [requiresManualScheduling, setRequiresManualScheduling] = useState(true);
const [capturePreferredTime, setCapturePreferredTime] = useState(true);
// Customer booking state
const [customerHasPreferredTime, setCustomerHasPreferredTime] = useState(true);
const [preferredDate, setPreferredDate] = useState('2025-12-26');
const [preferredTimeNotes, setPreferredTimeNotes] = useState('afternoons');
// Pending sidebar state
const [selectedPending, setSelectedPending] = useState<string | null>(null);
const showService = view === 'all' || view === 'service';
const showCustomer = view === 'all' || view === 'customer';
const showPending = view === 'all' || view === 'pending';
return (
<div className="bg-gray-100 dark:bg-gray-800 rounded-xl p-4 my-4">
<div className={`grid gap-4 ${view === 'all' ? 'grid-cols-1 lg:grid-cols-3' : 'grid-cols-1 max-w-md mx-auto'}`}>
{/* Service Configuration */}
{showService && (
<div className="bg-white dark:bg-gray-700 rounded-lg shadow-sm overflow-hidden">
<div className="bg-brand-500 text-white px-3 py-2">
<div className="flex items-center gap-2 text-sm font-bold">
<Settings size={14} />
Service Settings
</div>
</div>
<div className="p-3 space-y-3">
{/* Toggle */}
<div
className={`p-2 rounded border cursor-pointer transition-all ${
requiresManualScheduling
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
onClick={() => setRequiresManualScheduling(!requiresManualScheduling)}
>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
requiresManualScheduling ? 'bg-orange-500 border-orange-500' : 'border-gray-300'
}`}>
{requiresManualScheduling && <Check size={10} className="text-white" />}
</div>
<span className="font-medium text-gray-900 dark:text-white">
Requires Manual Scheduling
</span>
</label>
</div>
{/* Nested option */}
{requiresManualScheduling && (
<div
className={`p-2 rounded border cursor-pointer transition-all ml-3 ${
capturePreferredTime
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
onClick={() => setCapturePreferredTime(!capturePreferredTime)}
>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
capturePreferredTime ? 'bg-blue-500 border-blue-500' : 'border-gray-300'
}`}>
{capturePreferredTime && <Check size={10} className="text-white" />}
</div>
<span className="text-gray-800 dark:text-gray-200">
Ask for Preferred Time
</span>
</label>
</div>
)}
</div>
</div>
)}
{/* Customer Booking */}
{showCustomer && (
<div className="bg-white dark:bg-gray-700 rounded-lg shadow-sm overflow-hidden">
<div className="bg-green-500 text-white px-3 py-2">
<div className="flex items-center gap-2 text-sm font-bold">
<Globe size={14} />
Customer Booking
</div>
</div>
<div className="p-3 space-y-3">
{!requiresManualScheduling ? (
<div className="text-center py-4 text-gray-500 text-sm">
<Calendar size={24} className="mx-auto mb-2 opacity-50" />
Standard booking flow
</div>
) : (
<>
<div className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded border border-orange-200 dark:border-orange-700">
<div className="flex items-center gap-2 text-xs">
<Phone size={12} className="text-orange-600" />
<span className="text-orange-800 dark:text-orange-200">We'll call you to schedule</span>
</div>
</div>
{capturePreferredTime && (
<div
className={`p-2 rounded border cursor-pointer ${
customerHasPreferredTime
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-600'
}`}
onClick={() => setCustomerHasPreferredTime(!customerHasPreferredTime)}
>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
customerHasPreferredTime ? 'bg-blue-500 border-blue-500' : 'border-gray-300'
}`}>
{customerHasPreferredTime && <Check size={10} className="text-white" />}
</div>
<span className="text-gray-800 dark:text-gray-200">
I have a preferred time
</span>
</label>
</div>
)}
{capturePreferredTime && customerHasPreferredTime && (
<div className="space-y-2 pl-3 border-l-2 border-blue-300">
<input
type="date"
value={preferredDate}
onChange={(e) => setPreferredDate(e.target.value)}
className="w-full p-1.5 text-xs border rounded bg-white dark:bg-gray-600 dark:border-gray-500"
/>
<input
type="text"
value={preferredTimeNotes}
onChange={(e) => setPreferredTimeNotes(e.target.value)}
placeholder="e.g., afternoons"
className="w-full p-1.5 text-xs border rounded bg-white dark:bg-gray-600 dark:border-gray-500"
/>
</div>
)}
<button className="w-full py-1.5 bg-green-600 text-white text-sm rounded font-medium">
Request Callback
</button>
</>
)}
</div>
</div>
)}
{/* Pending Requests */}
{showPending && (
<div className="bg-white dark:bg-gray-700 rounded-lg shadow-sm overflow-hidden">
<div className="bg-orange-500 text-white px-3 py-2">
<div className="flex items-center gap-2 text-sm font-bold">
<Clock size={14} />
Pending Requests
</div>
</div>
<div className="p-3 space-y-2">
{/* Sample pending items */}
{[
{ id: '1', name: 'Jane Smith', preferredDate: 'Dec 26', preferredNotes: 'afternoons' },
{ id: '2', name: 'Bob Johnson', preferredDate: 'Dec 27', preferredNotes: '10:00 AM' },
{ id: '3', name: 'Lisa Park', preferredDate: null, preferredNotes: null },
].map(item => (
<div
key={item.id}
className={`p-2 bg-gray-50 dark:bg-gray-600 rounded border-l-4 border-orange-400 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-500 ${
selectedPending === item.id ? 'ring-2 ring-brand-500' : ''
}`}
onClick={() => setSelectedPending(selectedPending === item.id ? null : item.id)}
>
<div className="flex justify-between items-start">
<div>
<p className="font-medium text-sm text-gray-900 dark:text-white">{item.name}</p>
<p className="text-xs text-gray-500">Free Consultation</p>
</div>
<ChevronRight size={14} className={`text-gray-400 transition-transform ${selectedPending === item.id ? 'rotate-90' : ''}`} />
</div>
{item.preferredDate ? (
<div className="mt-1 flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<Calendar size={10} />
<span>Prefers: {item.preferredDate}, {item.preferredNotes}</span>
</div>
) : (
<div className="mt-1 flex items-center gap-1 text-xs text-gray-400">
<Clock size={10} />
<span className="italic">No preferred time</span>
</div>
)}
</div>
))}
{/* Detail panel */}
{selectedPending && (
<div className="mt-2 p-2 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-700">
<div className="flex items-center gap-2 text-xs text-blue-800 dark:text-blue-200 mb-2">
<Calendar size={12} />
<span className="font-medium">Preferred Schedule</span>
</div>
<p className="text-sm text-blue-700 dark:text-blue-300">
{selectedPending === '1' && 'December 26, 2025 - Afternoons'}
{selectedPending === '2' && 'December 27, 2025 - 10:00 AM'}
{selectedPending === '3' && 'No preference specified'}
</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Caption */}
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-3 italic">
Interactive demo - click to explore the workflow
</p>
</div>
);
};
export default UnscheduledBookingDemo;

View File

@@ -0,0 +1,485 @@
/**
* ServiceAddonManager - Component for managing addons on a service
*
* Allows adding, editing, deleting, and reordering addons for a service.
* Only shown when editing an existing service (addons need a service ID).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, Pencil, GripVertical, Loader2, Clock, DollarSign, X, ChevronDown, ChevronUp } from 'lucide-react';
import {
useServiceAddons,
useCreateServiceAddon,
useUpdateServiceAddon,
useDeleteServiceAddon,
useToggleServiceAddon,
ServiceAddonInput,
} from '../../hooks/useServiceAddons';
import { useResources } from '../../hooks/useResources';
import { ServiceAddon, AddonDurationMode, Resource } from '../../types';
import { CurrencyInput } from '../ui';
interface ServiceAddonManagerProps {
serviceId: number;
serviceName: string;
}
interface AddonFormData {
resource: number | null;
name: string;
description: string;
price_cents: number;
duration_mode: AddonDurationMode;
additional_duration: number;
}
const defaultFormData: AddonFormData = {
resource: null,
name: '',
description: '',
price_cents: 0,
duration_mode: 'CONCURRENT',
additional_duration: 15,
};
const ServiceAddonManager: React.FC<ServiceAddonManagerProps> = ({ serviceId, serviceName }) => {
const { t } = useTranslation();
const { data: addons, isLoading } = useServiceAddons(serviceId);
const { data: resources } = useResources({ type: 'EQUIPMENT' }); // Equipment resources for addons
const createAddon = useCreateServiceAddon();
const updateAddon = useUpdateServiceAddon();
const deleteAddon = useDeleteServiceAddon();
const toggleAddon = useToggleServiceAddon();
const [isExpanded, setIsExpanded] = useState(false);
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingAddon, setEditingAddon] = useState<ServiceAddon | null>(null);
const [formData, setFormData] = useState<AddonFormData>(defaultFormData);
// Filter out resources already used as addons (only for resource-based addons)
const usedResourceIds = new Set((addons || []).filter(a => a.resource).map(a => a.resource));
const availableResources = (resources || []).filter(
r => !usedResourceIds.has(parseInt(r.id)) || (editingAddon && editingAddon.resource === parseInt(r.id))
);
const openCreateForm = () => {
setEditingAddon(null);
setFormData(defaultFormData);
setIsFormOpen(true);
};
const openEditForm = (addon: ServiceAddon) => {
setEditingAddon(addon);
setFormData({
resource: addon.resource,
name: addon.name,
description: addon.description,
price_cents: addon.price_cents,
duration_mode: addon.duration_mode,
additional_duration: addon.additional_duration,
});
setIsFormOpen(true);
};
const closeForm = () => {
setIsFormOpen(false);
setEditingAddon(null);
setFormData(defaultFormData);
};
const handleSubmit = async () => {
// Name is required, but resource is optional
if (!formData.name.trim()) {
return;
}
const data: ServiceAddonInput = {
service: serviceId,
resource: formData.resource, // Can be null for simple price add-ons
name: formData.name,
description: formData.description,
price_cents: formData.price_cents,
duration_mode: formData.duration_mode,
additional_duration: formData.duration_mode === 'SEQUENTIAL' ? formData.additional_duration : 0,
};
try {
if (editingAddon) {
await updateAddon.mutateAsync({ id: editingAddon.id, updates: data });
} else {
await createAddon.mutateAsync(data);
}
closeForm();
} catch (error) {
console.error('Failed to save addon:', error);
}
};
const handleDelete = async (addon: ServiceAddon) => {
if (!confirm(`Delete addon "${addon.name}"?`)) {
return;
}
try {
await deleteAddon.mutateAsync({ id: addon.id, serviceId });
} catch (error) {
console.error('Failed to delete addon:', error);
}
};
const handleToggle = async (addon: ServiceAddon) => {
try {
await toggleAddon.mutateAsync({ id: addon.id, serviceId });
} catch (error) {
console.error('Failed to toggle addon:', error);
}
};
// Auto-fill name when resource is selected
const handleResourceChange = (resourceId: number) => {
const resource = resources?.find(r => parseInt(r.id) === resourceId);
setFormData({
...formData,
resource: resourceId,
name: formData.name || (resource?.name || ''),
});
};
const formatPrice = (cents: number) => {
return `$${(cents / 100).toFixed(2)}`;
};
return (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* Header */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{t('services.addons', 'Service Addons')}
</span>
{addons && addons.length > 0 && (
<span className="text-xs bg-brand-100 dark:bg-brand-900/30 text-brand-700 dark:text-brand-300 px-2 py-0.5 rounded-full">
{addons.length}
</span>
)}
</div>
{isExpanded ? (
<ChevronUp className="h-5 w-5 text-gray-500" />
) : (
<ChevronDown className="h-5 w-5 text-gray-500" />
)}
</button>
{/* Content */}
{isExpanded && (
<div className="p-4 space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
{t('services.addonsDescription', 'Add optional equipment or rooms that customers can book alongside this service.')}
</p>
{/* Loading */}
{isLoading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-brand-500" />
</div>
)}
{/* Addons List */}
{!isLoading && addons && addons.length > 0 && (
<div className="space-y-2">
{addons.map((addon) => (
<div
key={addon.id}
className={`flex items-center gap-3 p-3 rounded-lg border ${
addon.is_active
? 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700'
: 'bg-gray-50 dark:bg-gray-900 border-gray-300 dark:border-gray-600 opacity-60'
}`}
>
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white truncate">
{addon.name}
</span>
{!addon.is_active && (
<span className="text-xs text-gray-500 dark:text-gray-400">
({t('common.inactive', 'Inactive')})
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<span className="flex items-center gap-1">
<DollarSign className="h-3 w-3" />
{formatPrice(addon.price_cents)}
</span>
{addon.resource ? (
<>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{addon.duration_mode === 'CONCURRENT'
? t('services.concurrent', 'Same time')
: `+${addon.additional_duration} min`}
</span>
<span className="text-gray-400">
({addon.resource_name})
</span>
</>
) : (
<span className="text-purple-500 dark:text-purple-400 italic">
{t('services.priceOnly', 'Price add-on')}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => handleToggle(addon)}
className={`p-1.5 rounded transition-colors ${
addon.is_active
? 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20'
: 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={addon.is_active ? t('common.deactivate', 'Deactivate') : t('common.activate', 'Activate')}
>
<div className={`w-8 h-4 rounded-full ${addon.is_active ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'} relative`}>
<div className={`absolute w-3 h-3 bg-white rounded-full top-0.5 transition-all ${addon.is_active ? 'right-0.5' : 'left-0.5'}`} />
</div>
</button>
<button
type="button"
onClick={() => openEditForm(addon)}
className="p-1.5 text-gray-400 hover:text-brand-600 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded transition-colors"
title={t('common.edit', 'Edit')}
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleDelete(addon)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title={t('common.delete', 'Delete')}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{!isLoading && (!addons || addons.length === 0) && !isFormOpen && (
<div className="text-center py-6 text-gray-500 dark:text-gray-400">
<p className="text-sm">{t('services.noAddons', 'No addons configured')}</p>
</div>
)}
{/* Add Button - always show since addons can be created without resources */}
{!isFormOpen && (
<button
type="button"
onClick={openCreateForm}
className="w-full py-2 flex items-center justify-center gap-2 text-sm text-brand-600 dark:text-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/20 rounded-lg border border-dashed border-brand-300 dark:border-brand-700 transition-colors"
>
<Plus className="h-4 w-4" />
{t('services.addAddon', 'Add Addon')}
</button>
)}
{/* Addon Form */}
{isFormOpen && (
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-900/50">
<div className="flex items-center justify-between mb-4">
<h4 className="font-medium text-gray-900 dark:text-white">
{editingAddon
? t('services.editAddon', 'Edit Addon')
: t('services.newAddon', 'New Addon')}
</h4>
<button
type="button"
onClick={closeForm}
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Using div instead of form to avoid nested form issues */}
<div className="space-y-4">
{/* Resource Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.addonResource', 'Resource')}
<span className="text-gray-400 font-normal ml-1">{t('common.optional', '(optional)')}</span>
</label>
<select
value={formData.resource || ''}
onChange={(e) => {
const value = e.target.value;
if (value === '') {
setFormData({ ...formData, resource: null });
} else {
handleResourceChange(parseInt(value));
}
}}
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 focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
>
<option value="">{t('services.noResource', 'None (price add-on only)')}</option>
{availableResources.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.resourceHint', 'Select a resource to block it during the appointment, or leave empty for a simple price add-on.')}
</p>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.addonName', 'Display Name')} *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
placeholder={t('services.addonNamePlaceholder', 'e.g., Projector, Sound System')}
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 focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.addonDescription', 'Description')}
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
placeholder={t('services.addonDescriptionPlaceholder', 'Optional description for customers')}
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 focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
{/* Price */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.addonPrice', 'Additional Price')} *
</label>
<CurrencyInput
value={formData.price_cents}
onChange={(cents) => setFormData({ ...formData, price_cents: cents })}
required
placeholder="$0.00"
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 focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{t('services.addonPriceHint', 'This amount is added to the service price')}
</p>
</div>
{/* Duration Mode */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('services.addonDuration', 'Duration Mode')}
</label>
<div className="space-y-2">
<label className="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="radio"
name="duration_mode"
checked={formData.duration_mode === 'CONCURRENT'}
onChange={() => setFormData({ ...formData, duration_mode: 'CONCURRENT' })}
className="mt-1 w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
/>
<div>
<span className="font-medium text-gray-900 dark:text-white block">
{t('services.concurrent', 'Concurrent')}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('services.concurrentHint', 'Addon runs during the same time as the service')}
</span>
</div>
</label>
<label className="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="radio"
name="duration_mode"
checked={formData.duration_mode === 'SEQUENTIAL'}
onChange={() => setFormData({ ...formData, duration_mode: 'SEQUENTIAL' })}
className="mt-1 w-4 h-4 text-brand-600 border-gray-300 focus:ring-brand-500"
/>
<div>
<span className="font-medium text-gray-900 dark:text-white block">
{t('services.sequential', 'Sequential')}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{t('services.sequentialHint', 'Addon runs after the service ends, extending appointment time')}
</span>
</div>
</label>
</div>
</div>
{/* Additional Duration (for SEQUENTIAL) */}
{formData.duration_mode === 'SEQUENTIAL' && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{t('services.additionalDuration', 'Additional Duration (minutes)')} *
</label>
<input
type="number"
value={formData.additional_duration}
onChange={(e) => setFormData({ ...formData, additional_duration: parseInt(e.target.value) || 0 })}
required
min={5}
step={5}
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 focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
/>
</div>
)}
{/* Form Actions */}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={closeForm}
className="px-3 py-1.5 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
{t('common.cancel', 'Cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={createAddon.isPending || updateAddon.isPending || !formData.name.trim()}
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1"
>
{(createAddon.isPending || updateAddon.isPending) && (
<Loader2 className="h-3 w-3 animate-spin" />
)}
{editingAddon ? t('common.save', 'Save') : t('common.add', 'Add')}
</button>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
};
export default ServiceAddonManager;

View File

@@ -75,27 +75,45 @@ export const useAppointments = (filters?: AppointmentFilters) => {
const { data } = await apiClient.get(`/appointments/?${params}`);
// Transform backend format to frontend format
return data.map((a: any) => ({
id: String(a.id),
resourceId: a.resource_id ? String(a.resource_id) : null,
customerId: String(a.customer_id || a.customer),
customerName: a.customer_name || '',
serviceId: String(a.service_id || a.service),
startTime: new Date(a.start_time),
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
status: a.status as AppointmentStatus,
notes: a.notes || '',
// Payment fields (amounts stored in cents, convert to dollars for display)
depositAmount: a.deposit_amount ? parseFloat(a.deposit_amount) / 100 : null,
depositTransactionId: a.deposit_transaction_id || '',
finalPrice: a.final_price ? parseFloat(a.final_price) / 100 : null,
finalChargeTransactionId: a.final_charge_transaction_id || '',
isVariablePricing: a.is_variable_pricing || false,
remainingBalance: a.remaining_balance ? parseFloat(a.remaining_balance) / 100 : null,
overpaidAmount: a.overpaid_amount ? parseFloat(a.overpaid_amount) / 100 : null,
// Participants
participants: a.participants?.map(transformParticipant) || [],
}));
return data.map((a: any) => {
const mainResourceId = a.resource_id ? String(a.resource_id) : null;
// Extract addon resource IDs from participants
// (resource participants that are not the main resource)
const addonResourceIds = (a.participants || [])
.filter((p: any) =>
p.role === 'RESOURCE' &&
p.object_id &&
String(p.object_id) !== mainResourceId
)
.map((p: any) => String(p.object_id));
return {
id: String(a.id),
resourceId: mainResourceId,
customerId: String(a.customer_id || a.customer),
customerName: a.customer_name || '',
serviceId: String(a.service_id || a.service),
startTime: new Date(a.start_time),
durationMinutes: a.duration_minutes || calculateDuration(a.start_time, a.end_time),
status: a.status as AppointmentStatus,
notes: a.notes || '',
// Payment fields (amounts stored in cents, convert to dollars for display)
depositAmount: a.deposit_amount ? parseFloat(a.deposit_amount) / 100 : null,
depositTransactionId: a.deposit_transaction_id || '',
finalPrice: a.final_price ? parseFloat(a.final_price) / 100 : null,
finalChargeTransactionId: a.final_charge_transaction_id || '',
isVariablePricing: a.is_variable_pricing || false,
remainingBalance: a.remaining_balance ? parseFloat(a.remaining_balance) / 100 : null,
overpaidAmount: a.overpaid_amount ? parseFloat(a.overpaid_amount) / 100 : null,
// Participants
participants: a.participants?.map(transformParticipant) || [],
// Addon IDs (for populating edit modal)
addonIds: a.addon_ids || undefined,
// Addon resource IDs for linked blocks
addonResourceIds: addonResourceIds.length > 0 ? addonResourceIds : undefined,
};
});
},
});
};
@@ -117,10 +135,20 @@ export const useAppointment = (id: string) => {
queryKey: ['appointments', id],
queryFn: async () => {
const { data } = await apiClient.get(`/appointments/${id}/`);
const mainResourceId = data.resource_id ? String(data.resource_id) : null;
// Extract addon resource IDs from participants
const addonResourceIds = (data.participants || [])
.filter((p: any) =>
p.role === 'RESOURCE' &&
p.object_id &&
String(p.object_id) !== mainResourceId
)
.map((p: any) => String(p.object_id));
return {
id: String(data.id),
resourceId: data.resource_id ? String(data.resource_id) : null,
resourceId: mainResourceId,
customerId: String(data.customer_id || data.customer),
customerName: data.customer_name || '',
serviceId: String(data.service_id || data.service),
@@ -138,15 +166,18 @@ export const useAppointment = (id: string) => {
overpaidAmount: data.overpaid_amount ? parseFloat(data.overpaid_amount) / 100 : null,
// Participants
participants: data.participants?.map(transformParticipant) || [],
// Addon resource IDs for linked blocks
addonResourceIds: addonResourceIds.length > 0 ? addonResourceIds : undefined,
};
},
enabled: !!id,
});
};
// Extended create data that includes participants
// Extended create data that includes participants and addons
interface AppointmentCreateData extends Omit<Appointment, 'id'> {
participantsInput?: ParticipantInput[];
addonIds?: number[];
}
/**
@@ -162,7 +193,7 @@ export const useCreateAppointment = () => {
const backendData: Record<string, unknown> = {
service: parseInt(appointmentData.serviceId),
resource: appointmentData.resourceId ? parseInt(appointmentData.resourceId) : null,
resource_ids: appointmentData.resourceId ? [parseInt(appointmentData.resourceId)] : [],
start_time: startTime.toISOString(),
end_time: endTime.toISOString(),
notes: appointmentData.notes || '',
@@ -178,6 +209,15 @@ export const useCreateAppointment = () => {
backendData.participants_input = appointmentData.participantsInput.map(transformParticipantInput);
}
// Include addon IDs if provided (backend expects addon_ids_input)
if (appointmentData.addonIds && appointmentData.addonIds.length > 0) {
backendData.addon_ids_input = appointmentData.addonIds;
}
// Generate title - backend requires this field
// Title is auto-generated from service/customer if not provided
backendData.title = 'Appointment';
const { data } = await apiClient.post('/appointments/', backendData);
return data;
},
@@ -203,6 +243,7 @@ export const useUpdateAppointment = () => {
const backendData: any = {};
if (updates.serviceId) backendData.service = parseInt(updates.serviceId);
if (updates.customerId) backendData.customer = parseInt(updates.customerId);
if (updates.resourceId !== undefined) {
// Backend expects resource_ids as a list
backendData.resource_ids = updates.resourceId ? [parseInt(updates.resourceId)] : [];
@@ -227,6 +268,11 @@ export const useUpdateAppointment = () => {
backendData.participants_input = updates.participantsInput.map(transformParticipantInput);
}
// Handle addon IDs update
if (updates.addonIds !== undefined) {
backendData.addon_ids_input = updates.addonIds;
}
const { data } = await apiClient.patch(`/appointments/${id}/`, backendData);
return data;
},

View File

@@ -9,6 +9,9 @@ export interface PublicService {
price_cents: number;
deposit_amount_cents: number | null;
photos: string[] | null;
// Manual scheduling (unscheduled booking)
requires_manual_scheduling?: boolean;
capture_preferred_time?: boolean;
}
export interface PublicBusinessInfo {
@@ -74,11 +77,23 @@ export interface BusinessHoursResponse {
dates: BusinessHoursDay[];
}
export const usePublicAvailability = (serviceId: number | undefined, date: string | undefined) => {
export const usePublicAvailability = (
serviceId: number | undefined,
date: string | undefined,
addonIds?: number[]
) => {
// Build query key including addon IDs
const queryKey = ['publicAvailability', serviceId, date, addonIds?.join(',') || ''];
return useQuery<AvailabilityResponse>({
queryKey: ['publicAvailability', serviceId, date],
queryKey,
queryFn: async () => {
const response = await api.get(`/public/availability/?service_id=${serviceId}&date=${date}`);
let url = `/public/availability/?service_id=${serviceId}&date=${date}`;
// Add addon_ids if provided
if (addonIds && addonIds.length > 0) {
url += `&addon_ids=${addonIds.join(',')}`;
}
const response = await api.get(url);
return response.data;
},
enabled: !!serviceId && !!date,

View File

@@ -0,0 +1,186 @@
/**
* Service Addon Management Hooks
*
* Hooks for managing service addons - resources that can be offered
* as optional add-ons when customers book a service.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from '../api/client';
import { ServiceAddon, AddonDurationMode } from '../types';
// Input type for creating/updating addons
export interface ServiceAddonInput {
service: number;
resource: number | null; // Optional - null for simple price add-ons
name: string;
description?: string;
display_order?: number;
price_cents: number;
duration_mode: AddonDurationMode;
additional_duration?: number; // Required for SEQUENTIAL mode
is_active?: boolean;
}
/**
* Transform backend addon data to frontend format
*/
const transformAddon = (data: any): ServiceAddon => ({
id: data.id,
service: data.service,
resource: data.resource,
resource_name: data.resource_name,
resource_type: data.resource_type,
name: data.name,
description: data.description || '',
display_order: data.display_order ?? 0,
price: data.price ? parseFloat(data.price) : data.price_cents / 100,
price_cents: data.price_cents,
duration_mode: data.duration_mode,
additional_duration: data.additional_duration ?? 0,
is_active: data.is_active ?? true,
created_at: data.created_at,
updated_at: data.updated_at,
});
/**
* Hook to fetch all addons for a specific service
*/
export const useServiceAddons = (serviceId: number | string | null) => {
return useQuery<ServiceAddon[]>({
queryKey: ['service-addons', serviceId],
queryFn: async () => {
const { data } = await apiClient.get('/service-addons/', {
params: { service: serviceId, show_inactive: 'true' },
});
return data.map(transformAddon);
},
enabled: !!serviceId,
retry: false,
});
};
/**
* Hook to fetch addons for public booking (active only)
*/
export const usePublicServiceAddons = (serviceId: number | string | null) => {
return useQuery<{ addons: ServiceAddon[]; count: number }>({
queryKey: ['public-service-addons', serviceId],
queryFn: async () => {
const { data } = await apiClient.get('/public/service-addons/', {
params: { service_id: serviceId },
});
return {
addons: data.addons.map(transformAddon),
count: data.count,
};
},
enabled: !!serviceId,
retry: false,
});
};
/**
* Hook to get a single addon
*/
export const useServiceAddon = (id: number | string | null) => {
return useQuery<ServiceAddon>({
queryKey: ['service-addons', 'detail', id],
queryFn: async () => {
const { data } = await apiClient.get(`/service-addons/${id}/`);
return transformAddon(data);
},
enabled: !!id,
retry: false,
});
};
/**
* Hook to create a service addon
*/
export const useCreateServiceAddon = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (addonData: ServiceAddonInput) => {
const { data } = await apiClient.post('/service-addons/', addonData);
return transformAddon(data);
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['service-addons', data.service] });
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to update a service addon
*/
export const useUpdateServiceAddon = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, updates }: { id: number; updates: Partial<ServiceAddonInput> }) => {
const { data } = await apiClient.patch(`/service-addons/${id}/`, updates);
return transformAddon(data);
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['service-addons', data.service] });
queryClient.invalidateQueries({ queryKey: ['service-addons', 'detail', data.id] });
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to delete a service addon
*/
export const useDeleteServiceAddon = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, serviceId }: { id: number; serviceId: number }) => {
await apiClient.delete(`/service-addons/${id}/`);
return { id, serviceId };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['service-addons', result.serviceId] });
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to toggle addon active status
*/
export const useToggleServiceAddon = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, serviceId }: { id: number; serviceId: number }) => {
const { data } = await apiClient.post(`/service-addons/${id}/toggle_active/`);
return { ...data, serviceId };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['service-addons', result.serviceId] });
queryClient.invalidateQueries({ queryKey: ['services'] });
},
});
};
/**
* Hook to reorder addons (drag and drop)
*/
export const useReorderServiceAddons = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ serviceId, orderedIds }: { serviceId: number; orderedIds: number[] }) => {
const { data } = await apiClient.post('/service-addons/reorder/', { order: orderedIds });
return { data, serviceId };
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['service-addons', result.serviceId] });
},
});
};

View File

@@ -2,20 +2,27 @@ import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { ServiceSelection } from '../components/booking/ServiceSelection';
import { DateTimeSelection } from '../components/booking/DateTimeSelection';
import { AddonSelection } from '../components/booking/AddonSelection';
import { ManualSchedulingRequest } from '../components/booking/ManualSchedulingRequest';
import { AuthSection, User } from '../components/booking/AuthSection';
import { PaymentSection } from '../components/booking/PaymentSection';
import { Confirmation } from '../components/booking/Confirmation';
import { Steps } from '../components/booking/Steps';
import { ArrowLeft, ArrowRight } from 'lucide-react';
import { PublicService } from '../hooks/useBooking';
import { SelectedAddon } from '../types';
interface BookingState {
step: number;
service: PublicService | null;
selectedAddons: SelectedAddon[];
date: Date | null;
timeSlot: string | null;
user: User | null;
paymentMethod: string | null;
// Manual scheduling (unscheduled booking) fields
preferredDate: string | null;
preferredTimeNotes: string;
}
// Storage key for booking state
@@ -61,10 +68,13 @@ export const BookingFlow: React.FC = () => {
const [bookingState, setBookingState] = useState<BookingState>({
step: stepFromUrl,
service: savedState.service || null,
selectedAddons: savedState.selectedAddons || [],
date: savedState.date || null,
timeSlot: savedState.timeSlot || null,
user: savedState.user || null,
paymentMethod: savedState.paymentMethod || null
paymentMethod: savedState.paymentMethod || null,
preferredDate: savedState.preferredDate || null,
preferredTimeNotes: savedState.preferredTimeNotes || '',
});
// Update URL when step changes
@@ -95,10 +105,15 @@ export const BookingFlow: React.FC = () => {
// Handlers
const handleServiceSelect = (service: PublicService) => {
setBookingState(prev => ({ ...prev, service }));
// Reset addons when service changes
setBookingState(prev => ({ ...prev, service, selectedAddons: [] }));
setTimeout(nextStep, 300);
};
const handleAddonsChange = (addons: SelectedAddon[]) => {
setBookingState(prev => ({ ...prev, selectedAddons: addons }));
};
const handleDateChange = (date: Date) => {
setBookingState(prev => ({ ...prev, date }));
};
@@ -116,6 +131,13 @@ export const BookingFlow: React.FC = () => {
nextStep();
};
const handlePreferredTimeChange = (preferredDate: string | null, preferredTimeNotes: string) => {
setBookingState(prev => ({ ...prev, preferredDate, preferredTimeNotes }));
};
// Check if service requires manual scheduling
const isManualScheduling = bookingState.service?.requires_manual_scheduling === true;
// Reusable navigation footer component
const StepNavigation: React.FC<{
showBack?: boolean;
@@ -159,19 +181,52 @@ export const BookingFlow: React.FC = () => {
case 2:
return (
<div>
<DateTimeSelection
serviceId={bookingState.service?.id}
selectedDate={bookingState.date}
selectedTimeSlot={bookingState.timeSlot}
onDateChange={handleDateChange}
onTimeChange={handleTimeChange}
/>
<StepNavigation
showBack={true}
showContinue={true}
continueDisabled={!bookingState.date || !bookingState.timeSlot}
onContinue={nextStep}
/>
{/* Addon Selection - shown if service has addons (for both normal and manual scheduling) */}
{bookingState.service && (
<AddonSelection
serviceId={bookingState.service.id}
selectedAddons={bookingState.selectedAddons}
onAddonsChange={handleAddonsChange}
/>
)}
{/* Show either DateTimeSelection or ManualSchedulingRequest based on service setting */}
{isManualScheduling ? (
<>
{bookingState.service && (
<ManualSchedulingRequest
service={bookingState.service}
onPreferredTimeChange={handlePreferredTimeChange}
preferredDate={bookingState.preferredDate}
preferredTimeNotes={bookingState.preferredTimeNotes}
/>
)}
<StepNavigation
showBack={true}
showContinue={true}
continueDisabled={false}
continueLabel="Request Callback"
onContinue={nextStep}
/>
</>
) : (
<>
<DateTimeSelection
serviceId={bookingState.service?.id}
selectedDate={bookingState.date}
selectedTimeSlot={bookingState.timeSlot}
selectedAddonIds={bookingState.selectedAddons.map(a => a.addon_id)}
onDateChange={handleDateChange}
onTimeChange={handleTimeChange}
/>
<StepNavigation
showBack={true}
showContinue={true}
continueDisabled={!bookingState.date || !bookingState.timeSlot}
onContinue={nextStep}
/>
</>
)}
</div>
);
case 3:
@@ -208,7 +263,9 @@ export const BookingFlow: React.FC = () => {
<ArrowLeft className="w-5 h-5" />
</button>
<div className="text-sm text-gray-600 dark:text-gray-300">
{bookingState.step < 5 ? 'Book an Appointment' : 'Booking Complete'}
{bookingState.step < 5
? (isManualScheduling ? 'Request a Callback' : 'Book an Appointment')
: (isManualScheduling ? 'Request Submitted' : 'Booking Complete')}
</div>
</div>
{bookingState.user && bookingState.step < 5 && (
@@ -236,15 +293,48 @@ export const BookingFlow: React.FC = () => {
{bookingState.service.name} (${(bookingState.service.price_cents / 100).toFixed(2)})
</div>
)}
{bookingState.date && bookingState.timeSlot && (
{bookingState.selectedAddons.length > 0 && (
<>
<div className="w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
<div className="flex items-center">
<span className="font-medium text-gray-900 dark:text-white mr-2">Time:</span>
{bookingState.date.toLocaleDateString()} at {bookingState.timeSlot}
<span className="font-medium text-gray-900 dark:text-white mr-2">Extras:</span>
{bookingState.selectedAddons.map(a => a.name).join(', ')}
{' '}(+${(bookingState.selectedAddons.reduce((sum, a) => sum + a.price_cents, 0) / 100).toFixed(2)})
</div>
</>
)}
{/* Show scheduled time OR preferred time based on service type */}
{isManualScheduling ? (
bookingState.preferredDate || bookingState.preferredTimeNotes ? (
<>
<div className="w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
<div className="flex items-center">
<span className="font-medium text-gray-900 dark:text-white mr-2">Preferred:</span>
{bookingState.preferredDate && new Date(bookingState.preferredDate).toLocaleDateString()}
{bookingState.preferredDate && bookingState.preferredTimeNotes && ' - '}
{bookingState.preferredTimeNotes}
</div>
</>
) : (
<>
<div className="w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
<div className="flex items-center text-orange-600 dark:text-orange-400">
<span className="font-medium mr-2">Scheduling:</span>
We'll call to schedule
</div>
</>
)
) : (
bookingState.date && bookingState.timeSlot && (
<>
<div className="w-1 h-1 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
<div className="flex items-center">
<span className="font-medium text-gray-900 dark:text-white mr-2">Time:</span>
{bookingState.date.toLocaleDateString()} at {bookingState.timeSlot}
</div>
</>
)
)}
</div>
)}

View File

@@ -4,10 +4,10 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { Appointment, AppointmentStatus, User, Business, Resource, ParticipantInput } from '../types';
import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, Undo, Redo, ChevronLeft, ChevronRight, ChevronDown, Check, AlertTriangle } from 'lucide-react';
import { Clock, Calendar as CalendarIcon, Filter, GripVertical, CheckCircle2, Trash2, Undo, Redo, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Check, AlertTriangle, Link2, Phone, X } from 'lucide-react';
import { useAppointments, useUpdateAppointment, useDeleteAppointment, useCreateAppointment } from '../hooks/useAppointments';
import { EditAppointmentModal } from '../components/EditAppointmentModal';
import { CreateAppointmentModal } from '../components/CreateAppointmentModal';
import { AppointmentModal } from '../components/AppointmentModal';
import { Modal } from '../components/ui';
import { useResources } from '../hooks/useResources';
import { useServices } from '../hooks/useServices';
import { useAppointmentWebSocket } from '../hooks/useAppointmentWebSocket';
@@ -120,10 +120,14 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const overlayAutoScrollRef = useRef<NodeJS.Timeout | null>(null);
const monthOverlayDelayRef = useRef<NodeJS.Timeout | null>(null);
const pendingMonthDropRef = useRef<{ date: Date; rect: DOMRect } | null>(null);
const hasDragMovedRef = useRef(false); // Track if actual drag movement occurred
// Filter state
const [showFilterMenu, setShowFilterMenu] = useState(false);
const [showStatusLegend, setShowStatusLegend] = useState(false);
const [isPendingCollapsed, setIsPendingCollapsed] = useState(true); // Default collapsed
const [pendingDetailAppointment, setPendingDetailAppointment] = useState<Appointment | null>(null); // For modal
const prevPendingCountRef = useRef<number>(0); // Track for auto-open on new requests
const [filterStatuses, setFilterStatuses] = useState<Set<AppointmentStatus>>(new Set([
'PENDING', 'CONFIRMED', 'SCHEDULED', 'EN_ROUTE', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'CANCELED', 'NO_SHOW', 'NOSHOW'
]));
@@ -143,6 +147,9 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const [historyIndex, setHistoryIndex] = useState(-1);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const sidebarScrollRef = useRef<HTMLDivElement>(null); // Ref for sidebar resource list
const isScrollingSidebar = useRef(false); // Prevent scroll sync loops
const isScrollingTimeline = useRef(false);
const overlayScrollRef = useRef<HTMLDivElement>(null); // Ref for the MonthDropOverlay's scrollable area
const overlayContainerRef = useRef<HTMLDivElement>(null); // Ref for the overlay container (for wheel events)
const isOverOverlayRef = useRef(false); // Track if mouse is over the month drop overlay
@@ -163,6 +170,25 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [historyIndex, history]);
// Synchronized scrolling between sidebar resources and timeline
const handleSidebarScroll = useCallback(() => {
if (isScrollingTimeline.current) return;
isScrollingSidebar.current = true;
if (sidebarScrollRef.current && scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = sidebarScrollRef.current.scrollTop;
}
requestAnimationFrame(() => { isScrollingSidebar.current = false; });
}, []);
const handleTimelineScroll = useCallback(() => {
if (isScrollingSidebar.current) return;
isScrollingTimeline.current = true;
if (sidebarScrollRef.current && scrollContainerRef.current) {
sidebarScrollRef.current.scrollTop = scrollContainerRef.current.scrollTop;
}
requestAnimationFrame(() => { isScrollingTimeline.current = false; });
}, []);
// Handle wheel events on month drop overlay for horizontal scrolling
// Use a callback ref pattern to attach the listener when the element is available
const overlayWheelHandler = React.useCallback((e: WheelEvent) => {
@@ -655,10 +681,29 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const resourceLayouts = useMemo(() => {
return resources.map(resource => {
const allResourceApps = filteredAppointments.filter(a => a.resourceId === resource.id);
const layoutApps = allResourceApps.filter(a => a.id !== draggedAppointmentId);
// Get main appointments for this resource
const mainResourceApps = filteredAppointments.filter(a => a.resourceId === resource.id);
// Add preview for dragged appointment
// Get linked addon appointments (appointments where this resource is an addon)
const linkedAddonApps = filteredAppointments
.filter(a => a.addonResourceIds?.includes(resource.id))
.map(a => ({
...a,
isLinkedAddon: true,
linkedToAppointmentId: a.id,
// Use a unique ID for the linked block
id: `${a.id}-addon-${resource.id}`,
}));
const allResourceApps = [...mainResourceApps, ...linkedAddonApps];
const layoutApps = allResourceApps.filter(a => {
// Exclude dragged appointment (both main and linked versions)
if (a.id === draggedAppointmentId) return false;
if ((a as any).linkedToAppointmentId === draggedAppointmentId) return false;
return true;
});
// Add preview for dragged appointment (main resource)
if (previewState && previewState.resourceId === resource.id && draggedAppointmentId) {
const original = filteredAppointments.find(a => a.id === draggedAppointmentId);
if (original) {
@@ -666,9 +711,26 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
}
}
// Add preview for linked addon blocks (when main appointment is dragged)
if (previewState && draggedAppointmentId) {
const original = filteredAppointments.find(a => a.id === draggedAppointmentId);
// If this resource is an addon of the dragged appointment, show preview here too
if (original?.addonResourceIds?.includes(resource.id)) {
layoutApps.push({
...original,
startTime: previewState.startTime,
id: `PREVIEW-addon-${resource.id}`,
isLinkedAddon: true,
linkedToAppointmentId: draggedAppointmentId,
} as any);
}
}
// Apply resize state to appointments for live preview
const layoutAppsWithResize = layoutApps.map(apt => {
if (resizeState && apt.id === resizeState.appointmentId && resizeState.newStart && resizeState.newDuration) {
const linkedId = (apt as any).linkedToAppointmentId;
// Apply resize to both main appointment and its linked addon blocks
if (resizeState && (apt.id === resizeState.appointmentId || linkedId === resizeState.appointmentId) && resizeState.newStart && resizeState.newDuration) {
return { ...apt, startTime: resizeState.newStart, durationMinutes: resizeState.newDuration };
}
return apt;
@@ -698,7 +760,12 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const laneCount = Math.max(1, lanes.length);
const requiredHeight = Math.max(MIN_ROW_HEIGHT, (laneCount * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP);
const finalAppointments = [...visibleAppointments, ...allResourceApps.filter(a => a.id === draggedAppointmentId).map(a => ({ ...a, laneIndex: 0 }))];
// Include dragged appointment in final list (both main and linked)
const draggedApps = allResourceApps
.filter(a => a.id === draggedAppointmentId || (a as any).linkedToAppointmentId === draggedAppointmentId)
.map(a => ({ ...a, laneIndex: 0 }));
const finalAppointments = [...visibleAppointments, ...draggedApps];
return { resource, height: requiredHeight, appointments: finalAppointments, laneCount };
});
@@ -706,7 +773,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const handleDragStart = (e: React.DragEvent, appointmentId: string) => {
if (resizeState) return e.preventDefault();
setIsDragging(true);
hasDragMovedRef.current = false; // Reset - will be set to true when actual movement occurs
e.dataTransfer.setData('appointmentId', appointmentId);
e.dataTransfer.effectAllowed = 'move';
@@ -742,8 +809,11 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
clearInterval(overlayAutoScrollRef.current);
overlayAutoScrollRef.current = null;
}
// Reset isDragging after a short delay to allow click detection
setTimeout(() => setIsDragging(false), 100);
// Reset drag state after a short delay to prevent accidental clicks after actual dragging
setTimeout(() => {
setIsDragging(false);
hasDragMovedRef.current = false;
}, 100);
};
const handleMonthCellDragOver = (e: React.DragEvent, date: Date) => {
@@ -778,6 +848,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
if (!monthOverlayDelayRef.current) {
monthOverlayDelayRef.current = setTimeout(() => {
if (pendingMonthDropRef.current) {
hasDragMovedRef.current = true; // Actual drag movement detected
setIsDragging(true);
setMonthDropTarget(pendingMonthDropRef.current);
}
monthOverlayDelayRef.current = null;
@@ -870,53 +942,92 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const handleAppointmentClick = (appointment: Appointment) => {
// Only open modal if we didn't actually drag or resize
if (!isDragging && !isResizing) {
// Use ref to check for drag movement since onClick fires before isDragging state updates
if (!hasDragMovedRef.current && !isResizing) {
setSelectedAppointment(appointment);
}
};
// Handle saving appointment updates from EditAppointmentModal
// Handle saving appointment updates from AppointmentModal (edit mode)
const handleSaveAppointment = useCallback((updates: {
serviceId?: string;
customerIds?: string[];
startTime?: Date;
resourceId?: string | null;
durationMinutes?: number;
status?: AppointmentStatus;
notes?: string;
participantsInput?: ParticipantInput[];
addonIds?: number[];
}) => {
if (!selectedAppointment) return;
// Handle customer changes - first customer is primary, rest are participants
let allParticipants = updates.participantsInput || [];
if (updates.customerIds && updates.customerIds.length > 1) {
const additionalCustomerIds = updates.customerIds.slice(1);
allParticipants = [
...allParticipants,
...additionalCustomerIds.map(customerId => ({
role: 'CUSTOMER' as const,
userId: parseInt(customerId, 10),
})),
];
}
updateMutation.mutate({
id: selectedAppointment.id,
updates: {
...updates,
serviceId: updates.serviceId,
customerId: updates.customerIds?.[0],
startTime: updates.startTime,
resourceId: updates.resourceId ?? undefined,
durationMinutes: updates.durationMinutes,
status: updates.status,
notes: updates.notes,
participantsInput: allParticipants.length > 0 ? allParticipants : undefined,
addonIds: updates.addonIds,
}
});
setSelectedAppointment(null);
}, [selectedAppointment, updateMutation]);
// Handle creating new appointment from CreateAppointmentModal
// Handle creating new appointment from AppointmentModal (create mode)
const handleCreateAppointment = useCallback((appointmentData: {
serviceId: string;
customerId: string;
customerIds: string[];
startTime: Date;
resourceId?: string | null;
durationMinutes: number;
notes?: string;
participantsInput?: ParticipantInput[];
addonIds?: number[];
}) => {
// Use first customer as primary, add additional customers as participants
const primaryCustomerId = appointmentData.customerIds[0];
const additionalCustomerIds = appointmentData.customerIds.slice(1);
// Merge additional customers into participants
const allParticipants: ParticipantInput[] = [
...(appointmentData.participantsInput || []),
...additionalCustomerIds.map(customerId => ({
role: 'CUSTOMER' as const,
userId: parseInt(customerId, 10),
})),
];
createMutation.mutate({
serviceId: appointmentData.serviceId,
customerId: appointmentData.customerId,
customerId: primaryCustomerId,
customerName: '', // Will be set by backend
startTime: appointmentData.startTime,
resourceId: appointmentData.resourceId ?? null,
durationMinutes: appointmentData.durationMinutes,
status: 'SCHEDULED',
notes: appointmentData.notes || '',
participantsInput: appointmentData.participantsInput,
participantsInput: allParticipants.length > 0 ? allParticipants : undefined,
addonIds: appointmentData.addonIds,
}, {
onSuccess: () => {
setIsCreateModalOpen(false);
@@ -944,6 +1055,24 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
}
if (!targetResourceId) return;
// Prevent dropping on a resource that is an addon of the dragged appointment
const draggedAppointment = appointments.find(a => a.id === draggedAppointmentId);
if (draggedAppointment?.addonResourceIds?.includes(targetResourceId)) {
e.dataTransfer.dropEffect = 'none';
return; // Don't show preview on addon resources
}
// Prevent dropping on a resource that is not allowed for the service
if (draggedAppointment) {
const service = services.find(s => s.id === draggedAppointment.serviceId);
if (service && !service.all_resources && service.resource_ids?.length) {
if (!service.resource_ids.includes(targetResourceId)) {
e.dataTransfer.dropEffect = 'none';
return; // Don't show preview on resources not allowed for this service
}
}
}
// Calculate new start time, accounting for where on the appointment the drag started
const mouseMinutes = Math.round((offsetX / (PIXELS_PER_MINUTE * zoomLevel)) / 15) * 15;
const newStartMinutes = mouseMinutes - dragOffsetMinutes;
@@ -952,6 +1081,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
newStartTime.setTime(newStartTime.getTime() + newStartMinutes * 60000);
if (!previewState || previewState.resourceId !== targetResourceId || previewState.startTime.getTime() !== newStartTime.getTime()) {
hasDragMovedRef.current = true; // Actual drag movement detected
setIsDragging(true);
setPreviewState({ resourceId: targetResourceId, startTime: newStartTime });
}
};
@@ -962,6 +1093,27 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
if (appointmentId && previewState) {
const appointment = appointments.find(a => a.id === appointmentId);
if (appointment) {
// Prevent dropping on a resource that is an addon of this appointment
if (appointment.addonResourceIds?.includes(previewState.resourceId || '')) {
setDraggedAppointmentId(null); setPreviewState(null);
hasDragMovedRef.current = false;
return;
}
// Check if the target resource is allowed for this service
const service = services.find(s => s.id === appointment.serviceId);
if (service && previewState.resourceId) {
const isAllowedResource = service.all_resources ||
!service.resource_ids?.length ||
service.resource_ids.includes(previewState.resourceId);
if (!isAllowedResource) {
// Resource not allowed for this service - reject the drop
setDraggedAppointmentId(null); setPreviewState(null);
hasDragMovedRef.current = false;
return;
}
}
// Add to history
addToHistory({
type: 'move',
@@ -990,6 +1142,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
}
}
setDraggedAppointmentId(null); setPreviewState(null);
hasDragMovedRef.current = false;
};
const handleDropToPending = (e: React.DragEvent) => {
@@ -1002,6 +1155,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
});
}
setDraggedAppointmentId(null); setPreviewState(null);
hasDragMovedRef.current = false;
};
const handleDropToArchive = (e: React.DragEvent) => {
@@ -1011,6 +1165,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
deleteMutation.mutate(appointmentId);
}
setDraggedAppointmentId(null); setPreviewState(null);
hasDragMovedRef.current = false;
};
const handleSidebarDragOver = (e: React.DragEvent) => {
@@ -1024,6 +1179,24 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
const timeMarkers = Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => START_HOUR + i);
const pendingAppointments = filteredAppointments.filter(a => !a.resourceId);
// Auto-open pending sidebar on initial load if there are pending requests
// and auto-open when new pending requests come in via websocket
useEffect(() => {
const currentCount = pendingAppointments.length;
const prevCount = prevPendingCountRef.current;
// If this is the initial load and there are pending requests, auto-open
if (prevCount === 0 && currentCount > 0) {
setIsPendingCollapsed(false);
}
// If new pending requests came in (count increased), auto-open
else if (currentCount > prevCount && prevCount > 0) {
setIsPendingCollapsed(false);
}
prevPendingCountRef.current = currentCount;
}, [pendingAppointments.length]);
return (
<div className="flex flex-col h-full overflow-hidden select-none bg-white dark:bg-gray-900 transition-colors duration-200">
<div className="flex items-center justify-between px-6 py-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 shadow-sm shrink-0 z-10 transition-colors duration-200">
@@ -1327,25 +1500,50 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
{/* Month View - Calendar Grid */}
{viewMode === 'month' && (
<div className="flex flex-1 overflow-hidden">
{/* Pending Sidebar for Month View */}
{/* Pending Sidebar for Month View - Always visible, no collapse */}
<div className="flex flex-col bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 shrink-0 shadow-lg z-20 transition-colors duration-200" style={{ width: SIDEBAR_WIDTH }}>
<div className={`flex-1 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 flex flex-col transition-colors duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''}`}>
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0"><Clock size={12} /> Pending Requests ({pendingAppointments.length})</h3>
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
<div className={`bg-gray-50 dark:bg-gray-800 flex flex-col flex-1 transition-colors duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''}`}>
{/* Header - not a button, just a label */}
<div className="p-3 flex items-center text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider border-b border-gray-200 dark:border-gray-700 shrink-0">
<span className="flex items-center gap-2">
<Clock size={12} />
Pending Requests
{pendingAppointments.length > 0 && (
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{pendingAppointments.length}
</span>
)}
</span>
</div>
{/* Always visible list */}
<div className="space-y-2 overflow-y-auto flex-1 py-2 px-4">
{pendingAppointments.length === 0 && (<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>)}
{pendingAppointments.map(apt => {
const service = services.find(s => s.id === apt.serviceId);
const hasPreferredTime = apt.preferredDatetime || apt.preferredTimeNotes;
return (
<div
key={apt.id}
className="p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-sm cursor-pointer hover:shadow-md transition-all"
onClick={() => handleAppointmentClick(apt)}
onClick={() => setPendingDetailAppointment(apt)}
>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{apt.customerName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{service?.name}</p>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} /> {formatDuration(apt.durationMinutes)}
</div>
{/* Preferred time indicator */}
{hasPreferredTime ? (
<div className="mt-2 flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<CalendarIcon size={10} />
<span>
Prefers: {apt.preferredDatetime ? new Date(apt.preferredDatetime).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : ''}
{apt.preferredDatetime && apt.preferredTimeNotes ? ' - ' : ''}
{apt.preferredTimeNotes}
</span>
</div>
) : (
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} /> {formatDuration(apt.durationMinutes)}
</div>
)}
</div>
);
})}
@@ -1715,6 +1913,8 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
// Set preview state
if (!overlayPreview || overlayPreview.resourceId !== layout.resource.id ||
overlayPreview.hour !== hour || overlayPreview.minute !== minute) {
hasDragMovedRef.current = true; // Actual drag movement detected
setIsDragging(true);
setOverlayPreview({ resourceId: layout.resource.id, hour, minute });
}
}}
@@ -1842,7 +2042,7 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
)}
<div className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-center px-4 font-semibold text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wider shrink-0 transition-colors duration-200" style={{ height: HEADER_HEIGHT }}>Resources</div>
<div className="flex-1 overflow-hidden flex flex-col">
<div className="overflow-y-auto flex-1">
<div ref={sidebarScrollRef} onScroll={handleSidebarScroll} className="overflow-y-auto flex-1">
{resourceLayouts.map(layout => {
const isOverQuota = overQuotaResourceIds.has(layout.resource.id);
return (
@@ -1878,37 +2078,68 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
})}
</div>
</div>
<div className={`border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-4 h-80 flex flex-col transition-colors duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToPending}>
<h3 className="text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 flex items-center gap-2 shrink-0"><Clock size={12} /> Pending Requests ({pendingAppointments.length})</h3>
<div className="space-y-2 overflow-y-auto flex-1 mb-2">
{pendingAppointments.length === 0 && !draggedAppointmentId && (<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>)}
{draggedAppointmentId && (<div className="border-2 border-dashed border-blue-300 dark:border-blue-700 rounded-lg p-4 text-center mb-2 bg-blue-50 dark:bg-blue-900/30"><span className="text-sm text-blue-600 dark:text-blue-400 font-medium">Drop here to unassign</span></div>)}
{pendingAppointments.map(apt => {
const service = services.find(s => s.id === apt.serviceId);
return (
<div
key={apt.id}
className={`p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-all ${draggedAppointmentId === apt.id ? 'opacity-50' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, apt.id)}
onDragEnd={handleDragEnd}
onClick={() => handleAppointmentClick(apt)}
>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{apt.customerName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{service?.name}</p>
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} /> {formatDuration(apt.durationMinutes)}
</div>
</div>
)
})}
</div>
<div className={`shrink-0 mt-2 border-t border-gray-200 dark:border-gray-700 pt-2 transition-all duration-200 ${draggedAppointmentId ? 'opacity-100 translate-y-0' : 'opacity-50 translate-y-0'}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToArchive}><div className={`flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed transition-colors ${draggedAppointmentId ? 'border-red-300 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400' : 'border-gray-200 dark:border-gray-700 bg-transparent text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-500'}`}><Trash2 size={16} /><span className="text-xs font-medium">Drop here to archive</span></div></div>
<div className={`border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex flex-col transition-all duration-200 ${draggedAppointmentId ? 'bg-blue-50/50 dark:bg-blue-900/20' : ''} ${isPendingCollapsed ? '' : 'h-80'}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToPending}>
<button
onClick={() => setIsPendingCollapsed(!isPendingCollapsed)}
className="w-full p-3 flex items-center justify-between text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors shrink-0"
>
<span className="flex items-center gap-2">
<Clock size={12} />
Pending Requests
{pendingAppointments.length > 0 && (
<span className="bg-red-500 text-white text-[10px] font-bold px-1.5 py-0.5 rounded-full min-w-[18px] text-center">
{pendingAppointments.length}
</span>
)}
</span>
{isPendingCollapsed ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{!isPendingCollapsed && (
<>
<div className="space-y-2 overflow-y-auto flex-1 mb-2 px-4">
{pendingAppointments.length === 0 && !draggedAppointmentId && (<div className="text-xs text-gray-400 italic text-center py-4">No pending requests</div>)}
{draggedAppointmentId && (<div className="border-2 border-dashed border-blue-300 dark:border-blue-700 rounded-lg p-4 text-center mb-2 bg-blue-50 dark:bg-blue-900/30"><span className="text-sm text-blue-600 dark:text-blue-400 font-medium">Drop here to unassign</span></div>)}
{pendingAppointments.map(apt => {
const service = services.find(s => s.id === apt.serviceId);
const hasPreferredTime = apt.preferredDatetime || apt.preferredTimeNotes;
return (
<div
key={apt.id}
className={`p-3 bg-white dark:bg-gray-700 border border-l-4 border-gray-200 dark:border-gray-600 border-l-orange-400 dark:border-l-orange-500 rounded shadow-sm cursor-grab active:cursor-grabbing hover:shadow-md transition-all ${draggedAppointmentId === apt.id ? 'opacity-50' : ''}`}
draggable
onDragStart={(e) => handleDragStart(e, apt.id)}
onDragEnd={handleDragEnd}
onClick={() => setPendingDetailAppointment(apt)}
>
<p className="font-semibold text-sm text-gray-900 dark:text-white">{apt.customerName}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{service?.name}</p>
{/* Preferred time indicator */}
{hasPreferredTime ? (
<div className="mt-2 flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<CalendarIcon size={10} />
<span>
Prefers: {apt.preferredDatetime ? new Date(apt.preferredDatetime).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : ''}
{apt.preferredDatetime && apt.preferredTimeNotes ? ' - ' : ''}
{apt.preferredTimeNotes}
</span>
</div>
) : (
<div className="mt-2 flex items-center gap-1 text-xs text-gray-400 dark:text-gray-500">
<Clock size={10} /> {formatDuration(apt.durationMinutes)}
</div>
)}
</div>
)
})}
</div>
<div className={`shrink-0 mx-4 mb-4 border-t border-gray-200 dark:border-gray-700 pt-2 transition-all duration-200 ${draggedAppointmentId ? 'opacity-100 translate-y-0' : 'opacity-50 translate-y-0'}`} onDragOver={handleSidebarDragOver} onDrop={handleDropToArchive}><div className={`flex items-center justify-center gap-2 p-3 rounded-lg border-2 border-dashed transition-colors ${draggedAppointmentId ? 'border-red-300 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400' : 'border-gray-200 dark:border-gray-700 bg-transparent text-gray-400 hover:border-gray-300 dark:hover:border-gray-600 hover:text-gray-500'}`}><Trash2 size={16} /><span className="text-xs font-medium">Drop here to archive</span></div></div>
</>
)}
</div>
</div>
<div className="flex-1 flex flex-col overflow-hidden bg-white dark:bg-gray-900 relative transition-colors duration-200">
<div className="flex-1 overflow-auto timeline-scroll" ref={scrollContainerRef} onDragOver={handleTimelineDragOver} onDrop={handleTimelineDrop}>
<div className="flex-1 overflow-auto timeline-scroll" ref={scrollContainerRef} onScroll={handleTimelineScroll} onDragOver={handleTimelineDragOver} onDrop={handleTimelineDrop}>
<div style={{ width: timelineWidth, minWidth: '100%' }} className="relative min-h-full">
{/* Timeline Header */}
<div className="sticky top-0 z-10 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 transition-colors duration-200">
@@ -1998,11 +2229,36 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
/>
)}
{layout.appointments.map(apt => {
const isPreview = apt.id === 'PREVIEW'; const isDragged = apt.id === draggedAppointmentId; const startTime = new Date(apt.startTime); const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000); const colorClass = isPreview ? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80' : getStatusColor(apt.status, startTime, endTime); const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP;
const isPreview = apt.id === 'PREVIEW' || apt.id.startsWith('PREVIEW-addon-');
const isLinkedAddon = (apt as any).isLinkedAddon === true;
const linkedToId = (apt as any).linkedToAppointmentId;
const isDragged = apt.id === draggedAppointmentId || linkedToId === draggedAppointmentId;
const startTime = new Date(apt.startTime);
const endTime = new Date(startTime.getTime() + apt.durationMinutes * 60000);
// Linked addon blocks have semi-transparent dashed border style
const colorClass = isPreview
? 'bg-brand-50 dark:bg-brand-900/30 border-brand-400 dark:border-brand-700 border-dashed text-brand-700 dark:text-brand-400 opacity-80'
: isLinkedAddon
? 'bg-purple-50 dark:bg-purple-900/30 border-purple-400 dark:border-purple-700 border-dashed text-purple-700 dark:text-purple-400 opacity-70'
: getStatusColor(apt.status, startTime, endTime);
const topOffset = (apt.laneIndex * (EVENT_HEIGHT + EVENT_GAP)) + EVENT_GAP;
const service = services.find(s => s.id === apt.serviceId);
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: resizeState ? 'grabbing' : 'grab', pointerEvents: isPreview ? 'none' : 'auto' }} draggable={!resizeState && !isPreview} onDragStart={(e) => handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={(e) => { e.stopPropagation(); handleAppointmentClick(apt); }}>
{!isPreview && (<><div className="absolute left-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginLeft: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'start')} /><div className="absolute right-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginRight: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'end')} /></>)}
<div className="font-semibold text-sm truncate pointer-events-none">{apt.customerName}</div><div className="text-xs truncate opacity-80 pointer-events-none">{service?.name}</div><div className="mt-2 flex items-center gap-1 text-xs opacity-75 pointer-events-none truncate">{apt.status === 'COMPLETED' ? <CheckCircle2 size={12} className="flex-shrink-0" /> : <Clock size={12} className="flex-shrink-0" />}<span className="truncate">{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span className="mx-1 flex-shrink-0"></span><span className="truncate">{formatDuration(apt.durationMinutes)}</span></div>
// For linked addons, clicking should open the main appointment
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (isLinkedAddon && linkedToId) {
const mainApt = filteredAppointments.find(a => a.id === linkedToId);
if (mainApt) handleAppointmentClick(mainApt);
} else {
handleAppointmentClick(apt);
}
};
// Linked addon blocks are not directly draggable - drag the main appointment instead
const canDrag = !resizeState && !isPreview && !isLinkedAddon;
return (<div key={apt.id} className={`absolute rounded p-3 border-l-4 shadow-sm group overflow-hidden transition-all ${colorClass} ${isPreview ? 'z-40' : 'hover:shadow-md hover:z-50'} ${isDragged && !isLinkedAddon ? 'opacity-0 pointer-events-none' : ''}`} style={{ left: getOffset(startTime), width: getWidth(apt.durationMinutes), height: EVENT_HEIGHT, top: topOffset, zIndex: isPreview ? 40 : 10 + apt.laneIndex, cursor: isLinkedAddon ? 'pointer' : (resizeState ? 'grabbing' : 'grab'), pointerEvents: isPreview ? 'none' : 'auto' }} draggable={canDrag} onDragStart={(e) => canDrag && handleDragStart(e, apt.id)} onDragEnd={handleDragEnd} onClick={handleClick}>
{!isPreview && !isLinkedAddon && (<><div className="absolute left-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginLeft: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'start')} /><div className="absolute right-0 top-0 bottom-0 w-3 cursor-ew-resize bg-transparent hover:bg-blue-500/20 z-50" style={{ marginRight: '-4px' }} onMouseDown={(e) => handleResizeStart(e, apt, 'end')} /></>)}
{isLinkedAddon && <div className="absolute top-1 right-1 text-purple-500 dark:text-purple-400" title="Linked addon resource"><Link2 size={12} /></div>}
<div className="font-semibold text-sm truncate pointer-events-none">{isLinkedAddon ? `${apt.customerName}` : apt.customerName}</div><div className="text-xs truncate opacity-80 pointer-events-none">{isLinkedAddon ? '(Addon)' : service?.name}</div><div className="mt-2 flex items-center gap-1 text-xs opacity-75 pointer-events-none truncate">{apt.status === 'COMPLETED' ? <CheckCircle2 size={12} className="flex-shrink-0" /> : <Clock size={12} className="flex-shrink-0" />}<span className="truncate">{startTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span><span className="mx-1 flex-shrink-0"></span><span className="truncate">{formatDuration(apt.durationMinutes)}</span></div>
</div>);
})}</div>);
})}
@@ -2015,27 +2271,140 @@ const OwnerScheduler: React.FC<OwnerSchedulerProps> = ({ user, business }) => {
{/* Edit Appointment Modal */}
{selectedAppointment && (
<EditAppointmentModal
<AppointmentModal
mode="edit"
appointment={selectedAppointment}
resources={resources as Resource[]}
services={services}
onSave={handleSaveAppointment}
onClose={() => setSelectedAppointment(null)}
isSaving={updateMutation.isPending}
isSubmitting={updateMutation.isPending}
/>
)}
{/* Create Appointment Modal */}
{isCreateModalOpen && (
<CreateAppointmentModal
<AppointmentModal
mode="create"
resources={resources as Resource[]}
services={services}
initialDate={viewDate}
onCreate={handleCreateAppointment}
onClose={() => setIsCreateModalOpen(false)}
isCreating={createMutation.isPending}
isSubmitting={createMutation.isPending}
/>
)}
{/* Pending Request Detail Modal */}
{pendingDetailAppointment && (
<Modal
isOpen={true}
onClose={() => setPendingDetailAppointment(null)}
title="Pending Request Details"
size="md"
>
{(() => {
const apt = pendingDetailAppointment;
const service = services.find(s => s.id === apt.serviceId);
const hasPreferredTime = apt.preferredDatetime || apt.preferredTimeNotes;
return (
<div className="space-y-4">
{/* Customer Info */}
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center flex-shrink-0">
<span className="text-lg font-bold text-orange-600 dark:text-orange-400">
{apt.customerName?.charAt(0)?.toUpperCase() || '?'}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-lg text-gray-900 dark:text-white truncate">
{apt.customerName}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
{service?.name} - {formatDuration(apt.durationMinutes)}
</p>
</div>
<a
href={`tel:`}
className="p-2.5 bg-green-100 dark:bg-green-900/30 rounded-full hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors"
title="Call customer"
>
<Phone size={18} className="text-green-600 dark:text-green-400" />
</a>
</div>
{/* Preferred Schedule */}
{hasPreferredTime ? (
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-2 mb-2">
<CalendarIcon size={16} className="text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-blue-800 dark:text-blue-200">
Preferred Schedule
</span>
</div>
<div className="text-blue-700 dark:text-blue-300">
{apt.preferredDatetime && (
<span className="font-medium">
{new Date(apt.preferredDatetime).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric'
})}
</span>
)}
{apt.preferredDatetime && apt.preferredTimeNotes && <span> - </span>}
{apt.preferredTimeNotes && (
<span className="italic">{apt.preferredTimeNotes}</span>
)}
</div>
</div>
) : (
<div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<div className="flex items-center gap-2">
<Clock size={16} className="text-gray-400" />
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
No preferred schedule specified
</span>
</div>
</div>
)}
{/* Notes */}
{apt.notes && (
<div className="p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes</p>
<p className="text-sm text-gray-700 dark:text-gray-300">{apt.notes}</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
setPendingDetailAppointment(null);
setSelectedAppointment(apt);
}}
className="flex-1 py-2.5 bg-brand-600 text-white rounded-lg font-medium hover:bg-brand-700 transition-colors"
>
Schedule Now
</button>
<button
onClick={() => {
setPendingDetailAppointment(null);
setSelectedAppointment(apt);
}}
className="px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Edit
</button>
</div>
</div>
);
})()}
</Modal>
)}
</div>
);
};

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">

View File

@@ -34,7 +34,9 @@ import {
MousePointer,
Keyboard,
Layers,
Phone,
} from 'lucide-react';
import { UnscheduledBookingDemo } from '../../components/help/UnscheduledBookingDemo';
const HelpScheduler: React.FC = () => {
const { t } = useTranslation();
@@ -242,18 +244,18 @@ const HelpScheduler: React.FC = () => {
<p className="text-gray-600 dark:text-gray-300 mb-4">
Appointments are color-coded so you can see their status at a glance:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
<div className="flex items-center gap-3 p-3 bg-blue-100 dark:bg-blue-900/50 border-l-4 border-blue-500 rounded">
<span className="font-medium text-blue-900 dark:text-blue-200">Blue - Upcoming</span>
<span className="font-medium text-blue-900 dark:text-blue-200">Blue - Scheduled</span>
<span className="text-sm text-blue-700 dark:text-blue-300">Confirmed, hasn't started yet</span>
</div>
<div className="flex items-center gap-3 p-3 bg-yellow-100 dark:bg-yellow-900/50 border-l-4 border-yellow-500 rounded">
<span className="font-medium text-yellow-900 dark:text-yellow-200">Yellow - In Progress</span>
<span className="text-sm text-yellow-700 dark:text-yellow-300">Currently happening</span>
<span className="font-medium text-yellow-900 dark:text-yellow-200">Yellow - En Route</span>
<span className="text-sm text-yellow-700 dark:text-yellow-300">Staff is on the way</span>
</div>
<div className="flex items-center gap-3 p-3 bg-red-100 dark:bg-red-900/50 border-l-4 border-red-500 rounded">
<span className="font-medium text-red-900 dark:text-red-200">Red - Overdue</span>
<span className="text-sm text-red-700 dark:text-red-300">Past end time, not completed</span>
<div className="flex items-center gap-3 p-3 bg-amber-100 dark:bg-amber-900/50 border-l-4 border-amber-500 rounded">
<span className="font-medium text-amber-900 dark:text-amber-200">Amber - In Progress</span>
<span className="text-sm text-amber-700 dark:text-amber-300">Currently happening</span>
</div>
<div className="flex items-center gap-3 p-3 bg-green-100 dark:bg-green-900/50 border-l-4 border-green-500 rounded">
<span className="font-medium text-green-900 dark:text-green-200">Green - Completed</span>
@@ -268,6 +270,80 @@ const HelpScheduler: React.FC = () => {
<span className="text-sm text-gray-500 dark:text-gray-500">Appointment was cancelled</span>
</div>
</div>
{/* Visual Example */}
<h4 className="font-medium text-gray-900 dark:text-white mb-3">How Appointments Look on the Scheduler</h4>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="min-w-[500px]">
{/* Header Row */}
<div className="flex bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border-r border-gray-200 dark:border-gray-700">
RESOURCES
</div>
<div className="flex-1 flex">
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">9 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">10 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">11 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">12 PM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400">1 PM</div>
</div>
</div>
{/* Emily Rodriguez Row */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-3 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Emily Rodriguez</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Staff</div>
</div>
<div className="flex-1 flex items-center relative py-2 px-1 min-h-[60px]">
{/* Completed appointment */}
<div className="absolute left-1 w-[95px] h-[52px] bg-green-500 border-l-4 border-green-700 rounded-r shadow-sm flex flex-col justify-center px-2">
<div className="text-white text-xs font-medium truncate">Jane Doe</div>
<div className="text-green-100 text-[10px] truncate">Haircut</div>
<div className="text-green-200 text-[10px]">9:00 AM • 1h</div>
</div>
{/* Scheduled appointment */}
<div className="absolute left-[100px] w-[95px] h-[52px] bg-blue-500 border-l-4 border-blue-700 rounded-r shadow-sm flex flex-col justify-center px-2">
<div className="text-white text-xs font-medium truncate">John Smith</div>
<div className="text-blue-100 text-[10px] truncate">Consultation</div>
<div className="text-blue-200 text-[10px]">10:00 AM • 1h</div>
</div>
</div>
</div>
{/* Conference Room Row */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-3 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Conference Room</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Room</div>
</div>
<div className="flex-1 flex items-center relative py-2 px-1 min-h-[60px]">
{/* In Progress appointment */}
<div className="absolute left-[148px] w-[143px] h-[52px] bg-amber-500 border-l-4 border-amber-700 rounded-r shadow-sm flex flex-col justify-center px-2">
<div className="text-white text-xs font-medium truncate">Team Meeting</div>
<div className="text-amber-100 text-[10px] truncate">Sarah Wilson</div>
<div className="text-amber-200 text-[10px]">11:00 AM • 1.5h</div>
</div>
</div>
</div>
{/* Hair Station Row */}
<div className="flex">
<div className="w-32 px-3 py-3 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Hair Station 1</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Room</div>
</div>
<div className="flex-1 flex items-center relative py-2 px-1 min-h-[60px]">
{/* Cancelled appointment (faded) */}
<div className="absolute left-1 w-[95px] h-[52px] bg-gray-400 border-l-4 border-gray-500 rounded-r shadow-sm flex flex-col justify-center px-2 opacity-60">
<div className="text-white text-xs font-medium truncate line-through">Bob Jones</div>
<div className="text-gray-200 text-[10px] truncate">Hair Color</div>
<div className="text-gray-200 text-[10px]">9:00 AM • 1h</div>
</div>
</div>
</div>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 italic mt-2">
Each appointment shows the customer name, service type, start time, and duration. The left border color indicates the status.
</p>
</div>
</section>
@@ -406,7 +482,7 @@ const HelpScheduler: React.FC = () => {
When appointments overlap in time for the same resource, the scheduler automatically
stacks them in multiple "lanes" so you can see all of them:
</p>
<ul className="space-y-2">
<ul className="space-y-2 mb-6">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-600 dark:text-gray-300">
@@ -426,6 +502,63 @@ const HelpScheduler: React.FC = () => {
</span>
</li>
</ul>
{/* Visual Example */}
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Multi-Lane Example</h4>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="min-w-[500px]">
{/* Header Row */}
<div className="flex bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border-r border-gray-200 dark:border-gray-700">
RESOURCES
</div>
<div className="flex-1 flex">
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">9 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">10 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">11 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400">12 PM</div>
</div>
</div>
{/* Conference Room Row - with overlapping appointments */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-3 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Conference Room</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Room (Multi-lane)</div>
</div>
<div className="flex-1 relative py-2 px-1 min-h-[124px]">
{/* Lane 1 - First appointment */}
<div className="absolute top-2 left-1 w-[143px] h-[52px] bg-blue-500 border-l-4 border-blue-700 rounded-r shadow-sm flex flex-col justify-center px-2">
<div className="text-white text-xs font-medium truncate">Team Standup</div>
<div className="text-blue-100 text-[10px] truncate">Marketing Team</div>
<div className="text-blue-200 text-[10px]">9:00 AM 1.5h</div>
</div>
{/* Lane 2 - Overlapping appointment */}
<div className="absolute top-[62px] left-[50px] w-[143px] h-[52px] bg-green-500 border-l-4 border-green-700 rounded-r shadow-sm flex flex-col justify-center px-2">
<div className="text-white text-xs font-medium truncate">Client Call</div>
<div className="text-green-100 text-[10px] truncate">Sales Team</div>
<div className="text-green-200 text-[10px]">9:30 AM 1.5h</div>
</div>
</div>
</div>
{/* Single Lane Row for comparison */}
<div className="flex">
<div className="w-32 px-3 py-3 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
<div className="text-sm font-medium text-gray-700 dark:text-gray-300">Training Room</div>
<div className="text-xs text-gray-500 dark:text-gray-400">Room</div>
</div>
<div className="flex-1 flex items-center relative py-2 px-1 min-h-[60px]">
<div className="absolute left-[148px] w-[95px] h-[52px] bg-blue-500 border-l-4 border-blue-700 rounded-r shadow-sm flex flex-col justify-center px-2">
<div className="text-white text-xs font-medium truncate">Workshop</div>
<div className="text-blue-100 text-[10px] truncate">HR Dept</div>
<div className="text-blue-200 text-[10px]">11:00 AM 1h</div>
</div>
</div>
</div>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 italic mt-2">
The Conference Room row expands to show two overlapping appointments in separate lanes. The Training Room shows a normal single-lane row for comparison.
</p>
</div>
</section>
@@ -439,7 +572,7 @@ const HelpScheduler: React.FC = () => {
<p className="text-gray-600 dark:text-gray-300 mb-4">
The sidebar on the left shows all pending (unscheduled) appointment requests:
</p>
<ul className="space-y-2">
<ul className="space-y-2 mb-6">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-600 dark:text-gray-300">
@@ -465,6 +598,135 @@ const HelpScheduler: React.FC = () => {
</span>
</li>
</ul>
{/* Visual Example */}
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Pending Sidebar Example</h4>
<div className="flex gap-4">
{/* Sidebar mockup */}
<div className="w-48 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-3">
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-gray-200 dark:border-gray-700">
<Inbox size={14} className="text-gray-500" />
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">PENDING REQUESTS</span>
<span className="ml-auto bg-brand-500 text-white text-[10px] px-1.5 py-0.5 rounded-full">3</span>
</div>
{/* Pending appointment cards */}
<div className="space-y-2">
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-2 cursor-grab hover:border-brand-500 transition-colors">
<div className="text-xs font-medium text-gray-900 dark:text-white">Sarah Johnson</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">Haircut & Style</div>
<div className="text-[10px] text-brand-600 dark:text-brand-400">45 min $65</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-2 cursor-grab hover:border-brand-500 transition-colors">
<div className="text-xs font-medium text-gray-900 dark:text-white">Mike Chen</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">Consultation</div>
<div className="text-[10px] text-brand-600 dark:text-brand-400">30 min $50</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 p-2 cursor-grab hover:border-brand-500 transition-colors">
<div className="text-xs font-medium text-gray-900 dark:text-white">Lisa Park</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">Hair Color</div>
<div className="text-[10px] text-brand-600 dark:text-brand-400">2h $120</div>
</div>
</div>
{/* Archive zone */}
<div className="mt-3 pt-2 border-t border-dashed border-gray-300 dark:border-gray-600">
<div className="flex items-center justify-center gap-1 text-[10px] text-gray-400 dark:text-gray-500 py-2">
<Archive size={12} />
<span>Drop here to archive</span>
</div>
</div>
</div>
{/* Arrow and explanation */}
<div className="flex flex-col justify-center">
<div className="text-gray-400 dark:text-gray-500 text-2xl"></div>
<p className="text-xs text-gray-500 dark:text-gray-400 max-w-[200px]">
Drag appointments from the sidebar onto the timeline to schedule them
</p>
</div>
</div>
</div>
</section>
{/* Manual Scheduling Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Phone size={20} className="text-brand-500" />
Manual Scheduling Services
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Some services require manual scheduling - when enabled, online bookings go directly to the
Pending Requests sidebar instead of being auto-scheduled. This is useful for:
</p>
<ul className="space-y-2 mb-6">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-600 dark:text-gray-300">
<strong>Consultations:</strong> Free discovery calls where you need to contact the customer first
</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-600 dark:text-gray-300">
<strong>Custom services:</strong> Services that require discussion before scheduling
</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-1 flex-shrink-0" />
<span className="text-gray-600 dark:text-gray-300">
<strong>Variable duration:</strong> Services where the time needed depends on the specifics
</span>
</li>
</ul>
<h4 className="font-medium text-gray-900 dark:text-white mb-3">How It Works</h4>
<div className="space-y-4 mb-6">
<div className="flex items-start gap-3 p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
<span className="flex-shrink-0 w-6 h-6 bg-orange-500 text-white rounded-full flex items-center justify-center text-sm font-medium">1</span>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Service Configuration</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Enable "Requires Manual Scheduling" on any service. Optionally enable "Ask for Preferred Time"
to capture when the customer would like to be scheduled.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<span className="flex-shrink-0 w-6 h-6 bg-green-500 text-white rounded-full flex items-center justify-center text-sm font-medium">2</span>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Customer Books Online</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Customers see a "We'll call you to schedule" message instead of the regular date/time picker.
They can optionally provide their preferred date and time preferences.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<span className="flex-shrink-0 w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-sm font-medium">3</span>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Appears in Pending Sidebar</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
The booking appears in your Pending Requests sidebar with the customer's preferred times displayed.
Click on any request to see full details, then call the customer to confirm a time.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-indigo-50 dark:bg-indigo-900/20 rounded-lg border border-indigo-200 dark:border-indigo-800">
<span className="flex-shrink-0 w-6 h-6 bg-indigo-500 text-white rounded-full flex items-center justify-center text-sm font-medium">4</span>
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Schedule the Appointment</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Drag the pending request onto the timeline at the agreed time, or click "Schedule Now"
in the details modal to open the appointment editor.
</p>
</div>
</div>
</div>
<h4 className="font-medium text-gray-900 dark:text-white mb-3">Interactive Demo</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
Explore how manual scheduling works across the service settings, customer booking flow, and pending requests sidebar:
</p>
<UnscheduledBookingDemo view="all" />
</div>
</section>

View File

@@ -27,7 +27,14 @@ import {
Upload,
AlertCircle,
LayoutGrid,
Package,
Layers,
Timer,
Zap,
Phone,
Calendar,
} from 'lucide-react';
import { UnscheduledBookingDemo } from '../../components/help/UnscheduledBookingDemo';
const HelpServices: React.FC = () => {
const navigate = useNavigate();
@@ -169,6 +176,461 @@ const HelpServices: React.FC = () => {
</div>
</section>
{/* Manual Scheduling Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Phone size={20} className="text-brand-500" /> Manual Scheduling
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Some services may require you to call customers back to schedule manually, rather than allowing them to pick a time slot online. This is useful for:
</p>
<ul className="space-y-2 text-gray-600 dark:text-gray-300 mb-6">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span><strong>Consultations:</strong> Services that need a phone call to understand customer needs before scheduling</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span><strong>Custom services:</strong> Services with variable timing or requirements</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span><strong>High-touch bookings:</strong> Premium services where personal coordination is preferred</span>
</li>
</ul>
<h4 className="font-medium text-gray-900 dark:text-white mb-4">How It Works</h4>
<p className="text-gray-600 dark:text-gray-300 mb-4">
Try the interactive demo below to see the complete workflow:
</p>
{/* Interactive Demo */}
<UnscheduledBookingDemo view="all" />
<h4 className="font-medium text-gray-900 dark:text-white mt-6 mb-4">Settings</h4>
<div className="space-y-3">
<div className="flex items-start gap-3 p-4 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-200 dark:border-orange-800">
<Phone size={20} className="text-orange-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Requires Manual Scheduling</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When enabled, customers booking this service won't select a time slot. Instead, their request goes to your <strong>Pending Requests</strong> sidebar where you can call them back to schedule.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Calendar size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Ask for Preferred Time</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
When enabled (along with manual scheduling), customers can optionally indicate their preferred date and time preferences. This helps you find a suitable time when calling them back.
</p>
</div>
</div>
</div>
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-sm text-green-800 dark:text-green-200">
<strong>Tip:</strong> Pending requests appear in the Scheduler sidebar with a badge count. Click any request to see customer details and their preferred time, then use "Schedule Now" to assign a time slot.
</p>
</div>
</div>
</section>
{/* Service Addons Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<Package size={20} className="text-brand-500" /> Service Addons
</h2>
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
<p className="text-gray-600 dark:text-gray-300 mb-4">
Service addons are optional extras that customers can add to their appointment. Addons come in two types:
</p>
<ul className="space-y-2 text-gray-600 dark:text-gray-300 mb-4">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span><strong>Resource-based addons:</strong> Linked to equipment, rooms, or other resources that must be available when booking (e.g., Projector, Sound System).</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-purple-500 mt-0.5" />
<span><strong>Price-only addons:</strong> Simple extras that add cost without requiring a resource (e.g., Gift Wrapping, Rush Service, Premium Support).</span>
</li>
</ul>
<p className="text-gray-600 dark:text-gray-300 mb-4">
<strong>Example:</strong> A meeting room booking could offer a "Projector" addon (resource-based) and a "Catering Setup Fee" addon (price-only). When a customer selects the projector, the system checks that both the meeting room AND the projector are available. The catering fee simply adds to the total price without blocking any resource.
</p>
{/* Addon Properties */}
<h4 className="font-medium text-gray-900 dark:text-white mt-6 mb-4">Addon Properties</h4>
<div className="space-y-3">
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Tag size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Name</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
A descriptive name customers will see (e.g., "Projector", "Video Conferencing Kit", "Premium Sound System").
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Layers size={20} className="text-purple-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Resource (Optional)</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
The equipment, room, or other resource required for this addon. If selected, the resource's availability is checked during booking. Leave empty for price-only addons that don't require a resource.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<DollarSign size={20} className="text-green-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Price</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Additional cost added to the service price when the customer selects this addon.
</p>
</div>
</div>
</div>
{/* Duration Modes */}
<h4 className="font-medium text-gray-900 dark:text-white mt-6 mb-4">Duration Modes</h4>
<p className="text-gray-600 dark:text-gray-300 mb-4">
Each addon has a duration mode that determines how it affects the appointment time:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start gap-3 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<Zap size={20} className="text-blue-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Concurrent</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
The addon runs during the same time slot as the main service. <strong>No extra time added.</strong> The addon resource is blocked for the service duration.
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">
Example: "Projector" during a meeting room booking - the equipment is used throughout the session.
</p>
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded-lg border border-amber-200 dark:border-amber-800">
<Timer size={20} className="text-amber-500 mt-0.5 shrink-0" />
<div>
<h4 className="font-medium text-gray-900 dark:text-white">Sequential</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
The addon runs after the main service ends. <strong>Adds extra time</strong> to the appointment. The addon resource is blocked for that additional period.
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2 italic">
Example: "Equipment Cleanup" after using a specialized room - adds 15 minutes for reset.
</p>
</div>
</div>
</div>
{/* Managing Addons */}
<h4 className="font-medium text-gray-900 dark:text-white mt-6 mb-4">Managing Addons</h4>
<p className="text-gray-600 dark:text-gray-300 mb-4">
To add or manage addons for a service:
</p>
<ol className="space-y-3">
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">1</span>
<div>
<p className="text-sm text-gray-600 dark:text-gray-300">
Edit an existing service by clicking the pencil icon on its card.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">2</span>
<div>
<p className="text-sm text-gray-600 dark:text-gray-300">
Scroll down to the "Service Addons" section and click to expand it.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">3</span>
<div>
<p className="text-sm text-gray-600 dark:text-gray-300">
Click "Add Addon" to create a new addon. Select a resource, set the name, price, and duration mode.
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-brand-600 text-white text-sm flex items-center justify-center">4</span>
<div>
<p className="text-sm text-gray-600 dark:text-gray-300">
For existing addons, use the toggle to enable/disable, the pencil to edit, or the trash to delete.
</p>
</div>
</li>
</ol>
{/* How Addons Work in Booking */}
<h4 className="font-medium text-gray-900 dark:text-white mt-6 mb-4">How Addons Work During Booking</h4>
<p className="text-gray-600 dark:text-gray-300 mb-4">
When customers book a service with addons:
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span>After selecting a service, customers see available addons they can optionally select.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span>The total price updates to show the service price plus any selected addon prices.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span>Available time slots are calculated based on both the primary resource AND all addon resources being available.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span>For sequential addons, the system finds slots where the addon resource is free immediately after the service ends.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span>When the appointment is created, all resources (primary + addons) are blocked to prevent double-booking.</span>
</li>
</ul>
{/* How Addons Appear on the Scheduler */}
<h4 className="font-medium text-gray-900 dark:text-white mt-6 mb-4">How Addons Appear on the Scheduler</h4>
<p className="text-gray-600 dark:text-gray-300 mb-4">
When an appointment includes addons, the scheduler shows linked blocks to visualize all resources being used:
</p>
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-300 mb-4">
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span><strong>Main appointment block:</strong> Shows on the primary resource's row (e.g., Meeting Room A) with full appointment details.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span><strong>Linked addon blocks:</strong> Light purple blocks with a dashed left border appear on each addon resource's row (e.g., Projector). These show the customer name and "(Addon)" label.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span><strong>Link icon:</strong> Addon blocks display a small link icon indicating they're connected to a main appointment.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-brand-500 mt-0.5" />
<span><strong>Click to edit:</strong> Clicking any linked addon block opens the main appointment for editing.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={16} className="text-purple-500 mt-0.5" />
<span><strong>Price-only addons:</strong> These do NOT show separate blocks on the scheduler since they don't use resources. They only appear in the appointment details when editing.</span>
</li>
</ul>
{/* Visual Example */}
<h5 className="font-medium text-gray-800 dark:text-gray-200 mt-4 mb-3">Visual Example</h5>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Here's how a meeting room booking with a projector addon appears on the scheduler:
</p>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-4">
<div className="min-w-[500px]">
{/* Header Row */}
<div className="flex bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border-r border-gray-200 dark:border-gray-700">
Resources
</div>
<div className="flex-1 flex">
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">9:00 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">10:00 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">11:00 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400">12:00 PM</div>
</div>
</div>
{/* Meeting Room Row */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
Meeting Room A
</div>
<div className="flex-1 flex items-center relative py-2 px-1 min-h-[60px]">
{/* Main appointment block - styled like actual scheduler */}
<div className="absolute left-1 w-[190px] h-[52px] bg-blue-500 border-l-4 border-blue-700 rounded-r shadow-sm flex flex-col justify-center px-3">
<div className="text-white text-sm font-medium truncate">Team Meeting</div>
<div className="text-blue-100 text-xs truncate">John Smith</div>
<div className="text-blue-200 text-xs">9:00 - 10:00 AM</div>
</div>
</div>
</div>
{/* Projector Row */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
Projector
</div>
<div className="flex-1 flex items-center relative py-2 px-1 min-h-[60px]">
{/* Addon block - dashed left border only (like actual scheduler) */}
<div className="absolute left-1 w-[190px] h-[52px] bg-purple-100 dark:bg-purple-900/30 border-l-4 border-dashed border-purple-500 rounded-r shadow-sm flex flex-col justify-center px-3">
<div className="flex items-center gap-1.5">
<span className="text-purple-600 dark:text-purple-400 text-xs">🔗</span>
<span className="text-purple-700 dark:text-purple-300 text-sm font-medium truncate">John Smith</span>
</div>
<div className="text-purple-600 dark:text-purple-400 text-xs italic">(Addon)</div>
<div className="text-purple-500 dark:text-purple-500 text-xs">9:00 - 10:00 AM</div>
</div>
</div>
</div>
{/* Sound System Row (empty) */}
<div className="flex">
<div className="w-32 px-3 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
Sound System
</div>
<div className="flex-1 min-h-[60px] bg-gray-50/50 dark:bg-gray-800/50"></div>
</div>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 italic mb-4">
The main appointment (solid blue with left border) appears on Meeting Room A, while the linked projector addon (purple dashed left border) appears on the Projector row. Both are blocked for the same time slot.
</p>
{/* Sequential Addon Example */}
<h5 className="font-medium text-gray-800 dark:text-gray-200 mt-4 mb-3">Sequential Addon Example</h5>
<p className="text-sm text-gray-600 dark:text-gray-300 mb-3">
Here's how a sequential addon (like Equipment Cleanup) appears - notice it starts after the main appointment ends:
</p>
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden mb-4">
<div className="min-w-[500px]">
{/* Header Row */}
<div className="flex bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-2 text-xs font-medium text-gray-500 dark:text-gray-400 border-r border-gray-200 dark:border-gray-700">
Resources
</div>
<div className="flex-1 flex">
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">9:00 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">10:00 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400 border-r border-gray-100 dark:border-gray-800">11:00 AM</div>
<div className="w-24 px-2 py-2 text-xs text-center text-gray-500 dark:text-gray-400">12:00 PM</div>
</div>
</div>
{/* Training Room Row */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
<div className="w-32 px-3 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
Training Room
</div>
<div className="flex-1 flex items-center relative py-2 px-1 min-h-[60px]">
{/* Main appointment block */}
<div className="absolute left-1 w-[190px] h-[52px] bg-green-500 border-l-4 border-green-700 rounded-r shadow-sm flex flex-col justify-center px-3">
<div className="text-white text-sm font-medium truncate">Workshop Session</div>
<div className="text-green-100 text-xs truncate">Alice Johnson</div>
<div className="text-green-200 text-xs">9:00 - 10:00 AM</div>
</div>
</div>
</div>
{/* Cleanup Crew Row */}
<div className="flex">
<div className="w-32 px-3 py-3 text-sm font-medium text-gray-700 dark:text-gray-300 border-r border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
Cleanup Crew
</div>
<div className="flex-1 flex items-center relative py-2 px-1 min-h-[60px]">
{/* Sequential addon block - dashed left border only */}
<div className="absolute left-[195px] w-[70px] h-[52px] bg-purple-100 dark:bg-purple-900/30 border-l-4 border-dashed border-purple-500 rounded-r shadow-sm flex flex-col justify-center px-2">
<div className="flex items-center gap-1">
<span className="text-purple-600 dark:text-purple-400 text-[10px]">🔗</span>
<span className="text-purple-700 dark:text-purple-300 text-xs font-medium truncate">Cleanup</span>
</div>
<div className="text-purple-600 dark:text-purple-400 text-[10px] italic">(Addon)</div>
<div className="text-purple-500 text-[10px]">10:00-10:15</div>
</div>
</div>
</div>
</div>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 italic">
The main workshop (solid green) runs 9:00-10:00 AM, and the sequential cleanup addon (purple dashed left border) runs 10:00-10:15 AM on the Cleanup Crew resource row.
</p>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800 mb-4">
<h5 className="font-medium text-purple-800 dark:text-purple-200 mb-2">Moving Appointments with Addons</h5>
<ul className="space-y-1 text-sm text-purple-700 dark:text-purple-300">
<li className="flex items-start gap-2">
<ChevronRight size={14} className="text-purple-500 mt-0.5" />
<span>Drag the main appointment to change the time - all linked addon blocks move together.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={14} className="text-purple-500 mt-0.5" />
<span>Moving the main appointment to a different resource keeps the addon resources unchanged.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={14} className="text-purple-500 mt-0.5" />
<span>You cannot drop the main appointment onto one of its addon resources - this is blocked automatically.</span>
</li>
<li className="flex items-start gap-2">
<ChevronRight size={14} className="text-purple-500 mt-0.5" />
<span>Addon blocks cannot be dragged directly - always drag the main appointment to reschedule.</span>
</li>
</ul>
</div>
{/* Use Cases */}
<h4 className="font-medium text-gray-900 dark:text-white mt-6 mb-4">Common Use Cases for Addons</h4>
{/* Resource-based Addons */}
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Resource-Based Addons</h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Equipment Rentals</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Offer projectors, sound systems, or specialized equipment that can be added to meeting room bookings.
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Shared Resources</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Manage limited equipment that multiple services might need - the system prevents double-booking automatically.
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Extended Services</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Offer add-on time slots using sequential mode to extend appointments with additional services.
</p>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Setup/Cleanup Crews</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Add sequential addons for staff resources that prepare or clean up after appointments.
</p>
</div>
</div>
{/* Price-Only Addons */}
<h5 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Price-Only Addons</h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Rush/Priority Service</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Charge extra for expedited service or priority handling without needing a separate resource.
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Gift Wrapping</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Offer gift packaging or special presentation as an optional add-on charge.
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Insurance/Protection</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Add optional insurance or damage protection fees to bookings.
</p>
</div>
<div className="p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
<h5 className="font-medium text-gray-900 dark:text-white mb-2">Premium Support</h5>
<p className="text-sm text-gray-500 dark:text-gray-400">
Offer enhanced support or consultation as a simple price add-on.
</p>
</div>
</div>
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
<p className="text-sm text-green-800 dark:text-green-200">
<strong>Tip:</strong> Addons are great for upselling! Offer equipment rentals, premium options, or extended sessions that customers can add with one click.
</p>
</div>
</div>
</section>
{/* Drag and Drop Reordering Section */}
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">

View File

@@ -273,6 +273,13 @@ export interface Appointment {
location?: number | null; // FK to Location
// Participants (new field)
participants?: Participant[];
// Selected addon IDs
addonIds?: number[];
// Addon resource IDs (for showing linked blocks on addon resource columns)
addonResourceIds?: string[];
// Unscheduled booking - customer preferred time
preferredDatetime?: string; // ISO datetime string - customer's preferred date/time
preferredTimeNotes?: string; // General time preference (e.g., "afternoons", "weekends")
}
export interface Blocker {
@@ -351,6 +358,10 @@ export interface Service {
is_global?: boolean; // If true, service available at all locations
locations?: number[]; // Location IDs where service is offered (used when is_global=false)
// Manual scheduling (unscheduled booking)
requires_manual_scheduling?: boolean; // If true, online bookings go to Pending Requests
capture_preferred_time?: boolean; // If true, ask customers for their preferred time
// Buffer time (frontend-only for now)
prep_time?: number;
takedown_time?: number;
@@ -364,6 +375,51 @@ export interface Service {
// Category (future feature)
category?: string | null;
// Addons
addons?: ServiceAddon[];
addons_count?: number;
}
// Addon duration modes
export type AddonDurationMode = 'CONCURRENT' | 'SEQUENTIAL';
/**
* ServiceAddon - Configuration for a resource that can be offered as an add-on to a service.
*
* When a customer books a service with an addon:
* - Both the primary service resource AND the addon resource must be available
* - For CONCURRENT addons: both resources are blocked for the same time slot
* - For SEQUENTIAL addons: addon resource is blocked after the service ends
*/
export interface ServiceAddon {
id: number;
service: number;
resource: number | null; // Null for simple price add-ons (no resource needed)
resource_name: string | null;
resource_type?: string | null;
name: string;
description: string;
display_order: number;
price?: number; // Price in dollars (computed from price_cents)
price_cents: number;
duration_mode: AddonDurationMode;
additional_duration: number; // Extra minutes for SEQUENTIAL mode
is_active: boolean;
created_at?: string;
updated_at?: string;
}
/**
* SelectedAddon - Addon selection state during booking flow.
*/
export interface SelectedAddon {
addon_id: number;
resource_id: number;
name: string;
price_cents: number;
duration_mode: AddonDurationMode;
additional_duration: number;
}
export interface Metric {