initial commit
This commit is contained in:
@@ -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 && (
|
||||
<> · {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} 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);
|
||||
Reference in New Issue
Block a user