Fix reschedule token handling and improve admin notifications
- Fix getBookingByToken to only accept booking_access tokens - Add sweepExpiredRescheduleProposals with admin notifications - Return isExpired flag instead of throwing errors for expired proposals - Fix email template to use actual token expiry time - Remove duplicate admin emails in acceptReschedule - Add one-click accept/decline support via URL parameters
This commit is contained in:
@@ -212,8 +212,8 @@ export function AdminBookings() {
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{booking.customerName}</div>
|
||||
<div className="text-sm text-gray-500">{booking.customerEmail}</div>
|
||||
<div className="text-sm text-gray-500">{booking.customerPhone}</div>
|
||||
<div className="text-sm text-gray-500">{booking.customerEmail || '—'}</div>
|
||||
<div className="text-sm text-gray-500">{booking.customerPhone || '—'}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
|
@@ -5,6 +5,30 @@ import { queryClient } from "@/client/rpc-client";
|
||||
export function AdminCalendar() {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||
const [sendDeleteEmail, setSendDeleteEmail] = useState(false);
|
||||
const [deleteActionType, setDeleteActionType] = useState<'delete' | 'cancel'>('delete');
|
||||
|
||||
// Manual booking modal state
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createFormData, setCreateFormData] = useState({
|
||||
customerName: '',
|
||||
treatmentId: '',
|
||||
appointmentDate: '',
|
||||
appointmentTime: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
notes: ''
|
||||
});
|
||||
const [createError, setCreateError] = useState<string>('');
|
||||
|
||||
// Reschedule modal state
|
||||
const [showRescheduleModal, setShowRescheduleModal] = useState<string | null>(null);
|
||||
const [rescheduleFormData, setRescheduleFormData] = useState({
|
||||
appointmentDate: '',
|
||||
appointmentTime: ''
|
||||
});
|
||||
const [rescheduleError, setRescheduleError] = useState<string>('');
|
||||
|
||||
const { data: bookings } = useQuery(
|
||||
queryClient.bookings.live.list.experimental_liveOptions()
|
||||
@@ -14,10 +38,45 @@ export function AdminCalendar() {
|
||||
queryClient.treatments.live.list.experimental_liveOptions()
|
||||
);
|
||||
|
||||
// Optional query for available times when treatment and date are selected
|
||||
const { data: availableTimes } = useQuery({
|
||||
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
|
||||
input: {
|
||||
date: createFormData.appointmentDate,
|
||||
treatmentId: createFormData.treatmentId
|
||||
}
|
||||
}),
|
||||
enabled: !!createFormData.appointmentDate && !!createFormData.treatmentId
|
||||
});
|
||||
|
||||
// Available times for reschedule modal
|
||||
const { data: rescheduleAvailableTimes } = useQuery({
|
||||
...queryClient.recurringAvailability.getAvailableTimes.queryOptions({
|
||||
input: {
|
||||
date: rescheduleFormData.appointmentDate,
|
||||
treatmentId: (showRescheduleModal ? bookings?.find(b => b.id === showRescheduleModal)?.treatmentId : '') || ''
|
||||
}
|
||||
}),
|
||||
enabled: !!showRescheduleModal && !!rescheduleFormData.appointmentDate
|
||||
});
|
||||
|
||||
const { mutate: updateBookingStatus } = useMutation(
|
||||
queryClient.bookings.updateStatus.mutationOptions()
|
||||
);
|
||||
|
||||
const { mutate: removeBooking } = useMutation(
|
||||
queryClient.bookings.remove.mutationOptions()
|
||||
);
|
||||
|
||||
const { mutate: createManualBooking } = useMutation(
|
||||
queryClient.bookings.createManual.mutationOptions()
|
||||
);
|
||||
|
||||
// Propose reschedule mutation
|
||||
const { mutate: proposeReschedule, isLoading: isProposingReschedule } = useMutation(
|
||||
queryClient.bookings.proposeReschedule.mutationOptions()
|
||||
);
|
||||
|
||||
const getTreatmentName = (treatmentId: string) => {
|
||||
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
||||
};
|
||||
@@ -106,8 +165,116 @@ export function AdminCalendar() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId || !showDeleteConfirm) return;
|
||||
|
||||
if (deleteActionType === 'cancel') {
|
||||
// For cancel action, use updateStatus instead of remove
|
||||
updateBookingStatus({
|
||||
sessionId,
|
||||
id: showDeleteConfirm,
|
||||
status: "cancelled"
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteConfirm(null);
|
||||
setSendDeleteEmail(false);
|
||||
setDeleteActionType('delete');
|
||||
},
|
||||
onError: () => {
|
||||
// no-op; errors can be surfaced via existing patterns/toasts later
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// For delete action, use remove with email option
|
||||
removeBooking({
|
||||
sessionId,
|
||||
id: showDeleteConfirm,
|
||||
sendEmail: sendDeleteEmail,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowDeleteConfirm(null);
|
||||
setSendDeleteEmail(false);
|
||||
setDeleteActionType('delete');
|
||||
},
|
||||
onError: () => {
|
||||
// no-op; errors can be surfaced via existing patterns/toasts later
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
const handleCreateBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId) return;
|
||||
|
||||
createManualBooking({
|
||||
sessionId,
|
||||
...createFormData
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowCreateModal(false);
|
||||
setCreateFormData({
|
||||
customerName: '',
|
||||
treatmentId: '',
|
||||
appointmentDate: '',
|
||||
appointmentTime: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
notes: ''
|
||||
});
|
||||
setCreateError('');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setCreateError(error?.message || 'Fehler beim Erstellen der Buchung');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleFormChange = (field: string, value: string) => {
|
||||
setCreateFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
// Reset time when treatment or date changes
|
||||
...(field === 'treatmentId' || field === 'appointmentDate' ? { appointmentTime: '' } : {})
|
||||
}));
|
||||
setCreateError('');
|
||||
};
|
||||
|
||||
const handleRescheduleFormChange = (field: string, value: string) => {
|
||||
setRescheduleFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
...(field === 'appointmentDate' ? { appointmentTime: '' } : {})
|
||||
}));
|
||||
setRescheduleError('');
|
||||
};
|
||||
|
||||
const handleRescheduleBooking = () => {
|
||||
const sessionId = localStorage.getItem('sessionId');
|
||||
if (!sessionId || !showRescheduleModal) return;
|
||||
const booking = bookings?.find(b => b.id === showRescheduleModal);
|
||||
if (!booking) return;
|
||||
|
||||
proposeReschedule({
|
||||
sessionId,
|
||||
bookingId: booking.id,
|
||||
proposedDate: rescheduleFormData.appointmentDate,
|
||||
proposedTime: rescheduleFormData.appointmentTime,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setShowRescheduleModal(null);
|
||||
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
|
||||
setRescheduleError('');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setRescheduleError(error?.message || 'Fehler beim Senden des Vorschlags');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
|
||||
@@ -153,9 +320,17 @@ export function AdminCalendar() {
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{monthNames[month]} {year}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-4">
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{monthNames[month]} {year}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-pink-600 text-white rounded-lg hover:bg-pink-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
Termin erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => navigateMonth('next')}
|
||||
@@ -267,10 +442,10 @@ export function AdminCalendar() {
|
||||
<strong>Uhrzeit:</strong> {booking.appointmentTime}
|
||||
</div>
|
||||
<div>
|
||||
<strong>E-Mail:</strong> {booking.customerEmail}
|
||||
<strong>E-Mail:</strong> {booking.customerEmail || '—'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Telefon:</strong> {booking.customerPhone}
|
||||
<strong>Telefon:</strong> {booking.customerPhone || '—'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -293,7 +468,11 @@ export function AdminCalendar() {
|
||||
Bestätigen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(booking.id, "cancelled")}
|
||||
onClick={() => {
|
||||
setDeleteActionType('cancel');
|
||||
setShowDeleteConfirm(booking.id);
|
||||
setSendDeleteEmail(true); // Default to sending email for cancel
|
||||
}}
|
||||
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Stornieren
|
||||
@@ -302,13 +481,51 @@ export function AdminCalendar() {
|
||||
)}
|
||||
|
||||
{booking.status === "confirmed" && (
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(booking.id, "completed")}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Als erledigt markieren
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleStatusUpdate(booking.id, "completed")}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Als erledigt markieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRescheduleModal(booking.id);
|
||||
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
|
||||
setRescheduleError('');
|
||||
}}
|
||||
className="px-3 py-1 bg-orange-600 text-white text-sm rounded hover:bg-orange-700 transition-colors"
|
||||
>
|
||||
Umbuchen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(() => {
|
||||
const isPastDate = booking.appointmentDate < today;
|
||||
const isCompleted = booking.status === 'completed';
|
||||
const shouldDisableDelete = isPastDate || isCompleted;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!shouldDisableDelete) {
|
||||
setDeleteActionType('delete');
|
||||
setShowDeleteConfirm(booking.id);
|
||||
setSendDeleteEmail(isPastDate || isCompleted ? false : false); // Disable email for past/completed
|
||||
}
|
||||
}}
|
||||
disabled={shouldDisableDelete}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
shouldDisableDelete
|
||||
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
|
||||
: 'bg-red-600 text-white hover:bg-red-700'
|
||||
}`}
|
||||
title={shouldDisableDelete ? 'Löschen für vergangene/abgeschlossene Termine nicht verfügbar' : ''}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,6 +534,289 @@ export function AdminCalendar() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showDeleteConfirm !== null && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{deleteActionType === 'cancel' ? 'Termin stornieren?' : 'Termin löschen?'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{deleteActionType === 'cancel'
|
||||
? 'Dieser Termin wird als storniert markiert. Der Zeitslot wird wieder freigegeben.'
|
||||
: 'Dieser Termin wird als storniert markiert. Der Zeitslot wird wieder freigegeben.'
|
||||
}
|
||||
</p>
|
||||
{deleteActionType === 'delete' && (
|
||||
<label className="flex items-center space-x-2 mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sendDeleteEmail}
|
||||
onChange={(e) => setSendDeleteEmail(e.target.checked)}
|
||||
className="h-4 w-4 text-pink-600 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Stornierungsmail an Kunde senden</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row sm:space-x-3 space-y-2 sm:space-y-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(null);
|
||||
setSendDeleteEmail(false);
|
||||
setDeleteActionType('delete');
|
||||
}}
|
||||
className="px-4 py-2 rounded bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors w-full"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteBooking}
|
||||
className="px-4 py-2 rounded bg-red-600 text-white hover:bg-red-700 transition-colors w-full"
|
||||
>
|
||||
{deleteActionType === 'cancel' ? 'Stornieren' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Manual Booking Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-4">Termin erstellen</h4>
|
||||
|
||||
{createError && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-md text-sm">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Customer Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Kundenname *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createFormData.customerName}
|
||||
onChange={(e) => handleFormChange('customerName', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Treatment */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Behandlung *
|
||||
</label>
|
||||
<select
|
||||
value={createFormData.treatmentId}
|
||||
onChange={(e) => handleFormChange('treatmentId', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
>
|
||||
<option value="">Behandlung wählen</option>
|
||||
{treatments?.map(treatment => (
|
||||
<option key={treatment.id} value={treatment.id}>
|
||||
{treatment.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Datum *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={createFormData.appointmentDate}
|
||||
onChange={(e) => handleFormChange('appointmentDate', e.target.value)}
|
||||
min={today}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Uhrzeit *
|
||||
</label>
|
||||
<select
|
||||
value={createFormData.appointmentTime}
|
||||
onChange={(e) => handleFormChange('appointmentTime', e.target.value)}
|
||||
disabled={!createFormData.treatmentId || !createFormData.appointmentDate}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500 disabled:bg-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">Zeit wählen</option>
|
||||
{availableTimes?.map(time => (
|
||||
<option key={time} value={time}>
|
||||
{time}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(!createFormData.treatmentId || !createFormData.appointmentDate) && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Wähle zuerst Behandlung und Datum
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
E-Mail (optional)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={createFormData.customerEmail}
|
||||
onChange={(e) => handleFormChange('customerEmail', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Telefon (optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={createFormData.customerPhone}
|
||||
onChange={(e) => handleFormChange('customerPhone', e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notizen (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={createFormData.notes}
|
||||
onChange={(e) => handleFormChange('notes', e.target.value)}
|
||||
rows={3}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-pink-500 focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setCreateFormData({
|
||||
customerName: '',
|
||||
treatmentId: '',
|
||||
appointmentDate: '',
|
||||
appointmentTime: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
notes: ''
|
||||
});
|
||||
setCreateError('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateBooking}
|
||||
disabled={!createFormData.customerName || !createFormData.treatmentId || !createFormData.appointmentDate || !createFormData.appointmentTime}
|
||||
className="flex-1 px-4 py-2 bg-pink-600 text-white rounded-md hover:bg-pink-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Termin erstellen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reschedule Modal */}
|
||||
{showRescheduleModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-4">Termin umbuchen</h4>
|
||||
|
||||
{rescheduleError && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-3 py-2 rounded-md text-sm">
|
||||
{rescheduleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const booking = bookings?.find(b => b.id === showRescheduleModal);
|
||||
const treatmentName = booking ? getTreatmentName(booking.treatmentId) : '';
|
||||
return booking ? (
|
||||
<div className="mb-4 text-sm text-gray-700">
|
||||
<div className="mb-2"><strong>Kunde:</strong> {booking.customerName}</div>
|
||||
<div className="mb-2"><strong>Aktueller Termin:</strong> {booking.appointmentDate} um {booking.appointmentTime} Uhr</div>
|
||||
<div className="mb-2"><strong>Behandlung:</strong> {treatmentName}</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neues Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={rescheduleFormData.appointmentDate}
|
||||
onChange={(e) => handleRescheduleFormChange('appointmentDate', e.target.value)}
|
||||
min={today}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Neue Uhrzeit</label>
|
||||
<select
|
||||
value={rescheduleFormData.appointmentTime}
|
||||
onChange={(e) => handleRescheduleFormChange('appointmentTime', e.target.value)}
|
||||
disabled={!rescheduleFormData.appointmentDate}
|
||||
className="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-orange-500 disabled:bg-gray-100"
|
||||
>
|
||||
<option value="">Zeit wählen</option>
|
||||
{rescheduleAvailableTimes?.map(time => (
|
||||
<option key={time} value={time}>{time}</option>
|
||||
))}
|
||||
</select>
|
||||
{!rescheduleFormData.appointmentDate && (
|
||||
<p className="text-xs text-gray-500 mt-1">Wähle zuerst ein Datum</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 text-amber-800 px-3 py-2 rounded-md text-sm">
|
||||
Der Kunde erhält eine E-Mail mit dem Vorschlag. Er hat 48 Stunden Zeit zu antworten.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowRescheduleModal(null);
|
||||
setRescheduleFormData({ appointmentDate: '', appointmentTime: '' });
|
||||
setRescheduleError('');
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRescheduleBooking}
|
||||
disabled={!rescheduleFormData.appointmentDate || !rescheduleFormData.appointmentTime || isProposingReschedule}
|
||||
className="flex-1 px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isProposingReschedule ? 'Senden...' : 'Vorschlag senden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryClient } from "@/client/rpc-client";
|
||||
|
||||
@@ -57,12 +57,27 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
|
||||
const [rescheduleProposal, setRescheduleProposal] = useState<any | null>(null);
|
||||
const [rescheduleResult, setRescheduleResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
const [isDeclining, setIsDeclining] = useState(false);
|
||||
const [showDeclineConfirm, setShowDeclineConfirm] = useState(false);
|
||||
const [oneClickAction, setOneClickAction] = useState<string | null>(null);
|
||||
const [oneClickLoading, setOneClickLoading] = useState(false);
|
||||
|
||||
// Fetch booking details
|
||||
const { data: booking, isLoading, error, refetch } = useQuery(
|
||||
const { data: booking, isLoading, error, refetch, error: bookingError } = useQuery(
|
||||
queryClient.cancellation.getBookingByToken.queryOptions({ input: { token } })
|
||||
);
|
||||
|
||||
// Try fetching reschedule proposal if booking not found or error
|
||||
useQuery({
|
||||
...queryClient.cancellation.getRescheduleProposal.queryOptions({ input: { token } }),
|
||||
enabled: !!token && (!!bookingError || !booking),
|
||||
onSuccess: (data: any) => setRescheduleProposal(data),
|
||||
onError: () => setRescheduleProposal(null),
|
||||
});
|
||||
|
||||
// Cancellation mutation
|
||||
const cancelMutation = useMutation({
|
||||
...queryClient.cancellation.cancelByToken.mutationOptions(),
|
||||
@@ -85,12 +100,88 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
...queryClient.bookings.acceptReschedule.mutationOptions(),
|
||||
onSuccess: (result: any) => {
|
||||
setRescheduleResult({ success: true, message: result.message });
|
||||
setIsAccepting(false);
|
||||
setShowDeclineConfirm(false);
|
||||
refetch();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
|
||||
setIsAccepting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const declineMutation = useMutation({
|
||||
...queryClient.bookings.declineReschedule.mutationOptions(),
|
||||
onSuccess: (result: any) => {
|
||||
setRescheduleResult({ success: true, message: result.message });
|
||||
setIsDeclining(false);
|
||||
setShowDeclineConfirm(false);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setRescheduleResult({ success: false, message: error?.message || 'Ein Fehler ist aufgetreten.' });
|
||||
setIsDeclining(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsCancelling(true);
|
||||
setCancellationResult(null);
|
||||
cancelMutation.mutate({ token });
|
||||
};
|
||||
|
||||
// Handle one-click actions from URL parameters
|
||||
useEffect(() => {
|
||||
if (rescheduleProposal && !oneClickAction) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const action = urlParams.get('action');
|
||||
|
||||
if (action === 'accept' || action === 'decline') {
|
||||
setOneClickAction(action);
|
||||
}
|
||||
}
|
||||
}, [rescheduleProposal, oneClickAction]);
|
||||
|
||||
// Auto-execute one-click action
|
||||
useEffect(() => {
|
||||
if (oneClickAction && rescheduleProposal && !oneClickLoading && !rescheduleResult) {
|
||||
setOneClickLoading(true);
|
||||
|
||||
if (oneClickAction === 'accept') {
|
||||
const confirmAccept = window.confirm(
|
||||
`Möchtest du den neuen Termin am ${rescheduleProposal.proposed.date} um ${rescheduleProposal.proposed.time} Uhr akzeptieren?`
|
||||
);
|
||||
if (confirmAccept) {
|
||||
acceptMutation.mutate({ token });
|
||||
} else {
|
||||
setOneClickLoading(false);
|
||||
setOneClickAction(null);
|
||||
}
|
||||
} else if (oneClickAction === 'decline') {
|
||||
const confirmDecline = window.confirm(
|
||||
`Möchtest du den Vorschlag ablehnen? Dein ursprünglicher Termin am ${rescheduleProposal.original.date} um ${rescheduleProposal.original.time} Uhr bleibt dann bestehen.`
|
||||
);
|
||||
if (confirmDecline) {
|
||||
declineMutation.mutate({ token });
|
||||
} else {
|
||||
setOneClickLoading(false);
|
||||
setOneClickAction(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [oneClickAction, rescheduleProposal, oneClickLoading, rescheduleResult, acceptMutation, declineMutation, token]);
|
||||
|
||||
// Reset one-click loading when mutations complete
|
||||
useEffect(() => {
|
||||
if (rescheduleResult) {
|
||||
setOneClickLoading(false);
|
||||
setOneClickAction(null);
|
||||
}
|
||||
}, [rescheduleResult]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||
@@ -104,7 +195,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (error && !rescheduleProposal) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||
@@ -140,7 +231,7 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!booking) {
|
||||
if (!booking && !rescheduleProposal) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||
@@ -161,6 +252,141 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (rescheduleProposal) {
|
||||
const isExpired = rescheduleProposal.isExpired;
|
||||
const handleAccept = () => {
|
||||
setIsAccepting(true);
|
||||
setRescheduleResult(null);
|
||||
acceptMutation.mutate({ token });
|
||||
};
|
||||
const handleDecline = () => {
|
||||
setIsDeclining(true);
|
||||
setRescheduleResult(null);
|
||||
declineMutation.mutate({ token });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-pink-50 to-purple-50 py-8 px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<img src="/assets/stargilnails_logo_transparent_112.png" alt="Stargil Nails Logo" className="w-16 h-16 object-contain" />
|
||||
<span className={`px-4 py-2 rounded-full text-sm font-semibold bg-orange-100 text-orange-800`}>
|
||||
⚠️ Terminänderung vorgeschlagen
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Vorschlag zur Terminänderung</h1>
|
||||
<p className="text-gray-600 mt-1">Bitte bestätige, ob der neue Termin für dich passt.</p>
|
||||
</div>
|
||||
|
||||
{oneClickLoading && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-blue-50 border border-blue-200">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-500 mr-3"></div>
|
||||
<p className="text-blue-800">
|
||||
{oneClickAction === 'accept' ? 'Akzeptiere Termin...' : 'Lehne Termin ab...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rescheduleResult && (
|
||||
<div className={`mb-6 p-4 rounded-lg ${rescheduleResult.success ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'}`}>
|
||||
<p className={rescheduleResult.success ? 'text-green-800' : 'text-red-800'}>
|
||||
{rescheduleResult.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Vergleich</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border rounded-lg p-4 bg-gray-50">
|
||||
<div className="text-sm text-gray-500 font-semibold mb-1">Aktueller Termin</div>
|
||||
<div className="text-gray-900 font-medium">{rescheduleProposal.original.date} um {rescheduleProposal.original.time} Uhr</div>
|
||||
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-orange-50">
|
||||
<div className="text-sm text-orange-700 font-semibold mb-1">Neuer Vorschlag</div>
|
||||
<div className="text-gray-900 font-medium">{rescheduleProposal.proposed.date} um {rescheduleProposal.proposed.time} Uhr</div>
|
||||
<div className="text-gray-700 text-sm">{rescheduleProposal.booking.treatmentName}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 bg-yellow-50 border border-yellow-200 rounded-lg p-3 text-sm text-yellow-800">
|
||||
Bitte antworte bis {new Date(rescheduleProposal.expiresAt).toLocaleDateString('de-DE')} {new Date(rescheduleProposal.expiresAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} ({rescheduleProposal.hoursUntilExpiry} Stunden).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isExpired && !rescheduleResult && !oneClickLoading && (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
disabled={isAccepting}
|
||||
className="flex-1 bg-green-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isAccepting ? 'Akzeptiere...' : 'Neuen Termin akzeptieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeclineConfirm(true)}
|
||||
disabled={isDeclining}
|
||||
className="flex-1 bg-red-600 text-white py-3 px-4 rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Vorschlag ablehnen
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600">Wenn du ablehnst, bleibt dein ursprünglicher Termin bestehen.</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
Diese Terminänderung ist abgelaufen. Dein ursprünglicher Termin bleibt bestehen. Bei Fragen kontaktiere uns bitte.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDeclineConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h4 className="text-lg font-semibold text-gray-900 mb-2">Vorschlag ablehnen?</h4>
|
||||
<p className="text-sm text-gray-700 mb-4">
|
||||
Bist du sicher, dass du den neuen Terminvorschlag ablehnen möchtest?<br />
|
||||
Dein ursprünglicher Termin am {rescheduleProposal.original.date} um {rescheduleProposal.original.time} bleibt dann bestehen.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeclineConfirm(false)}
|
||||
className="flex-1 bg-gray-100 text-gray-700 py-2 rounded-lg hover:bg-gray-200"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDecline}
|
||||
disabled={isDeclining}
|
||||
className="flex-1 bg-red-600 text-white py-2 rounded-lg hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isDeclining ? 'Lehne ab...' : 'Ja, ablehnen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<a href="/" className="inline-flex items-center text-pink-600 hover:text-pink-700 font-medium">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Zurück zur Startseite
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = getStatusInfo(booking.status);
|
||||
|
||||
return (
|
||||
@@ -285,11 +511,11 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
||||
</div>
|
||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">E-Mail:</span>
|
||||
<span className="font-medium text-gray-900">{booking.customerEmail}</span>
|
||||
<span className="font-medium text-gray-900">{booking.customerEmail || '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-600">Telefon:</span>
|
||||
<span className="font-medium text-gray-900">{booking.customerPhone}</span>
|
||||
<span className="font-medium text-gray-900">{booking.customerPhone || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{booking.notes && (
|
||||
|
@@ -169,3 +169,145 @@ export async function renderAdminBookingNotificationHTML(params: {
|
||||
}
|
||||
|
||||
|
||||
export async function renderBookingRescheduleProposalHTML(params: {
|
||||
name: string;
|
||||
originalDate: string;
|
||||
originalTime: string;
|
||||
proposedDate: string;
|
||||
proposedTime: string;
|
||||
treatmentName: string;
|
||||
acceptUrl: string;
|
||||
declineUrl: string;
|
||||
expiresAt: string;
|
||||
}) {
|
||||
const formattedOriginalDate = formatDateGerman(params.originalDate);
|
||||
const formattedProposedDate = formatDateGerman(params.proposedDate);
|
||||
const expiryDate = new Date(params.expiresAt);
|
||||
const formattedExpiry = `${expiryDate.toLocaleDateString('de-DE')} ${expiryDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
const inner = `
|
||||
<p>Hallo ${params.name},</p>
|
||||
<p>wir müssen deinen Termin leider verschieben. Hier ist unser Vorschlag:</p>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #f59e0b; padding: 16px; margin: 20px 0; border-radius: 4px;">
|
||||
<p style="margin: 0; font-weight: 600; color: #92400e;">📅 Übersicht</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" style="width:100%; margin-top:8px; font-size:14px; color:#475569;">
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Alter Termin</strong></td>
|
||||
<td style="padding:6px 0;">${formattedOriginalDate} um ${params.originalTime} Uhr</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Neuer Vorschlag</strong></td>
|
||||
<td style="padding:6px 0; color:#b45309;"><strong>${formattedProposedDate} um ${params.proposedTime} Uhr</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0; width:45%"><strong>Behandlung</strong></td>
|
||||
<td style="padding:6px 0;">${params.treatmentName}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div style="background-color: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px; margin: 16px 0; border-radius: 4px; color:#92400e;">
|
||||
⏰ Bitte antworte bis ${formattedExpiry}.
|
||||
</div>
|
||||
<div style="text-align:center; margin: 20px 0;">
|
||||
<a href="${params.acceptUrl}" style="display:inline-block; background-color:#16a34a; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700; margin-right:8px;">Neuen Termin akzeptieren</a>
|
||||
<a href="${params.declineUrl}" style="display:inline-block; background-color:#dc2626; color:#ffffff; padding:12px 18px; border-radius:8px; text-decoration:none; font-weight:700;">Termin ablehnen</a>
|
||||
</div>
|
||||
<div style="background-color: #f8fafc; border-left: 4px solid #10b981; padding: 12px; margin: 16px 0; border-radius: 4px; color:#065f46;">
|
||||
Wenn du den Vorschlag ablehnst, bleibt dein ursprünglicher Termin bestehen und wir kontaktieren dich für eine alternative Lösung.
|
||||
</div>
|
||||
<p>Falls du einen komplett neuen Termin buchen möchtest, kannst du deinen aktuellen Termin stornieren und einen neuen Termin auf unserer Website buchen.</p>
|
||||
<p>Liebe Grüße,<br/>Stargirlnails Kiel</p>
|
||||
`;
|
||||
return renderBrandedEmail("Terminänderung vorgeschlagen", inner);
|
||||
}
|
||||
|
||||
export async function renderAdminRescheduleDeclinedHTML(params: {
|
||||
customerName: string;
|
||||
originalDate: string;
|
||||
originalTime: string;
|
||||
proposedDate: string;
|
||||
proposedTime: string;
|
||||
treatmentName: string;
|
||||
customerEmail?: string;
|
||||
customerPhone?: string;
|
||||
}) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag abgelehnt.</p>
|
||||
<div style="background-color:#f8fafc; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
${params.customerEmail ? `<li><strong>E-Mail:</strong> ${params.customerEmail}</li>` : ''}
|
||||
${params.customerPhone ? `<li><strong>Telefon:</strong> ${params.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr (bleibt bestehen)</li>
|
||||
<li><strong>Abgelehnter Vorschlag:</strong> ${formatDateGerman(params.proposedDate)} um ${params.proposedTime} Uhr</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Bitte kontaktiere den Kunden, um eine alternative Lösung zu finden.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Kunde hat Terminänderung abgelehnt", inner);
|
||||
}
|
||||
|
||||
export async function renderAdminRescheduleAcceptedHTML(params: {
|
||||
customerName: string;
|
||||
originalDate: string;
|
||||
originalTime: string;
|
||||
newDate: string;
|
||||
newTime: string;
|
||||
treatmentName: string;
|
||||
}) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p>der Kunde <strong>${params.customerName}</strong> hat den Terminänderungsvorschlag akzeptiert.</p>
|
||||
<div style="background-color:#ecfeff; border-left:4px solid #10b981; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:14px;">
|
||||
<li><strong>Kunde:</strong> ${params.customerName}</li>
|
||||
<li><strong>Behandlung:</strong> ${params.treatmentName}</li>
|
||||
<li><strong>Alter Termin:</strong> ${formatDateGerman(params.originalDate)} um ${params.originalTime} Uhr</li>
|
||||
<li><strong>Neuer Termin:</strong> ${formatDateGerman(params.newDate)} um ${params.newTime} Uhr ✅</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>Der Termin wurde automatisch aktualisiert.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Kunde hat Terminänderung akzeptiert", inner);
|
||||
}
|
||||
|
||||
export async function renderAdminRescheduleExpiredHTML(params: {
|
||||
expiredProposals: Array<{
|
||||
customerName: string;
|
||||
originalDate: string;
|
||||
originalTime: string;
|
||||
proposedDate: string;
|
||||
proposedTime: string;
|
||||
treatmentName: string;
|
||||
customerEmail?: string;
|
||||
customerPhone?: string;
|
||||
expiredAt: string;
|
||||
}>;
|
||||
}) {
|
||||
const inner = `
|
||||
<p>Hallo Admin,</p>
|
||||
<p><strong>${params.expiredProposals.length} Terminänderungsvorschlag${params.expiredProposals.length > 1 ? 'e' : ''} ${params.expiredProposals.length > 1 ? 'sind' : 'ist'} abgelaufen</strong> und wurde${params.expiredProposals.length > 1 ? 'n' : ''} automatisch entfernt.</p>
|
||||
<div style="background-color:#fef2f2; border-left:4px solid #ef4444; padding:16px; margin:16px 0; border-radius:4px;">
|
||||
<p style="margin:0 0 12px 0; font-weight:600; color:#dc2626;">⚠️ Abgelaufene Vorschläge:</p>
|
||||
${params.expiredProposals.map(proposal => `
|
||||
<div style="background-color:#ffffff; border:1px solid #fecaca; border-radius:4px; padding:12px; margin:8px 0;">
|
||||
<ul style="margin:0; padding:0; list-style:none; color:#475569; font-size:13px;">
|
||||
<li><strong>Kunde:</strong> ${proposal.customerName}</li>
|
||||
${proposal.customerEmail ? `<li><strong>E-Mail:</strong> ${proposal.customerEmail}</li>` : ''}
|
||||
${proposal.customerPhone ? `<li><strong>Telefon:</strong> ${proposal.customerPhone}</li>` : ''}
|
||||
<li><strong>Behandlung:</strong> ${proposal.treatmentName}</li>
|
||||
<li><strong>Ursprünglicher Termin:</strong> ${formatDateGerman(proposal.originalDate)} um ${proposal.originalTime} Uhr</li>
|
||||
<li><strong>Vorgeschlagener Termin:</strong> ${formatDateGerman(proposal.proposedDate)} um ${proposal.proposedTime} Uhr</li>
|
||||
<li><strong>Abgelaufen am:</strong> ${new Date(proposal.expiredAt).toLocaleString('de-DE')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
<p style="color:#dc2626; font-weight:600;">Bitte kontaktiere die Kunden, um eine alternative Lösung zu finden.</p>
|
||||
<p>Die ursprünglichen Termine bleiben bestehen.</p>
|
||||
`;
|
||||
return renderBrandedEmail("Abgelaufene Terminänderungsvorschläge", inner);
|
||||
}
|
||||
|
||||
|
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { randomUUID } from "crypto";
|
||||
import { createKV } from "../lib/create-kv.js";
|
||||
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates.js";
|
||||
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML, renderBookingRescheduleProposalHTML, renderAdminRescheduleAcceptedHTML, renderAdminRescheduleDeclinedHTML } from "../lib/email-templates.js";
|
||||
import { router as rootRouter } from "./index.js";
|
||||
import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
@@ -128,12 +128,23 @@ async function checkBookingConflicts(
|
||||
}
|
||||
}
|
||||
|
||||
const CreateBookingInputSchema = z.object({
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(), // ISO date string
|
||||
appointmentTime: z.string(), // HH:MM format
|
||||
notes: z.string().optional(),
|
||||
inspirationPhoto: z.string().optional(), // Base64 encoded image data
|
||||
});
|
||||
|
||||
const BookingSchema = z.object({
|
||||
id: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(), // ISO date string
|
||||
appointmentTime: z.string(), // HH:MM format
|
||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||
@@ -195,7 +206,7 @@ type Treatment = {
|
||||
const treatmentsKV = createKV<Treatment>("treatments");
|
||||
|
||||
const create = os
|
||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
||||
.input(CreateBookingInputSchema)
|
||||
.handler(async ({ input }) => {
|
||||
// console.log("Booking create called with input:", {
|
||||
// ...input,
|
||||
@@ -253,7 +264,7 @@ const create = os
|
||||
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
||||
const existing = await kv.getAllItems();
|
||||
const hasConflict = existing.some(b =>
|
||||
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
|
||||
(b.customerEmail && b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase()) &&
|
||||
b.appointmentDate === input.appointmentDate &&
|
||||
(b.status === "pending" || b.status === "confirmed")
|
||||
);
|
||||
@@ -329,7 +340,7 @@ const create = os
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
treatment: treatmentName,
|
||||
phone: input.customerPhone,
|
||||
phone: input.customerPhone || "Nicht angegeben",
|
||||
notes: input.notes,
|
||||
hasInspirationPhoto: !!input.inspirationPhoto
|
||||
});
|
||||
@@ -338,7 +349,7 @@ const create = os
|
||||
|
||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||
`Name: ${input.customerName}\n` +
|
||||
`Telefon: ${input.customerPhone}\n` +
|
||||
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
||||
`Behandlung: ${treatmentName}\n` +
|
||||
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||
@@ -425,6 +436,7 @@ const updateStatus = os
|
||||
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||||
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
|
||||
if (booking.customerEmail) {
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
@@ -438,7 +450,57 @@ const updateStatus = os
|
||||
customerName: booking.customerName,
|
||||
treatmentName: treatmentName
|
||||
});
|
||||
}
|
||||
} else if (input.status === "cancelled") {
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||
if (booking.customerEmail) {
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Dein Termin wurde abgesagt",
|
||||
text: `Hallo ${booking.customerName},\n\nleider wurde dein Termin am ${formattedDate} um ${booking.appointmentTime} abgesagt. Bitte buche einen neuen Termin.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nLiebe Grüße\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Email send failed:", e);
|
||||
}
|
||||
return updatedBooking;
|
||||
});
|
||||
|
||||
const remove = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
id: z.string(),
|
||||
sendEmail: z.boolean().optional().default(false)
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const booking = await kv.getItem(input.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
// Guard against deletion of past bookings or completed bookings
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const isPastDate = booking.appointmentDate < today;
|
||||
const isCompleted = booking.status === 'completed';
|
||||
|
||||
if (isPastDate || isCompleted) {
|
||||
// For past/completed bookings, disable email sending to avoid confusing customers
|
||||
if (input.sendEmail) {
|
||||
console.log(`Email sending disabled for past/completed booking ${input.id}`);
|
||||
}
|
||||
input.sendEmail = false;
|
||||
}
|
||||
|
||||
const wasAlreadyCancelled = booking.status === 'cancelled';
|
||||
const updatedBooking = { ...booking, status: "cancelled" as const };
|
||||
await kv.setItem(input.id, updatedBooking);
|
||||
|
||||
if (input.sendEmail && !wasAlreadyCancelled && booking.customerEmail) {
|
||||
try {
|
||||
const formattedDate = formatDateGerman(booking.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||
@@ -449,16 +511,132 @@ const updateStatus = os
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Email send failed:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Email send failed:", e);
|
||||
}
|
||||
|
||||
return updatedBooking;
|
||||
});
|
||||
|
||||
const remove = os.input(z.string()).handler(async ({ input }) => {
|
||||
await kv.removeItem(input);
|
||||
});
|
||||
// Admin-only manual booking creation (immediately confirmed)
|
||||
const createManual = os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
treatmentId: z.string(),
|
||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||
appointmentDate: z.string(),
|
||||
appointmentTime: z.string(),
|
||||
notes: z.string().optional(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
// Admin authentication
|
||||
await assertOwner(input.sessionId);
|
||||
|
||||
// Validate appointment time is on 15-minute grid
|
||||
const appointmentMinutes = parseTime(input.appointmentTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||
}
|
||||
|
||||
// Validate that the booking is not in the past
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
if (input.appointmentDate < today) {
|
||||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||
}
|
||||
|
||||
// For today's bookings, check if the time is not in the past
|
||||
if (input.appointmentDate === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (input.appointmentTime <= currentTime) {
|
||||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||
}
|
||||
}
|
||||
|
||||
// Get treatment duration for validation
|
||||
const treatment = await treatmentsKV.getItem(input.treatmentId);
|
||||
if (!treatment) {
|
||||
throw new Error("Behandlung nicht gefunden.");
|
||||
}
|
||||
|
||||
// Validate booking time against recurring rules
|
||||
await validateBookingAgainstRules(
|
||||
input.appointmentDate,
|
||||
input.appointmentTime,
|
||||
treatment.duration
|
||||
);
|
||||
|
||||
// Check for booking conflicts
|
||||
await checkBookingConflicts(
|
||||
input.appointmentDate,
|
||||
input.appointmentTime,
|
||||
treatment.duration
|
||||
);
|
||||
|
||||
const id = randomUUID();
|
||||
const booking = {
|
||||
id,
|
||||
treatmentId: input.treatmentId,
|
||||
customerName: input.customerName,
|
||||
customerEmail: input.customerEmail,
|
||||
customerPhone: input.customerPhone,
|
||||
appointmentDate: input.appointmentDate,
|
||||
appointmentTime: input.appointmentTime,
|
||||
notes: input.notes,
|
||||
bookedDurationMinutes: treatment.duration,
|
||||
status: "confirmed" as const,
|
||||
createdAt: new Date().toISOString()
|
||||
} as Booking;
|
||||
|
||||
// Save the booking
|
||||
await kv.setItem(id, booking);
|
||||
|
||||
// Create booking access token for status viewing and cancellation (always create token)
|
||||
const bookingAccessToken = await queryClient.cancellation.createToken({ bookingId: id });
|
||||
|
||||
// Send confirmation email if email is provided
|
||||
if (input.customerEmail) {
|
||||
void (async () => {
|
||||
try {
|
||||
const bookingUrl = generateUrl(`/booking/${bookingAccessToken.token}`);
|
||||
const formattedDate = formatDateGerman(input.appointmentDate);
|
||||
const homepageUrl = generateUrl();
|
||||
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: input.customerName,
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
cancellationUrl: bookingUrl
|
||||
});
|
||||
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: input.customerEmail,
|
||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||
text: `Hallo ${input.customerName},\n\nwir haben deinen Termin am ${formattedDate} um ${input.appointmentTime} bestätigt.\n\nWichtiger Hinweis: Die Allgemeinen Geschäftsbedingungen (AGB) findest du im Anhang dieser E-Mail. Bitte lies sie vor deinem Termin durch.\n\nTermin-Status ansehen und verwalten: ${bookingUrl}\nFalls du den Termin stornieren möchtest, kannst du das über den obigen Link tun.\n\nRechtliche Informationen: ${generateUrl('/legal')}\nZur Website: ${homepageUrl}\n\nBis bald!\nStargirlnails Kiel`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}, {
|
||||
date: input.appointmentDate,
|
||||
time: input.appointmentTime,
|
||||
durationMinutes: treatment.duration,
|
||||
customerName: input.customerName,
|
||||
treatmentName: treatment.name
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Email send failed for manual booking:", e);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Optionally return the token in the RPC response for UI to copy/share (admin usage only)
|
||||
return {
|
||||
...booking,
|
||||
bookingAccessToken: bookingAccessToken.token
|
||||
};
|
||||
});
|
||||
|
||||
const list = os.handler(async () => {
|
||||
return kv.getAllItems();
|
||||
@@ -495,10 +673,189 @@ const live = {
|
||||
|
||||
export const router = {
|
||||
create,
|
||||
createManual,
|
||||
updateStatus,
|
||||
remove,
|
||||
list,
|
||||
get,
|
||||
getByDate,
|
||||
live,
|
||||
// Admin proposes a reschedule for a confirmed booking
|
||||
proposeReschedule: os
|
||||
.input(z.object({
|
||||
sessionId: z.string(),
|
||||
bookingId: z.string(),
|
||||
proposedDate: z.string(),
|
||||
proposedTime: z.string(),
|
||||
}))
|
||||
.handler(async ({ input }) => {
|
||||
await assertOwner(input.sessionId);
|
||||
const booking = await kv.getItem(input.bookingId);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
if (booking.status !== "confirmed") throw new Error("Nur bestätigte Termine können umgebucht werden.");
|
||||
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
if (!treatment) throw new Error("Behandlung nicht gefunden.");
|
||||
|
||||
// Validate grid and not in past
|
||||
const appointmentMinutes = parseTime(input.proposedTime);
|
||||
if (appointmentMinutes % 15 !== 0) {
|
||||
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
|
||||
}
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
if (input.proposedDate < today) {
|
||||
throw new Error("Buchungen für vergangene Termine sind nicht möglich.");
|
||||
}
|
||||
if (input.proposedDate === today) {
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
if (input.proposedTime <= currentTime) {
|
||||
throw new Error("Buchungen für vergangene Uhrzeiten sind nicht möglich.");
|
||||
}
|
||||
}
|
||||
|
||||
await validateBookingAgainstRules(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration);
|
||||
await checkBookingConflicts(input.proposedDate, input.proposedTime, booking.bookedDurationMinutes || treatment.duration, booking.id);
|
||||
|
||||
// Invalidate and create new reschedule token via cancellation router
|
||||
const res = await queryClient.cancellation.createRescheduleToken({
|
||||
bookingId: booking.id,
|
||||
proposedDate: input.proposedDate,
|
||||
proposedTime: input.proposedTime,
|
||||
});
|
||||
const acceptUrl = generateUrl(`/booking/${res.token}?action=accept`);
|
||||
const declineUrl = generateUrl(`/booking/${res.token}?action=decline`);
|
||||
|
||||
// Send proposal email to customer
|
||||
if (booking.customerEmail) {
|
||||
const html = await renderBookingRescheduleProposalHTML({
|
||||
name: booking.customerName,
|
||||
originalDate: booking.appointmentDate,
|
||||
originalTime: booking.appointmentTime,
|
||||
proposedDate: input.proposedDate,
|
||||
proposedTime: input.proposedTime,
|
||||
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||||
acceptUrl,
|
||||
declineUrl,
|
||||
expiresAt: res.expiresAt,
|
||||
});
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Vorschlag zur Terminänderung",
|
||||
text: `Hallo ${booking.customerName}, wir schlagen vor, deinen Termin von ${formatDateGerman(booking.appointmentDate)} ${booking.appointmentTime} auf ${formatDateGerman(input.proposedDate)} ${input.proposedTime} zu verschieben. Akzeptieren: ${acceptUrl} | Ablehnen: ${declineUrl}`,
|
||||
html,
|
||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return { success: true, token: res.token };
|
||||
}),
|
||||
|
||||
// Customer accepts reschedule via token
|
||||
acceptReschedule: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||||
const booking = await kv.getItem(proposal.booking.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
if (booking.status !== "confirmed") throw new Error("Buchung ist nicht mehr in bestätigtem Zustand.");
|
||||
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
const duration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||
|
||||
// Re-validate slot to ensure still available
|
||||
await validateBookingAgainstRules(proposal.proposed.date, proposal.proposed.time, duration);
|
||||
await checkBookingConflicts(proposal.proposed.date, proposal.proposed.time, duration, booking.id);
|
||||
|
||||
const updated = { ...booking, appointmentDate: proposal.proposed.date, appointmentTime: proposal.proposed.time } as typeof booking;
|
||||
await kv.setItem(updated.id, updated);
|
||||
|
||||
// Remove token
|
||||
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||||
|
||||
// Send confirmation to customer (no BCC to avoid duplicate admin emails)
|
||||
if (updated.customerEmail) {
|
||||
const html = await renderBookingConfirmedHTML({
|
||||
name: updated.customerName,
|
||||
date: updated.appointmentDate,
|
||||
time: updated.appointmentTime,
|
||||
cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: updated.id })).token}`),
|
||||
});
|
||||
await sendEmailWithAGBAndCalendar({
|
||||
to: updated.customerEmail,
|
||||
subject: "Terminänderung bestätigt",
|
||||
text: `Hallo ${updated.customerName}, dein neuer Termin ist am ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime}.`,
|
||||
html,
|
||||
}, {
|
||||
date: updated.appointmentDate,
|
||||
time: updated.appointmentTime,
|
||||
durationMinutes: duration,
|
||||
customerName: updated.customerName,
|
||||
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (process.env.ADMIN_EMAIL) {
|
||||
const adminHtml = await renderAdminRescheduleAcceptedHTML({
|
||||
customerName: updated.customerName,
|
||||
originalDate: proposal.original.date,
|
||||
originalTime: proposal.original.time,
|
||||
newDate: updated.appointmentDate,
|
||||
newTime: updated.appointmentTime,
|
||||
treatmentName: (await treatmentsKV.getItem(updated.treatmentId))?.name || "Behandlung",
|
||||
});
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Reschedule akzeptiert - ${updated.customerName}`,
|
||||
text: `Reschedule akzeptiert: ${updated.customerName} von ${formatDateGerman(proposal.original.date)} ${proposal.original.time} auf ${formatDateGerman(updated.appointmentDate)} ${updated.appointmentTime}.`,
|
||||
html: adminHtml,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return { success: true, message: `Termin auf ${formatDateGerman(updated.appointmentDate)} um ${updated.appointmentTime} aktualisiert.` };
|
||||
}),
|
||||
|
||||
// Customer declines reschedule via token
|
||||
declineReschedule: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const proposal = await queryClient.cancellation.getRescheduleProposal({ token: input.token });
|
||||
const booking = await kv.getItem(proposal.booking.id);
|
||||
if (!booking) throw new Error("Booking not found");
|
||||
|
||||
// Remove token
|
||||
await queryClient.cancellation.removeRescheduleToken({ token: input.token });
|
||||
|
||||
// Notify customer that original stays
|
||||
if (booking.customerEmail) {
|
||||
await sendEmail({
|
||||
to: booking.customerEmail,
|
||||
subject: "Terminänderung abgelehnt",
|
||||
text: `Du hast den Vorschlag zur Terminänderung abgelehnt. Dein ursprünglicher Termin am ${formatDateGerman(booking.appointmentDate)} um ${booking.appointmentTime} bleibt bestehen.`,
|
||||
html: await renderBookingConfirmedHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime, cancellationUrl: generateUrl(`/booking/${(await queryClient.cancellation.createToken({ bookingId: booking.id })).token}`) }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Notify admin
|
||||
if (process.env.ADMIN_EMAIL) {
|
||||
const html = await renderAdminRescheduleDeclinedHTML({
|
||||
customerName: booking.customerName,
|
||||
originalDate: proposal.original.date,
|
||||
originalTime: proposal.original.time,
|
||||
proposedDate: proposal.proposed.date!,
|
||||
proposedTime: proposal.proposed.time!,
|
||||
treatmentName: (await treatmentsKV.getItem(booking.treatmentId))?.name || "Behandlung",
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
});
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `Reschedule abgelehnt - ${booking.customerName}`,
|
||||
text: `Abgelehnt: ${booking.customerName}. Ursprünglich: ${formatDateGerman(proposal.original.date)} ${proposal.original.time}. Vorschlag: ${formatDateGerman(proposal.proposed.date!)} ${proposal.proposed.time!}.`,
|
||||
html,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return { success: true, message: "Du hast den Vorschlag abgelehnt. Dein ursprünglicher Termin bleibt bestehen." };
|
||||
}),
|
||||
};
|
@@ -11,7 +11,12 @@ const BookingAccessTokenSchema = z.object({
|
||||
token: z.string(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
purpose: z.enum(["booking_access"]), // For future extensibility
|
||||
purpose: z.enum(["booking_access", "reschedule_proposal"]), // Extended for reschedule proposals
|
||||
// Optional metadata for reschedule proposals
|
||||
proposedDate: z.string().optional(),
|
||||
proposedTime: z.string().optional(),
|
||||
originalDate: z.string().optional(),
|
||||
originalTime: z.string().optional(),
|
||||
});
|
||||
|
||||
type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>;
|
||||
@@ -25,8 +30,8 @@ type Booking = {
|
||||
id: string;
|
||||
treatmentId: string;
|
||||
customerName: string;
|
||||
customerEmail: string;
|
||||
customerPhone: string;
|
||||
customerEmail?: string;
|
||||
customerPhone?: string;
|
||||
appointmentDate: string;
|
||||
appointmentTime: string;
|
||||
notes?: string;
|
||||
@@ -55,6 +60,15 @@ function formatDateGerman(dateString: string): string {
|
||||
return `${day}.${month}.${year}`;
|
||||
}
|
||||
|
||||
// Helper to invalidate all reschedule tokens for a specific booking
|
||||
async function invalidateRescheduleTokensForBooking(bookingId: string): Promise<void> {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const related = tokens.filter(t => t.bookingId === bookingId && t.purpose === "reschedule_proposal");
|
||||
for (const tok of related) {
|
||||
await cancellationKV.removeItem(tok.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Create cancellation token for a booking
|
||||
const createToken = os
|
||||
.input(z.object({ bookingId: z.string() }))
|
||||
@@ -93,7 +107,8 @@ const getBookingByToken = os
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const validToken = tokens.find(t =>
|
||||
t.token === input.token &&
|
||||
new Date(t.expiresAt) > new Date()
|
||||
new Date(t.expiresAt) > new Date() &&
|
||||
t.purpose === 'booking_access'
|
||||
);
|
||||
|
||||
if (!validToken) {
|
||||
@@ -217,4 +232,161 @@ export const router = {
|
||||
createToken,
|
||||
getBookingByToken,
|
||||
cancelByToken,
|
||||
// Create a reschedule proposal token (48h expiry)
|
||||
createRescheduleToken: os
|
||||
.input(z.object({ bookingId: z.string(), proposedDate: z.string(), proposedTime: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const booking = await bookingsKV.getItem(input.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
if (booking.status === "cancelled" || booking.status === "completed") {
|
||||
throw new Error("Reschedule not allowed for this booking");
|
||||
}
|
||||
|
||||
// Invalidate existing reschedule proposals for this booking
|
||||
await invalidateRescheduleTokensForBooking(input.bookingId);
|
||||
|
||||
// Create token that expires in 48 hours
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 48);
|
||||
|
||||
const token = randomUUID();
|
||||
const rescheduleToken: BookingAccessToken = {
|
||||
id: randomUUID(),
|
||||
bookingId: input.bookingId,
|
||||
token,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
purpose: "reschedule_proposal",
|
||||
proposedDate: input.proposedDate,
|
||||
proposedTime: input.proposedTime,
|
||||
originalDate: booking.appointmentDate,
|
||||
originalTime: booking.appointmentTime,
|
||||
};
|
||||
|
||||
await cancellationKV.setItem(rescheduleToken.id, rescheduleToken);
|
||||
return { token, expiresAt: expiresAt.toISOString() };
|
||||
}),
|
||||
|
||||
// Get reschedule proposal details by token
|
||||
getRescheduleProposal: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
|
||||
if (!proposal) {
|
||||
throw new Error("Ungültiger Reschedule-Token");
|
||||
}
|
||||
|
||||
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||
if (!booking) {
|
||||
throw new Error("Booking not found");
|
||||
}
|
||||
|
||||
const treatmentsKV = createKV<any>("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
|
||||
const now = new Date();
|
||||
const isExpired = new Date(proposal.expiresAt) <= now;
|
||||
const hoursUntilExpiry = isExpired ? 0 : Math.max(0, Math.round((new Date(proposal.expiresAt).getTime() - now.getTime()) / (1000 * 60 * 60)));
|
||||
|
||||
return {
|
||||
booking: {
|
||||
id: booking.id,
|
||||
customerName: booking.customerName,
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
status: booking.status,
|
||||
treatmentId: booking.treatmentId,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
},
|
||||
original: {
|
||||
date: proposal.originalDate || booking.appointmentDate,
|
||||
time: proposal.originalTime || booking.appointmentTime,
|
||||
},
|
||||
proposed: {
|
||||
date: proposal.proposedDate,
|
||||
time: proposal.proposedTime,
|
||||
},
|
||||
expiresAt: proposal.expiresAt,
|
||||
hoursUntilExpiry,
|
||||
isExpired,
|
||||
canRespond: booking.status === "confirmed" && !isExpired,
|
||||
};
|
||||
}),
|
||||
|
||||
// Helper endpoint to remove a reschedule token by value (used after accept/decline)
|
||||
removeRescheduleToken: os
|
||||
.input(z.object({ token: z.string() }))
|
||||
.handler(async ({ input }) => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const proposal = tokens.find(t => t.token === input.token && t.purpose === "reschedule_proposal");
|
||||
if (proposal) {
|
||||
await cancellationKV.removeItem(proposal.id);
|
||||
}
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Clean up expired reschedule proposals and notify admin
|
||||
sweepExpiredRescheduleProposals: os
|
||||
.handler(async () => {
|
||||
const tokens = await cancellationKV.getAllItems();
|
||||
const now = new Date();
|
||||
const expiredProposals = tokens.filter(t =>
|
||||
t.purpose === "reschedule_proposal" &&
|
||||
new Date(t.expiresAt) <= now
|
||||
);
|
||||
|
||||
if (expiredProposals.length === 0) {
|
||||
return { success: true, expiredCount: 0 };
|
||||
}
|
||||
|
||||
// Get booking details for each expired proposal
|
||||
const expiredDetails = [];
|
||||
for (const proposal of expiredProposals) {
|
||||
const booking = await bookingsKV.getItem(proposal.bookingId);
|
||||
if (booking) {
|
||||
const treatmentsKV = createKV<any>("treatments");
|
||||
const treatment = await treatmentsKV.getItem(booking.treatmentId);
|
||||
expiredDetails.push({
|
||||
customerName: booking.customerName,
|
||||
originalDate: proposal.originalDate || booking.appointmentDate,
|
||||
originalTime: proposal.originalTime || booking.appointmentTime,
|
||||
proposedDate: proposal.proposedDate,
|
||||
proposedTime: proposal.proposedTime,
|
||||
treatmentName: treatment?.name || "Unbekannte Behandlung",
|
||||
customerEmail: booking.customerEmail,
|
||||
customerPhone: booking.customerPhone,
|
||||
expiredAt: proposal.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the expired token
|
||||
await cancellationKV.removeItem(proposal.id);
|
||||
}
|
||||
|
||||
// Notify admin if there are expired proposals
|
||||
if (expiredDetails.length > 0 && process.env.ADMIN_EMAIL) {
|
||||
try {
|
||||
const { renderAdminRescheduleExpiredHTML } = await import("../lib/email-templates.js");
|
||||
const { sendEmail } = await import("../lib/email.js");
|
||||
|
||||
const html = await renderAdminRescheduleExpiredHTML({
|
||||
expiredProposals: expiredDetails,
|
||||
});
|
||||
|
||||
await sendEmail({
|
||||
to: process.env.ADMIN_EMAIL,
|
||||
subject: `${expiredDetails.length} abgelaufene Terminänderungsvorschläge`,
|
||||
text: `Es sind ${expiredDetails.length} Terminänderungsvorschläge abgelaufen. Details in der HTML-Version.`,
|
||||
html,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to send admin notification for expired proposals:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, expiredCount: expiredDetails.length };
|
||||
}),
|
||||
};
|
||||
|
Reference in New Issue
Block a user