Files
smoothschedule/frontend/src/components/AppointmentModal.tsx
poduck fa7ecf16b1 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>
2025-12-23 21:27:24 -05:00

739 lines
32 KiB
TypeScript

/**
* 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;