import { memo, useEffect, useState, useCallback, useRef } from 'react'; import { Save, X, User, Check } from 'lucide-react'; import { pb } from '../../lib/pocketbase'; import { loadSettings, getRoleLabel, showServiceLocationFields } from '../../lib/settings'; import { useSettings } from '../../store/settings'; import { formatPhoneInput } from '../../lib/phone'; import { logError } from '../../lib/userMessages'; import { isValidDate, todayISO } from '../../lib/appointments'; import { composeName, parseName, findMatchingCustomer } from '../../lib/customerName'; import { spellCheckProps } from '../../lib/spellcheck'; import type { CustomerWithVehicles, CustomerFormData } from '../../pages/Customers'; import type { Appointment } from '../../types'; interface AppointmentFormData { customerId: string; firstName: string; middleName: string; lastName: string; customerName: string; // composed for payload customerPhone: string; customerEmail: string; vehicleInfo: string; vin: string; date: string; time: string; arrivalWindowMinutes: number; tripMiles: number; durationMinutes: number; advisorName: string; serviceType: string; serviceLocation: string; notes: string; } export type CreateAppointmentFormData = AppointmentFormData; export const EMPTY_FORM: AppointmentFormData = { customerId: '', firstName: '', middleName: '', lastName: '', customerName: '', customerPhone: '', customerEmail: '', vehicleInfo: '', vin: '', date: '', time: '', arrivalWindowMinutes: 0, tripMiles: 0, durationMinutes: 60, advisorName: '', serviceType: '', serviceLocation: '', notes: '', }; interface Props { isOpen: boolean; onClose: () => void; onSave: (data: AppointmentFormData) => Promise; appointment: Appointment | null; customers: CustomerWithVehicles[]; } function AppointmentModalImpl({ isOpen, onClose, onSave, appointment, customers, }: Props) { const setupSettings = useSettings(); const customerNotesSpellCheck = spellCheckProps(setupSettings, setupSettings.spellCheckCustomerNotes); const roleLabel = getRoleLabel(setupSettings); const showLocationFields = showServiceLocationFields(setupSettings); const [form, setForm] = useState({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [showCustomerModal, setShowCustomerModal] = useState(false); const [showCustomerModalFromMatch, setShowCustomerModalFromMatch] = useState(false); // ── Customer matching ── const [suggestedCustomer, setSuggestedCustomer] = useState(null); const [dismissedMatch, setDismissedMatch] = useState(false); const matchTimeoutRef = useRef | null>(null); // Populate form from appointment being edited useEffect(() => { if (appointment) { const parsed = parseName(appointment.customerName || ''); setForm({ customerId: appointment.customerId || '', firstName: parsed.firstName, middleName: parsed.middleName, lastName: parsed.lastName, customerName: appointment.customerName || '', customerPhone: appointment.customerPhone || '', customerEmail: appointment.customerEmail || '', vehicleInfo: appointment.vehicleInfo || '', vin: appointment.vin || '', date: appointment.date || '', time: appointment.time || '', durationMinutes: appointment.durationMinutes ?? 60, advisorName: appointment.advisorName || '', serviceType: appointment.serviceType || '', serviceLocation: appointment.serviceLocation || '', tripMiles: appointment.tripMiles || 0, arrivalWindowMinutes: appointment.arrivalWindowMinutes || 0, notes: appointment.notes || '', }); setSuggestedCustomer(null); setDismissedMatch(false); } else { setForm({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor }); setSuggestedCustomer(null); setDismissedMatch(false); } setError(null); }, [appointment, isOpen]); // ── Customer matching logic (debounced) ── const runMatching = useCallback(() => { if (!form.customerId && !dismissedMatch) { const match = findMatchingCustomer(customers, { firstName: form.firstName, middleName: form.middleName, lastName: form.lastName, phone: form.customerPhone, }); setSuggestedCustomer(match); } else { setSuggestedCustomer(null); } }, [form.firstName, form.middleName, form.lastName, form.customerPhone, form.customerId, customers, dismissedMatch]); useEffect(() => { if (appointment) return; // don't match while editing an existing appointment if (matchTimeoutRef.current) clearTimeout(matchTimeoutRef.current); matchTimeoutRef.current = setTimeout(runMatching, 400); return () => { if (matchTimeoutRef.current) clearTimeout(matchTimeoutRef.current); }; }, [runMatching, appointment]); const handleUseExistingCustomer = useCallback(() => { if (!suggestedCustomer) return; const parsed = parseName(suggestedCustomer.name); setForm((prev) => ({ ...prev, customerId: suggestedCustomer.id, firstName: parsed.firstName, middleName: parsed.middleName, lastName: parsed.lastName, customerName: suggestedCustomer.name, customerPhone: suggestedCustomer.phone || prev.customerPhone, customerEmail: suggestedCustomer.email || prev.customerEmail, })); setSuggestedCustomer(null); setDismissedMatch(false); }, [suggestedCustomer]); const handleDismissMatch = useCallback(() => { setSuggestedCustomer(null); setDismissedMatch(true); }, []); // ── Create new customer in PB ── const ensureCustomerRecord = useCallback(async (): Promise => { // If we already have a customerId, return it if (form.customerId) return form.customerId; const uid = pb.authStore.model?.id; if (!uid) return ''; const name = composeName(form.firstName, form.middleName, form.lastName); if (!name.trim()) return ''; // Check once more for an existing customer in case one was added since mount const existingMatch = findMatchingCustomer(customers, { firstName: form.firstName, middleName: form.middleName, lastName: form.lastName, phone: form.customerPhone, }); if (existingMatch) { // Auto-pick the match setForm((prev) => ({ ...prev, customerId: existingMatch.id })); return existingMatch.id; } // Create the customer record try { const record = await pb.collection('customers').create({ name, phone: form.customerPhone, email: form.customerEmail, address: '', notes: '', userId: uid, }); const newId = record.id; setForm((prev) => ({ ...prev, customerId: newId })); return newId; } catch (err) { logError('Failed to create customer record', err); return ''; } }, [form, customers]); const handleCustomerCreated = async (data: CustomerFormData) => { const uid = pb.authStore.model?.id; if (!uid) return; const name = composeName(data.firstName, data.middleName, data.lastName) || data.name; const record = await pb.collection('customers').create({ name, firstName: data.firstName || '', middleName: data.middleName || '', lastName: data.lastName || '', phone: data.phone || '', email: data.email || '', address: data.address || '', notes: data.notes || '', userId: uid, }); const c: CustomerWithVehicles = { ...record as unknown as import('../../pages/Customers').CustomerRecord, vehicles: [], vehicleCount: 0, }; const parsed = parseName(name); handleChange('customerId', c.id); handleChange('firstName', parsed.firstName); handleChange('middleName', parsed.middleName); handleChange('lastName', parsed.lastName); handleChange('customerName', name); handleChange('customerPhone', c.phone || ''); handleChange('customerEmail', c.email || ''); setShowCustomerModal(false); setShowCustomerModalFromMatch(false); setSuggestedCustomer(null); setDismissedMatch(false); }; const handleChange = (field: keyof AppointmentFormData, value: string | number) => { setForm((prev) => { const next = { ...prev, [field]: value }; // When name fields change, recompose customerName if (field === 'firstName' || field === 'middleName' || field === 'lastName') { const newFirst = field === 'firstName' ? String(value) : prev.firstName; const newMiddle = field === 'middleName' ? String(value) : prev.middleName; const newLast = field === 'lastName' ? String(value) : prev.lastName; const composed = composeName(newFirst, newMiddle, newLast); if (composed !== prev.customerName) { next.customerName = composed; } } return next; }); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); const name = composeName(form.firstName, form.middleName, form.lastName); if (!name.trim()) { setError('Please enter at least the customer\'s first and last name.'); return; } if (!form.date) { setError('Date is required.'); return; } if (!isValidDate(form.date)) { setError('Please enter a valid appointment date.'); return; } if (form.date < todayISO()) { setError('Appointment date cannot be in the past.'); return; } if (!form.time) { setError('Time is required.'); return; } setSaving(true); try { // Auto-create customer record if one doesn't exist yet const customerId = await ensureCustomerRecord(); const payload: AppointmentFormData = { ...form, customerId: customerId || form.customerId, customerName: name, }; await onSave(payload); onClose(); } catch (err: unknown) { logError('Failed to save appointment', err); setError('Appointment could not be saved. Please try again.'); } finally { setSaving(false); } }; if (!isOpen) return null; const name = composeName(form.firstName, form.middleName, form.lastName); return ( <>

Appointment Details

{appointment ? 'Edit Appointment' : 'New Appointment'}

Capture customer, vehicle, and schedule details in one clean service record.

{error && (
{error}
)} {/* ── Customer match suggestion banner ── */} {suggestedCustomer && !form.customerId && (

Existing customer found

{suggestedCustomer.name} {suggestedCustomer.phone && ( <> · {suggestedCustomer.phone} )} {suggestedCustomer.email && ( <> · {suggestedCustomer.email} )}

)}

Customer Information

{/* First / Middle / Last name row */}
handleChange('firstName', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="John" />
handleChange('middleName', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="Michael" />
handleChange('lastName', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="Doe" />
{/* Phone + Email */}
handleChange('customerPhone', e.target.value.replace(/\D/g, ''))} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="(555) 123-4567" />
handleChange('customerEmail', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="john@example.com" />
{/* Selected customer indicator */} {form.customerId && (
Linked to customer record: {name || form.customerName}
)}

Vehicle Information

handleChange('vehicleInfo', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="2020 Toyota Camry" />
handleChange('vin', e.target.value.toUpperCase())} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="1HGBH41JXMN109186" />
handleChange('durationMinutes', parseInt(e.target.value, 10) || 0)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400" />
{showLocationFields && (
handleChange('tripMiles', parseFloat(e.target.value) || 0)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400" placeholder="0" />
)}

Schedule & Service

handleChange('date', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400" />
handleChange('time', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400" />
{showLocationFields && (
)}
handleChange('advisorName', e.target.value)} placeholder={loadSettings().serviceAdvisor || `${roleLabel} name`} className={`w-full rounded-xl border px-3 py-2.5 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20 ${ loadSettings().serviceAdvisor && form.advisorName === loadSettings().serviceAdvisor ? 'border-gray-200 bg-gray-50 text-gray-400 placeholder-gray-400 focus:border-emerald-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-500 dark:placeholder-gray-500 dark:focus:border-emerald-400' : 'border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:border-emerald-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400' }`} />
handleChange('serviceType', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="Oil Change, Brake Service, etc." />
{showLocationFields && (
handleChange('serviceLocation', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="123 Oak St, Knoxville, TN" />
)}