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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
157
frontend/src/components/booking/AddonSelection.tsx
Normal file
157
frontend/src/components/booking/AddonSelection.tsx
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
150
frontend/src/components/booking/ManualSchedulingRequest.tsx
Normal file
150
frontend/src/components/booking/ManualSchedulingRequest.tsx
Normal 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;
|
||||
240
frontend/src/components/help/UnscheduledBookingDemo.tsx
Normal file
240
frontend/src/components/help/UnscheduledBookingDemo.tsx
Normal 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;
|
||||
485
frontend/src/components/services/ServiceAddonManager.tsx
Normal file
485
frontend/src/components/services/ServiceAddonManager.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user