This commit includes: - Django backend with multi-tenancy (django-tenants) - React + TypeScript frontend with Vite - Platform administration API with role-based access control - Authentication system with token-based auth - Quick login dev tools for testing different user roles - CORS and CSRF configuration for local development - Docker development environment setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
253 lines
9.7 KiB
TypeScript
253 lines
9.7 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { CalendarPlus, Clock, User, Briefcase, MapPin, FileText, Loader2, Check } from 'lucide-react';
|
|
import { useServices } from '../hooks/useServices';
|
|
import { useResources } from '../hooks/useResources';
|
|
import { useCustomers } from '../hooks/useCustomers';
|
|
import { useCreateAppointment } from '../hooks/useAppointments';
|
|
import { format } from 'date-fns';
|
|
|
|
interface QuickAddAppointmentProps {
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
const QuickAddAppointment: React.FC<QuickAddAppointmentProps> = ({ onSuccess }) => {
|
|
const { t } = useTranslation();
|
|
const { data: services } = useServices();
|
|
const { data: resources } = useResources();
|
|
const { data: customers } = useCustomers();
|
|
const createAppointment = useCreateAppointment();
|
|
|
|
const [customerId, setCustomerId] = useState('');
|
|
const [serviceId, setServiceId] = useState('');
|
|
const [resourceId, setResourceId] = useState('');
|
|
const [date, setDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
|
const [time, setTime] = useState('09:00');
|
|
const [notes, setNotes] = useState('');
|
|
const [showSuccess, setShowSuccess] = useState(false);
|
|
|
|
// Get selected service to auto-fill duration
|
|
const selectedService = useMemo(() => {
|
|
return services?.find(s => s.id === serviceId);
|
|
}, [services, serviceId]);
|
|
|
|
// Generate time slots (every 15 minutes from 6am to 10pm)
|
|
const timeSlots = useMemo(() => {
|
|
const slots = [];
|
|
for (let hour = 6; hour <= 22; hour++) {
|
|
for (let minute = 0; minute < 60; minute += 15) {
|
|
const h = hour.toString().padStart(2, '0');
|
|
const m = minute.toString().padStart(2, '0');
|
|
slots.push(`${h}:${m}`);
|
|
}
|
|
}
|
|
return slots;
|
|
}, []);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (!serviceId || !date || !time) {
|
|
return;
|
|
}
|
|
|
|
const [hours, minutes] = time.split(':').map(Number);
|
|
const startTime = new Date(date);
|
|
startTime.setHours(hours, minutes, 0, 0);
|
|
|
|
try {
|
|
await createAppointment.mutateAsync({
|
|
customerId: customerId || undefined,
|
|
customerName: customerId ? (customers?.find(c => c.id === customerId)?.name || '') : 'Walk-in',
|
|
serviceId,
|
|
resourceId: resourceId || null,
|
|
startTime,
|
|
durationMinutes: selectedService?.durationMinutes || 60,
|
|
status: 'Scheduled',
|
|
notes,
|
|
});
|
|
|
|
// Show success state
|
|
setShowSuccess(true);
|
|
setTimeout(() => setShowSuccess(false), 2000);
|
|
|
|
// Reset form
|
|
setCustomerId('');
|
|
setServiceId('');
|
|
setResourceId('');
|
|
setNotes('');
|
|
setTime('09:00');
|
|
|
|
onSuccess?.();
|
|
} catch (error) {
|
|
console.error('Failed to create appointment:', error);
|
|
}
|
|
};
|
|
|
|
const activeCustomers = customers?.filter(c => c.status === 'Active') || [];
|
|
|
|
return (
|
|
<div className="p-6 bg-white dark:bg-gray-800 border border-gray-100 dark:border-gray-700 rounded-xl shadow-sm">
|
|
<div className="flex items-center gap-3 mb-6">
|
|
<div className="p-2 bg-brand-100 dark:bg-brand-900/30 rounded-lg">
|
|
<CalendarPlus className="h-5 w-5 text-brand-600 dark:text-brand-400" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{t('dashboard.quickAddAppointment', 'Quick Add Appointment')}
|
|
</h3>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{/* Customer Select */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
<User className="inline h-4 w-4 mr-1" />
|
|
{t('appointments.customer', 'Customer')}
|
|
</label>
|
|
<select
|
|
value={customerId}
|
|
onChange={(e) => setCustomerId(e.target.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('appointments.walkIn', 'Walk-in / No customer')}</option>
|
|
{activeCustomers.map((customer) => (
|
|
<option key={customer.id} value={customer.id}>
|
|
{customer.name} {customer.email && `(${customer.email})`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Service Select */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
<Briefcase className="inline h-4 w-4 mr-1" />
|
|
{t('appointments.service', 'Service')} *
|
|
</label>
|
|
<select
|
|
value={serviceId}
|
|
onChange={(e) => setServiceId(e.target.value)}
|
|
required
|
|
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('appointments.selectService', 'Select service...')}</option>
|
|
{services?.map((service) => (
|
|
<option key={service.id} value={service.id}>
|
|
{service.name} ({service.durationMinutes} min - ${service.price})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Resource Select (Optional) */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
<MapPin className="inline h-4 w-4 mr-1" />
|
|
{t('appointments.resource', 'Resource')}
|
|
</label>
|
|
<select
|
|
value={resourceId}
|
|
onChange={(e) => setResourceId(e.target.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('appointments.unassigned', 'Unassigned')}</option>
|
|
{resources?.map((resource) => (
|
|
<option key={resource.id} value={resource.id}>
|
|
{resource.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Date and Time */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
{t('appointments.date', 'Date')} *
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={date}
|
|
onChange={(e) => setDate(e.target.value)}
|
|
required
|
|
min={format(new Date(), 'yyyy-MM-dd')}
|
|
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>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
<Clock className="inline h-4 w-4 mr-1" />
|
|
{t('appointments.time', 'Time')} *
|
|
</label>
|
|
<select
|
|
value={time}
|
|
onChange={(e) => setTime(e.target.value)}
|
|
required
|
|
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"
|
|
>
|
|
{timeSlots.map((slot) => (
|
|
<option key={slot} value={slot}>
|
|
{slot}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Duration Display */}
|
|
{selectedService && (
|
|
<div className="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-2">
|
|
<Clock className="h-4 w-4" />
|
|
{t('appointments.duration', 'Duration')}: {selectedService.durationMinutes} {t('common.minutes', 'minutes')}
|
|
</div>
|
|
)}
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
<FileText className="inline h-4 w-4 mr-1" />
|
|
{t('appointments.notes', 'Notes')}
|
|
</label>
|
|
<textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={2}
|
|
placeholder={t('appointments.notesPlaceholder', 'Optional notes...')}
|
|
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 resize-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Submit Button */}
|
|
<button
|
|
type="submit"
|
|
disabled={createAppointment.isPending || !serviceId}
|
|
className={`w-full py-2.5 px-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2 ${
|
|
showSuccess
|
|
? 'bg-green-600 text-white'
|
|
: 'bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-50 disabled:cursor-not-allowed'
|
|
}`}
|
|
>
|
|
{createAppointment.isPending ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t('common.creating', 'Creating...')}
|
|
</>
|
|
) : showSuccess ? (
|
|
<>
|
|
<Check className="h-4 w-4" />
|
|
{t('common.created', 'Created!')}
|
|
</>
|
|
) : (
|
|
<>
|
|
<CalendarPlus className="h-4 w-4" />
|
|
{t('appointments.addAppointment', 'Add Appointment')}
|
|
</>
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default QuickAddAppointment;
|