/** * 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 = (props) => { const { resources, services, onClose } = props; const isEditMode = props.mode === 'edit'; const { t } = useTranslation(); // Form state const [selectedServiceId, setSelectedServiceId] = useState(''); const [selectedCustomers, setSelectedCustomers] = useState([]); const [customerSearch, setCustomerSearch] = useState(''); const [showCustomerDropdown, setShowCustomerDropdown] = useState(false); const [selectedDateTime, setSelectedDateTime] = useState(''); const [selectedResourceId, setSelectedResourceId] = useState(''); const [duration, setDuration] = useState(30); const [notes, setNotes] = useState(''); const [participants, setParticipants] = useState([]); const [selectedAddonIds, setSelectedAddonIds] = useState([]); const [status, setStatus] = useState('SCHEDULED'); // New customer form state const [showNewCustomerForm, setShowNewCustomerForm] = useState(false); const [newCustomerData, setNewCustomerData] = useState({ name: '', email: '', phone: '', }); // Initialize form state based on mode useEffect(() => { if (isEditMode) { const appointment = (props as EditModeProps).appointment; // Set service setSelectedServiceId(appointment.serviceId || ''); // Set customer(s) - convert from appointment data if (appointment.customerName) { const customerData: Customer = { id: appointment.customerId || '', name: appointment.customerName, email: appointment.customerEmail || '', phone: appointment.customerPhone || '', userId: appointment.customerId || '', }; setSelectedCustomers([customerData]); } // Set date/time const startTime = appointment.startTime; const localDateTime = new Date(startTime.getTime() - startTime.getTimezoneOffset() * 60000) .toISOString() .slice(0, 16); setSelectedDateTime(localDateTime); // Set other fields setSelectedResourceId(appointment.resourceId || ''); setDuration(appointment.durationMinutes || 30); setNotes(appointment.notes || ''); setStatus(appointment.status || 'SCHEDULED'); // Set addons if present if (appointment.addonIds) { setSelectedAddonIds(appointment.addonIds); } // Initialize staff participants from existing appointment participants if (appointment.participants) { const staffParticipants: ParticipantInput[] = appointment.participants .filter(p => p.role === 'STAFF') .map(p => ({ role: 'STAFF' as const, userId: p.userId ? parseInt(p.userId) : undefined, resourceId: p.resourceId ? parseInt(p.resourceId) : undefined, externalEmail: p.externalEmail, externalName: p.externalName, })); setParticipants(staffParticipants); } } else { // Create mode - set defaults const createProps = props as CreateModeProps; const date = createProps.initialDate || new Date(); const minutes = Math.ceil(date.getMinutes() / 15) * 15; date.setMinutes(minutes, 0, 0); const localDateTime = new Date(date.getTime() - date.getTimezoneOffset() * 60000) .toISOString() .slice(0, 16); setSelectedDateTime(localDateTime); setSelectedResourceId(createProps.initialResourceId || ''); } }, [isEditMode, props]); // Fetch customers for search const { data: customers = [] } = useCustomers({ search: customerSearch }); // Create customer mutation const createCustomer = useCreateCustomer(); // Fetch addons for selected service const { data: serviceAddons = [], isLoading: addonsLoading } = useServiceAddons( selectedServiceId ? selectedServiceId : null ); // Filter to only active addons const activeAddons = useMemo(() => { return serviceAddons.filter(addon => addon.is_active); }, [serviceAddons]); // Get selected service details const selectedService = useMemo(() => { return services.find(s => s.id === selectedServiceId); }, [services, selectedServiceId]); // When service changes, update duration to service default and reset addons const handleServiceChange = useCallback((serviceId: string) => { setSelectedServiceId(serviceId); setSelectedAddonIds([]); // Reset addon selections when service changes const service = services.find(s => s.id === serviceId); if (service) { setDuration(service.durationMinutes); } }, [services]); // Handle customer selection from search const handleSelectCustomer = useCallback((customer: Customer) => { // Don't add duplicates if (!selectedCustomers.find(c => c.id === customer.id)) { setSelectedCustomers(prev => [...prev, customer]); } setCustomerSearch(''); setShowCustomerDropdown(false); }, [selectedCustomers]); // Remove a selected customer const handleRemoveCustomer = useCallback((customerId: string) => { setSelectedCustomers(prev => prev.filter(c => c.id !== customerId)); }, []); // Handle creating a new customer const handleCreateCustomer = useCallback(async () => { if (!newCustomerData.name.trim() || !newCustomerData.email.trim()) return; try { const result = await createCustomer.mutateAsync({ name: newCustomerData.name.trim(), email: newCustomerData.email.trim(), phone: newCustomerData.phone.trim(), }); // Add the newly created customer to selection const newCustomer: Customer = { id: String(result.id), name: newCustomerData.name.trim(), email: newCustomerData.email.trim(), phone: newCustomerData.phone.trim(), userId: String(result.user_id || result.user), }; setSelectedCustomers(prev => [...prev, newCustomer]); // Reset and close form setNewCustomerData({ name: '', email: '', phone: '' }); setShowNewCustomerForm(false); setCustomerSearch(''); } catch (error) { console.error('Failed to create customer:', error); } }, [newCustomerData, createCustomer]); // Toggle addon selection const handleToggleAddon = useCallback((addonId: number) => { setSelectedAddonIds(prev => prev.includes(addonId) ? prev.filter(id => id !== addonId) : [...prev, addonId] ); }, []); // Calculate total duration including addons const totalDuration = useMemo(() => { let total = duration; selectedAddonIds.forEach(addonId => { const addon = activeAddons.find(a => a.id === addonId); if (addon && addon.duration_mode === 'SEQUENTIAL') { total += addon.additional_duration || 0; } }); return total; }, [duration, selectedAddonIds, activeAddons]); // Filter customers based on search (exclude already selected) const filteredCustomers = useMemo(() => { if (!customerSearch.trim()) return []; const selectedIds = new Set(selectedCustomers.map(c => c.id)); return customers.filter(c => !selectedIds.has(c.id)).slice(0, 10); }, [customers, customerSearch, selectedCustomers]); // Validation const canSubmit = useMemo(() => { return selectedServiceId && selectedCustomers.length > 0 && selectedDateTime && duration >= 15; }, [selectedServiceId, selectedCustomers, selectedDateTime, duration]); // Handle submit const handleSubmit = useCallback(() => { if (!canSubmit) return; const startTime = new Date(selectedDateTime); if (isEditMode) { (props as EditModeProps).onSave({ serviceId: selectedServiceId, customerIds: selectedCustomers.map(c => c.id), startTime, resourceId: selectedResourceId || null, durationMinutes: totalDuration, status, notes: notes.trim() || undefined, participantsInput: participants.length > 0 ? participants : undefined, addonIds: selectedAddonIds.length > 0 ? selectedAddonIds : undefined, }); } else { (props as CreateModeProps).onCreate({ serviceId: selectedServiceId, customerIds: selectedCustomers.map(c => c.id), startTime, resourceId: selectedResourceId || null, durationMinutes: totalDuration, notes: notes.trim() || undefined, participantsInput: participants.length > 0 ? participants : undefined, addonIds: selectedAddonIds.length > 0 ? selectedAddonIds : undefined, }); } }, [canSubmit, selectedServiceId, selectedCustomers, selectedDateTime, selectedResourceId, totalDuration, status, notes, participants, selectedAddonIds, isEditMode, props]); // Format price for display const formatPrice = (cents: number) => { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', }).format(cents / 100); }; const isSubmitting = props.isSubmitting || false; const modalContent = (
e.stopPropagation()} > {/* Header */}

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

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

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

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