initial commit

This commit is contained in:
ray
2026-07-12 10:01:39 -04:00
commit fc8290d668
185 changed files with 38831 additions and 0 deletions
@@ -0,0 +1,641 @@
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 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<void>;
appointment: Appointment | null;
customers: CustomerWithVehicles[];
}
function AppointmentModalImpl({
isOpen,
onClose,
onSave,
appointment,
customers,
}: Props) {
const setupSettings = useSettings();
const roleLabel = getRoleLabel(setupSettings);
const showLocationFields = showServiceLocationFields(setupSettings);
const [form, setForm] = useState<AppointmentFormData>({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCustomerModal, setShowCustomerModal] = useState(false);
const [showCustomerModalFromMatch, setShowCustomerModalFromMatch] = useState(false);
// ── Customer matching ──
const [suggestedCustomer, setSuggestedCustomer] = useState<CustomerWithVehicles | null>(null);
const [dismissedMatch, setDismissedMatch] = useState(false);
const matchTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string> => {
// 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 (
<>
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-8">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-3xl overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between border-b border-gray-200 bg-gradient-to-r from-emerald-50 via-white to-white px-6 py-5 dark:border-gray-700 dark:from-emerald-950/20 dark:via-gray-900 dark:to-gray-900">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-400">
Appointment Details
</p>
<h2 className="mt-1 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
{appointment ? 'Edit Appointment' : 'New Appointment'}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Capture customer, vehicle, and schedule details in one clean service record.
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/70 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6 bg-gray-50/70 px-6 py-6 dark:bg-gray-950/40">
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{/* ── Customer match suggestion banner ── */}
{suggestedCustomer && !form.customerId && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-700 dark:bg-emerald-900/20">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-800">
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
Existing customer found
</p>
<p className="mt-0.5 text-sm text-emerald-700 dark:text-emerald-300">
<span className="font-semibold">{suggestedCustomer.name}</span>
{suggestedCustomer.phone && (
<> &middot; {suggestedCustomer.phone}</>
)}
{suggestedCustomer.email && (
<> &middot; {suggestedCustomer.email}</>
)}
</p>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={handleUseExistingCustomer}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700"
>
<Check className="h-3.5 w-3.5" />
Use Existing Customer
</button>
<button
type="button"
onClick={handleDismissMatch}
className="rounded-lg border border-emerald-300 bg-white px-3 py-1.5 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-600 dark:bg-gray-800 dark:text-emerald-300 dark:hover:bg-emerald-900/30"
>
Continue as New
</button>
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-6">
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Customer Information</h3>
{/* First / Middle / Last name row */}
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.firstName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Middle
</label>
<input
type="text"
value={form.middleName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.lastName}
onChange={(e) => 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"
/>
</div>
</div>
{/* Phone + Email */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Phone</label>
<input type="tel" value={formatPhoneInput(form.customerPhone)} onChange={(e) => 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" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input type="email" value={form.customerEmail} onChange={(e) => 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" />
</div>
</div>
{/* Selected customer indicator */}
{form.customerId && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50/50 px-3 py-2 dark:border-emerald-700 dark:bg-emerald-900/10">
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
<span className="text-xs text-emerald-700 dark:text-emerald-300">
Linked to customer record: <strong>{name || form.customerName}</strong>
</span>
</div>
)}
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Vehicle Information</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Vehicle Info</label>
<input type="text" value={form.vehicleInfo} onChange={(e) => 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" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">VIN</label>
<input type="text" value={form.vin} onChange={(e) => 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" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Duration (min)</label>
<input type="number" min="0" value={form.durationMinutes} onChange={(e) => 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" />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Trip Miles</label>
<input type="number" min="0" step="0.1" value={form.tripMiles || ''} onChange={(e) => 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" />
</div>
)}
</div>
</section>
</div>
<div className="space-y-6">
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Schedule & Service</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Date <span className="text-red-500">*</span></label>
<input type="date" value={form.date} onChange={(e) => 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" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Time <span className="text-red-500">*</span></label>
<input type="time" value={form.time} onChange={(e) => 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" />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Arrival Window</label>
<select value={form.arrivalWindowMinutes} onChange={(e) => handleChange('arrivalWindowMinutes', 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">
<option value={0}>Exact time</option>
<option value={60}>&#xb1;1 hour</option>
<option value={120}>&#xb1;2 hours</option>
<option value={180}>&#xb1;3 hours</option>
<option value={240}>&#xb1;4 hours</option>
</select>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{roleLabel}</label>
<input
type="text"
value={form.advisorName}
onChange={(e) => 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'
}`}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Service Type</label>
<input type="text" value={form.serviceType} onChange={(e) => 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." />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Service Address</label>
<input type="text" value={form.serviceLocation} onChange={(e) => 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" />
</div>
)}
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">Notes</label>
<textarea value={form.notes} onChange={(e) => handleChange('notes', e.target.value)} rows={6} className="mt-3 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="Any additional notes..." />
</section>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-1 pt-5 dark:border-gray-700 dark:bg-transparent">
<p className="text-sm text-gray-500 dark:text-gray-400">
Confirm the service slot and customer details before saving.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={saving || !form.firstName.trim() || !form.lastName.trim()}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:from-emerald-700 hover:to-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-800"
>
{saving ? (
<>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
{appointment ? 'Update' : 'Create'}
</>
)}
</button>
</div>
</div>
</form>
</div>
</div>
{/* Create Customer Modal */}
{(showCustomerModal || showCustomerModalFromMatch) && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Customer</h2>
<button onClick={() => { setShowCustomerModal(false); setShowCustomerModalFromMatch(false); }} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-5 w-5" /></button>
</div>
<div className="space-y-4 p-6">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">First <span className="text-red-500">*</span></label>
<input type="text" id="new-customer-first" defaultValue={form.firstName} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="John" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Middle</label>
<input type="text" id="new-customer-middle" defaultValue={form.middleName} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="Michael" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Last <span className="text-red-500">*</span></label>
<input type="text" id="new-customer-last" defaultValue={form.lastName} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="Doe" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Phone</label>
<input type="tel" id="new-customer-phone" defaultValue={form.customerPhone} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="Phone" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input type="email" id="new-customer-email" defaultValue={form.customerEmail} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="Email" />
</div>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onClick={() => { setShowCustomerModal(false); setShowCustomerModalFromMatch(false); }} className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">Cancel</button>
<button type="button" onClick={() => {
const firstInput = document.getElementById('new-customer-first') as HTMLInputElement;
const middleInput = document.getElementById('new-customer-middle') as HTMLInputElement;
const lastInput = document.getElementById('new-customer-last') as HTMLInputElement;
const phoneInput = document.getElementById('new-customer-phone') as HTMLInputElement;
const emailInput = document.getElementById('new-customer-email') as HTMLInputElement;
const firstName = firstInput?.value?.trim() || '';
const middleName = middleInput?.value?.trim() || '';
const lastName = lastInput?.value?.trim() || '';
const name = composeName(firstName, middleName, lastName);
if (!name) return;
handleCustomerCreated({
name,
firstName,
middleName,
lastName,
phone: phoneInput?.value || '',
email: emailInput?.value || '',
address: '',
notes: '',
}).catch(() => {});
}} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">Create Customer</button>
</div>
</div>
</div>
</div>
)}
</>);
}
export const AppointmentModal = memo(AppointmentModalImpl);