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">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-gray-900">{booking.customerName}</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.customerEmail || '—'}</div>
|
||||||
<div className="text-sm text-gray-500">{booking.customerPhone}</div>
|
<div className="text-sm text-gray-500">{booking.customerPhone || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
@@ -5,6 +5,30 @@ import { queryClient } from "@/client/rpc-client";
|
|||||||
export function AdminCalendar() {
|
export function AdminCalendar() {
|
||||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
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(
|
const { data: bookings } = useQuery(
|
||||||
queryClient.bookings.live.list.experimental_liveOptions()
|
queryClient.bookings.live.list.experimental_liveOptions()
|
||||||
@@ -14,10 +38,45 @@ export function AdminCalendar() {
|
|||||||
queryClient.treatments.live.list.experimental_liveOptions()
|
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(
|
const { mutate: updateBookingStatus } = useMutation(
|
||||||
queryClient.bookings.updateStatus.mutationOptions()
|
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) => {
|
const getTreatmentName = (treatmentId: string) => {
|
||||||
return treatments?.find(t => t.id === treatmentId)?.name || "Unbekannte Behandlung";
|
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 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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">Kalender - Bevorstehende Buchungen</h2>
|
||||||
@@ -153,9 +320,17 @@ export function AdminCalendar() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h3 className="text-xl font-semibold text-gray-900">
|
<div className="flex items-center space-x-4">
|
||||||
{monthNames[month]} {year}
|
<h3 className="text-xl font-semibold text-gray-900">
|
||||||
</h3>
|
{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
|
<button
|
||||||
onClick={() => navigateMonth('next')}
|
onClick={() => navigateMonth('next')}
|
||||||
@@ -267,10 +442,10 @@ export function AdminCalendar() {
|
|||||||
<strong>Uhrzeit:</strong> {booking.appointmentTime}
|
<strong>Uhrzeit:</strong> {booking.appointmentTime}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>E-Mail:</strong> {booking.customerEmail}
|
<strong>E-Mail:</strong> {booking.customerEmail || '—'}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<strong>Telefon:</strong> {booking.customerPhone}
|
<strong>Telefon:</strong> {booking.customerPhone || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -293,7 +468,11 @@ export function AdminCalendar() {
|
|||||||
Bestätigen
|
Bestätigen
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Stornieren
|
Stornieren
|
||||||
@@ -302,13 +481,51 @@ export function AdminCalendar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{booking.status === "confirmed" && (
|
{booking.status === "confirmed" && (
|
||||||
<button
|
<>
|
||||||
onClick={() => handleStatusUpdate(booking.id, "completed")}
|
<button
|
||||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors"
|
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>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,6 +534,289 @@ export function AdminCalendar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { queryClient } from "@/client/rpc-client";
|
import { queryClient } from "@/client/rpc-client";
|
||||||
|
|
||||||
@@ -57,12 +57,27 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
|||||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
const [showCancelConfirm, setShowCancelConfirm] = useState(false);
|
||||||
const [isCancelling, setIsCancelling] = useState(false);
|
const [isCancelling, setIsCancelling] = useState(false);
|
||||||
const [cancellationResult, setCancellationResult] = useState<{ success: boolean; message: string; formattedDate?: string } | null>(null);
|
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
|
// 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 } })
|
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
|
// Cancellation mutation
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
...queryClient.cancellation.cancelByToken.mutationOptions(),
|
...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 = () => {
|
const handleCancel = () => {
|
||||||
setIsCancelling(true);
|
setIsCancelling(true);
|
||||||
setCancellationResult(null);
|
setCancellationResult(null);
|
||||||
cancelMutation.mutate({ token });
|
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) {
|
if (isLoading) {
|
||||||
return (
|
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="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 (
|
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="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">
|
<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 (
|
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="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">
|
<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);
|
const statusInfo = getStatusInfo(booking.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -285,11 +511,11 @@ export default function BookingStatusPage({ token }: BookingStatusPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2 border-b border-gray-100">
|
<div className="flex justify-between py-2 border-b border-gray-100">
|
||||||
<span className="text-gray-600">E-Mail:</span>
|
<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>
|
||||||
<div className="flex justify-between py-2">
|
<div className="flex justify-between py-2">
|
||||||
<span className="text-gray-600">Telefon:</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{booking.notes && (
|
{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 { randomUUID } from "crypto";
|
||||||
import { createKV } from "../lib/create-kv.js";
|
import { createKV } from "../lib/create-kv.js";
|
||||||
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.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 { router as rootRouter } from "./index.js";
|
||||||
import { createORPCClient } from "@orpc/client";
|
import { createORPCClient } from "@orpc/client";
|
||||||
import { RPCLink } from "@orpc/client/fetch";
|
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({
|
const BookingSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
treatmentId: z.string(),
|
treatmentId: z.string(),
|
||||||
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
customerName: z.string().min(2, "Name muss mindestens 2 Zeichen lang sein"),
|
||||||
customerEmail: z.string().email("Ungültige E-Mail-Adresse"),
|
customerEmail: z.string().email("Ungültige E-Mail-Adresse").optional(),
|
||||||
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein"),
|
customerPhone: z.string().min(5, "Telefonnummer muss mindestens 5 Zeichen lang sein").optional(),
|
||||||
appointmentDate: z.string(), // ISO date string
|
appointmentDate: z.string(), // ISO date string
|
||||||
appointmentTime: z.string(), // HH:MM format
|
appointmentTime: z.string(), // HH:MM format
|
||||||
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
|
||||||
@@ -195,7 +206,7 @@ type Treatment = {
|
|||||||
const treatmentsKV = createKV<Treatment>("treatments");
|
const treatmentsKV = createKV<Treatment>("treatments");
|
||||||
|
|
||||||
const create = os
|
const create = os
|
||||||
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
|
.input(CreateBookingInputSchema)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
// console.log("Booking create called with input:", {
|
// console.log("Booking create called with input:", {
|
||||||
// ...input,
|
// ...input,
|
||||||
@@ -253,7 +264,7 @@ const create = os
|
|||||||
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
if (!process.env.DISABLE_DUPLICATE_CHECK) {
|
||||||
const existing = await kv.getAllItems();
|
const existing = await kv.getAllItems();
|
||||||
const hasConflict = existing.some(b =>
|
const hasConflict = existing.some(b =>
|
||||||
b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase() &&
|
(b.customerEmail && b.customerEmail.toLowerCase() === input.customerEmail.toLowerCase()) &&
|
||||||
b.appointmentDate === input.appointmentDate &&
|
b.appointmentDate === input.appointmentDate &&
|
||||||
(b.status === "pending" || b.status === "confirmed")
|
(b.status === "pending" || b.status === "confirmed")
|
||||||
);
|
);
|
||||||
@@ -329,7 +340,7 @@ const create = os
|
|||||||
date: input.appointmentDate,
|
date: input.appointmentDate,
|
||||||
time: input.appointmentTime,
|
time: input.appointmentTime,
|
||||||
treatment: treatmentName,
|
treatment: treatmentName,
|
||||||
phone: input.customerPhone,
|
phone: input.customerPhone || "Nicht angegeben",
|
||||||
notes: input.notes,
|
notes: input.notes,
|
||||||
hasInspirationPhoto: !!input.inspirationPhoto
|
hasInspirationPhoto: !!input.inspirationPhoto
|
||||||
});
|
});
|
||||||
@@ -338,7 +349,7 @@ const create = os
|
|||||||
|
|
||||||
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
const adminText = `Neue Buchungsanfrage eingegangen:\n\n` +
|
||||||
`Name: ${input.customerName}\n` +
|
`Name: ${input.customerName}\n` +
|
||||||
`Telefon: ${input.customerPhone}\n` +
|
`Telefon: ${input.customerPhone || "Nicht angegeben"}\n` +
|
||||||
`Behandlung: ${treatmentName}\n` +
|
`Behandlung: ${treatmentName}\n` +
|
||||||
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
`Datum: ${formatDateGerman(input.appointmentDate)}\n` +
|
||||||
`Uhrzeit: ${input.appointmentTime}\n` +
|
`Uhrzeit: ${input.appointmentTime}\n` +
|
||||||
@@ -425,6 +436,7 @@ const updateStatus = os
|
|||||||
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
|
||||||
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
|
||||||
|
|
||||||
|
if (booking.customerEmail) {
|
||||||
await sendEmailWithAGBAndCalendar({
|
await sendEmailWithAGBAndCalendar({
|
||||||
to: booking.customerEmail,
|
to: booking.customerEmail,
|
||||||
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
subject: "Dein Termin wurde bestätigt - AGB im Anhang",
|
||||||
@@ -438,7 +450,57 @@ const updateStatus = os
|
|||||||
customerName: booking.customerName,
|
customerName: booking.customerName,
|
||||||
treatmentName: treatmentName
|
treatmentName: treatmentName
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else if (input.status === "cancelled") {
|
} 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 formattedDate = formatDateGerman(booking.appointmentDate);
|
||||||
const homepageUrl = generateUrl();
|
const homepageUrl = generateUrl();
|
||||||
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
const html = await renderBookingCancelledHTML({ name: booking.customerName, date: booking.appointmentDate, time: booking.appointmentTime });
|
||||||
@@ -449,16 +511,132 @@ const updateStatus = os
|
|||||||
html,
|
html,
|
||||||
bcc: process.env.ADMIN_EMAIL ? [process.env.ADMIN_EMAIL] : undefined,
|
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;
|
return updatedBooking;
|
||||||
});
|
});
|
||||||
|
|
||||||
const remove = os.input(z.string()).handler(async ({ input }) => {
|
// Admin-only manual booking creation (immediately confirmed)
|
||||||
await kv.removeItem(input);
|
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 () => {
|
const list = os.handler(async () => {
|
||||||
return kv.getAllItems();
|
return kv.getAllItems();
|
||||||
@@ -495,10 +673,189 @@ const live = {
|
|||||||
|
|
||||||
export const router = {
|
export const router = {
|
||||||
create,
|
create,
|
||||||
|
createManual,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
remove,
|
remove,
|
||||||
list,
|
list,
|
||||||
get,
|
get,
|
||||||
getByDate,
|
getByDate,
|
||||||
live,
|
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(),
|
token: z.string(),
|
||||||
expiresAt: z.string(),
|
expiresAt: z.string(),
|
||||||
createdAt: 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>;
|
type BookingAccessToken = z.output<typeof BookingAccessTokenSchema>;
|
||||||
@@ -25,8 +30,8 @@ type Booking = {
|
|||||||
id: string;
|
id: string;
|
||||||
treatmentId: string;
|
treatmentId: string;
|
||||||
customerName: string;
|
customerName: string;
|
||||||
customerEmail: string;
|
customerEmail?: string;
|
||||||
customerPhone: string;
|
customerPhone?: string;
|
||||||
appointmentDate: string;
|
appointmentDate: string;
|
||||||
appointmentTime: string;
|
appointmentTime: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -55,6 +60,15 @@ function formatDateGerman(dateString: string): string {
|
|||||||
return `${day}.${month}.${year}`;
|
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
|
// Create cancellation token for a booking
|
||||||
const createToken = os
|
const createToken = os
|
||||||
.input(z.object({ bookingId: z.string() }))
|
.input(z.object({ bookingId: z.string() }))
|
||||||
@@ -93,7 +107,8 @@ const getBookingByToken = os
|
|||||||
const tokens = await cancellationKV.getAllItems();
|
const tokens = await cancellationKV.getAllItems();
|
||||||
const validToken = tokens.find(t =>
|
const validToken = tokens.find(t =>
|
||||||
t.token === input.token &&
|
t.token === input.token &&
|
||||||
new Date(t.expiresAt) > new Date()
|
new Date(t.expiresAt) > new Date() &&
|
||||||
|
t.purpose === 'booking_access'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validToken) {
|
if (!validToken) {
|
||||||
@@ -217,4 +232,161 @@ export const router = {
|
|||||||
createToken,
|
createToken,
|
||||||
getBookingByToken,
|
getBookingByToken,
|
||||||
cancelByToken,
|
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