644 lines
36 KiB
TypeScript
644 lines
36 KiB
TypeScript
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<void>;
|
|
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<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 && (
|
|
<> · {suggestedCustomer.phone}</>
|
|
)}
|
|
{suggestedCustomer.email && (
|
|
<> · {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}>±1 hour</option>
|
|
<option value={120}>±2 hours</option>
|
|
<option value={180}>±3 hours</option>
|
|
<option value={240}>±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} {...customerNotesSpellCheck} 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);
|