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:
738
frontend/src/components/AppointmentModal.tsx
Normal file
738
frontend/src/components/AppointmentModal.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user